commit
ca92bd321e
155 files changed
+7499
-1287
No files matched your search
@@ -99,8 +99,6 @@ team-api/
|
||||
├── docs/ # 项目文档
|
||||
│ ├── 开发计划-v2/ # 分周期开发计划(周期一~六)
|
||||
│ └── 协议文档/ # 协议相关文档
|
||||
├── new-api/ # 参考项目(只读,不修改)
|
||||
├── sub2api/ # 参考项目(只读,不修改)
|
||||
├── main.go # 应用入口
|
||||
├── go.mod
|
||||
└── Makefile
|
||||
@@ -223,7 +221,7 @@ cmd(路由注册)
|
||||
|
||||
### 多租户隔离
|
||||
|
||||
行级隔离:所有业务表包含 `tenant_id` 字段,GoFrame ORM 中间件全局注入 `WHERE tenant_id = ?`。请求链路通过 Context 传递 tenant_id。
|
||||
行级隔离:所有业务表包含 `tenant_id` 字段。**没有 ORM 全局注入/hook 这类安全网**——隔离完全靠 logic 层手写过滤:租户身份一律取 `middleware.GetTenantID(ctx)`(来自 TenantAuth 注入的 ctx,客户端无法伪造),所有涉及租户数据的查询/更新必须显式带 `tenant_id` 条件;按 ID 取对象时用双键校验(`WHERE id = ? AND tenant_id = ?`)。新增查询漏写该条件即直接跨租户,评审时必须逐条核对。
|
||||
|
||||
### 路由前缀
|
||||
|
||||
@@ -491,3 +489,7 @@ cmd(路由注册)
|
||||
| `gf gen pb` | 生成 protobuf 文件 | 需要时 |
|
||||
| `gf run` | 热编译运行开发服务器 | 开发调试 |
|
||||
| `gf build` | 编译生产二进制 | 构建部署 |
|
||||
|
||||
|
||||
## AI工具要求
|
||||
不允许在提交内容中加入AI工具相关的身份表示和内容,不允许将AI作为仓库贡献者提交
|
||||
@@ -1,5 +1,11 @@
|
||||
Team-API
|
||||
Copyright (C) 2026 qianfree <406615373@qq.com>
|
||||
Team-API Community Edition
|
||||
Copyright (C) 2026 CHENGQIAN WU (qianfree) <wuchengqian5@163.com>
|
||||
|
||||
This copy of Team-API is offered under the GNU Affero General Public License
|
||||
version 3 or any later version. Additional commercial licensing rights, such as
|
||||
waivers of AGPL obligations or contractual SaaS/OEM/resale rights, are granted
|
||||
only under a separate written agreement with the copyright holder; this file
|
||||
does not grant those additional rights.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
|
||||
@@ -399,6 +399,8 @@ make migrate-status # 查看迁移状态
|
||||
|
||||
请遵循项目现有代码风格和 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/) 提交规范。
|
||||
|
||||
项目当前由 qianfree 单独维护。未来如接受外部贡献,将在合并贡献前提供贡献者许可协议(CLA),以确保 AGPL 与商业授权的权利链清晰完整。
|
||||
|
||||
## 在线交流
|
||||
欢迎加QQ群聊天吹水:1095286563
|
||||
|
||||
@@ -407,7 +409,7 @@ make migrate-status # 查看迁移状态
|
||||
|
||||
## 许可证
|
||||
|
||||
Copyright (C) 2026 qianfree. 本项目采用 [GNU Affero General Public License v3.0](LICENSE) 许可证。
|
||||
Copyright (C) 2026 qianfree。本项目社区版采用 [AGPL v3.0 或更高版本](LICENSE) 许可证。
|
||||
|
||||
### 核心要求
|
||||
|
||||
@@ -417,7 +419,9 @@ Copyright (C) 2026 qianfree. 本项目采用 [GNU Affero General Public License
|
||||
|
||||
### 商业授权
|
||||
|
||||
如果你想将本项目代码用于闭源商业产品,AGPL-3.0 许可证不适用。需要单独获取商业授权,请联系:**406615373@qq.com**
|
||||
如果你希望在不履行 AGPL 开源义务的情况下闭源使用本项目,需要单独获取商业授权,请联系:**406615373@qq.com**。
|
||||
商业授权不转让本项目的著作权、专利权、商标权或其他知识产权,具体权利以双方签署的书面协议为准。
|
||||
|
||||
|
||||
## 致谢
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# 第三方开源组件声明 / Third-Party Notices
|
||||
|
||||
版权所有 © 2026 qianfree。
|
||||
|
||||
Team-API 使用了以下开源组件,感谢各项目的作者与贡献者。
|
||||
本文件用于满足各组件许可证的署名要求,商业授权交付时需随产品附带。
|
||||
本文件不改变 Team-API 的主许可证,也不授予任何额外的软件使用、复制、修改、分发、SaaS、OEM 或转售权利;各第三方组件仍受其各自许可证约束。
|
||||
|
||||
> 本文件由脚本扫描 `go.mod`(Go 模块缓存)与 `web/*/node_modules`(package.json 的 license 字段)生成。
|
||||
> 重新生成方式见文末。
|
||||
|
||||
## 许可证分布概览
|
||||
|
||||
- **Go 依赖**:以本文件表格为准;发布前应重新扫描并复核许可证,重点关注 GPL/AGPL/LGPL 等 copyleft 许可证
|
||||
- **前端依赖**:以本文件表格为准;发布前应重新扫描并复核生产依赖与构建期依赖
|
||||
|
||||
## Go 依赖(含间接依赖)
|
||||
|
||||
> 下表覆盖生成时已缓存的 122 个模块;另有 66 个模块(均为其他平台专用的间接依赖,如 Windows/macOS 专属包)本次未下载,发布前用文末方法重新扫描补全。
|
||||
|
||||
| 模块 | 版本 | 许可证 |
|
||||
|------|------|--------|
|
||||
| `github.com/aws/aws-sdk-go-v2` | v1.41.6 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream` | v1.7.9 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/config` | v1.32.16 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/credentials` | v1.19.15 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/feature/ec2/imds` | v1.18.22 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/internal/configsources` | v1.4.22 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/internal/endpoints/v2` | v2.7.22 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/internal/v4a` | v1.4.23 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding` | v1.13.8 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/internal/checksum` | v1.9.14 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/internal/presigned-url` | v1.13.22 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/internal/s3shared` | v1.19.22 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/s3` | v1.99.1 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/signin` | v1.0.10 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/sso` | v1.30.16 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/ssooidc` | v1.35.20 | Apache-2.0 |
|
||||
| `github.com/aws/aws-sdk-go-v2/service/sts` | v1.42.0 | Apache-2.0 |
|
||||
| `github.com/aws/smithy-go` | v1.25.0 | Apache-2.0 |
|
||||
| `github.com/go-logr/logr` | v1.4.3 | Apache-2.0 |
|
||||
| `github.com/go-logr/stdr` | v1.2.2 | Apache-2.0 |
|
||||
| `github.com/pquerna/otp` | v1.5.0 | Apache-2.0 |
|
||||
| `github.com/richardlehane/mscfb` | v1.0.6 | Apache-2.0 |
|
||||
| `github.com/richardlehane/msoleps` | v1.0.6 | Apache-2.0 |
|
||||
| `github.com/sethvargo/go-retry` | v0.3.0 | Apache-2.0 |
|
||||
| `github.com/tklauser/numcpus` | v0.6.1 | Apache-2.0 |
|
||||
| `github.com/wenlng/go-captcha-assets` | v1.0.7 | Apache-2.0 |
|
||||
| `github.com/wenlng/go-captcha/v2` | v2.0.5 | Apache-2.0 |
|
||||
| `go.opentelemetry.io/auto/sdk` | v1.2.1 | Apache-2.0 |
|
||||
| `go.opentelemetry.io/otel` | v1.43.0 | Apache-2.0 |
|
||||
| `go.opentelemetry.io/otel/metric` | v1.43.0 | Apache-2.0 |
|
||||
| `go.opentelemetry.io/otel/sdk` | v1.39.0 | Apache-2.0 |
|
||||
| `go.opentelemetry.io/otel/sdk/metric` | v1.39.0 | Apache-2.0 |
|
||||
| `go.opentelemetry.io/otel/trace` | v1.43.0 | Apache-2.0 |
|
||||
| `gopkg.in/yaml.v3` | v3.0.1 | Apache-2.0 |
|
||||
| `github.com/emirpasic/gods/v2` | v2.0.0-alpha | BSD-2-Clause |
|
||||
| `github.com/gorilla/websocket` | v1.5.3 | BSD-2-Clause |
|
||||
| `github.com/magiconair/properties` | v1.8.10 | BSD-2-Clause |
|
||||
| `github.com/pmezard/go-difflib` | v1.0.0 | BSD-2-Clause |
|
||||
| `github.com/redis/go-redis/v9` | v9.19.0 | BSD-2-Clause |
|
||||
| `github.com/zeebo/xxh3` | v1.1.0 | BSD-2-Clause |
|
||||
| `gopkg.in/check.v1` | v1.0.0-20201130134442-10cb98267c6c | BSD-2-Clause |
|
||||
| `github.com/cloudflare/ahocorasick` | v0.0.0-20240916140611-054963ec9396 | BSD-3-Clause |
|
||||
| `github.com/fsnotify/fsnotify` | v1.9.0 | BSD-3-Clause |
|
||||
| `github.com/google/go-cmp` | v0.7.0 | BSD-3-Clause |
|
||||
| `github.com/google/go-querystring` | v1.0.0 | BSD-3-Clause |
|
||||
| `github.com/google/uuid` | v1.6.0 | BSD-3-Clause |
|
||||
| `github.com/grokify/html-strip-tags-go` | v0.1.0 | BSD-3-Clause |
|
||||
| `github.com/lufia/plan9stats` | v0.0.0-20211012122336-39d0f177ccd0 | BSD-3-Clause |
|
||||
| `github.com/remyoudompheng/bigfft` | v0.0.0-20230129092748-24d4a6f8daec | BSD-3-Clause |
|
||||
| `github.com/rogpeppe/go-internal` | v1.14.1 | BSD-3-Clause |
|
||||
| `github.com/shirou/gopsutil/v3` | v3.24.5 | BSD-3-Clause |
|
||||
| `github.com/tklauser/go-sysconf` | v0.3.12 | BSD-3-Clause |
|
||||
| `github.com/xuri/efp` | v0.0.1 | BSD-3-Clause |
|
||||
| `github.com/xuri/excelize/v2` | v2.10.1 | BSD-3-Clause |
|
||||
| `github.com/xuri/nfp` | v0.0.2-0.20250530014748-2ddeb826f9a9 | BSD-3-Clause |
|
||||
| `golang.org/x/crypto` | v0.53.0 | BSD-3-Clause |
|
||||
| `golang.org/x/image` | v0.25.0 | BSD-3-Clause |
|
||||
| `golang.org/x/net` | v0.56.0 | BSD-3-Clause |
|
||||
| `golang.org/x/sync` | v0.22.0 | BSD-3-Clause |
|
||||
| `golang.org/x/sys` | v0.47.0 | BSD-3-Clause |
|
||||
| `golang.org/x/text` | v0.40.0 | BSD-3-Clause |
|
||||
| `golang.org/x/time` | v0.15.0 | BSD-3-Clause |
|
||||
| `modernc.org/libc` | v1.72.1 | BSD-3-Clause |
|
||||
| `modernc.org/mathutil` | v1.7.1 | BSD-3-Clause |
|
||||
| `modernc.org/memory` | v1.11.0 | BSD-3-Clause |
|
||||
| `modernc.org/sqlite` | v1.49.1 | BSD-3-Clause |
|
||||
| `github.com/golang/freetype` | v0.0.0-20170609003504-e2365dfdc4a0 | FTL / GPL-2.0-or-later(本项目选择 FTL) |
|
||||
| `github.com/shoenig/go-m1cpu` | v0.2.1 | MPL-2.0 |
|
||||
| `github.com/shoenig/test` | v1.7.0 | MPL-2.0 |
|
||||
| `github.com/davecgh/go-spew` | v1.1.1 | ISC |
|
||||
| `github.com/BurntSushi/toml` | v1.5.0 | MIT |
|
||||
| `github.com/Masterminds/semver/v3` | v3.5.0 | MIT |
|
||||
| `github.com/alicebob/miniredis/v2` | v2.38.0 | MIT |
|
||||
| `github.com/aliyun/aliyun-oss-go-sdk` | v3.0.2+incompatible | MIT |
|
||||
| `github.com/boombuler/barcode` | v1.0.1-0.20190219062509-6c824513bacc | MIT |
|
||||
| `github.com/bsm/ginkgo/v2` | v2.12.0 | MIT |
|
||||
| `github.com/bsm/gomega` | v1.27.10 | MIT |
|
||||
| `github.com/cespare/xxhash/v2` | v2.3.0 | MIT |
|
||||
| `github.com/clbanning/mxj` | v1.8.4 | MIT |
|
||||
| `github.com/clbanning/mxj/v2` | v2.7.0 | MIT |
|
||||
| `github.com/dustin/go-humanize` | v1.0.1 | MIT |
|
||||
| `github.com/fatih/color` | v1.18.0 | MIT |
|
||||
| `github.com/go-ole/go-ole` | v1.2.6 | MIT |
|
||||
| `github.com/gogf/gf/contrib/drivers/pgsql/v2` | v2.10.2 | MIT |
|
||||
| `github.com/gogf/gf/contrib/nosql/redis/v2` | v2.10.2 | MIT |
|
||||
| `github.com/gogf/gf/v2` | v2.10.2 | MIT |
|
||||
| `github.com/golang-jwt/jwt/v5` | v5.3.1 | MIT |
|
||||
| `github.com/klauspost/cpuid/v2` | v2.2.10 | MIT |
|
||||
| `github.com/kr/pretty` | v0.3.1 | MIT |
|
||||
| `github.com/kr/text` | v0.2.0 | MIT |
|
||||
| `github.com/lib/pq` | v1.10.9 | MIT |
|
||||
| `github.com/mattn/go-colorable` | v0.1.13 | MIT |
|
||||
| `github.com/mattn/go-isatty` | v0.0.21 | MIT |
|
||||
| `github.com/mattn/go-runewidth` | v0.0.16 | MIT |
|
||||
| `github.com/mfridman/interpolate` | v0.0.2 | MIT |
|
||||
| `github.com/mitchellh/mapstructure` | v1.4.3 | MIT |
|
||||
| `github.com/mozillazg/go-httpheader` | v0.2.1 | MIT |
|
||||
| `github.com/ncruces/go-strftime` | v1.0.0 | MIT |
|
||||
| `github.com/olekukonko/errors` | v1.1.0 | MIT |
|
||||
| `github.com/olekukonko/ll` | v0.0.9 | MIT |
|
||||
| `github.com/olekukonko/tablewriter` | v1.1.0 | MIT |
|
||||
| `github.com/power-devops/perfstat` | v0.0.0-20210106213030-5aafc221ea8c | MIT |
|
||||
| `github.com/pressly/goose/v3` | v3.27.1 | MIT |
|
||||
| `github.com/rivo/uniseg` | v0.2.0 | MIT |
|
||||
| `github.com/robfig/cron/v3` | v3.0.1 | MIT |
|
||||
| `github.com/samber/lo` | v1.53.0 | MIT |
|
||||
| `github.com/shopspring/decimal` | v1.4.0 | MIT |
|
||||
| `github.com/stretchr/testify` | v1.11.1 | MIT |
|
||||
| `github.com/tencentyun/cos-go-sdk-v5` | v0.7.73 | MIT |
|
||||
| `github.com/tidwall/gjson` | v1.18.0 | MIT |
|
||||
| `github.com/tidwall/match` | v1.1.1 | MIT |
|
||||
| `github.com/tidwall/pretty` | v1.2.0 | MIT |
|
||||
| `github.com/tidwall/sjson` | v1.2.5 | MIT |
|
||||
| `github.com/tiendc/go-deepcopy` | v1.7.2 | MIT |
|
||||
| `github.com/yuin/gopher-lua` | v1.1.1 | MIT |
|
||||
| `github.com/yusufpapurcu/wmi` | v1.2.4 | MIT |
|
||||
| `go.uber.org/atomic` | v1.11.0 | MIT |
|
||||
| `go.uber.org/goleak` | v1.3.0 | MIT |
|
||||
| `go.uber.org/multierr` | v1.11.0 | MIT |
|
||||
| `gopkg.in/alexcesaro/quotedprintable.v3` | v3.0.0-20150716171945-2caba252f4dc | MIT |
|
||||
| `gopkg.in/gomail.v2` | v2.0.0-20160411212932-81ebce5c23df | MIT |
|
||||
| `github.com/qianfree/team-api/relaykit` | v0.0.0-00010101000000-000000000000 | NO_LICENSE_FILE |
|
||||
|
||||
### Go 依赖特别说明
|
||||
|
||||
- `github.com/golang/freetype`:双许可证(FTL / GPL-2.0+),本项目**选择 FTL**(类 BSD 带广告条款),不触发 GPL 义务
|
||||
- `github.com/shoenig/go-m1cpu`、`github.com/shoenig/test`:MPL-2.0(文件级弱传染),均为间接依赖(前者仅 macOS 平台编译、后者仅测试用),商业分发合规,保留其 LICENSE 即可
|
||||
- `github.com/qianfree/team-api/relaykit`:本项目自有子模块,非第三方
|
||||
|
||||
## 前端依赖(web/admin + web/tenant,含开发依赖)
|
||||
|
||||
- **0BSD**(1 个): `tslib`
|
||||
- **Apache-2.0**(3 个): `detect-libc`, `echarts`, `typescript`
|
||||
- **BSD-2-Clause**(1 个): `entities`
|
||||
- **BSD-3-Clause**(4 个): `highlight.js`, `source-map-js`, `speakingurl`, `zrender`
|
||||
- **ISC**(2 个): `graceful-fs`, `picocolors`
|
||||
- **MIT**(89 个): `alien-signals`, `async-validator`, `asynckit`, `axios`, `b-tween`, `b-validate`, `birpc`, `call-bind-apply-helpers`, `chart.js`, `color-convert`, `color-name`, `color-string`, `color`, `combined-stream`, `compute-scroll-into-view`, `copy-anything`, `css-render`, `csstype`, `date-fns-tz`, `date-fns`, `dayjs`, `delayed-stream`, `dunder-proto`, `enhanced-resolve`, `es-define-property`, `es-errors`, `es-object-atoms`, `es-set-tostringtag`, `estree-walker`, `evtd`, `fdir`, `follow-redirects`, `form-data`, `function-bind`, `get-intrinsic`, `get-proto`, `gopd`, `has-symbols`, `has-tostringtag`, `hasown`, `hookable`, `is-arrayish`, `is-what`, `jiti`, `lodash-es`, `lodash`, `magic-string`, `marked`, `marked`, `math-intrinsics`, `mime-db`, `mime-types`, `mitt`, `muggle-string`, `naive-ui`, `nanoid`, `number-precision`, `packrup`, `path-browserify`, `perfect-debounce`, `picomatch`, `pinia`, `postcss`, `proxy-from-env`, `resize-observer-polyfill`, `rfdc`, `rolldown`, `scroll-into-view-if-needed`, `seemly`, `simple-swizzle`, `superjson`, `tailwindcss`, `tapable`, `tinyglobby`, `treemate`, `undici-types`, `unhead`, `vdirs`, `vite`, `vooks`, `vscode-uri`, `vue-chartjs`, `vue-demi`, `vue-echarts`, `vue-router`, `vue-tsc`, `vueuc`, `vue`, `zhead`
|
||||
- **MPL-2.0**(2 个): `lightningcss-win32-x64-msvc`, `lightningcss`
|
||||
|
||||
### 前端依赖特别说明
|
||||
|
||||
- `lightningcss`(MPL-2.0):仅构建期 CSS 处理工具,不进入运行时产物
|
||||
|
||||
---
|
||||
|
||||
## 重新生成方法
|
||||
|
||||
```bash
|
||||
# Go 依赖:列出全部模块并在模块缓存中检查 LICENSE 文件
|
||||
go list -m -f "{{if not .Main}}{{.Path}}|{{.Version}}|{{.Dir}}{{end}}" all
|
||||
# 推荐工具:go install github.com/google/go-licenses@latest
|
||||
# go-licenses report ./... 可生成带许可证文本链接的完整报告
|
||||
|
||||
# 前端依赖(在各 web/* 目录下):
|
||||
# bunx license-checker --summary / npx license-checker --json
|
||||
```
|
||||
|
||||
> ⚠️ 本文件列出版本为生成时快照。每次发布商业版本前应重新扫描,重点核对**新增依赖**是否引入 copyleft 类许可证。
|
||||
+13
-1
@@ -37,7 +37,9 @@ type IAdminV1 interface {
|
||||
ForwardingTraceGet(ctx context.Context, req *v1.ForwardingTraceGetReq) (res *v1.ForwardingTraceGetRes, err error)
|
||||
OperationLogExport(ctx context.Context, req *v1.OperationLogExportReq) (res *v1.OperationLogExportRes, err error)
|
||||
ContentFilterLogList(ctx context.Context, req *v1.ContentFilterLogListReq) (res *v1.ContentFilterLogListRes, err error)
|
||||
ContentFilterLogClear(ctx context.Context, req *v1.ContentFilterLogClearReq) (res *v1.ContentFilterLogClearRes, err error)
|
||||
AdminLogin(ctx context.Context, req *v1.AdminLoginReq) (res *v1.AdminLoginRes, err error)
|
||||
AdminMe(ctx context.Context, req *v1.AdminMeReq) (res *v1.AdminMeRes, err error)
|
||||
AdminLogout(ctx context.Context, req *v1.AdminLogoutReq) (res *v1.AdminLogoutRes, err error)
|
||||
AdminRefresh(ctx context.Context, req *v1.AdminRefreshReq) (res *v1.AdminRefreshRes, err error)
|
||||
AdminSessionList(ctx context.Context, req *v1.AdminSessionListReq) (res *v1.AdminSessionListRes, err error)
|
||||
@@ -74,6 +76,7 @@ type IAdminV1 interface {
|
||||
ChannelErrorTrend(ctx context.Context, req *v1.ChannelErrorTrendReq) (res *v1.ChannelErrorTrendRes, err error)
|
||||
ChannelErrorTopChannels(ctx context.Context, req *v1.ChannelErrorTopChannelsReq) (res *v1.ChannelErrorTopChannelsRes, err error)
|
||||
ChannelErrorCategories(ctx context.Context, req *v1.ChannelErrorCategoriesReq) (res *v1.ChannelErrorCategoriesRes, err error)
|
||||
ChannelErrorClear(ctx context.Context, req *v1.ChannelErrorClearReq) (res *v1.ChannelErrorClearRes, err error)
|
||||
ChannelOAuthAuthURL(ctx context.Context, req *v1.ChannelOAuthAuthURLReq) (res *v1.ChannelOAuthAuthURLRes, err error)
|
||||
ChannelOAuthExchange(ctx context.Context, req *v1.ChannelOAuthExchangeReq) (res *v1.ChannelOAuthExchangeRes, err error)
|
||||
ChannelOAuthRefresh(ctx context.Context, req *v1.ChannelOAuthRefreshReq) (res *v1.ChannelOAuthRefreshRes, err error)
|
||||
@@ -206,7 +209,6 @@ type IAdminV1 interface {
|
||||
PaymentSettingsUpdate(ctx context.Context, req *v1.PaymentSettingsUpdateReq) (res *v1.PaymentSettingsUpdateRes, err error)
|
||||
AdminPermissionList(ctx context.Context, req *v1.AdminPermissionListReq) (res *v1.AdminPermissionListRes, err error)
|
||||
AdminPermissionUpdate(ctx context.Context, req *v1.AdminPermissionUpdateReq) (res *v1.AdminPermissionUpdateRes, err error)
|
||||
AdminDataScopeUpdate(ctx context.Context, req *v1.AdminDataScopeUpdateReq) (res *v1.AdminDataScopeUpdateRes, err error)
|
||||
AdminAllPermissions(ctx context.Context, req *v1.AdminAllPermissionsReq) (res *v1.AdminAllPermissionsRes, err error)
|
||||
PlanList(ctx context.Context, req *v1.PlanListReq) (res *v1.PlanListRes, err error)
|
||||
PlanCreate(ctx context.Context, req *v1.PlanCreateReq) (res *v1.PlanCreateRes, err error)
|
||||
@@ -234,6 +236,15 @@ type IAdminV1 interface {
|
||||
RedemptionDisable(ctx context.Context, req *v1.RedemptionDisableReq) (res *v1.RedemptionDisableRes, err error)
|
||||
RedemptionUsages(ctx context.Context, req *v1.RedemptionUsagesReq) (res *v1.RedemptionUsagesRes, err error)
|
||||
RedemptionExport(ctx context.Context, req *v1.RedemptionExportReq) (res *v1.RedemptionExportRes, err error)
|
||||
AdminRoleList(ctx context.Context, req *v1.AdminRoleListReq) (res *v1.AdminRoleListRes, err error)
|
||||
AdminRoleDetail(ctx context.Context, req *v1.AdminRoleDetailReq) (res *v1.AdminRoleDetailRes, err error)
|
||||
AdminRoleCreate(ctx context.Context, req *v1.AdminRoleCreateReq) (res *v1.AdminRoleCreateRes, err error)
|
||||
AdminRoleUpdate(ctx context.Context, req *v1.AdminRoleUpdateReq) (res *v1.AdminRoleUpdateRes, err error)
|
||||
AdminRoleDelete(ctx context.Context, req *v1.AdminRoleDeleteReq) (res *v1.AdminRoleDeleteRes, err error)
|
||||
AdminRoleStatusUpdate(ctx context.Context, req *v1.AdminRoleStatusUpdateReq) (res *v1.AdminRoleStatusUpdateRes, err error)
|
||||
AdminRoleReset(ctx context.Context, req *v1.AdminRoleResetReq) (res *v1.AdminRoleResetRes, err error)
|
||||
AdminUserRoleList(ctx context.Context, req *v1.AdminUserRoleListReq) (res *v1.AdminUserRoleListRes, err error)
|
||||
AdminUserRoleAssign(ctx context.Context, req *v1.AdminUserRoleAssignReq) (res *v1.AdminUserRoleAssignRes, err error)
|
||||
Admin2FASetup(ctx context.Context, req *v1.Admin2FASetupReq) (res *v1.Admin2FASetupRes, err error)
|
||||
Admin2FAEnable(ctx context.Context, req *v1.Admin2FAEnableReq) (res *v1.Admin2FAEnableRes, err error)
|
||||
Admin2FADisable(ctx context.Context, req *v1.Admin2FADisableReq) (res *v1.Admin2FADisableRes, err error)
|
||||
@@ -247,6 +258,7 @@ type IAdminV1 interface {
|
||||
AdminSettingsUpdate(ctx context.Context, req *v1.AdminSettingsUpdateReq) (res *v1.AdminSettingsUpdateRes, err error)
|
||||
AdminStorageTest(ctx context.Context, req *v1.AdminStorageTestReq) (res *v1.AdminStorageTestRes, err error)
|
||||
AdminEmailTest(ctx context.Context, req *v1.AdminEmailTestReq) (res *v1.AdminEmailTestRes, err error)
|
||||
AdminSupportPendingSummary(ctx context.Context, req *v1.AdminSupportPendingSummaryReq) (res *v1.AdminSupportPendingSummaryRes, err error)
|
||||
TaskList(ctx context.Context, req *v1.TaskListReq) (res *v1.TaskListRes, err error)
|
||||
TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (res *v1.TaskDetailRes, err error)
|
||||
TaskCancel(ctx context.Context, req *v1.TaskCancelReq) (res *v1.TaskCancelRes, err error)
|
||||
|
||||
@@ -30,6 +30,9 @@ type AdminUserItem struct {
|
||||
LastLoginIp string `json:"last_login_ip"`
|
||||
LockedUntil string `json:"locked_until"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// Roles 是该账号已分配的角色。role=super_admin 时为空数组(超管权限来自账号属性,
|
||||
// 不通过角色授予);非超管且为空数组表示「未分配角色」=零权限,界面应醒目提示。
|
||||
Roles []AdminRoleBrief `json:"roles"`
|
||||
}
|
||||
|
||||
// AdminUserCreateReq 创建管理员请求
|
||||
@@ -39,6 +42,9 @@ type AdminUserCreateReq struct {
|
||||
Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"`
|
||||
Email string `json:"email" v:"email#邮箱格式不正确" dc:"邮箱"`
|
||||
Role string `json:"role" d:"admin" v:"in:super_admin,admin#角色只能是 super_admin 或 admin" dc:"角色:super_admin / admin"`
|
||||
// RoleIDs 在创建时一并分配角色。不传则账号无任何角色(0 权限,只能访问自助接口),
|
||||
// 这是安全的默认值,但界面应提示「未分配角色」。role=super_admin 时本字段忽略。
|
||||
RoleIDs []int64 `json:"role_ids" dc:"分配的角色ID列表"`
|
||||
}
|
||||
|
||||
type AdminUserCreateRes struct {
|
||||
|
||||
@@ -142,3 +142,13 @@ type ContentFilterLogListRes struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// ContentFilterLogClearReq 清空全部内容过滤拦截日志(硬删除,用于日志大量堆积时快速释放数据库空间)
|
||||
type ContentFilterLogClearReq struct {
|
||||
g.Meta `path:"/audit/content-filter-logs/clear" method:"delete" mime:"json" tags:"管理后台-审计" summary:"清空内容过滤拦截日志(硬删除全部)"`
|
||||
}
|
||||
|
||||
// ContentFilterLogClearRes 清空内容过滤拦截日志响应
|
||||
type ContentFilterLogClearRes struct {
|
||||
Deleted int64 `json:"deleted" dc:"删除的记录数"`
|
||||
}
|
||||
+31
-5
@@ -23,9 +23,32 @@ type AdminLoginRes struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
} `json:"user"`
|
||||
// Permissions 是该账号的有效权限点(角色并集 ∪ 特批)。前端据此渲染菜单与按钮;
|
||||
// 后端鉴权始终以数据库/缓存为准,这里只影响展示。
|
||||
Permissions []string `json:"permissions"`
|
||||
Roles []AdminRoleBrief `json:"roles"` // 已分配角色(超管为空数组)
|
||||
PendingAgreements []*LoginPendingAgreement `json:"pending_agreements,omitempty"` // 待接受协议列表
|
||||
}
|
||||
|
||||
// AdminMeReq 当前登录用户信息(权限刷新用)
|
||||
//
|
||||
// 权限变更后,已登录用户此前需要重新登录才能拿到新权限。前端在应用启动与 token 刷新后
|
||||
// 调用本接口刷新,使菜单与按钮跟上最新权限。
|
||||
type AdminMeReq struct {
|
||||
g.Meta `path:"/auth/me" method:"get" mime:"json" tags:"管理后台-认证" summary:"当前登录用户信息与有效权限"`
|
||||
}
|
||||
|
||||
// AdminMeRes 当前登录用户信息响应
|
||||
type AdminMeRes struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Roles []AdminRoleBrief `json:"roles"`
|
||||
}
|
||||
|
||||
// LoginPendingAgreement 登录时返回的待接受协议信息
|
||||
type LoginPendingAgreement struct {
|
||||
Id int64 `json:"id"`
|
||||
@@ -74,11 +97,14 @@ type AdminSessionItem struct {
|
||||
UserId int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
DeviceInfo string `json:"device_info"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
// Role 是会话所属账号的角色(super_admin / admin),
|
||||
// 前端据此隐藏非本人超管会话的撤销入口(后端另有 assertCanOperateSessions 兜底)
|
||||
Role string `json:"role"`
|
||||
IpAddress string `json:"ip_address"`
|
||||
DeviceInfo string `json:"device_info"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
}
|
||||
|
||||
// AdminRevokeSessionReq 踢出指定会话请求
|
||||
|
||||
@@ -64,3 +64,13 @@ type ChannelErrorCategoriesReq struct {
|
||||
type ChannelErrorCategoriesRes struct {
|
||||
Data []map[string]string `json:"data"`
|
||||
}
|
||||
|
||||
// ChannelErrorClearReq 清空全部渠道错误事件(硬删除,用于错误大量堆积时快速释放数据库空间)
|
||||
type ChannelErrorClearReq struct {
|
||||
g.Meta `path:"/monitor/channel-errors/clear" method:"delete" mime:"json" tags:"管理后台-监控" summary:"清空渠道错误事件(硬删除全部)"`
|
||||
}
|
||||
|
||||
// ChannelErrorClearRes 清空渠道错误事件响应
|
||||
type ChannelErrorClearRes struct {
|
||||
Deleted int64 `json:"deleted" dc:"删除的记录数"`
|
||||
}
|
||||
@@ -11,7 +11,7 @@ type HelpCategoryCreateReq struct {
|
||||
g.Meta `path:"/help-categories" method:"post" mime:"json" tags:"管理后台-帮助中心" summary:"创建帮助分类"`
|
||||
ParentId int64 `json:"parent_id" d:"0" dc:"父分类ID,0为顶级"`
|
||||
Name string `json:"name" v:"required|length:1,100" dc:"分类名称"`
|
||||
Slug string `json:"slug" v:"required|length:1,100" dc:"URL友好标识"`
|
||||
Slug string `json:"slug" v:"required|length:1,100|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入分类Slug|分类Slug长度为1-100位|分类Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"`
|
||||
Description string `json:"description" dc:"分类描述"`
|
||||
SortOrder int `json:"sort_order" d:"0" dc:"排序序号"`
|
||||
Icon string `json:"icon" dc:"图标名称"`
|
||||
@@ -27,7 +27,7 @@ type HelpCategoryUpdateReq struct {
|
||||
Id int64 `json:"id" in:"path" v:"required|min:1"`
|
||||
ParentId int64 `json:"parent_id" d:"0" dc:"父分类ID,0为顶级"`
|
||||
Name string `json:"name" v:"required|length:1,100" dc:"分类名称"`
|
||||
Slug string `json:"slug" v:"required|length:1,100" dc:"URL友好标识"`
|
||||
Slug string `json:"slug" v:"required|length:1,100|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入分类Slug|分类Slug长度为1-100位|分类Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"`
|
||||
Description string `json:"description" dc:"分类描述"`
|
||||
SortOrder int `json:"sort_order" d:"0" dc:"排序序号"`
|
||||
Icon string `json:"icon" dc:"图标名称"`
|
||||
@@ -77,7 +77,7 @@ type HelpArticleCreateReq struct {
|
||||
g.Meta `path:"/help-articles" method:"post" mime:"json" tags:"管理后台-帮助中心" summary:"创建帮助文章"`
|
||||
CategoryId int64 `json:"category_id" v:"required|min:1" dc:"所属分类ID"`
|
||||
Title string `json:"title" v:"required|length:1,200" dc:"文章标题"`
|
||||
Slug string `json:"slug" v:"required|length:1,200" dc:"URL友好标识"`
|
||||
Slug string `json:"slug" v:"required|length:1,200|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入文章Slug|文章Slug长度为1-200位|文章Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"`
|
||||
Content string `json:"content" v:"required" dc:"文章内容(Markdown)"`
|
||||
Summary string `json:"summary" dc:"文章摘要"`
|
||||
Status string `json:"status" d:"draft" v:"in:draft,published" dc:"状态"`
|
||||
@@ -94,7 +94,7 @@ type HelpArticleUpdateReq struct {
|
||||
Id int64 `json:"id" in:"path" v:"required|min:1"`
|
||||
CategoryId int64 `json:"category_id" v:"required|min:1" dc:"所属分类ID"`
|
||||
Title string `json:"title" v:"required|length:1,200" dc:"文章标题"`
|
||||
Slug string `json:"slug" v:"required|length:1,200" dc:"URL友好标识"`
|
||||
Slug string `json:"slug" v:"required|length:1,200|regex:^[a-z0-9]([a-z0-9-]*[a-z0-9])?$#请输入文章Slug|文章Slug长度为1-200位|文章Slug仅允许小写字母、数字、中划线" dc:"URL友好标识"`
|
||||
Content string `json:"content" dc:"文章内容(Markdown),不传则不更新"`
|
||||
Summary string `json:"summary" dc:"文章摘要"`
|
||||
Status string `json:"status" v:"in:draft,published" dc:"状态"`
|
||||
|
||||
+25
-13
@@ -28,19 +28,12 @@ type AdminPermissionUpdateReq struct {
|
||||
|
||||
type AdminPermissionUpdateRes struct{}
|
||||
|
||||
// AdminDataScopeUpdateReq 更新管理员数据范围请求
|
||||
type AdminDataScopeUpdateReq struct {
|
||||
g.Meta `path:"/users/{id}/data-scopes" method:"put" mime:"json" tags:"管理后台-权限管理" summary:"更新管理员数据范围"`
|
||||
Id int64 `json:"id" in:"path" v:"required" dc:"管理员ID"`
|
||||
DataScopes []DataScopeInput `json:"data_scopes" dc:"数据范围列表"`
|
||||
}
|
||||
|
||||
type DataScopeInput struct {
|
||||
ScopeType string `json:"scope_type" v:"required|in:all,tenant_group,tenant#请选择范围类型|范围类型无效" dc:"范围类型"`
|
||||
ScopeValue string `json:"scope_value" dc:"范围值"`
|
||||
}
|
||||
|
||||
type AdminDataScopeUpdateRes struct{}
|
||||
// 数据范围(sys_admin_data_scopes)的写接口已下线。
|
||||
//
|
||||
// 该表可以配置「全部 / 租户组 / 指定租户」,但全站没有任何查询在读它做过滤
|
||||
// (GetDataScopes 零调用方),配出来的范围不会生效。留着一个能存、能显示、
|
||||
// 却不约束任何数据的开关,比没有更危险:以为限定了范围的账号实际能看全量数据。
|
||||
// 表与存量数据保留,待数据范围真正落到查询链路上再恢复配置入口。
|
||||
|
||||
// AdminAllPermissionsReq 获取所有可用权限点请求
|
||||
type AdminAllPermissionsReq struct {
|
||||
@@ -49,6 +42,25 @@ type AdminAllPermissionsReq struct {
|
||||
|
||||
type AdminAllPermissionsRes struct {
|
||||
Groups []PermissionGroup `json:"groups"`
|
||||
// Modules 是「模块 × 档位」的配置元数据,角色配置界面据此渲染 19 行单选,
|
||||
// 而不是把 60 个权限点摊成复选框。Groups 仍保留,供高级模式按权限点勾选。
|
||||
Modules []PermissionModuleMeta `json:"modules"`
|
||||
// Dangerous 列出需要标红并二次确认的高危权限点(权限点 → 风险说明)
|
||||
Dangerous map[string]string `json:"dangerous"`
|
||||
}
|
||||
|
||||
// PermissionModuleMeta 单个模块的档位配置元数据
|
||||
type PermissionModuleMeta struct {
|
||||
Module string `json:"module"` // 模块名,与 PermissionGroup.Name 对应
|
||||
Label string `json:"label"` // 中文标签
|
||||
Tiers []TierOption `json:"tiers"` // 该模块实际存在差异的档位(等价档位已折叠,界面不渲染无意义选项)
|
||||
}
|
||||
|
||||
// TierOption 档位选项及其展开后的权限点
|
||||
type TierOption struct {
|
||||
Tier string `json:"tier"` // none / read / operate / full
|
||||
Label string `json:"label"` // 无 / 只读 / 操作 / 完全
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
type PermissionGroup struct {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package v1
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 角色管理
|
||||
//
|
||||
// 超级管理员不在本接口的管辖范围:它由 sys_admin_users.role 判定并在鉴权时短路,
|
||||
// 界面上作为一张固定卡片展示「全部权限(不可配置)」。
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// AdminRoleBrief 角色简要信息(登录响应、用户列表等处展示用)
|
||||
type AdminRoleBrief struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
}
|
||||
|
||||
// AdminRoleItem 角色列表项
|
||||
type AdminRoleItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
Sort int `json:"sort"`
|
||||
UserCount int `json:"user_count"` // 关联的管理员账号数(删除前的影响面提示)
|
||||
PermCount int `json:"perm_count"` // 已授予的权限点数量
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminRoleListReq 角色列表请求
|
||||
type AdminRoleListReq struct {
|
||||
g.Meta `path:"/roles" method:"get" mime:"json" tags:"管理后台-角色" summary:"角色列表"`
|
||||
}
|
||||
|
||||
// AdminRoleListRes 角色列表响应(角色数量少,不分页)
|
||||
type AdminRoleListRes struct {
|
||||
List []AdminRoleItem `json:"list"`
|
||||
}
|
||||
|
||||
// AdminRoleModuleTier 角色在某个模块上的档位选择
|
||||
type AdminRoleModuleTier struct {
|
||||
Module string `json:"module"` // 模块名,如 tenant
|
||||
Tier string `json:"tier"` // none / read / operate / full;空串表示不匹配任何档位(自定义)
|
||||
}
|
||||
|
||||
// AdminRoleDetailReq 角色详情请求
|
||||
type AdminRoleDetailReq struct {
|
||||
g.Meta `path:"/roles/{id}" method:"get" mime:"json" tags:"管理后台-角色" summary:"角色详情"`
|
||||
ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"`
|
||||
}
|
||||
|
||||
// AdminRoleDetailRes 角色详情响应
|
||||
//
|
||||
// 同时给出权限点全集与档位视图:界面默认按档位渲染(19 行单选),
|
||||
// 高级模式切到权限点级勾选。二者是同一份数据的两个视图。
|
||||
type AdminRoleDetailRes struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
Sort int `json:"sort"`
|
||||
UserCount int `json:"user_count"`
|
||||
Permissions []string `json:"permissions"` // 权限点全集
|
||||
ModuleTiers []AdminRoleModuleTier `json:"module_tiers"` // 按模块归纳的档位视图
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminRoleCreateReq 新建角色请求
|
||||
type AdminRoleCreateReq struct {
|
||||
g.Meta `path:"/roles" method:"post" mime:"json" tags:"管理后台-角色" summary:"新建角色"`
|
||||
Code string `json:"code" v:"required#请输入角色标识" dc:"角色标识(小写字母开头,可含数字与下划线,创建后不可修改)"`
|
||||
Name string `json:"name" v:"required#请输入角色名称" dc:"角色显示名称"`
|
||||
Description string `json:"description" dc:"角色说明"`
|
||||
Sort int `json:"sort" dc:"排序权重"`
|
||||
Permissions []string `json:"permissions" dc:"权限点列表(与 copy_from_role_id 二选一,同时给出时以本字段为准)"`
|
||||
CopyFromRoleID int64 `json:"copy_from_role_id" dc:"从现有角色复制权限(建「实习运营」就是复制「运营」再收掉几个模块)"`
|
||||
}
|
||||
|
||||
// AdminRoleCreateRes 新建角色响应
|
||||
type AdminRoleCreateRes struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// AdminRoleUpdateReq 更新角色请求(code 不可修改)
|
||||
type AdminRoleUpdateReq struct {
|
||||
g.Meta `path:"/roles/{id}" method:"put" mime:"json" tags:"管理后台-角色" summary:"更新角色"`
|
||||
ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"`
|
||||
Name *string `json:"name" dc:"角色显示名称"`
|
||||
Description *string `json:"description" dc:"角色说明"`
|
||||
Sort *int `json:"sort" dc:"排序权重"`
|
||||
Permissions *[]string `json:"permissions" dc:"权限点列表(全量覆盖,传 null 表示不改动权限)"`
|
||||
}
|
||||
|
||||
// AdminRoleUpdateRes 更新角色响应
|
||||
type AdminRoleUpdateRes struct{}
|
||||
|
||||
// AdminRoleDeleteReq 删除角色请求
|
||||
type AdminRoleDeleteReq struct {
|
||||
g.Meta `path:"/roles/{id}" method:"delete" mime:"json" tags:"管理后台-角色" summary:"删除角色"`
|
||||
ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"`
|
||||
}
|
||||
|
||||
// AdminRoleDeleteRes 删除角色响应
|
||||
//
|
||||
// 预置角色同样可删 —— is_builtin 只标识来源,不构成删除保护。
|
||||
// 不存在把自己锁死的风险:超级管理员由 sys_admin_users.role 判定,不依赖任何角色记录。
|
||||
type AdminRoleDeleteRes struct {
|
||||
AffectedUsers int `json:"affected_users"` // 因此次删除而失去该角色权限的账号数
|
||||
}
|
||||
|
||||
// AdminRoleStatusUpdateReq 启用/禁用角色请求
|
||||
type AdminRoleStatusUpdateReq struct {
|
||||
g.Meta `path:"/roles/{id}/status" method:"put" mime:"json" tags:"管理后台-角色" summary:"启用/禁用角色"`
|
||||
ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"`
|
||||
IsEnabled bool `json:"is_enabled" dc:"是否启用:禁用后该角色权限对全部关联用户立即失效"`
|
||||
}
|
||||
|
||||
// AdminRoleStatusUpdateRes 启用/禁用角色响应
|
||||
type AdminRoleStatusUpdateRes struct{}
|
||||
|
||||
// AdminRoleResetReq 恢复预置角色的默认权限请求
|
||||
type AdminRoleResetReq struct {
|
||||
g.Meta `path:"/roles/{id}/reset" method:"post" mime:"json" tags:"管理后台-角色" summary:"恢复预置角色默认权限"`
|
||||
ID int64 `json:"id" in:"path" v:"required" dc:"角色ID"`
|
||||
}
|
||||
|
||||
// AdminRoleResetRes 恢复默认权限响应
|
||||
type AdminRoleResetRes struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 用户角色分配
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// AdminUserRoleListReq 查询管理员已分配角色
|
||||
type AdminUserRoleListReq struct {
|
||||
g.Meta `path:"/users/{id}/roles" method:"get" mime:"json" tags:"管理后台-角色" summary:"查询用户角色"`
|
||||
Id int64 `json:"id" in:"path" v:"required" dc:"管理员用户ID"`
|
||||
}
|
||||
|
||||
// AdminUserRoleListRes 用户角色列表响应
|
||||
type AdminUserRoleListRes struct {
|
||||
List []AdminRoleBrief `json:"list"`
|
||||
}
|
||||
|
||||
// AdminUserRoleAssignReq 分配管理员角色请求(全量覆盖)
|
||||
type AdminUserRoleAssignReq struct {
|
||||
g.Meta `path:"/users/{id}/roles" method:"put" mime:"json" tags:"管理后台-角色" summary:"分配用户角色"`
|
||||
Id int64 `json:"id" in:"path" v:"required" dc:"管理员用户ID"`
|
||||
RoleIDs []int64 `json:"role_ids" dc:"角色ID列表(全量覆盖;传空数组表示不分配任何角色,该账号将只剩自助接口权限)"`
|
||||
}
|
||||
|
||||
// AdminUserRoleAssignRes 分配用户角色响应
|
||||
type AdminUserRoleAssignRes struct{}
|
||||
@@ -66,6 +66,10 @@ type Admin2FAVerifyRes struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
} `json:"user"`
|
||||
// 与 AdminLoginRes 保持一致:2FA 是登录的第二段,同样要下发权限,
|
||||
// 否则开了两步验证的账号前端权限集为空,菜单全不显示。
|
||||
Permissions []string `json:"permissions"`
|
||||
Roles []AdminRoleBrief `json:"roles"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// === 顶栏铃铛(客服支持待办聚合) ===
|
||||
|
||||
// AdminSupportPendingSummaryReq 顶栏铃铛的待办计数。
|
||||
// 口径与工作台刻意不同:工作台只报「超 SLA / 未分配」等异常项避免永久红点;
|
||||
// 铃铛报全量待处理 —— 管理员处理一条少一条,归零即「没有客户在等回复」,
|
||||
// 是运营收件箱语义而非消息通知语义(无已读概念,不落消息表)。
|
||||
type AdminSupportPendingSummaryReq struct {
|
||||
g.Meta `path:"/support/pending-summary" method:"get" mime:"json" tags:"管理后台-待办提醒" summary:"顶栏铃铛待办计数"`
|
||||
}
|
||||
|
||||
type AdminSupportPendingSummaryRes struct {
|
||||
Tickets int `json:"tickets" dc:"待处理工单数(pending + reopened)"`
|
||||
TicketsOverdue int `json:"tickets_overdue" dc:"其中超 SLA 未首次响应的工单数"`
|
||||
Feedbacks int `json:"feedbacks" dc:"待处理反馈数"`
|
||||
FeedbacksWait string `json:"feedbacks_wait" dc:"最早一条待处理反馈已等待时长(如「3 天」;无待处理时为空)"`
|
||||
Total int `json:"total" dc:"tickets + feedbacks,铃铛角标总数"`
|
||||
GeneratedAt string `json:"generated_at" dc:"数据生成时间(RFC3339),命中缓存时为缓存写入时刻"`
|
||||
}
|
||||
@@ -142,6 +142,8 @@ type OpenKeyItem struct {
|
||||
type OpenKeyCreateReq struct {
|
||||
g.Meta `path:"/v1/keys" method:"post" mime:"json" tags:"开放平台API" summary:"创建API Key"`
|
||||
Name string `json:"name" v:"required|length:2,100#请输入Key名称|名称长度2-100" dc:"Key名称"`
|
||||
ProjectID int64 `json:"project_id" dc:"关联项目 ID(可选;填写后该 Key 随项目归档一并失效)"`
|
||||
Scope string `json:"scope" d:"full" v:"in:full,chat_only,embeddings_only,images_only,read_only#权限范围无效" dc:"权限范围"`
|
||||
ModelScopes []string `json:"model_scopes" dc:"可用模型列表(为空则不限)"`
|
||||
QuotaLimit float64 `json:"quota_limit" d:"0" dc:"额度上限(0=不限)"`
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ type ITenantV1 interface {
|
||||
TenantRequestAuditLogs(ctx context.Context, req *v1.TenantRequestAuditLogsReq) (res *v1.TenantRequestAuditLogsRes, err error)
|
||||
TenantRequestAuditLogDetail(ctx context.Context, req *v1.TenantRequestAuditLogDetailReq) (res *v1.TenantRequestAuditLogDetailRes, err error)
|
||||
TenantRegister(ctx context.Context, req *v1.TenantRegisterReq) (res *v1.TenantRegisterRes, err error)
|
||||
TenantRegisterRateLimit(ctx context.Context, req *v1.TenantRegisterRateLimitReq) (res *v1.TenantRegisterRateLimitRes, err error)
|
||||
TenantLogin(ctx context.Context, req *v1.TenantLoginReq) (res *v1.TenantLoginRes, err error)
|
||||
TenantLogout(ctx context.Context, req *v1.TenantLogoutReq) (res *v1.TenantLogoutRes, err error)
|
||||
TenantRefresh(ctx context.Context, req *v1.TenantRefreshReq) (res *v1.TenantRefreshRes, err error)
|
||||
@@ -35,6 +36,7 @@ type ITenantV1 interface {
|
||||
TenantSessionList(ctx context.Context, req *v1.TenantSessionListReq) (res *v1.TenantSessionListRes, err error)
|
||||
TenantRevokeSession(ctx context.Context, req *v1.TenantRevokeSessionReq) (res *v1.TenantRevokeSessionRes, err error)
|
||||
TenantUsageLogs(ctx context.Context, req *v1.TenantUsageLogsReq) (res *v1.TenantUsageLogsRes, err error)
|
||||
TenantUsageLogsSummary(ctx context.Context, req *v1.TenantUsageLogsSummaryReq) (res *v1.TenantUsageLogsSummaryRes, err error)
|
||||
TenantUsageLogsExport(ctx context.Context, req *v1.TenantUsageLogsExportReq) (res *v1.TenantUsageLogsExportRes, err error)
|
||||
TenantRequestClosure(ctx context.Context, req *v1.TenantRequestClosureReq) (res *v1.TenantRequestClosureRes, err error)
|
||||
TenantCancelClosure(ctx context.Context, req *v1.TenantCancelClosureReq) (res *v1.TenantCancelClosureRes, err error)
|
||||
@@ -132,8 +134,6 @@ type ITenantV1 interface {
|
||||
TenantPlanList(ctx context.Context, req *v1.TenantPlanListReq) (res *v1.TenantPlanListRes, err error)
|
||||
TenantPlanCurrent(ctx context.Context, req *v1.TenantPlanCurrentReq) (res *v1.TenantPlanCurrentRes, err error)
|
||||
TenantPlanCancelAutoRenew(ctx context.Context, req *v1.TenantPlanCancelAutoRenewReq) (res *v1.TenantPlanCancelAutoRenewRes, err error)
|
||||
SandboxChat(ctx context.Context, req *v1.SandboxChatReq) (res *v1.SandboxChatRes, err error)
|
||||
SandboxQuota(ctx context.Context, req *v1.SandboxQuotaReq) (res *v1.SandboxQuotaRes, err error)
|
||||
TenantPluginList(ctx context.Context, req *v1.TenantPluginListReq) (res *v1.TenantPluginListRes, err error)
|
||||
TenantPluginDetail(ctx context.Context, req *v1.TenantPluginDetailReq) (res *v1.TenantPluginDetailRes, err error)
|
||||
TenantPluginConfigUpdate(ctx context.Context, req *v1.TenantPluginConfigUpdateReq) (res *v1.TenantPluginConfigUpdateRes, err error)
|
||||
|
||||
@@ -34,6 +34,20 @@ type TenantRegisterRes struct {
|
||||
PendingAgreements []*TenantLoginPendingAgreement `json:"pending_agreements,omitempty"` // 待接受协议列表
|
||||
}
|
||||
|
||||
// TenantRegisterRateLimitReq 查询当前 IP 的注册限流状态
|
||||
type TenantRegisterRateLimitReq struct {
|
||||
g.Meta `path:"/auth/register-rate-limit" method:"get" mime:"json" tags:"租户控制台-认证" summary:"查询注册限流状态" group:"public" middleware:"-"`
|
||||
}
|
||||
|
||||
type TenantRegisterRateLimitRes struct {
|
||||
HourlyLimit int `json:"hourly_limit" dc:"每小时注册上限(0=未启用)"`
|
||||
HourlyRemaining int `json:"hourly_remaining" dc:"本小时剩余注册次数"`
|
||||
HourlyResetSeconds int `json:"hourly_reset_seconds" dc:"小时窗口重置剩余秒数(0=无计数记录)"`
|
||||
DailyLimit int `json:"daily_limit" dc:"每天注册上限(0=未启用)"`
|
||||
DailyRemaining int `json:"daily_remaining" dc:"今日剩余注册次数"`
|
||||
DailyResetSeconds int `json:"daily_reset_seconds" dc:"天窗口重置剩余秒数(0=无计数记录)"`
|
||||
}
|
||||
|
||||
// TenantLoginReq 租户登录请求
|
||||
type TenantLoginReq struct {
|
||||
g.Meta `path:"/auth/login" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户登录" group:"public" middleware:"-"`
|
||||
|
||||
@@ -23,6 +23,24 @@ type TenantUsageLogsRes struct {
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// TenantUsageLogsSummaryReq 用量日志统计请求(复用列表筛选条件,不含分页)
|
||||
type TenantUsageLogsSummaryReq struct {
|
||||
g.Meta `path:"/usage-logs/summary" method:"get" mime:"json" tags:"租户控制台-用量" summary:"用量日志统计汇总"`
|
||||
Username string `json:"username" in:"query" dc:"用户名(模糊匹配)"`
|
||||
Model string `json:"model" in:"query"`
|
||||
Status string `json:"status" in:"query"`
|
||||
RequestType int `json:"request_type" in:"query"`
|
||||
StartDate string `json:"start_date" in:"query"`
|
||||
EndDate string `json:"end_date" in:"query"`
|
||||
}
|
||||
|
||||
// TenantUsageLogsSummaryRes 用量日志统计汇总
|
||||
type TenantUsageLogsSummaryRes struct {
|
||||
TotalCost float64 `json:"total_cost" dc:"总费用(本位币,与列表费用列同口径:actual_cost 优先,0/NULL 回退 total_cost)"`
|
||||
TotalOutputTokens int64 `json:"total_output_tokens" dc:"总输出 token 数"`
|
||||
CacheReadRatio float64 `json:"cache_read_ratio" dc:"缓存读取占比(%),= cache_read / input_tokens"`
|
||||
}
|
||||
|
||||
// TenantUsageLogsExportReq 导出用量日志请求
|
||||
type TenantUsageLogsExportReq struct {
|
||||
g.Meta `path:"/usage-logs/export" method:"get" mime:"json" tags:"租户控制台-用量" summary:"导出用量日志"`
|
||||
|
||||
@@ -21,6 +21,12 @@ type OpenAppListRes struct {
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// OpenAppItem 是应用列表项。
|
||||
//
|
||||
// 注:opn_apps.is_sandbox 列仍在,但整个系统没有任何一处读它,创建/更新接口也从未提供过
|
||||
// 入参 —— 它恒为 false,是一个只在界面上挂着「沙箱」徽标、实际拿的却是生产权限的假承诺,
|
||||
// 故不在此暴露。要让沙箱成立,必须先定义其语义(独立数据域?只读?独立配额?)并在
|
||||
// CheckOpenPermission / 各 handler 中真正强制,再恢复该字段与配置入口。
|
||||
type OpenAppItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -28,7 +34,6 @@ type OpenAppItem struct {
|
||||
AppID string `json:"app_id"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Status string `json:"status"`
|
||||
IsSandbox bool `json:"is_sandbox"`
|
||||
RateLimit int `json:"rate_limit"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package v1
|
||||
|
||||
import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// PlaygroundMessage 对话消息(Playground 和 Sandbox 共用)
|
||||
type PlaygroundMessage struct {
|
||||
Role string `json:"role" v:"required|in:system,user,assistant#请指定角色|角色无效"`
|
||||
Content string `json:"content" v:"required#消息内容不能为空"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Sandbox(模拟调用,不计费)
|
||||
// ============================================================
|
||||
|
||||
// SandboxChatReq Sandbox 对话请求
|
||||
type SandboxChatReq struct {
|
||||
g.Meta `path:"/sandbox/chat" method:"post" mime:"json" tags:"租户控制台-Playground" summary:"Sandbox模拟对话"`
|
||||
Model string `json:"model" v:"required#请选择模型" dc:"模型名称"`
|
||||
Messages []PlaygroundMessage `json:"messages" v:"required|length:1,50#请输入消息|消息数量超出限制" dc:"对话消息列表"`
|
||||
Temperature *float64 `json:"temperature" dc:"温度参数 (0-2)"`
|
||||
MaxTokens *int `json:"max_tokens" dc:"最大输出 Token 数"`
|
||||
Stream bool `json:"stream" d:"true" dc:"是否流式响应"`
|
||||
}
|
||||
|
||||
type SandboxChatRes struct {
|
||||
Content string `json:"content"`
|
||||
IsSandbox bool `json:"is_sandbox"`
|
||||
RemainingQuota int `json:"remaining_quota" dc:"本月剩余沙箱额度"`
|
||||
}
|
||||
|
||||
// SandboxQuotaReq 沙箱额度查询
|
||||
type SandboxQuotaReq struct {
|
||||
g.Meta `path:"/sandbox/quota" method:"get" tags:"租户控制台-Playground" summary:"查询沙箱额度"`
|
||||
}
|
||||
|
||||
type SandboxQuotaRes struct {
|
||||
TotalQuota int `json:"total_quota"`
|
||||
RemainingQuota int `json:"remaining_quota"`
|
||||
UsedQuota int `json:"used_quota"`
|
||||
}
|
||||
@@ -54,12 +54,13 @@ type TenantTicketGetReq struct {
|
||||
}
|
||||
|
||||
type TenantTicketReplyItem struct {
|
||||
Id int64 `json:"id"`
|
||||
TicketId int64 `json:"ticket_id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
UserType string `json:"user_type"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
Id int64 `json:"id"`
|
||||
TicketId int64 `json:"ticket_id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
UserType string `json:"user_type"`
|
||||
AuthorName string `json:"author_name"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type TenantTicketAttachmentItem struct {
|
||||
|
||||
@@ -841,3 +841,79 @@ sender := common.NewEmailSender(&common.EmailConfig{
|
||||
- 同一份配置有多个消费方时,**必须收敛到同一个加载函数**(本例为 `EmailConfigFromOptions`),禁止各处自行拼装配置结构体。
|
||||
|
||||
排查信号:某个功能"配置明明填了却不生效",而同类功能正常;`grep -rn "g.Cfg()" ` 命中的键在 `manifest/config/*.yaml` 中搜不到。
|
||||
|
||||
### 2026-09-01:测试里用 `gtest.DataPath()` 当 server 名,Windows 下 `Start()` 静默失败
|
||||
|
||||
**问题**:`internal/middleware` 的 `request_id_test.go` 三个用例在 Windows 上全部失败,断言形如 `EXPECT 0 == 10`、`EXPECT 0 == 100`,或客户端报 `EOF`——看起来像中间件没生成 RequestId,实则请求根本没发出去。
|
||||
|
||||
**原因**:测试用路径当服务器名:
|
||||
|
||||
```go
|
||||
s := g.Server(gtest.DataPath("request-id-test")) // 名字是 D:\...\testdata\request-id-test
|
||||
s.Start() // 返回值被丢弃
|
||||
```
|
||||
|
||||
GoFrame 用**服务器名**拼 session 存储目录:`%TEMP%\gsessions\<server-name>`。名字里带盘符冒号时路径非法,`os.MkdirAll` 失败,`Start()` 返回 error——但测试没接这个返回值,于是服务器没监听,`GetListenedPort()` 返回 `-1`,客户端请求 `http://127.0.0.1:-1` 直接 EOF。Linux 下 `gtest.DataPath` 返回的路径不含冒号,同样的代码能跑通,所以问题只在 Windows 暴露。
|
||||
|
||||
同一文件还继承了 `manifest/config/config.yaml` 的 `server.address: :18888`(真实应用端口),即便名字合法也会与本机运行中的服务抢端口。
|
||||
|
||||
**修复**:服务器名改用普通标识符,并显式绑定空闲端口:
|
||||
|
||||
```go
|
||||
s := g.Server("mw-request-id-dual") // 纯名字,不是路径
|
||||
s.SetAddr("127.0.0.1:0") // 不继承配置文件端口,让系统分配
|
||||
s.SetDumpRouterMap(false)
|
||||
s.Start()
|
||||
```
|
||||
|
||||
**正确做法(通用规则)**:
|
||||
|
||||
- **`g.Server(name)` 的参数是「服务器名」不是「路径」**:它会参与 session 目录等文件路径拼接,只能用普通标识符(字母/数字/连字符)。`gtest.DataPath()` 是用来定位测试数据文件的,不要拿来当名字。
|
||||
- **测试里起 server 必须 `SetAddr("127.0.0.1:0")`**:`g.Server()` 会读取项目配置,默认继承生产监听端口,在本机跑着服务时必然冲突。
|
||||
- **不要丢弃 `s.Start()` 的 error**:Start 失败后测试仍会继续跑,症状表现为断言数值不对或 EOF,与真正原因(服务器没起来)完全无关,极易误导排查方向。
|
||||
|
||||
排查信号:测试断言"期望 N 实际 0"且伴随 `EOF`;`GetListenedPort()` 返回 `-1`;同一测试在 Linux CI 通过但本地 Windows 失败。
|
||||
|
||||
### 2026-09-01(二):channel_proxy_url 双数据源分裂——设置页保存的代理对 relay 渠道转发不生效
|
||||
|
||||
**问题**:管理后台「系统设置 → 渠道配置 → 代理地址」保存后,渠道编辑中开启「使用代理」的请求并不走该代理。设置页把 `channel_proxy_url` 写入 `sys_options`,但 relay 层(`relay/common/http_client.go` 的 `GetSystemProxyURL`)读的是 `g.Cfg().MustGet(ctx, "channel_proxy_url")`——即 `manifest/config/config.yaml` 配置文件里的**同名 key**。两套数据源互不相通:设置页的值只被 `oauth/client.go`(OAuth 出站)读到,它宣称的主要用途(渠道转发代理、Realtime WebSocket 拨号)从未读到过。
|
||||
|
||||
**原因**:同一配置 key 被两个模块各自实现了读取逻辑,一个走设置注册表(`sys_options`),一个走 `g.Cfg()`(配置文件),没有收敛到同一加载入口。`g.Cfg().MustGet` 对键不存在不报错、静默返回空串(见 2026-08-31 记录),掩盖了分裂——渠道一直"代理未配置",无人察觉。
|
||||
|
||||
**修复**:
|
||||
|
||||
1. `relay/common/http_client.go`:`GetSystemProxyURL` 不再读 `g.Cfg()`(含 10s TTL 轮询缓存一并删除),改为读包内 atomic 值,新增 `SetSystemProxyURL` setter;
|
||||
2. `internal/cmd/cmd.go`:沿用 `SetGlobalRequestTimeoutSeconds` 的桥接注入模式(relay 不反向 import internal,会循环依赖)——启动时 + `OnSettingsChanged("channel_proxy_url")` 时从 `sys_options` 读取并注入,免重启、多实例经 Redis pub/sub 各自刷新;
|
||||
3. `config.example.yaml` 删除已失效的 `channel_proxy_url` key,配置文件不再承载该配置。
|
||||
|
||||
**正确做法(通用规则)**:
|
||||
|
||||
- **一个配置 key 只能有一个数据源、一个加载入口**。凡注册进设置注册表(`sys_options`,管理员可改)的 key,所有消费方——包括 relay 这类不 import internal 的独立模块——都必须经同一加载函数取值;独立模块用「setter 注入 + OnSettingsChanged 刷新」桥接(本项目既有模式:全局超时 `SetGlobalRequestTimeoutSeconds`)。
|
||||
- **禁止用 `g.Cfg()` 读取任何注册表里的 key**:同名 key 在配置文件里恰好存在时会造成"双真相源",配置文件的值会静默覆盖管理员在后台设置的值(或反之),且 `MustGet` 对键缺失不报错,分裂长期不可见。
|
||||
|
||||
排查信号:设置页某配置"保存了但不生效";`grep -rn "g.Cfg()"` 命中的 key 同时出现在 `settings_registry.go` 中。
|
||||
|
||||
### 2026-09-03(三):ghttp 归一化尾部斜杠但不归一化中间的重复斜杠
|
||||
|
||||
**问题**:管理后台的授权(`AdminPermissionGuard`)按 `r.URL.Path` 做精确路径 / 前缀 / 前缀+后缀三段匹配。若框架对路径的归一化范围与预期不符,同一个 handler 就可能有多种匹配结果——加一个斜杠就绕到另一条(可能更弱的)规则上。
|
||||
|
||||
**框架实测行为**(GoFrame v2,`ghttp` 路由,实测端口探针验证):
|
||||
|
||||
| 请求路径 | 是否匹配到路由 | handler 里的 `r.URL.Path` |
|
||||
|----------|----------------|---------------------------|
|
||||
| `/api/admin/roles` | ✅ | `/api/admin/roles` |
|
||||
| `/api/admin/roles/` | ✅ | `/api/admin/roles`(**尾部斜杠已被去掉**) |
|
||||
| `/api/admin/roles//` | ✅ | `/api/admin/roles`(同上) |
|
||||
| `/api/admin//roles` | ✅ | `/api/admin//roles`(**中间的重复斜杠原样保留**) |
|
||||
| `/api/admin/./roles`、`/api/x/../admin/roles` | ❌ 404 | — |
|
||||
| `/api/admin/ROLES` | ❌ 404 | — |
|
||||
|
||||
即:**尾部斜杠会被归一化,路径中间的空段不会**;`.` / `..` 段与大小写变体压根匹配不到路由。
|
||||
|
||||
**正确做法(通用规则)**:
|
||||
|
||||
- **任何按 `r.URL.Path` 做字符串匹配的中间件(鉴权、限流、审计采样、灰度路由),都不能假设路径已规范化**。中间的 `//` 会带着原样路径进到 handler,字符串规则却按规范形式书写,两者对不上。
|
||||
- 处理方式选**拒收**而非**重写**:重写会把原本因规则不匹配而被拒的变体变成放行(等于放宽授权面),拒收既不放宽也不留缝隙。本项目在 `AdminPermissionGuard` 入口用 `isCanonicalPath` 直接 403,正常客户端不会产生这类路径。
|
||||
- 尾部斜杠已由框架处理,无需自行 trim,**也不要因此把 `/xxx/` 当作合法输入去写规则**——handler 永远看不到它。
|
||||
|
||||
排查信号:某接口"多加一个斜杠就能绕过权限/限流";中间件按路径匹配的规则表里出现了同一 handler 的两条规则。
|
||||
+58
-4
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -34,6 +35,7 @@ import (
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
"github.com/qianfree/team-api/internal/response"
|
||||
"github.com/qianfree/team-api/internal/utility/crypto"
|
||||
relaycommon "github.com/qianfree/team-api/relay/common"
|
||||
|
||||
adminHandler "github.com/qianfree/team-api/internal/handler/admin"
|
||||
"github.com/qianfree/team-api/internal/handler/landing"
|
||||
@@ -120,7 +122,7 @@ var (
|
||||
common.InitChannelErrorWriter()
|
||||
|
||||
// Initialize async audit log writer
|
||||
common.InitAuditLogWriter()
|
||||
common.InitAuditLogWriter(ctx)
|
||||
|
||||
// Initialize async channel debug log writer + 注入 relay 层提交钩子
|
||||
common.InitChannelDebugLogWriter()
|
||||
@@ -129,12 +131,32 @@ var (
|
||||
// Initialize async error log writer
|
||||
response.InitErrorLogWriter()
|
||||
|
||||
// 按配置拉起 pprof 调试端口(「性能配置-调试」):仅监听本机回环,禁止公网暴露;
|
||||
// 端口在进程启动时读取,变更后需重启生效
|
||||
if port := common.Config().GetInt(ctx, "pprof_port"); port > 0 && port <= 65535 {
|
||||
go func() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
||||
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
||||
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
||||
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
||||
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
||||
if listenErr := http.ListenAndServe(fmt.Sprintf("127.0.0.1:%d", port), mux); listenErr != nil {
|
||||
g.Log().Warningf(ctx, "pprof server exited: %v", listenErr)
|
||||
}
|
||||
}()
|
||||
g.Log().Infof(ctx, "pprof listening on 127.0.0.1:%d", port)
|
||||
}
|
||||
|
||||
// Register cron jobs
|
||||
common.InitCronScheduler()
|
||||
cs := common.GetCronScheduler()
|
||||
registerCronJobs(cs)
|
||||
cs.StartBackground(ctx)
|
||||
|
||||
// relay 层运行时配置注入(全局超时兜底/系统代理;渠道级 settings 仍优先)
|
||||
syncGlobalRelaySettings(ctx)
|
||||
|
||||
// 启动钱包物化器(boot goroutine,秒级刷新 Redis 权威钱包状态到 DB;不走 cron)
|
||||
billing.StartWalletMaterializer(ctx)
|
||||
|
||||
@@ -223,7 +245,10 @@ var (
|
||||
|
||||
// Tenant — public endpoints use g.Meta middleware:"-" to skip auth
|
||||
group.Group("/tenant", func(g *ghttp.RouterGroup) {
|
||||
g.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.TenantAuth, middleware.Idempotency)
|
||||
// OperationLog 与管理后台共用:中间件按 ctx 的 userType/tenantId 落
|
||||
// aud_operation_logs,租户写操作(成员/Key/工单等)此前完全无留痕,
|
||||
// 且租户端"操作日志"页因 tenant_id 恒为 0 永远为空,挂上后一并修复。
|
||||
g.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.TenantAuth, middleware.Idempotency, middleware.OperationLog)
|
||||
g.Bind(tenantController.NewV1())
|
||||
})
|
||||
|
||||
@@ -255,9 +280,14 @@ var (
|
||||
})
|
||||
|
||||
// Open Platform API — HMAC-SHA256 authentication
|
||||
// 中间件与 /api/tenant 对齐:演示模式同样要拦住写操作(否则演示部署可经开放平台
|
||||
// 真实改数据),维护模式头同样要下发,幂等同样要生效 —— 开放平台是对外的、
|
||||
// 天然会重试的接口,没有幂等语义比控制台更危险。
|
||||
// Idempotency 必须排在 OpenPlatformAuth 之后:它按认证主体隔离幂等键。
|
||||
s.Group("/api/open", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(middleware.MiddlewareHandlerResponse)
|
||||
group.Middleware(middleware.OpenPlatformAuth)
|
||||
// OperationLog 以应用身份(open_app + 应用ID)记录写操作,与管理/租户端共用
|
||||
group.Middleware(middleware.DemoMode, middleware.MaintenanceMode, middleware.OpenPlatformAuth, middleware.Idempotency, middleware.OperationLog)
|
||||
group.Bind(openController.NewV1())
|
||||
})
|
||||
|
||||
@@ -332,7 +362,7 @@ func printBanner() {
|
||||
fmt.Printf(" %sTeam-API%s %s%s%s %s| %s%s企业级大模型 API 网关系统%s\n", cyan, reset, green, consts.Version, reset, dim, reset, dim, reset)
|
||||
fmt.Printf(" %shttps://github.com/qianfree/team-api%s\n", dim, reset)
|
||||
fmt.Println()
|
||||
fmt.Printf(" %sAGPL v3.0 开源协议 | Copyright © 2025-2026 Team-API Contributors%s\n", dim, reset)
|
||||
fmt.Printf(" %sAGPL-3.0-or-later | Copyright © 2026 qianfree%s\n", dim, reset)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
@@ -363,6 +393,30 @@ func watchChannelAutoTestInterval(cs *common.CronScheduler) {
|
||||
})
|
||||
}
|
||||
|
||||
// syncGlobalRelaySettings 将系统设置中 relay 层的运行时配置注入 relay 层,并在配置变更时动态刷新(免重启):
|
||||
// - 全局超时:优先级 渠道级 settings.timeout_seconds > 此处注入的全局值 > relay 层内置默认(180s/300s)
|
||||
// - 系统代理:channel_proxy_url,渠道编辑中开启「使用代理」的请求经此代理转发
|
||||
//
|
||||
// relay 包不反向 import internal(会循环依赖),故由本函数桥接注入。
|
||||
func syncGlobalRelaySettings(ctx context.Context) {
|
||||
apply := func(ctx context.Context) {
|
||||
relaycommon.SetGlobalRequestTimeoutSeconds(common.Config().GetInt(ctx, "request_timeout_seconds"))
|
||||
relaycommon.SetGlobalStreamIdleTimeoutSeconds(common.Config().GetInt(ctx, "streaming_timeout_seconds"))
|
||||
relaycommon.SetSystemProxyURL(common.Config().GetString(ctx, "channel_proxy_url"))
|
||||
}
|
||||
apply(ctx)
|
||||
common.OnSettingsChanged(func(ctx context.Context, key string) {
|
||||
if key != "request_timeout_seconds" && key != "streaming_timeout_seconds" && key != "channel_proxy_url" {
|
||||
return
|
||||
}
|
||||
apply(ctx)
|
||||
// 代理 URL 可能含认证凭据,只记录是否已配置,不落日志
|
||||
g.Log().Infof(ctx, "全局 relay 配置已刷新: request=%ds stream_idle=%ds proxy_configured=%v",
|
||||
relaycommon.GlobalRequestTimeoutSeconds(), relaycommon.GlobalStreamIdleTimeoutSeconds(),
|
||||
relaycommon.GetSystemProxyURL() != "")
|
||||
})
|
||||
}
|
||||
|
||||
// registerCronJobs 集中注册所有定时任务,避免散落在主启动流程中。
|
||||
// 新增 cron 任务时在此追加一项即可;任务名(用于分布式锁 key)需保持唯一且稳定。
|
||||
func registerCronJobs(cs *common.CronScheduler) {
|
||||
|
||||
+13
-12
@@ -150,18 +150,17 @@ const (
|
||||
CodeInvalidConfirmToken = 10046
|
||||
|
||||
// Security errors (10047+)
|
||||
CodeTotpRequired = 10047
|
||||
CodeTotpInvalid = 10048
|
||||
CodeTotpAlreadyEnabled = 10049
|
||||
CodeTotpNotEnabled = 10050
|
||||
CodeBackupCodeInvalid = 10051
|
||||
CodeTurnstileFailed = 10052
|
||||
CodeIpRestricted = 10053
|
||||
CodeHighRisk2FARequired = 10054
|
||||
CodeNoAvailableApiKey = 10055
|
||||
CodeSandboxQuotaExceeded = 10056
|
||||
CodeCaptchaRequired = 10057
|
||||
CodeCaptchaFailed = 10058
|
||||
CodeTotpRequired = 10047
|
||||
CodeTotpInvalid = 10048
|
||||
CodeTotpAlreadyEnabled = 10049
|
||||
CodeTotpNotEnabled = 10050
|
||||
CodeBackupCodeInvalid = 10051
|
||||
CodeTurnstileFailed = 10052
|
||||
CodeIpRestricted = 10053
|
||||
CodeHighRisk2FARequired = 10054
|
||||
CodeNoAvailableApiKey = 10055
|
||||
CodeCaptchaRequired = 10057
|
||||
CodeCaptchaFailed = 10058
|
||||
|
||||
// OAuth errors (10059+)
|
||||
CodeOAuthDisabled = 10059
|
||||
@@ -183,6 +182,7 @@ const (
|
||||
CodeHelpCategorySlugExists = 10074
|
||||
CodeHelpArticleNotFound = 10075
|
||||
CodeHelpArticleSlugExists = 10076
|
||||
CodeHelpRateLimitExceeded = 10099
|
||||
|
||||
// Registration errors
|
||||
CodeRegistrationDisabled = 10077
|
||||
@@ -338,6 +338,7 @@ const (
|
||||
MsgHelpCategorySlugExists = "分类标识已存在"
|
||||
MsgHelpArticleNotFound = "帮助文章不存在"
|
||||
MsgHelpArticleSlugExists = "文章标识已存在"
|
||||
MsgHelpRateLimitExceeded = "访问过于频繁,请稍后再试"
|
||||
// Registration errors
|
||||
MsgRegistrationDisabled = "注册功能已关闭"
|
||||
MsgEmailVerificationDisabled = "注册邮箱验证未启用"
|
||||
|
||||
@@ -34,3 +34,6 @@ func (c *ControllerV1) ContentFilterLogList(ctx context.Context, req *v1.Content
|
||||
func (c *ControllerV1) ForwardingTraceGet(ctx context.Context, req *v1.ForwardingTraceGetReq) (res *v1.ForwardingTraceGetRes, err error) {
|
||||
return service.Admin().ForwardingTraceGet(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) ContentFilterLogClear(ctx context.Context, req *v1.ContentFilterLogClearReq) (res *v1.ContentFilterLogClearRes, err error) {
|
||||
return service.Admin().ContentFilterLogClear(ctx, req)
|
||||
}
|
||||
@@ -28,3 +28,6 @@ func (c *ControllerV1) AdminForceLogout(ctx context.Context, req *v1.AdminForceL
|
||||
func (c *ControllerV1) AdminChangePassword(ctx context.Context, req *v1.AdminChangePasswordReq) (res *v1.AdminChangePasswordRes, err error) {
|
||||
return service.Admin().ChangePassword(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminMe(ctx context.Context, req *v1.AdminMeReq) (res *v1.AdminMeRes, err error) {
|
||||
return service.Admin().GetMe(ctx, req)
|
||||
}
|
||||
@@ -22,3 +22,6 @@ func (c *ControllerV1) ChannelErrorTopChannels(ctx context.Context, req *v1.Chan
|
||||
func (c *ControllerV1) ChannelErrorCategories(ctx context.Context, req *v1.ChannelErrorCategoriesReq) (res *v1.ChannelErrorCategoriesRes, err error) {
|
||||
return service.Admin().ChannelErrorCategories(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) ChannelErrorClear(ctx context.Context, req *v1.ChannelErrorClearReq) (res *v1.ChannelErrorClearRes, err error) {
|
||||
return service.Admin().ChannelErrorClear(ctx, req)
|
||||
}
|
||||
@@ -13,9 +13,6 @@ func (c *ControllerV1) AdminPermissionList(ctx context.Context, req *v1.AdminPer
|
||||
func (c *ControllerV1) AdminPermissionUpdate(ctx context.Context, req *v1.AdminPermissionUpdateReq) (res *v1.AdminPermissionUpdateRes, err error) {
|
||||
return service.Admin().UpdateUserPermissions(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminDataScopeUpdate(ctx context.Context, req *v1.AdminDataScopeUpdateReq) (res *v1.AdminDataScopeUpdateRes, err error) {
|
||||
return service.Admin().UpdateUserDataScopes(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminAllPermissions(ctx context.Context, req *v1.AdminAllPermissionsReq) (res *v1.AdminAllPermissionsRes, err error) {
|
||||
return service.Admin().GetAllPermissions(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
)
|
||||
|
||||
func (c *ControllerV1) AdminRoleList(ctx context.Context, req *v1.AdminRoleListReq) (res *v1.AdminRoleListRes, err error) {
|
||||
return service.Admin().ListRoles(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminRoleDetail(ctx context.Context, req *v1.AdminRoleDetailReq) (res *v1.AdminRoleDetailRes, err error) {
|
||||
return service.Admin().GetRoleDetail(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminRoleCreate(ctx context.Context, req *v1.AdminRoleCreateReq) (res *v1.AdminRoleCreateRes, err error) {
|
||||
return service.Admin().CreateRole(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminRoleUpdate(ctx context.Context, req *v1.AdminRoleUpdateReq) (res *v1.AdminRoleUpdateRes, err error) {
|
||||
return service.Admin().UpdateRole(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminRoleDelete(ctx context.Context, req *v1.AdminRoleDeleteReq) (res *v1.AdminRoleDeleteRes, err error) {
|
||||
return service.Admin().DeleteRole(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminRoleStatusUpdate(ctx context.Context, req *v1.AdminRoleStatusUpdateReq) (res *v1.AdminRoleStatusUpdateRes, err error) {
|
||||
return service.Admin().UpdateRoleStatus(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminRoleReset(ctx context.Context, req *v1.AdminRoleResetReq) (res *v1.AdminRoleResetRes, err error) {
|
||||
return service.Admin().ResetRoleDefaults(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminUserRoleList(ctx context.Context, req *v1.AdminUserRoleListReq) (res *v1.AdminUserRoleListRes, err error) {
|
||||
return service.Admin().GetUserRoles(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminUserRoleAssign(ctx context.Context, req *v1.AdminUserRoleAssignReq) (res *v1.AdminUserRoleAssignRes, err error) {
|
||||
return service.Admin().AssignUserRoles(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
)
|
||||
|
||||
func (c *ControllerV1) AdminSupportPendingSummary(ctx context.Context, req *v1.AdminSupportPendingSummaryReq) (res *v1.AdminSupportPendingSummaryRes, err error) {
|
||||
return service.Admin().GetSupportPendingSummary(ctx, req)
|
||||
}
|
||||
@@ -28,3 +28,6 @@ func (c *ControllerV1) TenantSessionList(ctx context.Context, req *v1.TenantSess
|
||||
func (c *ControllerV1) TenantRevokeSession(ctx context.Context, req *v1.TenantRevokeSessionReq) (res *v1.TenantRevokeSessionRes, err error) {
|
||||
return service.Tenant().RevokeSession(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) TenantRegisterRateLimit(ctx context.Context, req *v1.TenantRegisterRateLimitReq) (res *v1.TenantRegisterRateLimitRes, err error) {
|
||||
return service.Tenant().GetRegisterRateLimitStatus(ctx, req)
|
||||
}
|
||||
@@ -13,3 +13,6 @@ func (c *ControllerV1) TenantUsageLogs(ctx context.Context, req *v1.TenantUsageL
|
||||
func (c *ControllerV1) TenantUsageLogsExport(ctx context.Context, req *v1.TenantUsageLogsExportReq) (res *v1.TenantUsageLogsExportRes, err error) {
|
||||
return service.Tenant().ExportUsageLogs(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) TenantUsageLogsSummary(ctx context.Context, req *v1.TenantUsageLogsSummaryReq) (res *v1.TenantUsageLogsSummaryRes, err error) {
|
||||
return service.Tenant().UsageLogsSummary(ctx, req)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
)
|
||||
|
||||
func (c *ControllerV1) SandboxChat(ctx context.Context, req *v1.SandboxChatReq) (res *v1.SandboxChatRes, err error) {
|
||||
return service.Tenant().SandboxChat(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) SandboxQuota(ctx context.Context, req *v1.SandboxQuotaReq) (res *v1.SandboxQuotaRes, err error) {
|
||||
return service.Tenant().SandboxQuota(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// SysAdminRolePermissionsDao is the data access object for the table sys_admin_role_permissions.
|
||||
type SysAdminRolePermissionsDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns SysAdminRolePermissionsColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// SysAdminRolePermissionsColumns defines and stores column names for the table sys_admin_role_permissions.
|
||||
type SysAdminRolePermissionsColumns struct {
|
||||
Id string // 主键ID
|
||||
RoleId string // 角色ID(逻辑关联 sys_admin_roles.id,无外键,删除角色时由业务层级联清理)
|
||||
PermissionPoint string // 权限点标识(如 tenant:create、channel:edit)
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// sysAdminRolePermissionsColumns holds the columns for the table sys_admin_role_permissions.
|
||||
var sysAdminRolePermissionsColumns = SysAdminRolePermissionsColumns{
|
||||
Id: "id",
|
||||
RoleId: "role_id",
|
||||
PermissionPoint: "permission_point",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewSysAdminRolePermissionsDao creates and returns a new DAO object for table data access.
|
||||
func NewSysAdminRolePermissionsDao(handlers ...gdb.ModelHandler) *SysAdminRolePermissionsDao {
|
||||
return &SysAdminRolePermissionsDao{
|
||||
group: "default",
|
||||
table: "sys_admin_role_permissions",
|
||||
columns: sysAdminRolePermissionsColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *SysAdminRolePermissionsDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *SysAdminRolePermissionsDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *SysAdminRolePermissionsDao) Columns() SysAdminRolePermissionsColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *SysAdminRolePermissionsDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *SysAdminRolePermissionsDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *SysAdminRolePermissionsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// SysAdminRolesDao is the data access object for the table sys_admin_roles.
|
||||
type SysAdminRolesDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns SysAdminRolesColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// SysAdminRolesColumns defines and stores column names for the table sys_admin_roles.
|
||||
type SysAdminRolesColumns struct {
|
||||
Id string // 主键ID
|
||||
Code string // 角色标识(小写字母/数字/下划线,创建后不可修改,是权限缓存与审计日志的稳定标识;保留字 super_admin 不可占用)
|
||||
Name string // 角色显示名称(可修改)
|
||||
Description string // 角色说明,在分配界面展示
|
||||
IsBuiltin string // 是否系统预置:仅用于标识来源与支撑「恢复默认权限」,不构成删除保护(预置角色同样可改可删)
|
||||
IsEnabled string // 是否启用:禁用后该角色的权限对全部关联用户立即失效,无需解除关联
|
||||
Sort string // 排序权重(升序)
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// sysAdminRolesColumns holds the columns for the table sys_admin_roles.
|
||||
var sysAdminRolesColumns = SysAdminRolesColumns{
|
||||
Id: "id",
|
||||
Code: "code",
|
||||
Name: "name",
|
||||
Description: "description",
|
||||
IsBuiltin: "is_builtin",
|
||||
IsEnabled: "is_enabled",
|
||||
Sort: "sort",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewSysAdminRolesDao creates and returns a new DAO object for table data access.
|
||||
func NewSysAdminRolesDao(handlers ...gdb.ModelHandler) *SysAdminRolesDao {
|
||||
return &SysAdminRolesDao{
|
||||
group: "default",
|
||||
table: "sys_admin_roles",
|
||||
columns: sysAdminRolesColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *SysAdminRolesDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *SysAdminRolesDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *SysAdminRolesDao) Columns() SysAdminRolesColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *SysAdminRolesDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *SysAdminRolesDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *SysAdminRolesDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// ==========================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// ==========================================================================
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// SysAdminUserRolesDao is the data access object for the table sys_admin_user_roles.
|
||||
type SysAdminUserRolesDao struct {
|
||||
table string // table is the underlying table name of the DAO.
|
||||
group string // group is the database configuration group name of the current DAO.
|
||||
columns SysAdminUserRolesColumns // columns contains all the column names of Table for convenient usage.
|
||||
handlers []gdb.ModelHandler // handlers for customized model modification.
|
||||
}
|
||||
|
||||
// SysAdminUserRolesColumns defines and stores column names for the table sys_admin_user_roles.
|
||||
type SysAdminUserRolesColumns struct {
|
||||
Id string // 主键ID
|
||||
AdminUserId string // 管理员用户ID(逻辑关联 sys_admin_users.id,无外键)
|
||||
RoleId string // 角色ID(逻辑关联 sys_admin_roles.id,无外键)
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
}
|
||||
|
||||
// sysAdminUserRolesColumns holds the columns for the table sys_admin_user_roles.
|
||||
var sysAdminUserRolesColumns = SysAdminUserRolesColumns{
|
||||
Id: "id",
|
||||
AdminUserId: "admin_user_id",
|
||||
RoleId: "role_id",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
}
|
||||
|
||||
// NewSysAdminUserRolesDao creates and returns a new DAO object for table data access.
|
||||
func NewSysAdminUserRolesDao(handlers ...gdb.ModelHandler) *SysAdminUserRolesDao {
|
||||
return &SysAdminUserRolesDao{
|
||||
group: "default",
|
||||
table: "sys_admin_user_roles",
|
||||
columns: sysAdminUserRolesColumns,
|
||||
handlers: handlers,
|
||||
}
|
||||
}
|
||||
|
||||
// DB retrieves and returns the underlying raw database management object of the current DAO.
|
||||
func (dao *SysAdminUserRolesDao) DB() gdb.DB {
|
||||
return g.DB(dao.group)
|
||||
}
|
||||
|
||||
// Table returns the table name of the current DAO.
|
||||
func (dao *SysAdminUserRolesDao) Table() string {
|
||||
return dao.table
|
||||
}
|
||||
|
||||
// Columns returns all column names of the current DAO.
|
||||
func (dao *SysAdminUserRolesDao) Columns() SysAdminUserRolesColumns {
|
||||
return dao.columns
|
||||
}
|
||||
|
||||
// Group returns the database configuration group name of the current DAO.
|
||||
func (dao *SysAdminUserRolesDao) Group() string {
|
||||
return dao.group
|
||||
}
|
||||
|
||||
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
|
||||
func (dao *SysAdminUserRolesDao) Ctx(ctx context.Context) *gdb.Model {
|
||||
model := dao.DB().Model(dao.table)
|
||||
for _, handler := range dao.handlers {
|
||||
model = handler(model)
|
||||
}
|
||||
return model.Safe().Ctx(ctx)
|
||||
}
|
||||
|
||||
// Transaction wraps the transaction logic using function f.
|
||||
// It rolls back the transaction and returns the error if function f returns a non-nil error.
|
||||
// It commits the transaction and returns nil if function f returns nil.
|
||||
//
|
||||
// Note: Do not commit or roll back the transaction in function f,
|
||||
// as it is automatically handled by this function.
|
||||
func (dao *SysAdminUserRolesDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
|
||||
return dao.Ctx(ctx).Transaction(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"github.com/qianfree/team-api/internal/dao/internal"
|
||||
)
|
||||
|
||||
// sysAdminRolePermissionsDao is the data access object for the table sys_admin_role_permissions.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type sysAdminRolePermissionsDao struct {
|
||||
*internal.SysAdminRolePermissionsDao
|
||||
}
|
||||
|
||||
var (
|
||||
// SysAdminRolePermissions is a globally accessible object for table sys_admin_role_permissions operations.
|
||||
SysAdminRolePermissions = sysAdminRolePermissionsDao{internal.NewSysAdminRolePermissionsDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"github.com/qianfree/team-api/internal/dao/internal"
|
||||
)
|
||||
|
||||
// sysAdminRolesDao is the data access object for the table sys_admin_roles.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type sysAdminRolesDao struct {
|
||||
*internal.SysAdminRolesDao
|
||||
}
|
||||
|
||||
var (
|
||||
// SysAdminRoles is a globally accessible object for table sys_admin_roles operations.
|
||||
SysAdminRoles = sysAdminRolesDao{internal.NewSysAdminRolesDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -0,0 +1,22 @@
|
||||
// =================================================================================
|
||||
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
|
||||
// =================================================================================
|
||||
|
||||
package dao
|
||||
|
||||
import (
|
||||
"github.com/qianfree/team-api/internal/dao/internal"
|
||||
)
|
||||
|
||||
// sysAdminUserRolesDao is the data access object for the table sys_admin_user_roles.
|
||||
// You can define custom methods on it to extend its functionality as needed.
|
||||
type sysAdminUserRolesDao struct {
|
||||
*internal.SysAdminUserRolesDao
|
||||
}
|
||||
|
||||
var (
|
||||
// SysAdminUserRoles is a globally accessible object for table sys_admin_user_roles operations.
|
||||
SysAdminUserRoles = sysAdminUserRolesDao{internal.NewSysAdminUserRolesDao()}
|
||||
)
|
||||
|
||||
// Add your custom methods and functionality below.
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
@@ -46,6 +47,16 @@ func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.A
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 批量取角色,避免逐个账号各查一次(N+1)
|
||||
userIDs := make([]int64, len(users))
|
||||
for i, u := range users {
|
||||
userIDs[i] = u.Id
|
||||
}
|
||||
rolesByUser, err := loadRolesByUsers(ctx, userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]v1.AdminUserItem, len(users))
|
||||
for i, u := range users {
|
||||
lockedUntil := ""
|
||||
@@ -63,6 +74,7 @@ func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.A
|
||||
LastLoginIp: u.LastLoginIp,
|
||||
LockedUntil: lockedUntil,
|
||||
CreatedAt: u.CreatedAt.String(),
|
||||
Roles: rolesByUser[u.Id],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +86,64 @@ func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.A
|
||||
}, nil
|
||||
}
|
||||
|
||||
// assertCanManageAdminUser 校验当前调用者是否有资格操作目标管理员账号。
|
||||
//
|
||||
// 账号管理(改资料 / 改密 / 禁用 / 删除 / 配特批权限)按 user:* 授权、允许下放,
|
||||
// 但下放的前提是「操作本身不能带来提权」。两条底线:
|
||||
// - 目标是超级管理员 → 非超管一律不可操作。否则重置一次超管密码就能接管整个系统,
|
||||
// 角色管理限定超管的那道闸门形同虚设。
|
||||
// - 目标的有效权限不是调用者的子集 → 不可操作。否则一个只有 user:edit 的账号可以
|
||||
// 重置权限更高账号的密码,登进去就完成了横向提权。
|
||||
//
|
||||
// 超级管理员短路放行;操作自己不受限(自身权限的变更另有 sanitizeGrantedPermissions 把关)。
|
||||
// 与 sanitizeGrantedPermissions 的「不能授予自己不具备的权限」同源:能碰的对象不得强于自己。
|
||||
func assertCanManageAdminUser(ctx context.Context, targetUserID int64, targetRole string) error {
|
||||
operatorRole := common.GetCtxUserRole(ctx)
|
||||
if operatorRole == "super_admin" {
|
||||
return nil
|
||||
}
|
||||
if targetRole == "super_admin" {
|
||||
return common.NewBusinessError(consts.CodeForbidden, "不能操作超级管理员账号")
|
||||
}
|
||||
|
||||
operatorID := common.GetCtxUserID(ctx)
|
||||
// 无调用者身份(命令行工具、内部调用)时不做判定,交由上层接口鉴权把关
|
||||
if operatorID == 0 || operatorID == targetUserID {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetPerms, err := GetEffectivePermissions(ctx, targetUserID, targetRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ownPerms, err := GetEffectivePermissions(ctx, operatorID, operatorRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ownSet := make(map[string]bool, len(ownPerms))
|
||||
for _, p := range ownPerms {
|
||||
ownSet[p] = true
|
||||
}
|
||||
for _, p := range targetPerms {
|
||||
if !ownSet[p] {
|
||||
return common.NewBusinessError(consts.CodeForbidden, "目标账号拥有你不具备的权限,无法对其执行此操作")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// assertCanGrantSuperAdmin 拦截「非超管把账号设成超级管理员」。
|
||||
//
|
||||
// role 字段的取值校验(ValidateAdminRole)只管值合不合法,管不了谁有资格赋这个值。
|
||||
// 少了这道判断,一个拿到 user:create / user:edit 的账号可以直接把自己或新建账号
|
||||
// 提成 super_admin —— 这是比分配角色更直接的提权入口,必须与角色分配同级管控。
|
||||
func assertCanGrantSuperAdmin(ctx context.Context, role string) error {
|
||||
if role != "super_admin" || common.GetCtxUserRole(ctx) == "super_admin" {
|
||||
return nil
|
||||
}
|
||||
return common.NewBusinessError(consts.CodeForbidden, "只有超级管理员才能设置超级管理员账号")
|
||||
}
|
||||
|
||||
// CreateUser creates a new admin user.
|
||||
func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v1.AdminUserCreateRes, error) {
|
||||
username := strings.TrimSpace(req.Username)
|
||||
@@ -126,26 +196,55 @@ func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v
|
||||
if err := common.ValidateAdminRole(role); err != nil {
|
||||
return nil, common.NewBadRequestError(err.Error())
|
||||
}
|
||||
|
||||
result, err := dao.SysAdminUsers.Ctx(ctx).Data(do.SysAdminUsers{
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
Email: email,
|
||||
DisplayName: username,
|
||||
Role: role,
|
||||
Status: "active",
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
// Race condition: another request inserted a colliding username/email
|
||||
// between our pre-check and this insert. Translate the DB unique
|
||||
// violation into a friendly business error.
|
||||
if common.IsDuplicateKeyError(err) {
|
||||
return nil, common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
||||
}
|
||||
if err := assertCanGrantSuperAdmin(ctx, role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建账号时附带角色属于角色分配,与角色管理同级:仅超管可用。
|
||||
// 否则拥有 user:create 的人可以造一个挂着高权限角色、密码由自己设定的账号,
|
||||
// 等价于给自己提权。这里明确报错而非静默忽略——静默会造出一个零权限账号,
|
||||
// 使用者以为分配成功了。
|
||||
if len(req.RoleIDs) > 0 && common.GetCtxUserRole(ctx) != "super_admin" {
|
||||
return nil, common.NewBusinessError(consts.CodeForbidden, "分配角色仅超级管理员可用,请先创建账号再由超级管理员分配角色")
|
||||
}
|
||||
|
||||
// 账号与角色关联同事务写入:此前新建的 admin 账号不带任何权限,登录后处处 403,
|
||||
// 是「系统缺乏角色管理」最直接的体感来源。
|
||||
var id int64
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
result, err := dao.SysAdminUsers.Ctx(ctx).Data(do.SysAdminUsers{
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
Email: email,
|
||||
DisplayName: username,
|
||||
Role: role,
|
||||
Status: "active",
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
// Race condition: another request inserted a colliding username/email
|
||||
// between our pre-check and this insert. Translate the DB unique
|
||||
// violation into a friendly business error.
|
||||
if common.IsDuplicateKeyError(err) {
|
||||
return common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
id, err = result.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 超级管理员权限来自账号属性而非角色,忽略传入的 role_ids
|
||||
if role == "super_admin" || len(req.RoleIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return replaceUserRoles(ctx, id, req.RoleIDs)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, _ := result.LastInsertId()
|
||||
return &v1.AdminUserCreateRes{ID: id}, nil
|
||||
}
|
||||
|
||||
@@ -166,6 +265,9 @@ func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v
|
||||
if targetUser.Role == "super_admin" {
|
||||
return nil, common.NewBadRequestError("不能修改超级管理员信息")
|
||||
}
|
||||
if err := assertCanManageAdminUser(ctx, req.Id, targetUser.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := do.SysAdminUsers{}
|
||||
if req.DisplayName != nil {
|
||||
@@ -188,6 +290,9 @@ func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v
|
||||
if err := common.ValidateAdminRole(*req.Role); err != nil {
|
||||
return nil, common.NewBadRequestError("角色无效")
|
||||
}
|
||||
if err := assertCanGrantSuperAdmin(ctx, *req.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.Role = *req.Role
|
||||
}
|
||||
|
||||
@@ -225,14 +330,34 @@ func (s *sAdmin) DeleteUser(ctx context.Context, req *v1.AdminUserDeleteReq) (*v
|
||||
if user.Role == "super_admin" {
|
||||
return nil, common.NewBadRequestError("不能删除超级管理员")
|
||||
}
|
||||
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Delete user first, then revoke sessions to avoid leaving the user
|
||||
// in a state where sessions are gone but the user still exists.
|
||||
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Delete()
|
||||
//
|
||||
// 权限相关记录同事务清理:这些表都没有外键,数据库不会代劳,
|
||||
// 漏删会留下悬空的角色关联与特批权限。
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if _, err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := dao.SysAdminUserRoles.Ctx(ctx).Where("admin_user_id", req.Id).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := dao.SysAdminRolePerms.Ctx(ctx).Where("admin_user_id", req.Id).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := dao.SysAdminDataScopes.Ctx(ctx).Where("admin_user_id", req.Id).Delete()
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
InvalidateUserPermCache(ctx, req.Id)
|
||||
|
||||
// Revoke all sessions
|
||||
common.RevokeAllSessions(ctx, "admin", req.Id)
|
||||
|
||||
@@ -266,6 +391,9 @@ func (s *sAdmin) UpdateUserStatus(ctx context.Context, req *v1.AdminUserUpdateSt
|
||||
if user.Role == "super_admin" {
|
||||
return nil, common.NewBadRequestError("不能修改超级管理员状态")
|
||||
}
|
||||
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Update(do.SysAdminUsers{
|
||||
Status: req.Status,
|
||||
@@ -283,17 +411,24 @@ func (s *sAdmin) UpdateUserStatus(ctx context.Context, req *v1.AdminUserUpdateSt
|
||||
}
|
||||
|
||||
// UnlockUser 清除管理员的登录锁定状态(重置失败计数与锁定截止时间)。
|
||||
//
|
||||
// 解锁是本文件里唯一漏掉 assertCanManageAdminUser 的账号处置动作:锁定本身是暴力破解
|
||||
// 的防线,非超管若能解锁超级管理员账号,就等于可以无限次重置对方的失败计数继续爆破。
|
||||
func (s *sAdmin) UnlockUser(ctx context.Context, req *v1.AdminUserUnlockReq) (*v1.AdminUserUnlockRes, error) {
|
||||
var user *struct {
|
||||
Id int64 `json:"id"`
|
||||
Id int64 `json:"id"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Scan(&user)
|
||||
err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Fields("id, role").Scan(&user)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, common.NewNotFoundError("管理员")
|
||||
}
|
||||
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Data(map[string]interface{}{
|
||||
"failed_attempts": 0,
|
||||
@@ -311,6 +446,21 @@ func (s *sAdmin) ResetUserPassword(ctx context.Context, req *v1.AdminUserResetPa
|
||||
return nil, common.NewBusinessError(consts.CodePasswordTooWeak, consts.MsgPasswordTooWeak)
|
||||
}
|
||||
|
||||
// 改密等于账号接管:此前不校验目标是谁,拿到 user:edit 就能重置超级管理员的密码。
|
||||
var targetUser *struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
err := dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Fields("role").Scan(&targetUser)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetUser == nil {
|
||||
return nil, common.NewNotFoundError("管理员")
|
||||
}
|
||||
if err := assertCanManageAdminUser(ctx, req.Id, targetUser.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
passwordHash, err := crypto.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
@@ -260,24 +259,6 @@ func (s *sAdmin) ListSensitiveAccessLogs(ctx context.Context, req *v1.SensitiveL
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LogSensitiveAccess inserts a sensitive data access log entry.
|
||||
func LogSensitiveAccess(ctx context.Context, userID int64, userType, resourceType string, resourceID int64, action, reason, ip, userAgent string) error {
|
||||
_, err := common.AuditModelCtx(ctx, "aud_sensitive_access_logs").Data(do.AudSensitiveAccessLogs{
|
||||
UserId: userID,
|
||||
UserType: userType,
|
||||
ResourceType: resourceType,
|
||||
ResourceId: resourceID,
|
||||
Action: action,
|
||||
Reason: reason,
|
||||
IpAddress: ip,
|
||||
UserAgent: userAgent,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "记录敏感数据访问日志失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MaskSensitiveData 委托给 common.MaskSensitiveData
|
||||
func MaskSensitiveData(data string) string {
|
||||
return common.MaskSensitiveData(data)
|
||||
@@ -397,6 +378,9 @@ func (s *sAdmin) GetRequestAuditLogDetail(ctx context.Context, req *v1.RequestAu
|
||||
if record == nil {
|
||||
return nil, common.NewNotFoundError("审计日志")
|
||||
}
|
||||
// 详情含请求/响应体(full 级别下为明文对话内容),属敏感读取,落留痕
|
||||
common.LogSensitiveAccess(ctx, common.GetCtxUserID(ctx), "admin", "request_log", req.Id, "view", "查看请求审计日志详情")
|
||||
|
||||
b, _ := json.Marshal(record)
|
||||
var detail map[string]any
|
||||
_ = json.Unmarshal(b, &detail)
|
||||
@@ -600,3 +584,18 @@ func (s *sAdmin) ContentFilterLogList(ctx context.Context, req *v1.ContentFilter
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ContentFilterLogClear 硬删除全部内容过滤拦截日志。
|
||||
// 拦截日志为追加型审计流水,堆积后用 TRUNCATE 秒级清空并立即归还磁盘空间
|
||||
// (DELETE 需等 VACUUM 回收)。TRUNCATE 不返回行数,故先取删除前条数作为反馈。
|
||||
// 表无数据库级外键,TRUNCATE 安全。
|
||||
func (s *sAdmin) ContentFilterLogClear(ctx context.Context, _ *v1.ContentFilterLogClearReq) (*v1.ContentFilterLogClearRes, error) {
|
||||
count, err := dao.AudContentFilterLogs.Ctx(ctx).Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := g.DB().Ctx(ctx).Exec(ctx, "TRUNCATE TABLE aud_content_filter_logs RESTART IDENTITY"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v1.ContentFilterLogClearRes{Deleted: int64(count)}, nil
|
||||
}
|
||||
+112
-19
@@ -84,12 +84,13 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog
|
||||
if !crypto.VerifyPassword(req.Password, user.PasswordHash) {
|
||||
_ = common.RecordLoginHistory(ctx, "admin", user.Id, 0, "password", ipAddress, ua, deviceFP, false, "密码错误")
|
||||
|
||||
// 原子递增失败次数
|
||||
// 原子递增失败次数;锁定阈值与时长取自安全配置(sys_options)
|
||||
updateData := do.SysAdminUsers{FailedAttempts: gdb.Raw("failed_attempts + 1")}
|
||||
failedAttempts := user.FailedAttempts + 1
|
||||
maxAttempts, lockoutMinutes := common.LoginLockoutPolicy(ctx)
|
||||
|
||||
if failedAttempts >= 5 {
|
||||
lockedUntil := time.Now().Add(30 * time.Minute)
|
||||
if failedAttempts >= maxAttempts {
|
||||
lockedUntil := time.Now().Add(time.Duration(lockoutMinutes) * time.Minute)
|
||||
updateData.LockedUntil = gtime.NewFromTime(lockedUntil)
|
||||
_, err := dao.SysAdminUsers.Ctx(ctx).
|
||||
Where("id", user.Id).
|
||||
@@ -98,7 +99,7 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog
|
||||
g.Log().Errorf(ctx, "更新管理员账号锁定状态失败: %v", err)
|
||||
}
|
||||
return nil, common.NewBusinessError(consts.CodeAccountLocked,
|
||||
"连续 5 次密码错误,账号已锁定 30 分钟")
|
||||
fmt.Sprintf("连续 %d 次密码错误,账号已锁定 %d 分钟", maxAttempts, lockoutMinutes))
|
||||
}
|
||||
|
||||
_, err := dao.SysAdminUsers.Ctx(ctx).
|
||||
@@ -160,6 +161,7 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "create session")
|
||||
}
|
||||
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
|
||||
|
||||
// Generate token pair
|
||||
tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "admin", user.Role, 0, sessionID, jti)
|
||||
@@ -200,6 +202,7 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog
|
||||
res.User.Username = user.Username
|
||||
res.User.DisplayName = user.DisplayName
|
||||
res.User.Role = user.Role
|
||||
res.Permissions, res.Roles = loadSessionPermissions(ctx, user.Id, user.Role)
|
||||
|
||||
// 检查待接受协议
|
||||
if common.Config().GetBool(ctx, "agreement_enabled") {
|
||||
@@ -239,13 +242,14 @@ func (s *sAdmin) Logout(ctx context.Context, _ *v1.AdminLogoutReq) (*v1.AdminLog
|
||||
|
||||
// Refresh handles token refresh.
|
||||
func (s *sAdmin) Refresh(ctx context.Context, req *v1.AdminRefreshReq) (*v1.AdminRefreshRes, error) {
|
||||
refreshTokenHash := common.HashRefreshToken(req.RefreshToken)
|
||||
|
||||
// Find session by refresh token hash
|
||||
session, err := common.GetSessionByRefreshHash(ctx, refreshTokenHash)
|
||||
// 定位会话并做重放检测:已轮换的旧令牌再次出现时,会话在内部被整体吊销
|
||||
session, replayed, err := common.GetSessionByRefreshToken(ctx, req.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, common.NewUnauthorizedError("会话不存在")
|
||||
}
|
||||
if replayed {
|
||||
return nil, common.NewUnauthorizedError("登录状态已失效,请重新登录")
|
||||
}
|
||||
if session == nil {
|
||||
return nil, common.NewUnauthorizedError("会话已过期或不存在")
|
||||
}
|
||||
@@ -259,17 +263,10 @@ func (s *sAdmin) Refresh(ctx context.Context, req *v1.AdminRefreshReq) (*v1.Admi
|
||||
return nil, common.NewBusinessError(consts.CodeTokenRevoked, consts.MsgTokenRevoked)
|
||||
}
|
||||
|
||||
// Generate new refresh token
|
||||
newRefreshToken, err := common.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newRefreshTokenHash := common.HashRefreshToken(newRefreshToken)
|
||||
|
||||
// Rotate session
|
||||
// Rotate session(新令牌内嵌会话 ID,供下次重放检测定位)
|
||||
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
|
||||
deviceInfo := common.ExtractDeviceInfo(ctx)
|
||||
err = common.RefreshSession(ctx, session.Id, refreshTokenHash, newRefreshTokenHash, ipAddress, deviceInfo)
|
||||
newRefreshToken, err := common.RotateSessionRefreshToken(ctx, session.Id, req.RefreshToken, ipAddress, deviceInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -342,9 +339,9 @@ func (s *sAdmin) ListSessions(ctx context.Context, req *v1.AdminSessionListReq)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
var users []entity.SysAdminUsers
|
||||
_ = dao.SysAdminUsers.Ctx(ctx).Fields("id, username, display_name").WhereIn("id", ids).Scan(&users)
|
||||
_ = dao.SysAdminUsers.Ctx(ctx).Fields("id, username, display_name, role").WhereIn("id", ids).Scan(&users)
|
||||
for _, u := range users {
|
||||
userMap[u.Id] = adminUserBrief{Username: u.Username, DisplayName: u.DisplayName}
|
||||
userMap[u.Id] = adminUserBrief{Username: u.Username, DisplayName: u.DisplayName, Role: u.Role}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +352,7 @@ func (s *sAdmin) ListSessions(ctx context.Context, req *v1.AdminSessionListReq)
|
||||
UserId: sess.UserId,
|
||||
Username: userMap[sess.UserId].Username,
|
||||
DisplayName: userMap[sess.UserId].DisplayName,
|
||||
Role: userMap[sess.UserId].Role,
|
||||
IpAddress: sess.IpAddress,
|
||||
DeviceInfo: sess.DeviceInfo,
|
||||
IsCurrent: sess.Id == currentSessionID,
|
||||
@@ -375,6 +373,31 @@ func (s *sAdmin) ListSessions(ctx context.Context, req *v1.AdminSessionListReq)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// assertCanOperateSessions 校验当前调用者能否处置目标管理员账号的会话。
|
||||
//
|
||||
// 超级管理员的登录态任何人都不能代为操作(包括其他超管):踢掉超管会话等同于
|
||||
// 掐断平台最高权限入口,一旦下放就存在「借会话管理打击超管」的滥用路径。
|
||||
// 唯一例外是账号本人管理自己的登录设备(等同在其他设备登出)。
|
||||
// 查看不受限:会话列表对超管账号仍然展示,只是不可操作。
|
||||
func assertCanOperateSessions(ctx context.Context, targetUserID int64) error {
|
||||
if targetUserID == 0 {
|
||||
return nil
|
||||
}
|
||||
var user *entity.SysAdminUsers
|
||||
err := dao.SysAdminUsers.Ctx(ctx).Where("id", targetUserID).Scan(&user)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return err
|
||||
}
|
||||
// 目标账号不存在或不是超管:无可保护对象,交由路由层 user:edit 把关
|
||||
if user == nil || user.Role != "super_admin" {
|
||||
return nil
|
||||
}
|
||||
if common.GetCtxUserID(ctx) == targetUserID {
|
||||
return nil
|
||||
}
|
||||
return common.NewBusinessError(consts.CodeForbidden, "超级管理员的会话仅限本人管理,其他人只能查看")
|
||||
}
|
||||
|
||||
// RevokeSession revokes a specific session.
|
||||
func (s *sAdmin) RevokeSession(ctx context.Context, req *v1.AdminRevokeSessionReq) (*v1.AdminRevokeSessionRes, error) {
|
||||
// Look up session to get jti for Redis revocation
|
||||
@@ -383,6 +406,9 @@ func (s *sAdmin) RevokeSession(ctx context.Context, req *v1.AdminRevokeSessionRe
|
||||
return nil, err
|
||||
}
|
||||
if sess != nil {
|
||||
if err := assertCanOperateSessions(ctx, sess.UserId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
common.MarkSessionRevoked(ctx, sess.Jti)
|
||||
}
|
||||
err = common.RevokeSession(ctx, "admin", req.Id)
|
||||
@@ -394,6 +420,9 @@ func (s *sAdmin) RevokeSession(ctx context.Context, req *v1.AdminRevokeSessionRe
|
||||
|
||||
// ForceLogout revokes all sessions for a specific user.
|
||||
func (s *sAdmin) ForceLogout(ctx context.Context, req *v1.AdminForceLogoutReq) (*v1.AdminForceLogoutRes, error) {
|
||||
if err := assertCanOperateSessions(ctx, req.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err := common.RevokeAllSessions(ctx, "admin", req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -447,3 +476,67 @@ func (s *sAdmin) ChangePassword(ctx context.Context, req *v1.AdminChangePassword
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// loadSessionPermissions 装载登录响应所需的有效权限与角色。
|
||||
//
|
||||
// 权限查询失败不阻断登录:鉴权以服务端为准,前端权限集只影响菜单与按钮渲染。
|
||||
// 此时返回空集合,用户会看到一个空菜单,刷新页面(走 /auth/me)即可恢复,
|
||||
// 好过因为一次查询抖动把人挡在登录页外。
|
||||
func loadSessionPermissions(ctx context.Context, userID int64, role string) ([]string, []v1.AdminRoleBrief) {
|
||||
perms, err := GetEffectivePermissions(ctx, userID, role)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "加载管理员 %d 有效权限失败: %v", userID, err)
|
||||
perms = []string{}
|
||||
}
|
||||
if perms == nil {
|
||||
perms = []string{}
|
||||
}
|
||||
|
||||
roles := []v1.AdminRoleBrief{}
|
||||
// 超级管理员不通过角色获得权限,不查关联表
|
||||
if role != "super_admin" {
|
||||
if briefs, err := GetUserRoleBriefs(ctx, userID); err != nil {
|
||||
g.Log().Warningf(ctx, "加载管理员 %d 角色失败: %v", userID, err)
|
||||
} else if briefs != nil {
|
||||
roles = briefs
|
||||
}
|
||||
}
|
||||
return perms, roles
|
||||
}
|
||||
|
||||
// GetMe 返回当前登录用户的信息与有效权限。
|
||||
//
|
||||
// 权限此前只在登录响应里下发一次,之后前端从 localStorage 恢复:管理员改了某人的权限,
|
||||
// 对方不重新登录就一直按旧权限渲染菜单。前端在应用启动与 token 刷新后调用本接口刷新。
|
||||
func (s *sAdmin) GetMe(ctx context.Context, _ *v1.AdminMeReq) (*v1.AdminMeRes, error) {
|
||||
userID := common.GetCtxUserID(ctx)
|
||||
if userID == 0 {
|
||||
return nil, common.NewBusinessError(consts.CodeUnauthorized, consts.MsgUnauthorized)
|
||||
}
|
||||
|
||||
var user *struct {
|
||||
Id int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
err := dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Scan(&user)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, common.NewNotFoundError("用户")
|
||||
}
|
||||
|
||||
perms, roles := loadSessionPermissions(ctx, user.Id, user.Role)
|
||||
return &v1.AdminMeRes{
|
||||
ID: user.Id,
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Email: user.Email,
|
||||
Role: user.Role,
|
||||
Permissions: perms,
|
||||
Roles: roles,
|
||||
}, nil
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
)
|
||||
|
||||
@@ -195,3 +196,18 @@ func (s *sAdmin) ChannelErrorCategories(ctx context.Context, req *v1.ChannelErro
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ChannelErrorClear 硬删除全部渠道错误事件。
|
||||
// 错误风暴时事件表堆积很快,用 TRUNCATE 而非逐行 DELETE 秒级清空并立即归还磁盘空间。
|
||||
// TRUNCATE 不返回行数,故先取删除前条数作为反馈。表无数据库级外键,TRUNCATE 安全。
|
||||
// 注意异步写入器(DefaultChannelErrorWriter)清空后继续写入新事件,属预期行为。
|
||||
func (s *sAdmin) ChannelErrorClear(ctx context.Context, _ *v1.ChannelErrorClearReq) (*v1.ChannelErrorClearRes, error) {
|
||||
count, err := dao.ChnErrorEvents.Ctx(ctx).Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := g.DB().Ctx(ctx).Exec(ctx, "TRUNCATE TABLE chn_error_events RESTART IDENTITY"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v1.ChannelErrorClearRes{Deleted: int64(count)}, nil
|
||||
}
|
||||
@@ -28,6 +28,13 @@ func (s *sAdmin) CreateHelpCategory(ctx context.Context, req *v1.HelpCategoryCre
|
||||
return nil, common.NewBusinessError(consts.CodeHelpCategorySlugExists, consts.MsgHelpCategorySlugExists)
|
||||
}
|
||||
|
||||
// 父分类校验:必须存在、必须是顶级分类(仅支持两级)
|
||||
if req.ParentId > 0 {
|
||||
if err = s.validateHelpCategoryParent(ctx, 0, req.ParentId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
isVisible := true
|
||||
if req.IsVisible != nil {
|
||||
isVisible = *req.IsVisible
|
||||
@@ -77,6 +84,13 @@ func (s *sAdmin) UpdateHelpCategory(ctx context.Context, req *v1.HelpCategoryUpd
|
||||
return nil, common.NewBusinessError(consts.CodeBadRequest, "不能将分类设置为自己的子分类")
|
||||
}
|
||||
|
||||
// 父分类校验:必须存在、必须是顶级分类(仅支持两级),且祖先链中不能出现自己(防环导致分类互相锁定无法删除)
|
||||
if req.ParentId > 0 {
|
||||
if err = s.validateHelpCategoryParent(ctx, req.Id, req.ParentId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
updateData := do.SptCategories{
|
||||
ParentId: req.ParentId,
|
||||
Name: req.Name,
|
||||
@@ -100,6 +114,45 @@ func (s *sAdmin) UpdateHelpCategory(ctx context.Context, req *v1.HelpCategoryUpd
|
||||
return &v1.HelpCategoryUpdateRes{}, nil
|
||||
}
|
||||
|
||||
// validateHelpCategoryParent 校验父分类:必须存在、必须是顶级分类(仅支持两级结构),
|
||||
// 且父分类的祖先链中不能出现 selfId(否则会形成 A→B→A 的环,环上分类因互为父子被删除校验拦截,永远无法删除)
|
||||
func (s *sAdmin) validateHelpCategoryParent(ctx context.Context, selfId, parentId int64) error {
|
||||
var parent *struct {
|
||||
Id int64 `json:"id"`
|
||||
ParentId int64 `json:"parent_id"`
|
||||
}
|
||||
err := dao.SptCategories.Ctx(ctx).Where("id", parentId).Scan(&parent)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return err
|
||||
}
|
||||
if parent == nil {
|
||||
return common.NewBusinessError(consts.CodeHelpCategoryNotFound, consts.MsgHelpCategoryNotFound)
|
||||
}
|
||||
if parent.ParentId != 0 {
|
||||
return common.NewBusinessError(consts.CodeBadRequest, "仅支持两级分类,父分类必须是顶级分类")
|
||||
}
|
||||
|
||||
// 沿祖先链向上检查是否会出现自己(数据异常时用深度上限兜底)
|
||||
current := parent.ParentId
|
||||
for i := 0; i < 50 && current != 0; i++ {
|
||||
if current == selfId {
|
||||
return common.NewBusinessError(consts.CodeBadRequest, "不能将分类挂到自身或其子分类下")
|
||||
}
|
||||
var ancestor *struct {
|
||||
ParentId int64 `json:"parent_id"`
|
||||
}
|
||||
err = dao.SptCategories.Ctx(ctx).Where("id", current).Scan(&ancestor)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return err
|
||||
}
|
||||
if ancestor == nil {
|
||||
return nil
|
||||
}
|
||||
current = ancestor.ParentId
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteHelpCategory 删除帮助分类
|
||||
func (s *sAdmin) DeleteHelpCategory(ctx context.Context, req *v1.HelpCategoryDeleteReq) (*v1.HelpCategoryDeleteRes, error) {
|
||||
var cat *struct {
|
||||
@@ -223,9 +276,10 @@ func (s *sAdmin) CreateHelpArticle(ctx context.Context, req *v1.HelpArticleCreat
|
||||
// UpdateHelpArticle 更新帮助文章
|
||||
func (s *sAdmin) UpdateHelpArticle(ctx context.Context, req *v1.HelpArticleUpdateReq) (*v1.HelpArticleUpdateRes, error) {
|
||||
var article *struct {
|
||||
Id int64 `json:"id"`
|
||||
CategoryId int64 `json:"category_id"`
|
||||
Status string `json:"status"`
|
||||
Id int64 `json:"id"`
|
||||
CategoryId int64 `json:"category_id"`
|
||||
Status string `json:"status"`
|
||||
PublishedAt *gtime.Time `json:"published_at"`
|
||||
}
|
||||
err := dao.SptArticles.Ctx(ctx).Where("id", req.Id).Scan(&article)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
@@ -269,8 +323,8 @@ func (s *sAdmin) UpdateHelpArticle(ctx context.Context, req *v1.HelpArticleUpdat
|
||||
data.Content = req.Content
|
||||
}
|
||||
|
||||
// 从草稿发布时设置发布时间
|
||||
if req.Status == "published" && article.Status != "published" {
|
||||
// 从草稿发布时设置发布时间(仅首次发布固定;下架再上架不重置,保持原始发布时间与排序位置)
|
||||
if req.Status == "published" && article.Status != "published" && article.PublishedAt == nil {
|
||||
data.PublishedAt = gtime.Now()
|
||||
}
|
||||
|
||||
|
||||
@@ -230,7 +230,7 @@ func deductWalletForRefundTx(ctx context.Context, orderID int64, tenantID int64)
|
||||
}
|
||||
}
|
||||
if credit == nil {
|
||||
return billing.Zero, common.NewBadRequestError("未找到该订单的充值入账流水,无法确定应扣回的美元金额")
|
||||
return billing.Zero, common.NewBadRequestError("未找到该订单的充值入账流水,无法确定应扣回的入账金额")
|
||||
}
|
||||
if !credit.Amount.GreaterThan(billing.Zero) {
|
||||
return billing.Zero, common.NewBadRequestError("充值入账流水金额异常,无法退款")
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
@@ -70,7 +69,7 @@ var predefinedPermissionGroups = []v1.PermissionGroup{
|
||||
Name: "audit",
|
||||
Label: "审计日志",
|
||||
Permissions: []string{
|
||||
"audit:view", "audit:export", "audit:read_sensitive",
|
||||
"audit:view", "audit:export", "audit:read_sensitive", "audit:clear",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -98,7 +97,8 @@ var predefinedPermissionGroups = []v1.PermissionGroup{
|
||||
Name: "system",
|
||||
Label: "系统设置",
|
||||
Permissions: []string{
|
||||
"system:view", "system:edit", "system:update", "system:plugin",
|
||||
// 注:在线自更新无权限点 —— 整个 update 域硬性限定超管(superAdminOnlyRules)
|
||||
"system:view", "system:edit", "system:plugin",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -133,7 +133,7 @@ var predefinedPermissionGroups = []v1.PermissionGroup{
|
||||
Name: "member",
|
||||
Label: "成员管理",
|
||||
Permissions: []string{
|
||||
"member:view", "member:import", "member:model_scope",
|
||||
"member:view", "member:import", "member:manage", "member:model_scope",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -152,6 +152,13 @@ var predefinedPermissionGroups = []v1.PermissionGroup{
|
||||
},
|
||||
}
|
||||
|
||||
// PermissionGroups 返回全部预定义权限点分组。
|
||||
// 供 middleware 的权限规则覆盖测试校验「规则引用的权限点确实存在」,
|
||||
// 避免规则里的拼写错误造成接口对所有非超管角色永久 403。
|
||||
func PermissionGroups() []v1.PermissionGroup {
|
||||
return predefinedPermissionGroups
|
||||
}
|
||||
|
||||
// GetUserPermissions returns permission points and data scopes for an admin user.
|
||||
func (s *sAdmin) GetUserPermissions(ctx context.Context, req *v1.AdminPermissionListReq) (*v1.AdminPermissionListRes, error) {
|
||||
// Get permission points
|
||||
@@ -215,6 +222,18 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss
|
||||
if user.Role == "super_admin" {
|
||||
return nil, common.NewBadRequestError("超级管理员无需配置权限")
|
||||
}
|
||||
if err := assertCanManageAdminUser(ctx, req.Id, user.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 特批权限与角色权限一样进有效权限的并集,因此必须走同一道 sanitize:
|
||||
// 校验权限点存在 + 去重排序 + 禁止授予自己不具备的权限。
|
||||
// 此前这里只做「权限点是否存在」的校验,一个拿到 user:edit 的账号可以给自己
|
||||
// 特批 system:plugin / billing:refund,绕开「角色管理仅超管」的全部管控。
|
||||
perms, err := sanitizeGrantedPermissions(ctx, req.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 事务采用 ctx 传播式写法:闭包内统一使用 dao.Xxx.Ctx(ctx),事务由 ctx 自动挂载。
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
@@ -226,18 +245,10 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate permission points against predefined set
|
||||
validPerms := buildValidPermissionSet()
|
||||
for _, p := range req.Permissions {
|
||||
if !validPerms[p] {
|
||||
return gerror.Newf("无效的权限点: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new permissions
|
||||
if len(req.Permissions) > 0 {
|
||||
data := make([]do.SysAdminRolePerms, len(req.Permissions))
|
||||
for i, p := range req.Permissions {
|
||||
if len(perms) > 0 {
|
||||
data := make([]do.SysAdminRolePerms, len(perms))
|
||||
for i, p := range perms {
|
||||
data[i] = do.SysAdminRolePerms{
|
||||
AdminUserId: req.Id,
|
||||
PermissionPoint: p,
|
||||
@@ -255,89 +266,82 @@ func (s *sAdmin) UpdateUserPermissions(ctx context.Context, req *v1.AdminPermiss
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// UpdateUserDataScopes updates data scopes for an admin user.
|
||||
func (s *sAdmin) UpdateUserDataScopes(ctx context.Context, req *v1.AdminDataScopeUpdateReq) (*v1.AdminDataScopeUpdateRes, error) {
|
||||
// Check if target is super_admin
|
||||
var user *struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
err := dao.SysAdminUsers.Ctx(ctx).
|
||||
Where("id", req.Id).Scan(&user)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, common.NewNotFoundError("用户")
|
||||
}
|
||||
if user.Role == "super_admin" {
|
||||
return nil, common.NewBadRequestError("超级管理员无需配置数据范围")
|
||||
}
|
||||
|
||||
// 事务采用 ctx 传播式写法:闭包内统一使用 dao.Xxx.Ctx(ctx),事务由 ctx 自动挂载。
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
// Delete existing data scopes
|
||||
_, err := dao.SysAdminDataScopes.Ctx(ctx).
|
||||
Where("admin_user_id", req.Id).
|
||||
Delete()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Insert new data scopes
|
||||
if len(req.DataScopes) > 0 {
|
||||
data := make([]do.SysAdminDataScopes, len(req.DataScopes))
|
||||
for i, sc := range req.DataScopes {
|
||||
data[i] = do.SysAdminDataScopes{
|
||||
AdminUserId: req.Id,
|
||||
ScopeType: sc.ScopeType,
|
||||
ScopeValue: sc.ScopeValue,
|
||||
}
|
||||
}
|
||||
_, err = dao.SysAdminDataScopes.Ctx(ctx).Data(data).Insert()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 特批权限参与有效权限计算,变更后必须让该用户的权限缓存失效
|
||||
InvalidateUserPermCache(ctx, req.Id)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetAllPermissions returns all predefined permission groups.
|
||||
// tierLabels 是档位的中文标签,随元数据下发给前端,避免前端硬编码一份。
|
||||
var tierLabels = map[string]string{
|
||||
TierNone: "无",
|
||||
TierRead: "只读",
|
||||
TierOperate: "操作",
|
||||
TierFull: "完全",
|
||||
}
|
||||
|
||||
// GetAllPermissions returns all predefined permission groups plus tier metadata.
|
||||
//
|
||||
// 同时返回权限点分组(高级模式用)与「模块 × 档位」元数据(默认配置界面用):
|
||||
// 二者是同一份数据的两个视图,由后端统一给出,避免前端复刻一份档位定义造成漂移。
|
||||
func (s *sAdmin) GetAllPermissions(ctx context.Context, _ *v1.AdminAllPermissionsReq) (*v1.AdminAllPermissionsRes, error) {
|
||||
modules := make([]v1.PermissionModuleMeta, 0, len(predefinedPermissionGroups))
|
||||
for _, grp := range predefinedPermissionGroups {
|
||||
tiers := ModuleTiers(grp.Name)
|
||||
opts := make([]v1.TierOption, 0, len(tiers))
|
||||
for _, tier := range tiers {
|
||||
opts = append(opts, v1.TierOption{
|
||||
Tier: tier,
|
||||
Label: tierLabels[tier],
|
||||
Permissions: TierPermissions(grp.Name, tier),
|
||||
})
|
||||
}
|
||||
modules = append(modules, v1.PermissionModuleMeta{
|
||||
Module: grp.Name,
|
||||
Label: grp.Label,
|
||||
Tiers: opts,
|
||||
})
|
||||
}
|
||||
|
||||
return &v1.AdminAllPermissionsRes{
|
||||
Groups: predefinedPermissionGroups,
|
||||
Groups: predefinedPermissionGroups,
|
||||
Modules: modules,
|
||||
Dangerous: dangerousPermissions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HasPermission checks if an admin user has a specific permission point.
|
||||
// super_admin always returns true.
|
||||
//
|
||||
// 有效权限 = ∪(已启用角色的权限) ∪ 用户特批权限,详见 GetEffectivePermissions。
|
||||
// 结果走 300s 双层缓存:鉴权在每个请求上都跑,此前每次一条 COUNT 查询。
|
||||
//
|
||||
// 签名保持不变,middleware/rbac.go 无需改动。
|
||||
// super_admin 直接放行,不查表也不进缓存。
|
||||
func HasPermission(ctx context.Context, userID int64, role string, permission string) bool {
|
||||
if role == "super_admin" {
|
||||
return true
|
||||
}
|
||||
|
||||
count, err := dao.SysAdminRolePerms.Ctx(ctx).
|
||||
Where("admin_user_id", userID).
|
||||
Where("permission_point", permission).
|
||||
Count()
|
||||
perms, err := GetEffectivePermissions(ctx, userID, role)
|
||||
if err != nil {
|
||||
// 查询失败时 fail-closed:宁可误拒也不误放,鉴权失败不应静默放行
|
||||
return false
|
||||
}
|
||||
|
||||
return count > 0
|
||||
for _, p := range perms {
|
||||
if p == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetDataScopes returns data scopes for an admin user.
|
||||
// super_admin always returns "all".
|
||||
//
|
||||
// ⚠️ 当前无调用方:数据范围只被存储与回显,没有任何查询按它过滤,配置了也不生效。
|
||||
// 写接口已随之下线(见 api/admin/v1/permission.go)。要让这个特性真正成立,
|
||||
// 必须先把它接进各列表查询的 WHERE 条件,再恢复配置入口 —— 在那之前不要仅凭
|
||||
// 本函数存在就认为数据范围已被强制执行。
|
||||
func GetDataScopes(ctx context.Context, userID int64, role string) ([]v1.DataScopeItem, error) {
|
||||
if role == "super_admin" {
|
||||
return []v1.DataScopeItem{{ScopeType: "all"}}, nil
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ctxAs 构造带调用者身份的上下文。
|
||||
// 键名与 middleware 写入 ctx 的保持一致(见 internal/logic/common/session.go)。
|
||||
func ctxAs(role string, userID int64) context.Context {
|
||||
ctx := context.WithValue(context.Background(), "role", role)
|
||||
return context.WithValue(ctx, "userId", userID)
|
||||
}
|
||||
|
||||
// TestAssertCanGrantSuperAdmin 守住「谁能把账号设成超级管理员」。
|
||||
//
|
||||
// sys_admin_users.role 是超管的唯一判定依据,鉴权时直接短路放行。若允许非超管写这个字段,
|
||||
// 一个拿到 user:create / user:edit 的账号可以直接把自己提成超管 —— 比分配角色更直接,
|
||||
// 且完全绕过 superAdminOnlyRules。字段级取值校验(ValidateAdminRole)管不了这件事。
|
||||
func TestAssertCanGrantSuperAdmin(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
operator string
|
||||
targetRole string
|
||||
wantBlocked bool
|
||||
}{
|
||||
{"超管可以设置超管", "super_admin", "super_admin", false},
|
||||
{"超管可以设置普通管理员", "super_admin", "admin", false},
|
||||
{"非超管不能设置超管", "admin", "super_admin", true},
|
||||
{"非超管可以设置普通管理员", "admin", "admin", false},
|
||||
{"无身份上下文时不能设置超管", "", "super_admin", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := assertCanGrantSuperAdmin(ctxAs(c.operator, 1), c.targetRole)
|
||||
if c.wantBlocked && err == nil {
|
||||
t.Fatalf("应被拦截,实际放行")
|
||||
}
|
||||
if !c.wantBlocked && err != nil {
|
||||
t.Fatalf("应放行,实际被拦截: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssertCanManageAdminUserBlocksSuperAdminTarget 守住「非超管不得操作超管账号」。
|
||||
//
|
||||
// 账号管理(改密/禁用/删除/配特批权限)按 user:* 授权、允许下放。若不限制目标,
|
||||
// 拿到 user:edit 的账号重置一次超管密码就能接管系统,角色管理限定超管那道闸门形同虚设。
|
||||
//
|
||||
// 只覆盖不触库的分支:超管调用者短路、目标为超管、无调用者身份、操作自己。
|
||||
// 「目标权限须是调用者子集」需要读库,由集成测试覆盖。
|
||||
func TestAssertCanManageAdminUserBlocksSuperAdminTarget(t *testing.T) {
|
||||
if err := assertCanManageAdminUser(ctxAs("super_admin", 1), 2, "super_admin"); err != nil {
|
||||
t.Fatalf("超管操作任意账号应放行: %v", err)
|
||||
}
|
||||
if err := assertCanManageAdminUser(ctxAs("admin", 2), 1, "super_admin"); err == nil {
|
||||
t.Fatal("非超管操作超管账号应被拦截,实际放行")
|
||||
}
|
||||
// 无调用者身份(命令行工具、内部调用):交由上层接口鉴权把关,此处不拦
|
||||
if err := assertCanManageAdminUser(ctxAs("admin", 0), 3, "admin"); err != nil {
|
||||
t.Fatalf("无调用者身份时不应拦截: %v", err)
|
||||
}
|
||||
// 操作自己:自身权限的变更另有 sanitizeGrantedPermissions 把关
|
||||
if err := assertCanManageAdminUser(ctxAs("admin", 5), 5, "admin"); err != nil {
|
||||
t.Fatalf("操作自己不应被拦截: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/model/do"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 权限档位模型
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 权限档位:配置界面按「模块 × 档位」呈现,而不是把 60 个权限点摊成复选框。
|
||||
// 档位按【可逆性】而非【读/写/删】划分 —— 典型例子是租户:停用(suspend)是可撤销的
|
||||
// 日常运营动作,删除与销户不可逆。若把三者塞进同一档,就会被迫在「运营不能停服」和
|
||||
// 「运营能删客户」之间二选一。
|
||||
const (
|
||||
TierNone = "none" // 无:该模块不可见
|
||||
TierRead = "read" // 只读:只能查看
|
||||
TierOperate = "operate" // 操作:日常增改与业务动作
|
||||
TierFull = "full" // 完全:含删除与高风险动作
|
||||
)
|
||||
|
||||
// tierPermissions 是「模块 + 档位 → 权限点集合」的静态映射表。
|
||||
//
|
||||
// 刻意手写而非按 view/create/edit/delete 规则化推导:各模块的权限点语义差异太大
|
||||
// (billing:refund 是资金操作、audit:read_sensitive 是隐私、member:model_scope 是配置),
|
||||
// 规则化只会产生错误的归类。高档位包含低档位的全部权限点。
|
||||
var tierPermissions = map[string]map[string][]string{
|
||||
"dashboard": {
|
||||
TierRead: {"dashboard:view"},
|
||||
},
|
||||
"tenant": {
|
||||
TierRead: {"tenant:view"},
|
||||
TierOperate: {"tenant:view", "tenant:create", "tenant:edit", "tenant:suspend"},
|
||||
TierFull: {"tenant:view", "tenant:create", "tenant:edit", "tenant:suspend", "tenant:delete", "tenant:close"},
|
||||
},
|
||||
"member": {
|
||||
TierRead: {"member:view"},
|
||||
TierOperate: {"member:view", "member:import", "member:manage"},
|
||||
TierFull: {"member:view", "member:import", "member:manage", "member:model_scope"},
|
||||
},
|
||||
"channel": {
|
||||
TierRead: {"channel:view"},
|
||||
TierOperate: {"channel:view", "channel:create", "channel:edit", "channel:test"},
|
||||
TierFull: {"channel:view", "channel:create", "channel:edit", "channel:test", "channel:delete"},
|
||||
},
|
||||
"model": {
|
||||
TierRead: {"model:view"},
|
||||
TierOperate: {"model:view", "model:create", "model:edit"},
|
||||
TierFull: {"model:view", "model:create", "model:edit", "model:delete"},
|
||||
},
|
||||
"billing": {
|
||||
TierRead: {"billing:view"},
|
||||
TierOperate: {"billing:view", "billing:export"},
|
||||
TierFull: {"billing:view", "billing:export", "billing:refund"},
|
||||
},
|
||||
"plan": {
|
||||
TierRead: {"plan:view"},
|
||||
TierOperate: {"plan:view", "plan:create", "plan:edit"},
|
||||
TierFull: {"plan:view", "plan:create", "plan:edit", "plan:delete"},
|
||||
},
|
||||
"order": {
|
||||
TierRead: {"order:view"},
|
||||
TierFull: {"order:view", "order:refund"},
|
||||
},
|
||||
"promo": {
|
||||
TierRead: {"promo:view"},
|
||||
TierOperate: {"promo:view", "promo:create", "promo:edit"},
|
||||
},
|
||||
"redemption": {
|
||||
TierRead: {"redemption:view"},
|
||||
TierOperate: {"redemption:view", "redemption:create", "redemption:edit"},
|
||||
},
|
||||
"invoice": {
|
||||
TierRead: {"invoice:view"},
|
||||
TierOperate: {"invoice:view", "invoice:manage"},
|
||||
},
|
||||
"operation": {
|
||||
TierRead: {"operation:view"},
|
||||
TierOperate: {"operation:view", "operation:edit"},
|
||||
},
|
||||
"support": {
|
||||
TierRead: {"support:view"},
|
||||
TierOperate: {"support:view", "support:reply"},
|
||||
TierFull: {"support:view", "support:reply", "support:edit"},
|
||||
},
|
||||
"monitor": {
|
||||
TierRead: {"monitor:view"},
|
||||
TierOperate: {"monitor:view", "monitor:edit"},
|
||||
},
|
||||
"task": {
|
||||
TierRead: {"task:view"},
|
||||
TierOperate: {"task:view", "task:edit"},
|
||||
},
|
||||
"file": {
|
||||
TierRead: {"file:view"},
|
||||
TierFull: {"file:view", "file:delete", "file:cleanup"},
|
||||
},
|
||||
"audit": {
|
||||
TierRead: {"audit:view"},
|
||||
TierOperate: {"audit:view", "audit:export"},
|
||||
TierFull: {"audit:view", "audit:export", "audit:read_sensitive", "audit:clear"},
|
||||
},
|
||||
"user": {
|
||||
TierRead: {"user:view"},
|
||||
TierOperate: {"user:view", "user:create", "user:edit"},
|
||||
TierFull: {"user:view", "user:create", "user:edit", "user:delete"},
|
||||
},
|
||||
"system": {
|
||||
TierRead: {"system:view"},
|
||||
TierOperate: {"system:view", "system:edit"},
|
||||
// 在线自更新硬性限定超管、无权限点,「完全」档与「操作」档的差异只剩插件
|
||||
TierFull: {"system:view", "system:edit", "system:plugin"},
|
||||
},
|
||||
}
|
||||
|
||||
// dangerousPermissions 是需要在界面上标红并二次确认的高危权限点。
|
||||
// 四类:资金、隐私、系统、提权。
|
||||
var dangerousPermissions = map[string]string{
|
||||
"billing:refund": "可对任意租户钱包退款/调账",
|
||||
"order:refund": "可对订单发起退款到支付渠道",
|
||||
"audit:read_sensitive": "可查看敏感数据访问日志",
|
||||
"audit:clear": "可硬删除全部拦截日志(不可恢复)",
|
||||
"system:edit": "可修改支付、汇率、存储、邮件等全站配置",
|
||||
"system:plugin": "可安装/启停插件(等同于部署代码)",
|
||||
"user:create": "可创建管理员账号",
|
||||
"user:edit": "可修改管理员账号与其角色(含提权)",
|
||||
"user:delete": "可删除管理员账号",
|
||||
"member:manage": "可重置任意租户成员的密码(等同接管该成员账号)",
|
||||
}
|
||||
|
||||
// tierOrder 定义档位由低到高的顺序,用于「权限点集合 → 档位」的反向推导。
|
||||
var tierOrder = []string{TierNone, TierRead, TierOperate, TierFull}
|
||||
|
||||
// roleCodeRegexp 限制角色标识的取值:小写字母、数字、下划线,2-50 字符。
|
||||
// code 参与权限缓存键与审计日志,必须稳定且可读。
|
||||
var roleCodeRegexp = regexp.MustCompile(`^[a-z][a-z0-9_]{1,49}$`)
|
||||
|
||||
// reservedRoleCode 是保留字:超级管理员由 sys_admin_users.role 判定并短路鉴权,
|
||||
// 不允许在角色表里出现同名角色造成语义混淆。
|
||||
const reservedRoleCode = "super_admin"
|
||||
|
||||
// TierPermissions 返回指定模块与档位对应的权限点集合。
|
||||
// 档位不存在(如 dashboard 没有 operate 档)时返回 nil,调用方应回落到更低档位。
|
||||
func TierPermissions(module, tier string) []string {
|
||||
return tierPermissions[module][tier]
|
||||
}
|
||||
|
||||
// ModuleTiers 返回某模块实际存在差异的档位列表(含 none,由低到高)。
|
||||
//
|
||||
// 界面只渲染这些档位:dashboard 只有 无/只读,promo 只有 无/只读/操作。
|
||||
// 避免让用户在「操作」和「完全」两个完全等价的选项之间纠结。
|
||||
func ModuleTiers(module string) []string {
|
||||
defs, ok := tierPermissions[module]
|
||||
if !ok {
|
||||
return []string{TierNone}
|
||||
}
|
||||
out := []string{TierNone}
|
||||
var prev []string
|
||||
for _, tier := range tierOrder[1:] {
|
||||
perms, exists := defs[tier]
|
||||
if !exists || samePermissionSet(perms, prev) {
|
||||
continue
|
||||
}
|
||||
out = append(out, tier)
|
||||
prev = perms
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TierForPermissions 从一组权限点反推它属于某模块的哪个档位。
|
||||
// 完全匹配某档位时返回该档位,否则返回空串表示「自定义」(界面切到高级模式展示)。
|
||||
func TierForPermissions(module string, perms []string) string {
|
||||
owned := make([]string, 0, len(perms))
|
||||
prefix := module + ":"
|
||||
for _, p := range perms {
|
||||
if strings.HasPrefix(p, prefix) {
|
||||
owned = append(owned, p)
|
||||
}
|
||||
}
|
||||
if len(owned) == 0 {
|
||||
return TierNone
|
||||
}
|
||||
// 由高到低比对,命中即返回(高档位是低档位的超集,先比高档避免误判)
|
||||
for i := len(tierOrder) - 1; i >= 1; i-- {
|
||||
if defs, ok := tierPermissions[module][tierOrder[i]]; ok && samePermissionSet(owned, defs) {
|
||||
return tierOrder[i]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsDangerousPermission 判断权限点是否属于需要二次确认的高危权限。
|
||||
func IsDangerousPermission(perm string) (string, bool) {
|
||||
reason, ok := dangerousPermissions[perm]
|
||||
return reason, ok
|
||||
}
|
||||
|
||||
func samePermissionSet(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
sa := append([]string(nil), a...)
|
||||
sb := append([]string(nil), b...)
|
||||
sort.Strings(sa)
|
||||
sort.Strings(sb)
|
||||
for i := range sa {
|
||||
if sa[i] != sb[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 有效权限计算与缓存
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// adminPermCache 缓存每个管理员的有效权限集合。
|
||||
//
|
||||
// 鉴权在每个请求上都要跑,此前是一次 COUNT 查询。复用项目的双层缓存
|
||||
// (L1 gcache + L2 Redis + 跨实例失效订阅),TTL 与「用户信息 300s」的既定策略一致。
|
||||
var adminPermCache = lcommon.NewCache("admin_perm", 300*time.Second)
|
||||
|
||||
// permCacheKey 按用户维度组织缓存键。
|
||||
func permCacheKey(userID int64) string {
|
||||
return g.NewVar(userID).String()
|
||||
}
|
||||
|
||||
// InvalidateUserPermCache 清除单个用户的权限缓存(角色关联、特批权限变更时调用)。
|
||||
func InvalidateUserPermCache(ctx context.Context, userID int64) {
|
||||
adminPermCache.Delete(ctx, permCacheKey(userID))
|
||||
}
|
||||
|
||||
// InvalidateAllPermCache 清空全部管理员的权限缓存。
|
||||
//
|
||||
// 角色维度的变更(改角色权限、启停、删除)会影响该角色下的所有用户。管理员账号在
|
||||
// 几十量级且这类操作低频,直接清空整个前缀比精确定位受影响用户更简单也更不容易漏。
|
||||
func InvalidateAllPermCache(ctx context.Context) {
|
||||
adminPermCache.DeleteByPattern(ctx, "*")
|
||||
}
|
||||
|
||||
// GetEffectivePermissions 返回管理员的有效权限点集合。
|
||||
//
|
||||
// 有效权限 = ∪(已启用角色的权限) ∪ 用户特批权限(sys_admin_role_perms)
|
||||
//
|
||||
// 只做并集不做否定:带 deny 规则的 RBAC 在排查「这个人为什么没权限」时需要回溯整条
|
||||
// 规则链,运维成本远大于收益。要收权限就去掉角色或改角色。
|
||||
//
|
||||
// super_admin 不查表,直接返回全部权限点。
|
||||
func GetEffectivePermissions(ctx context.Context, userID int64, role string) ([]string, error) {
|
||||
if role == "super_admin" {
|
||||
return AllPermissionPoints(), nil
|
||||
}
|
||||
|
||||
var cached []string
|
||||
if adminPermCache.GetJSON(ctx, permCacheKey(userID), &cached) {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
perms, err := loadEffectivePermissions(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
adminPermCache.Set(ctx, permCacheKey(userID), perms)
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
// loadEffectivePermissions 从数据库计算有效权限(不走缓存)。
|
||||
func loadEffectivePermissions(ctx context.Context, userID int64) ([]string, error) {
|
||||
set := make(map[string]bool)
|
||||
|
||||
// 1) 角色权限:仅统计已启用的角色。
|
||||
// 角色被禁用后其权限立即失效,不需要解除用户关联。
|
||||
var rolePerms []struct {
|
||||
PermissionPoint string `json:"permission_point"`
|
||||
}
|
||||
err := dao.SysAdminRolePermissions.Ctx(ctx).
|
||||
As("rp").
|
||||
LeftJoin("sys_admin_roles r", "r.id = rp.role_id").
|
||||
LeftJoin("sys_admin_user_roles ur", "ur.role_id = rp.role_id").
|
||||
Where("ur.admin_user_id", userID).
|
||||
Where("r.is_enabled", true).
|
||||
Fields("rp.permission_point").
|
||||
Scan(&rolePerms)
|
||||
if err = lcommon.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range rolePerms {
|
||||
set[p.PermissionPoint] = true
|
||||
}
|
||||
|
||||
// 2) 用户特批权限:独立于角色,角色禁用不影响特批。
|
||||
var extraPerms []struct {
|
||||
PermissionPoint string `json:"permission_point"`
|
||||
}
|
||||
err = dao.SysAdminRolePerms.Ctx(ctx).
|
||||
Where("admin_user_id", userID).
|
||||
Fields("permission_point").
|
||||
Scan(&extraPerms)
|
||||
if err = lcommon.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range extraPerms {
|
||||
set[p.PermissionPoint] = true
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(set))
|
||||
for p := range set {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AllPermissionPoints 返回系统定义的全部权限点(超级管理员使用)。
|
||||
func AllPermissionPoints() []string {
|
||||
var out []string
|
||||
for _, grp := range predefinedPermissionGroups {
|
||||
out = append(out, grp.Permissions...)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 用户角色关联
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetUserRoleBriefs 返回用户已分配角色的简要信息(登录响应与用户列表展示用)。
|
||||
func GetUserRoleBriefs(ctx context.Context, userID int64) ([]v1.AdminRoleBrief, error) {
|
||||
var rows []v1.AdminRoleBrief
|
||||
err := dao.SysAdminUserRoles.Ctx(ctx).
|
||||
As("ur").
|
||||
LeftJoin("sys_admin_roles r", "r.id = ur.role_id").
|
||||
Where("ur.admin_user_id", userID).
|
||||
Fields("r.id, r.code, r.name, r.is_enabled").
|
||||
OrderAsc("r.sort").
|
||||
Scan(&rows)
|
||||
if err = lcommon.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// replaceUserRoles 全量覆盖用户的角色关联(调用方负责事务与缓存失效)。
|
||||
func replaceUserRoles(ctx context.Context, userID int64, roleIDs []int64) error {
|
||||
if _, err := dao.SysAdminUserRoles.Ctx(ctx).
|
||||
Where("admin_user_id", userID).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(roleIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 去重,避免前端重复提交同一角色触发唯一约束
|
||||
seen := make(map[int64]bool, len(roleIDs))
|
||||
data := make([]do.SysAdminUserRoles, 0, len(roleIDs))
|
||||
for _, id := range roleIDs {
|
||||
if id <= 0 || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
data = append(data, do.SysAdminUserRoles{AdminUserId: userID, RoleId: id})
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 校验角色存在,避免写入悬空关联(无外键,DB 不会拦)
|
||||
count, err := dao.SysAdminRoles.Ctx(ctx).WhereIn("id", roleIDs).Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count != len(data) {
|
||||
return lcommon.NewBadRequestError("包含不存在的角色")
|
||||
}
|
||||
|
||||
_, err = dao.SysAdminUserRoles.Ctx(ctx).Data(data).Insert()
|
||||
return err
|
||||
}
|
||||
|
||||
// AssignUserRoles 为管理员分配角色(全量覆盖)。
|
||||
func (s *sAdmin) AssignUserRoles(ctx context.Context, req *v1.AdminUserRoleAssignReq) (*v1.AdminUserRoleAssignRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := loadAdminUserRole(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.Role == "super_admin" {
|
||||
return nil, lcommon.NewBadRequestError("超级管理员拥有全部权限,无需分配角色")
|
||||
}
|
||||
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return replaceUserRoles(ctx, req.Id, req.RoleIDs)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
InvalidateUserPermCache(ctx, req.Id)
|
||||
return &v1.AdminUserRoleAssignRes{}, nil
|
||||
}
|
||||
|
||||
// GetUserRoles 查询管理员已分配的角色。
|
||||
func (s *sAdmin) GetUserRoles(ctx context.Context, req *v1.AdminUserRoleListReq) (*v1.AdminUserRoleListRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := loadAdminUserRole(ctx, req.Id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
briefs, err := GetUserRoleBriefs(ctx, req.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if briefs == nil {
|
||||
briefs = []v1.AdminRoleBrief{}
|
||||
}
|
||||
return &v1.AdminUserRoleListRes{List: briefs}, nil
|
||||
}
|
||||
|
||||
// loadAdminUserRole 读取管理员账号的特权标记,顺带校验账号存在。
|
||||
func loadAdminUserRole(ctx context.Context, userID int64) (*struct {
|
||||
Role string `json:"role"`
|
||||
}, error) {
|
||||
var user *struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
err := dao.SysAdminUsers.Ctx(ctx).Where("id", userID).Fields("role").Scan(&user)
|
||||
if err = lcommon.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, lcommon.NewNotFoundError("用户")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// loadRolesByUsers 批量查询多个管理员的角色,返回 userID → 角色列表。
|
||||
//
|
||||
// 用户列表页要展示每个账号的角色,逐个查询会产生 N+1;这里一次 IN 查询后在内存里分组。
|
||||
// 结果保证每个入参 userID 都有对应条目(无角色者为空切片),前端可直接据此提示「未分配角色」。
|
||||
func loadRolesByUsers(ctx context.Context, userIDs []int64) (map[int64][]v1.AdminRoleBrief, error) {
|
||||
out := make(map[int64][]v1.AdminRoleBrief, len(userIDs))
|
||||
for _, id := range userIDs {
|
||||
out[id] = []v1.AdminRoleBrief{}
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
AdminUserId int64 `json:"admin_user_id"`
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
}
|
||||
err := dao.SysAdminUserRoles.Ctx(ctx).
|
||||
As("ur").
|
||||
LeftJoin("sys_admin_roles r", "r.id = ur.role_id").
|
||||
WhereIn("ur.admin_user_id", userIDs).
|
||||
Fields("ur.admin_user_id, r.id, r.code, r.name, r.is_enabled").
|
||||
OrderAsc("r.sort").
|
||||
Scan(&rows)
|
||||
if err = lcommon.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
out[r.AdminUserId] = append(out[r.AdminUserId], v1.AdminRoleBrief{
|
||||
ID: r.ID,
|
||||
Code: r.Code,
|
||||
Name: r.Name,
|
||||
IsEnabled: r.IsEnabled,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/model/do"
|
||||
)
|
||||
|
||||
// builtinRoleDefaults 是三个预置角色的默认权限,用于「恢复默认权限」。
|
||||
//
|
||||
// 与 migrations/000020_admin_role_management.sql 的种子数据必须一致,
|
||||
// 由 TestBuiltinRoleDefaultsMatchMigration 在 CI 阶段比对,防止两处漂移。
|
||||
//
|
||||
// 注意这些只是初始值而非系统契约:用户可以任意修改,甚至删掉预置角色自建一套。
|
||||
var builtinRoleDefaults = map[string][]string{
|
||||
// 管理员:除 user:*(账号与角色)与 system:plugin 外的全部权限
|
||||
//(在线自更新为超管专属,不设权限点)。
|
||||
// 与运营的区别就是「能碰钱(退款)和系统配置」—— 去掉这两项,两个角色就没有分开的必要。
|
||||
"admin": {
|
||||
"dashboard:view",
|
||||
"channel:view", "channel:create", "channel:edit", "channel:test", "channel:delete",
|
||||
"model:view", "model:create", "model:edit", "model:delete",
|
||||
"tenant:view", "tenant:create", "tenant:edit", "tenant:suspend", "tenant:delete", "tenant:close",
|
||||
"member:view", "member:import", "member:manage", "member:model_scope",
|
||||
"plan:view", "plan:create", "plan:edit", "plan:delete",
|
||||
"billing:view", "billing:export", "billing:refund",
|
||||
"order:view", "order:refund",
|
||||
"promo:view", "promo:create", "promo:edit",
|
||||
"redemption:view", "redemption:create", "redemption:edit",
|
||||
"invoice:view", "invoice:manage",
|
||||
"operation:view", "operation:edit",
|
||||
"support:view", "support:reply", "support:edit",
|
||||
"monitor:view", "monitor:edit",
|
||||
"task:view", "task:edit",
|
||||
"file:view", "file:delete", "file:cleanup",
|
||||
"audit:view", "audit:export",
|
||||
"system:view", "system:edit",
|
||||
},
|
||||
// 运营:平台日常运营,渠道与模型的完整权限(配渠道就得填 Key,这是本职工作)。
|
||||
// 不给退款、审计、系统设置、账号管理。租户停在「操作」档:能停服,不能删除与销户。
|
||||
"operator": {
|
||||
"dashboard:view",
|
||||
"channel:view", "channel:create", "channel:edit", "channel:test", "channel:delete",
|
||||
"model:view", "model:create", "model:edit", "model:delete",
|
||||
"tenant:view", "tenant:create", "tenant:edit", "tenant:suspend",
|
||||
"member:view", "member:import", "member:manage", "member:model_scope",
|
||||
"plan:view", "plan:create", "plan:edit", "plan:delete",
|
||||
"billing:view",
|
||||
"order:view",
|
||||
"promo:view", "promo:create", "promo:edit",
|
||||
"redemption:view", "redemption:create", "redemption:edit",
|
||||
"invoice:view", "invoice:manage",
|
||||
"operation:view", "operation:edit",
|
||||
"support:view", "support:reply", "support:edit",
|
||||
"monitor:view", "monitor:edit",
|
||||
"task:view", "task:edit",
|
||||
"file:view", "file:delete", "file:cleanup",
|
||||
},
|
||||
// 技术支持:除财务面外全站只读 + 工单完整权限。
|
||||
// 排查主力工具不在财务面:请求审计日志走 audit:view,错误日志与渠道错误监控走 monitor:view。
|
||||
"support": {
|
||||
"dashboard:view",
|
||||
"channel:view",
|
||||
"model:view",
|
||||
"tenant:view",
|
||||
"member:view",
|
||||
"plan:view",
|
||||
"operation:view",
|
||||
"support:view", "support:reply", "support:edit",
|
||||
"monitor:view",
|
||||
"task:view",
|
||||
"file:view",
|
||||
"audit:view",
|
||||
},
|
||||
}
|
||||
|
||||
// assertSuperAdmin 校验调用者是超级管理员。
|
||||
//
|
||||
// 与 middleware 的 superAdminOnlyRules 是两道独立的闸门:中间件按路由拦截,这里按方法拦截。
|
||||
// 双重校验不是冗余 —— 路由表漏配、内部调用绕过 HTTP 层、将来有人给角色接口加了新路径,
|
||||
// 任何一种情况下都还有一道守住。权限体系的元操作值得这个代价。
|
||||
func assertSuperAdmin(ctx context.Context) error {
|
||||
if lcommon.GetCtxUserRole(ctx) == "super_admin" {
|
||||
return nil
|
||||
}
|
||||
return lcommon.NewBusinessError(consts.CodeForbidden, "角色权限管理仅超级管理员可用")
|
||||
}
|
||||
|
||||
// ListRoles 返回角色列表(角色数量少,不分页)。
|
||||
func (s *sAdmin) ListRoles(ctx context.Context, _ *v1.AdminRoleListReq) (*v1.AdminRoleListRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var roles []struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
Sort int `json:"sort"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
err := dao.SysAdminRoles.Ctx(ctx).OrderAsc("sort").OrderAsc("id").Scan(&roles)
|
||||
if err = lcommon.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userCounts, err := countByRole(ctx, dao.SysAdminUserRoles.Ctx(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
permCounts, err := countByRole(ctx, dao.SysAdminRolePermissions.Ctx(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := make([]v1.AdminRoleItem, len(roles))
|
||||
for i, r := range roles {
|
||||
list[i] = v1.AdminRoleItem{
|
||||
ID: r.ID,
|
||||
Code: r.Code,
|
||||
Name: r.Name,
|
||||
Description: r.Description,
|
||||
IsBuiltin: r.IsBuiltin,
|
||||
IsEnabled: r.IsEnabled,
|
||||
Sort: r.Sort,
|
||||
UserCount: userCounts[r.ID],
|
||||
PermCount: permCounts[r.ID],
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
return &v1.AdminRoleListRes{List: list}, nil
|
||||
}
|
||||
|
||||
// countByRole 按 role_id 聚合计数,避免在角色列表里对每个角色各查一次(N+1)。
|
||||
func countByRole(ctx context.Context, model *gdb.Model) (map[int64]int, error) {
|
||||
var rows []struct {
|
||||
RoleId int64 `json:"role_id"`
|
||||
Cnt int `json:"cnt"`
|
||||
}
|
||||
err := model.Fields("role_id, COUNT(*) AS cnt").Group("role_id").Scan(&rows)
|
||||
if err = lcommon.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[int64]int, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.RoleId] = r.Cnt
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetRoleDetail 返回角色详情,同时给出权限点全集与按模块归纳的档位视图。
|
||||
func (s *sAdmin) GetRoleDetail(ctx context.Context, req *v1.AdminRoleDetailReq) (*v1.AdminRoleDetailRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
role, err := loadRole(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
perms, err := loadRolePermissions(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userCount, err := dao.SysAdminUserRoles.Ctx(ctx).Where("role_id", req.ID).Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &v1.AdminRoleDetailRes{
|
||||
ID: role.ID,
|
||||
Code: role.Code,
|
||||
Name: role.Name,
|
||||
Description: role.Description,
|
||||
IsBuiltin: role.IsBuiltin,
|
||||
IsEnabled: role.IsEnabled,
|
||||
Sort: role.Sort,
|
||||
UserCount: userCount,
|
||||
Permissions: perms,
|
||||
ModuleTiers: buildModuleTiers(perms),
|
||||
CreatedAt: role.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// buildModuleTiers 把权限点集合归纳成每个模块的档位选择,供界面按档位渲染。
|
||||
func buildModuleTiers(perms []string) []v1.AdminRoleModuleTier {
|
||||
modules := make([]string, 0, len(tierPermissions))
|
||||
for m := range tierPermissions {
|
||||
modules = append(modules, m)
|
||||
}
|
||||
sort.Strings(modules)
|
||||
|
||||
out := make([]v1.AdminRoleModuleTier, 0, len(modules))
|
||||
for _, m := range modules {
|
||||
out = append(out, v1.AdminRoleModuleTier{Module: m, Tier: TierForPermissions(m, perms)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CreateRole 新建角色。权限可直接给出,也可从现有角色复制。
|
||||
func (s *sAdmin) CreateRole(ctx context.Context, req *v1.AdminRoleCreateReq) (*v1.AdminRoleCreateRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code := strings.ToLower(strings.TrimSpace(req.Code))
|
||||
if code == reservedRoleCode {
|
||||
return nil, lcommon.NewBadRequestError("super_admin 是保留标识,超级管理员由账号属性判定,不能作为角色")
|
||||
}
|
||||
if !roleCodeRegexp.MatchString(code) {
|
||||
return nil, lcommon.NewBadRequestError("角色标识需以小写字母开头,仅含小写字母、数字与下划线,长度 2-50")
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, lcommon.NewBadRequestError("请输入角色名称")
|
||||
}
|
||||
|
||||
exists, err := dao.SysAdminRoles.Ctx(ctx).Where("code", code).Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists > 0 {
|
||||
return nil, lcommon.NewBadRequestError("角色标识已存在:" + code)
|
||||
}
|
||||
|
||||
// 权限来源:显式给出的权限点优先,其次从指定角色复制
|
||||
perms := req.Permissions
|
||||
if len(perms) == 0 && req.CopyFromRoleID > 0 {
|
||||
if _, err := loadRole(ctx, req.CopyFromRoleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if perms, err = loadRolePermissions(ctx, req.CopyFromRoleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
perms, err = sanitizeGrantedPermissions(ctx, perms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var newID int64
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
result, err := dao.SysAdminRoles.Ctx(ctx).Data(do.SysAdminRoles{
|
||||
Code: code,
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
IsBuiltin: false,
|
||||
IsEnabled: true,
|
||||
Sort: req.Sort,
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
if lcommon.IsDuplicateKeyError(err) {
|
||||
return lcommon.NewBadRequestError("角色标识已存在:" + code)
|
||||
}
|
||||
return err
|
||||
}
|
||||
newID, err = result.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceRolePermissions(ctx, newID, perms)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &v1.AdminRoleCreateRes{ID: newID}, nil
|
||||
}
|
||||
|
||||
// UpdateRole 更新角色的名称、说明、排序与权限(code 不可修改)。
|
||||
func (s *sAdmin) UpdateRole(ctx context.Context, req *v1.AdminRoleUpdateReq) (*v1.AdminRoleUpdateRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := loadRole(ctx, req.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := g.Map{}
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name == "" {
|
||||
return nil, lcommon.NewBadRequestError("角色名称不能为空")
|
||||
}
|
||||
data["name"] = name
|
||||
}
|
||||
if req.Description != nil {
|
||||
data["description"] = strings.TrimSpace(*req.Description)
|
||||
}
|
||||
if req.Sort != nil {
|
||||
data["sort"] = *req.Sort
|
||||
}
|
||||
|
||||
var perms []string
|
||||
if req.Permissions != nil {
|
||||
var err error
|
||||
if perms, err = sanitizeGrantedPermissions(ctx, *req.Permissions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if len(data) > 0 {
|
||||
data["updated_at"] = gdb.Raw("now()")
|
||||
if _, err := dao.SysAdminRoles.Ctx(ctx).Where("id", req.ID).Data(data).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if req.Permissions != nil {
|
||||
return replaceRolePermissions(ctx, req.ID, perms)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 角色权限变更影响该角色下的全部用户,直接清空前缀
|
||||
if req.Permissions != nil {
|
||||
InvalidateAllPermCache(ctx)
|
||||
}
|
||||
return &v1.AdminRoleUpdateRes{}, nil
|
||||
}
|
||||
|
||||
// UpdateRoleStatus 启用/禁用角色。禁用后该角色的权限对全部关联用户立即失效。
|
||||
func (s *sAdmin) UpdateRoleStatus(ctx context.Context, req *v1.AdminRoleStatusUpdateReq) (*v1.AdminRoleStatusUpdateRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := loadRole(ctx, req.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err := dao.SysAdminRoles.Ctx(ctx).Where("id", req.ID).Data(g.Map{
|
||||
"is_enabled": req.IsEnabled,
|
||||
"updated_at": gdb.Raw("now()"),
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
InvalidateAllPermCache(ctx)
|
||||
return &v1.AdminRoleStatusUpdateRes{}, nil
|
||||
}
|
||||
|
||||
// DeleteRole 删除角色,并在同一事务内级联清理其权限行与用户关联。
|
||||
//
|
||||
// 无外键,级联必须由业务层完成,否则会留下悬空的权限行与用户关联。
|
||||
// 预置角色同样可删:is_builtin 只标识来源,不构成删除保护。
|
||||
//
|
||||
// 不存在把自己锁死的风险 —— 超级管理员由 sys_admin_users.role 判定并短路鉴权,
|
||||
// 不依赖任何角色记录,即便角色被删光也能登录并重建,因此不设「至少保留一个角色」的约束。
|
||||
func (s *sAdmin) DeleteRole(ctx context.Context, req *v1.AdminRoleDeleteReq) (*v1.AdminRoleDeleteRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := loadRole(ctx, req.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
affected, err := dao.SysAdminUserRoles.Ctx(ctx).Where("role_id", req.ID).Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
if _, err := dao.SysAdminUserRoles.Ctx(ctx).Where("role_id", req.ID).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := dao.SysAdminRolePermissions.Ctx(ctx).Where("role_id", req.ID).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := dao.SysAdminRoles.Ctx(ctx).Where("id", req.ID).Delete()
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
InvalidateAllPermCache(ctx)
|
||||
return &v1.AdminRoleDeleteRes{AffectedUsers: affected}, nil
|
||||
}
|
||||
|
||||
// ResetRoleDefaults 把预置角色的权限恢复为出厂默认值。
|
||||
func (s *sAdmin) ResetRoleDefaults(ctx context.Context, req *v1.AdminRoleResetReq) (*v1.AdminRoleResetRes, error) {
|
||||
if err := assertSuperAdmin(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
role, err := loadRole(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !role.IsBuiltin {
|
||||
return nil, lcommon.NewBadRequestError("只有系统预置角色才有默认权限可恢复")
|
||||
}
|
||||
defaults, ok := builtinRoleDefaults[role.Code]
|
||||
if !ok {
|
||||
return nil, lcommon.NewBadRequestError("该角色没有登记默认权限")
|
||||
}
|
||||
|
||||
perms, err := sanitizeGrantedPermissions(ctx, defaults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return replaceRolePermissions(ctx, req.ID, perms)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
InvalidateAllPermCache(ctx)
|
||||
return &v1.AdminRoleResetRes{Permissions: perms}, nil
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 内部辅助
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type roleRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
Sort int `json:"sort"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func loadRole(ctx context.Context, id int64) (*roleRecord, error) {
|
||||
var role *roleRecord
|
||||
err := dao.SysAdminRoles.Ctx(ctx).Where("id", id).Scan(&role)
|
||||
if err = lcommon.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == nil {
|
||||
return nil, lcommon.NewNotFoundError("角色")
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func loadRolePermissions(ctx context.Context, roleID int64) ([]string, error) {
|
||||
var rows []struct {
|
||||
PermissionPoint string `json:"permission_point"`
|
||||
}
|
||||
err := dao.SysAdminRolePermissions.Ctx(ctx).
|
||||
Where("role_id", roleID).
|
||||
Fields("permission_point").
|
||||
OrderAsc("permission_point").
|
||||
Scan(&rows)
|
||||
if err = lcommon.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = r.PermissionPoint
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// replaceRolePermissions 全量覆盖角色权限(调用方负责事务与缓存失效)。
|
||||
func replaceRolePermissions(ctx context.Context, roleID int64, perms []string) error {
|
||||
if _, err := dao.SysAdminRolePermissions.Ctx(ctx).
|
||||
Where("role_id", roleID).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(perms) == 0 {
|
||||
return nil
|
||||
}
|
||||
data := make([]do.SysAdminRolePermissions, len(perms))
|
||||
for i, p := range perms {
|
||||
data[i] = do.SysAdminRolePermissions{RoleId: roleID, PermissionPoint: p}
|
||||
}
|
||||
_, err := dao.SysAdminRolePermissions.Ctx(ctx).Data(data).Insert()
|
||||
return err
|
||||
}
|
||||
|
||||
// sanitizeGrantedPermissions 校验并规整待授予的权限点:
|
||||
// 1. 去重、去空、排序,保证同一份权限集合在库里的表示是稳定的
|
||||
// 2. 拒绝未定义的权限点 —— 写错一个字(channel:veiw)不会有编译期报错,
|
||||
// 但会让该模块对该角色永久失效,且排查时毫无线索
|
||||
// 3. 防自我提权:非超管不得授予自己当前不具备的权限点
|
||||
//
|
||||
// 第 3 条是兜底。按预置矩阵只有超管拥有 user:edit,但自定义角色可能把 user:* 授予他人,
|
||||
// 那时若不拦截,一个有 user:edit 的账号就能给自己加满权限,角色体系形同虚设。
|
||||
func sanitizeGrantedPermissions(ctx context.Context, perms []string) ([]string, error) {
|
||||
valid := buildValidPermissionSet()
|
||||
|
||||
seen := make(map[string]bool, len(perms))
|
||||
out := make([]string, 0, len(perms))
|
||||
for _, p := range perms {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || seen[p] {
|
||||
continue
|
||||
}
|
||||
if !valid[p] {
|
||||
return nil, lcommon.NewBadRequestError("无效的权限点:" + p)
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
|
||||
operatorRole := lcommon.GetCtxUserRole(ctx)
|
||||
if operatorRole == "super_admin" {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
operatorID := lcommon.GetCtxUserID(ctx)
|
||||
if operatorID == 0 {
|
||||
// 没有调用者身份(如后台任务)时不做提权判断,交由上层接口鉴权把关
|
||||
return out, nil
|
||||
}
|
||||
own, err := GetEffectivePermissions(ctx, operatorID, operatorRole)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ownSet := make(map[string]bool, len(own))
|
||||
for _, p := range own {
|
||||
ownSet[p] = true
|
||||
}
|
||||
for _, p := range out {
|
||||
if !ownSet[p] {
|
||||
return nil, lcommon.NewBadRequestError("不能授予自己不具备的权限:" + p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 档位模型
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestTierIsSuperset 高档位必须包含低档位的全部权限点。
|
||||
// 档位是「递进」语义,若高档漏了低档的某个权限,用户从「操作」升到「完全」反而会丢权限。
|
||||
func TestTierIsSuperset(t *testing.T) {
|
||||
for module, defs := range tierPermissions {
|
||||
read, hasRead := defs[TierRead]
|
||||
operate, hasOperate := defs[TierOperate]
|
||||
full, hasFull := defs[TierFull]
|
||||
|
||||
if hasRead && hasOperate {
|
||||
assertSubset(t, module, TierRead, read, TierOperate, operate)
|
||||
}
|
||||
if hasOperate && hasFull {
|
||||
assertSubset(t, module, TierOperate, operate, TierFull, full)
|
||||
}
|
||||
if hasRead && hasFull {
|
||||
assertSubset(t, module, TierRead, read, TierFull, full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertSubset(t *testing.T, module, loTier string, lo []string, hiTier string, hi []string) {
|
||||
t.Helper()
|
||||
set := make(map[string]bool, len(hi))
|
||||
for _, p := range hi {
|
||||
set[p] = true
|
||||
}
|
||||
for _, p := range lo {
|
||||
if !set[p] {
|
||||
t.Errorf("模块 %s:%s 档的 %q 未包含在 %s 档中,升档会丢权限", module, loTier, p, hiTier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTierPermissionsAreDefined 档位映射表里的权限点必须真实存在。
|
||||
// 写错一个字不会有编译期报错,但会让该档位永久失效。
|
||||
func TestTierPermissionsAreDefined(t *testing.T) {
|
||||
valid := buildValidPermissionSet()
|
||||
for module, defs := range tierPermissions {
|
||||
for tier, perms := range defs {
|
||||
for _, p := range perms {
|
||||
if !valid[p] {
|
||||
t.Errorf("模块 %s 的 %s 档引用了未定义的权限点 %q", module, tier, p)
|
||||
}
|
||||
if !strings.HasPrefix(p, module+":") {
|
||||
t.Errorf("模块 %s 的 %s 档混入了其他模块的权限点 %q", module, tier, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryPermissionGroupHasTiers 每个权限点分组都要有对应的档位定义,
|
||||
// 否则该模块在角色配置界面上无法配置(只能走高级模式),等于遗漏。
|
||||
func TestEveryPermissionGroupHasTiers(t *testing.T) {
|
||||
for _, grp := range predefinedPermissionGroups {
|
||||
if _, ok := tierPermissions[grp.Name]; !ok {
|
||||
t.Errorf("权限分组 %q 没有档位定义,角色界面将无法配置该模块", grp.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModuleTiersFoldsEquivalent 等价档位必须折叠,界面不渲染无意义的选项。
|
||||
func TestModuleTiersFoldsEquivalent(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"dashboard": {TierNone, TierRead}, // 只有 view,无操作/完全之分
|
||||
"promo": {TierNone, TierRead, TierOperate}, // 没有删除动作,无「完全」档
|
||||
"order": {TierNone, TierRead, TierFull}, // 只读 → 含退款,没有中间档
|
||||
"tenant": {TierNone, TierRead, TierOperate, TierFull}, // 三档齐全
|
||||
}
|
||||
for module, want := range cases {
|
||||
got := ModuleTiers(module)
|
||||
if !equalStrings(got, want) {
|
||||
t.Errorf("ModuleTiers(%q) = %v, want %v", module, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTierRoundTrip 档位 → 权限点 → 档位 必须能原样还原,
|
||||
// 否则界面在「保存后重新打开」时会把用户选的档位显示成别的档。
|
||||
func TestTierRoundTrip(t *testing.T) {
|
||||
for module := range tierPermissions {
|
||||
for _, tier := range ModuleTiers(module) {
|
||||
perms := TierPermissions(module, tier)
|
||||
if got := TierForPermissions(module, perms); got != tier {
|
||||
t.Errorf("模块 %s:档位 %s 的权限集反推为 %q,未能还原", module, tier, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTierForPermissionsCustom 不匹配任何档位的权限组合应返回空串(界面显示「自定义」)。
|
||||
func TestTierForPermissionsCustom(t *testing.T) {
|
||||
// 只给 delete 不给 view:不属于任何档位
|
||||
if got := TierForPermissions("model", []string{"model:delete"}); got != "" {
|
||||
t.Errorf("自定义权限组合应返回空串,实际 %q", got)
|
||||
}
|
||||
// 无该模块任何权限 = none
|
||||
if got := TierForPermissions("model", []string{"tenant:view"}); got != TierNone {
|
||||
t.Errorf("无该模块权限应返回 none,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 预置角色:锁住安全关键的取舍
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestBuiltinRoleDefaultsAreValid 预置角色的权限点必须真实存在且无重复。
|
||||
func TestBuiltinRoleDefaultsAreValid(t *testing.T) {
|
||||
valid := buildValidPermissionSet()
|
||||
for code, perms := range builtinRoleDefaults {
|
||||
seen := make(map[string]bool, len(perms))
|
||||
for _, p := range perms {
|
||||
if !valid[p] {
|
||||
t.Errorf("预置角色 %s 引用了未定义的权限点 %q", code, p)
|
||||
}
|
||||
if seen[p] {
|
||||
t.Errorf("预置角色 %s 重复声明权限点 %q", code, p)
|
||||
}
|
||||
seen[p] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuiltinRoleBoundaries 锁定三个角色的分界线。
|
||||
//
|
||||
// 这些不是实现细节而是安全决策:改动它们意味着改变谁能碰钱、谁能改系统、谁能提权。
|
||||
// 本测试让这类改动必须是显式的。
|
||||
func TestBuiltinRoleBoundaries(t *testing.T) {
|
||||
has := func(code, perm string) bool {
|
||||
for _, p := range builtinRoleDefaults[code] {
|
||||
if p == perm {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 地基:能改权限的人只能是超级管理员。
|
||||
// 任何预置角色拿到 user:* 都意味着它能给自己加满权限,角色体系随即失效。
|
||||
for code := range builtinRoleDefaults {
|
||||
for _, perm := range []string{"user:view", "user:create", "user:edit", "user:delete"} {
|
||||
if has(code, perm) {
|
||||
t.Errorf("预置角色 %s 不应拥有账号管理权限 %s —— 能改权限的人只能是超级管理员", code, perm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 插件是代码执行面、敏感日志是隐私面,仅限超管(在线自更新无权限点,超管专属)
|
||||
for code := range builtinRoleDefaults {
|
||||
for _, perm := range []string{"system:plugin", "audit:read_sensitive"} {
|
||||
if has(code, perm) {
|
||||
t.Errorf("预置角色 %s 不应拥有 %s(仅限超级管理员)", code, perm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 管理员 = 能碰钱与系统配置;这正是它与运营分开存在的理由
|
||||
for _, perm := range []string{"billing:refund", "order:refund", "system:edit"} {
|
||||
if !has("admin", perm) {
|
||||
t.Errorf("管理员应拥有 %s(去掉退款与系统设置,管理员与运营就没有分开的必要)", perm)
|
||||
}
|
||||
}
|
||||
|
||||
// 运营 = 业务配置全权,但碰不了钱
|
||||
for _, perm := range []string{"channel:edit", "channel:delete", "model:edit", "plan:edit"} {
|
||||
if !has("operator", perm) {
|
||||
t.Errorf("运营应拥有 %s(平台运营的本职工作就是配渠道配模型)", perm)
|
||||
}
|
||||
}
|
||||
for _, perm := range []string{"billing:refund", "order:refund", "system:edit", "system:view", "audit:view"} {
|
||||
if has("operator", perm) {
|
||||
t.Errorf("运营不应拥有 %s", perm)
|
||||
}
|
||||
}
|
||||
// 租户停在「操作」档:能停服(可撤销),不能删除与销户(不可逆)
|
||||
if !has("operator", "tenant:suspend") {
|
||||
t.Error("运营应拥有 tenant:suspend(欠费/违规停服是日常运营手段)")
|
||||
}
|
||||
for _, perm := range []string{"tenant:delete", "tenant:close"} {
|
||||
if has("operator", perm) {
|
||||
t.Errorf("运营不应拥有 %s(不可逆操作留给管理员)", perm)
|
||||
}
|
||||
}
|
||||
|
||||
// 技术支持 = 除财务面外全站只读,唯一写权限是工单
|
||||
for _, perm := range []string{"support:reply", "support:edit", "audit:view", "monitor:view", "channel:view"} {
|
||||
if !has("support", perm) {
|
||||
t.Errorf("技术支持应拥有 %s(排查问题所需)", perm)
|
||||
}
|
||||
}
|
||||
for _, perm := range []string{"billing:view", "order:view", "promo:view", "redemption:view", "invoice:view"} {
|
||||
if has("support", perm) {
|
||||
t.Errorf("技术支持不应看到财务数据 %s", perm)
|
||||
}
|
||||
}
|
||||
// 除 support:* 外不得有任何写权限
|
||||
for _, p := range builtinRoleDefaults["support"] {
|
||||
if strings.HasPrefix(p, "support:") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(p, ":view") {
|
||||
t.Errorf("技术支持除工单外应全部只读,但拥有写权限 %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuiltinRoleDefaultsMatchMigration 保证 Go 侧的「恢复默认权限」与迁移种子一致。
|
||||
//
|
||||
// 两处各存一份权限清单是必要的(迁移是 SQL、恢复默认是 Go),但漂移后果隐蔽:
|
||||
// 新装部署拿到迁移的版本,点了「恢复默认」却变成 Go 的版本,同一个角色在不同部署里权限不同。
|
||||
func TestBuiltinRoleDefaultsMatchMigration(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../../migrations/000020_admin_role_management.sql")
|
||||
if err != nil {
|
||||
t.Fatalf("读取迁移脚本失败: %v", err)
|
||||
}
|
||||
sql := string(raw)
|
||||
|
||||
// 匹配 unnest(ARRAY[ ... ]) ... WHERE r.code = 'xxx'
|
||||
blockRe := regexp.MustCompile(`(?s)unnest\(ARRAY\[(.*?)\]\).*?r\.code = '([a-z_]+)'`)
|
||||
permRe := regexp.MustCompile(`'([a-z_]+:[a-z_]+)'`)
|
||||
|
||||
found := make(map[string][]string)
|
||||
for _, m := range blockRe.FindAllStringSubmatch(sql, -1) {
|
||||
body, code := m[1], m[2]
|
||||
var perms []string
|
||||
for _, pm := range permRe.FindAllStringSubmatch(body, -1) {
|
||||
perms = append(perms, pm[1])
|
||||
}
|
||||
found[code] = perms
|
||||
}
|
||||
|
||||
if len(found) != len(builtinRoleDefaults) {
|
||||
t.Fatalf("迁移脚本里解析到 %d 个角色种子,Go 侧定义了 %d 个",
|
||||
len(found), len(builtinRoleDefaults))
|
||||
}
|
||||
|
||||
for code, want := range builtinRoleDefaults {
|
||||
got, ok := found[code]
|
||||
if !ok {
|
||||
t.Errorf("迁移脚本缺少角色 %s 的权限种子", code)
|
||||
continue
|
||||
}
|
||||
if !equalStringSets(got, want) {
|
||||
t.Errorf("角色 %s 的权限在迁移脚本与 Go 定义之间不一致:\n 仅迁移有: %v\n 仅 Go 有: %v",
|
||||
code, difference(got, want), difference(want, got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDangerousPermissionsAreDefined 高危权限清单必须引用真实存在的权限点。
|
||||
func TestDangerousPermissionsAreDefined(t *testing.T) {
|
||||
valid := buildValidPermissionSet()
|
||||
for p := range dangerousPermissions {
|
||||
if !valid[p] {
|
||||
t.Errorf("高危权限清单引用了未定义的权限点 %q", p)
|
||||
}
|
||||
}
|
||||
// 资金与提权类必须在清单内 —— 它们是二次确认的核心对象
|
||||
for _, p := range []string{"billing:refund", "order:refund", "user:edit", "system:edit"} {
|
||||
if _, ok := IsDangerousPermission(p); !ok {
|
||||
t.Errorf("%s 应被标记为高危权限", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 角色标识校验
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestRoleCodeValidation(t *testing.T) {
|
||||
valid := []string{"operator", "sales", "ops_l2", "support2", "ab"}
|
||||
invalid := []string{
|
||||
"", // 空
|
||||
"a", // 太短
|
||||
"Operator", // 大写
|
||||
"2ops", // 数字开头
|
||||
"ops-l2", // 连字符
|
||||
"ops l2", // 空格
|
||||
"客服", // 中文
|
||||
strings.Repeat("a", 51), // 超长
|
||||
}
|
||||
for _, c := range valid {
|
||||
if !roleCodeRegexp.MatchString(c) {
|
||||
t.Errorf("角色标识 %q 应被接受", c)
|
||||
}
|
||||
}
|
||||
for _, c := range invalid {
|
||||
if roleCodeRegexp.MatchString(c) {
|
||||
t.Errorf("角色标识 %q 应被拒绝", c)
|
||||
}
|
||||
}
|
||||
// super_admin 形式上合法,但属于保留字,由业务层单独拦截
|
||||
if !roleCodeRegexp.MatchString(reservedRoleCode) {
|
||||
t.Error("保留字 super_admin 形式上应合法(由业务层按保留字拦截,而非靠正则)")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 辅助
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestSamePermissionSet(t *testing.T) {
|
||||
if !samePermissionSet([]string{"a", "b"}, []string{"b", "a"}) {
|
||||
t.Error("顺序不同的相同集合应判定为相等")
|
||||
}
|
||||
if samePermissionSet([]string{"a"}, []string{"a", "b"}) {
|
||||
t.Error("长度不同的集合应判定为不等")
|
||||
}
|
||||
if samePermissionSet(nil, []string{"a"}) {
|
||||
t.Error("nil 与非空集合应判定为不等")
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalStringSets(a, b []string) bool {
|
||||
sa := append([]string(nil), a...)
|
||||
sb := append([]string(nil), b...)
|
||||
sort.Strings(sa)
|
||||
sort.Strings(sb)
|
||||
return equalStrings(sa, sb)
|
||||
}
|
||||
|
||||
// difference 返回在 a 中但不在 b 中的元素。
|
||||
func difference(a, b []string) []string {
|
||||
set := make(map[string]bool, len(b))
|
||||
for _, s := range b {
|
||||
set[s] = true
|
||||
}
|
||||
var out []string
|
||||
for _, s := range a {
|
||||
if !set[s] {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -69,6 +69,7 @@ func (s *sAdmin) Verify2FA(ctx context.Context, req *v1.Admin2FAVerifyReq) (*v1.
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "create session")
|
||||
}
|
||||
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
|
||||
|
||||
// Generate token pair
|
||||
tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "admin", user.Role, 0, sessionID, jti)
|
||||
@@ -98,6 +99,7 @@ func (s *sAdmin) Verify2FA(ctx context.Context, req *v1.Admin2FAVerifyReq) (*v1.
|
||||
res.User.Username = user.Username
|
||||
res.User.DisplayName = user.DisplayName
|
||||
res.User.Role = user.Role
|
||||
res.Permissions, res.Roles = loadSessionPermissions(ctx, user.Id, user.Role)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -254,6 +256,8 @@ func (s *sAdmin) LoginHistory(ctx context.Context, req *v1.AdminLoginHistoryReq)
|
||||
type adminUserBrief struct {
|
||||
Username string
|
||||
DisplayName string
|
||||
// Role 用于会话列表标记超管会话(前端隐藏撤销入口),其他场景可为空
|
||||
Role string
|
||||
}
|
||||
|
||||
func adminUserIdsByUsername(ctx context.Context, keyword string) ([]int64, error) {
|
||||
|
||||
@@ -86,6 +86,8 @@ func (s *sAdmin) UpdateSettings(ctx context.Context, req *v1.AdminSettingsUpdate
|
||||
// validateCrossFieldSettings validates interdependent settings within a category.
|
||||
func (s *sAdmin) validateCrossFieldSettings(ctx context.Context, category string, values map[string]string) error {
|
||||
switch category {
|
||||
case "general":
|
||||
return s.validateGeneralSettings(ctx, values)
|
||||
case "security":
|
||||
return s.validateSecuritySettings(ctx, values)
|
||||
case "channel":
|
||||
@@ -95,6 +97,20 @@ func (s *sAdmin) validateCrossFieldSettings(ctx context.Context, category string
|
||||
}
|
||||
}
|
||||
|
||||
// validateGeneralSettings 校验基础配置项:维护时长必须为 Go 时长格式(如 2h、30m、1h30m)。
|
||||
// API 维护中间件按该格式解析 Retry-After,格式非法会静默回退 300s,故在保存入口拦截。
|
||||
func (s *sAdmin) validateGeneralSettings(_ context.Context, values map[string]string) error {
|
||||
durationStr, ok := values["maintenance_duration"]
|
||||
if !ok || strings.TrimSpace(durationStr) == "" {
|
||||
return nil
|
||||
}
|
||||
d, err := time.ParseDuration(strings.TrimSpace(durationStr))
|
||||
if err != nil || d <= 0 {
|
||||
return common.NewBadRequestError("维护时长格式不正确,请使用如 2h、30m、1h30m 的时长格式")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSecuritySettings 校验安全配置项之间的依赖:启用 Turnstile 前必须已配置密钥。
|
||||
func (s *sAdmin) validateSecuritySettings(ctx context.Context, values map[string]string) error {
|
||||
enabled, ok := values["turnstile_enabled"]
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 顶栏铃铛:客服支持待办聚合
|
||||
//
|
||||
// 与工作台(workbench.go)的分工:工作台回答「哪些事异常了」,
|
||||
// 刻意不报工单总数避免永久红点;铃铛回答「有多少客户在等回复」,
|
||||
// 报全量 pending —— 处理完自动归零,不需要任何人点「完成」。
|
||||
// 待办不落库、实时派生,整份结果进 Redis 缓存 30s(与工作台同模式),
|
||||
// 菜单红点轮询与铃铛各自独立缓存键,互不挤兑。
|
||||
// ============================================================
|
||||
|
||||
const (
|
||||
supportPendingCacheKey = "admin:support_pending:v1"
|
||||
supportPendingCacheTTL = 30 * time.Second
|
||||
)
|
||||
|
||||
// supportPendingCollected 一轮聚合的完整结果(进缓存的就是它)。
|
||||
type supportPendingCollected struct {
|
||||
Tickets int `json:"tickets"`
|
||||
TicketsOverdue int `json:"tickets_overdue"`
|
||||
Feedbacks int `json:"feedbacks"`
|
||||
FeedbacksWait string `json:"feedbacks_wait"`
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
}
|
||||
|
||||
// GetSupportPendingSummary 顶栏铃铛待办计数。
|
||||
func (s *sAdmin) GetSupportPendingSummary(ctx context.Context, _ *v1.AdminSupportPendingSummaryReq) (*v1.AdminSupportPendingSummaryRes, error) {
|
||||
col := s.collectSupportPendingCached(ctx)
|
||||
return &v1.AdminSupportPendingSummaryRes{
|
||||
Tickets: col.Tickets,
|
||||
TicketsOverdue: col.TicketsOverdue,
|
||||
Feedbacks: col.Feedbacks,
|
||||
FeedbacksWait: col.FeedbacksWait,
|
||||
Total: col.Tickets + col.Feedbacks,
|
||||
GeneratedAt: col.GeneratedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// collectSupportPendingCached 读缓存,未命中则重新聚合。
|
||||
// 缓存读写失败一律降级为直接查询 —— 铃铛不能因为 Redis 抖动就消失。
|
||||
func (s *sAdmin) collectSupportPendingCached(ctx context.Context) *supportPendingCollected {
|
||||
if v, err := g.Redis().Get(ctx, supportPendingCacheKey); err == nil && !v.IsEmpty() {
|
||||
var col supportPendingCollected
|
||||
if json.Unmarshal(v.Bytes(), &col) == nil {
|
||||
return &col
|
||||
}
|
||||
}
|
||||
|
||||
col := s.collectSupportPending(ctx)
|
||||
|
||||
if b, err := json.Marshal(col); err == nil {
|
||||
if _, err := g.Redis().Do(ctx, "SET", supportPendingCacheKey, b, "EX", int(supportPendingCacheTTL.Seconds())); err != nil {
|
||||
g.Log().Warningf(ctx, "support pending: 写缓存失败: %v", err)
|
||||
}
|
||||
}
|
||||
return col
|
||||
}
|
||||
|
||||
// collectSupportPending 两个独立查询,任一失败只丢自己那部分(记 warning),
|
||||
// 不拖垮整个铃铛 —— 半个铃铛也好过没有铃铛。
|
||||
func (s *sAdmin) collectSupportPending(ctx context.Context) *supportPendingCollected {
|
||||
col := &supportPendingCollected{GeneratedAt: gtime.Now().Format(time.RFC3339)}
|
||||
|
||||
// 工单:pending + reopened 全量。超 SLA 口径与工作台 collector 一致:
|
||||
// 仅 pending 计超时(reopened 说明已有人处理过,不算「未首响」)。
|
||||
rows, err := g.DB().Ctx(ctx).Query(ctx,
|
||||
`SELECT COUNT(*) AS cnt,
|
||||
COUNT(*) FILTER (WHERE status = 'pending' AND created_at <= ?) AS overdue
|
||||
FROM spt_tickets
|
||||
WHERE status IN ('pending', 'reopened')`, gtime.Now().Add(-wbTicketSLAHours*time.Hour))
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "support pending: 查询待处理工单失败: %v", err)
|
||||
} else if len(rows) > 0 {
|
||||
col.Tickets = rows[0]["cnt"].Int()
|
||||
col.TicketsOverdue = rows[0]["overdue"].Int()
|
||||
}
|
||||
|
||||
// 反馈:pending 全量 + 最早一条的等待时长(反馈无 SLA 概念,只展示积压程度)。
|
||||
fbRows, err := g.DB().Ctx(ctx).Query(ctx,
|
||||
`SELECT COUNT(*) AS cnt, MIN(created_at) AS oldest
|
||||
FROM spt_feedbacks WHERE status = 'pending'`)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "support pending: 查询待处理反馈失败: %v", err)
|
||||
} else if len(fbRows) > 0 {
|
||||
col.Feedbacks = fbRows[0]["cnt"].Int()
|
||||
if oldest := fbRows[0]["oldest"].GTime(); oldest != nil {
|
||||
col.FeedbacksWait = humanizeSince(oldest)
|
||||
}
|
||||
}
|
||||
return col
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/shopspring/decimal"
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
@@ -13,6 +12,7 @@ import (
|
||||
tenantLogic "github.com/qianfree/team-api/internal/logic/common"
|
||||
relay "github.com/qianfree/team-api/internal/logic/relay"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// ListTenantModels 列出租户已分配的模型
|
||||
|
||||
@@ -33,6 +33,16 @@ func IsCNY(ctx context.Context) bool {
|
||||
return Currency(ctx) == consts.BillingCurrencyCNY
|
||||
}
|
||||
|
||||
// CurrencySymbol 返回本位币的货币符号,用于服务端生成的展示文本
|
||||
// (计费摘要、CSV/Excel 导出等前端拿不到结构化金额、无法走 useCurrency 的场景)。
|
||||
// 仅适用于 bil_ 记账层金额;ord_/pln_ 层金额恒为 CNY,直接写 ¥,不要用本函数。
|
||||
func CurrencySymbol(ctx context.Context) string {
|
||||
if Currency(ctx) == consts.BillingCurrencyCNY {
|
||||
return "¥"
|
||||
}
|
||||
return "$"
|
||||
}
|
||||
|
||||
// ConvertCNYToUSD 将人民币金额转换为美元,向上取整到小数点后 6 位(decimal 原生版)。
|
||||
// 用 decimal 精确乘法替代 float64 链式运算,避免 cnyAmount×rate 的浮点误差。
|
||||
func ConvertCNYToUSD(ctx context.Context, cnyAmount float64) decimal.Decimal {
|
||||
|
||||
@@ -26,6 +26,7 @@ type RateLimitConfig struct {
|
||||
TenantQPS int // 租户级 QPS 限制(默认 1000)
|
||||
UserQPS int // 用户级 QPS 限制(默认 100)
|
||||
KeyQPS int // Key 级 QPS 限制(默认 60)
|
||||
SystemConc int // 系统级并发限制(默认 0,不限制)——全平台在途请求总闸门
|
||||
TenantConc int // 租户级并发限制(默认 0,不限制)
|
||||
}
|
||||
|
||||
@@ -35,6 +36,7 @@ var DefaultRateLimitConfig = RateLimitConfig{
|
||||
TenantQPS: 1000,
|
||||
UserQPS: 100,
|
||||
KeyQPS: 60,
|
||||
SystemConc: 0,
|
||||
TenantConc: 0,
|
||||
}
|
||||
|
||||
@@ -64,6 +66,9 @@ func LoadRateLimitConfig(ctx context.Context) RateLimitConfig {
|
||||
if v := cfg.GetInt(ctx, "key_qps_limit"); v > 0 {
|
||||
c.KeyQPS = v
|
||||
}
|
||||
if v := cfg.GetInt(ctx, "global_concurrency_limit"); v > 0 {
|
||||
c.SystemConc = v
|
||||
}
|
||||
if v := cfg.GetInt(ctx, "tenant_concurrency_limit"); v > 0 {
|
||||
c.TenantConc = v
|
||||
}
|
||||
@@ -207,20 +212,20 @@ func withKeyQPS(config RateLimitConfig, keyQPS int) RateLimitConfig {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 并发限流:单个 Lua 脚本完成 2 级获取 + 失败回滚(租户 + 模型)
|
||||
// 并发限流:单个 Lua 脚本完成 3 级获取 + 失败回滚(系统 + 租户 + 模型)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// acquireConcurrentLua 一次 EVAL 完成 2 级并发许可获取(租户 + 模型)。
|
||||
// KEYS[1..2]: tenant/model 的 Redis key
|
||||
// ARGV[1..2]: 各级并发限制(0 表示不检查该级)
|
||||
// ARGV[3]: EXPIRE 秒数
|
||||
// acquireConcurrentLua 一次 EVAL 完成 3 级并发许可获取(系统 + 租户 + 模型)。
|
||||
// KEYS[1..3]: system/tenant/model 的 Redis key
|
||||
// ARGV[1..3]: 各级并发限制(0 表示不检查该级)
|
||||
// ARGV[4]: EXPIRE 秒数
|
||||
//
|
||||
// 逐级尝试获取,任一级失败则回滚已获取的许可。
|
||||
// 返回 1=成功, 0=失败。
|
||||
var acquireConcurrentLua = `
|
||||
local keys = {KEYS[1], KEYS[2]}
|
||||
local limits = {tonumber(ARGV[1]), tonumber(ARGV[2])}
|
||||
local expire = tonumber(ARGV[3])
|
||||
local keys = {KEYS[1], KEYS[2], KEYS[3]}
|
||||
local limits = {tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])}
|
||||
local expire = tonumber(ARGV[4])
|
||||
local acquired = {}
|
||||
|
||||
local function tryAcquire(i)
|
||||
@@ -243,16 +248,17 @@ local function tryAcquire(i)
|
||||
return true
|
||||
end
|
||||
|
||||
if not tryAcquire(1) then return 0 end
|
||||
if not tryAcquire(2) then return 0 end
|
||||
for i = 1, 3 do
|
||||
if not tryAcquire(i) then return 0 end
|
||||
end
|
||||
return 1
|
||||
`
|
||||
|
||||
// releaseConcurrentLua 一次 EVAL 释放 2 级并发许可(租户 + 模型)。
|
||||
// releaseConcurrentLua 一次 EVAL 释放 3 级并发许可(系统 + 租户 + 模型)。
|
||||
// ARGV[1] = expire 秒数(用于 SET 后恢复 TTL)
|
||||
var releaseConcurrentLua = `
|
||||
local expire = tonumber(ARGV[1])
|
||||
for i = 1, 2 do
|
||||
for i = 1, 3 do
|
||||
local v = redis.call("DECR", KEYS[i])
|
||||
if v < 0 then
|
||||
redis.call("SET", KEYS[i], 0)
|
||||
@@ -262,11 +268,14 @@ end
|
||||
return 1
|
||||
`
|
||||
|
||||
// AcquireConcurrent 获取并发许可(两级:租户→模型)
|
||||
// AcquireConcurrent 获取并发许可(三级:系统→租户→模型)
|
||||
// 系统级并发限制来自全局配置 global_concurrency_limit(0 表示不限),
|
||||
// 是全平台在途请求总闸门(Redis 计数跨实例共享,多副本天然生效);
|
||||
// 租户级并发限制优先使用 tnt_tenants.max_concurrency,0 表示不限;
|
||||
// 模型级并发限制使用 mdl_tenant_models.max_concurrency,NULL 表示不限;
|
||||
// 若未设置则回退到全局配置 tenant_concurrency_limit。
|
||||
func AcquireConcurrent(ctx context.Context, config RateLimitConfig, tenantID, userID, apiKeyID int64, modelName string) bool {
|
||||
systemKey := "conc:system"
|
||||
tenantKey := fmt.Sprintf("conc:tenant:%d", tenantID)
|
||||
modelKey := fmt.Sprintf("conc:tenant:%d:model:%s", tenantID, modelName)
|
||||
|
||||
@@ -274,9 +283,9 @@ func AcquireConcurrent(ctx context.Context, config RateLimitConfig, tenantID, us
|
||||
tenantLimit := getTenantConcurrencyLimit(ctx, tenantID)
|
||||
modelLimit := getModelConcurrencyLimit(ctx, tenantID, modelName)
|
||||
|
||||
result, err := g.Redis().Do(ctx, "EVAL", acquireConcurrentLua, 2,
|
||||
tenantKey, modelKey,
|
||||
tenantLimit, modelLimit,
|
||||
result, err := g.Redis().Do(ctx, "EVAL", acquireConcurrentLua, 3,
|
||||
systemKey, tenantKey, modelKey,
|
||||
config.SystemConc, tenantLimit, modelLimit,
|
||||
300) // EXPIRE 秒数
|
||||
if err != nil {
|
||||
return true // Redis 不可用,允许通过
|
||||
@@ -285,14 +294,15 @@ func AcquireConcurrent(ctx context.Context, config RateLimitConfig, tenantID, us
|
||||
return result.Int() == 1
|
||||
}
|
||||
|
||||
// ReleaseConcurrent 释放并发许可(租户 + 模型两级)
|
||||
// 单次 EVAL 释放所有 2 个 key。
|
||||
// ReleaseConcurrent 释放并发许可(系统 + 租户 + 模型三级)
|
||||
// 单次 EVAL 释放所有 3 个 key。
|
||||
func ReleaseConcurrent(ctx context.Context, tenantID, userID, apiKeyID int64, modelName string) {
|
||||
systemKey := "conc:system"
|
||||
tenantKey := fmt.Sprintf("conc:tenant:%d", tenantID)
|
||||
modelKey := fmt.Sprintf("conc:tenant:%d:model:%s", tenantID, modelName)
|
||||
|
||||
g.Redis().Do(ctx, "EVAL", releaseConcurrentLua, 2,
|
||||
tenantKey, modelKey,
|
||||
g.Redis().Do(ctx, "EVAL", releaseConcurrentLua, 3,
|
||||
systemKey, tenantKey, modelKey,
|
||||
300) // EXPIRE 秒数
|
||||
}
|
||||
|
||||
|
||||
@@ -389,7 +389,7 @@ func SettleWithUsage(ctx context.Context, tenantID, userID, apiKeyID, channelID
|
||||
if pricingResult != nil {
|
||||
snapshot := GenerateBillingSnapshot(pricingResult, breakdown, usage, settlementResult, relayInfo)
|
||||
snapshotJSON = SnapshotToJSON(snapshot)
|
||||
summaryText = GenerateBillingSummary(snapshot)
|
||||
summaryText = GenerateBillingSummary(ctx, snapshot)
|
||||
settlementResult.BillingMode = pricingResult.BillingMode
|
||||
settlementResult.BillingSource = pricingResult.BillingSource
|
||||
settlementResult.RateMultiplier = pricingResult.DiscountRatio
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
@@ -172,12 +173,17 @@ func buildTokenCosts(pricing *PricingResult, breakdown *CostBreakdown) map[strin
|
||||
return costs
|
||||
}
|
||||
|
||||
// GenerateBillingSummary 生成人类可读的计费摘要文本(中文)
|
||||
func GenerateBillingSummary(snapshot *BillingSnapshot) string {
|
||||
// GenerateBillingSummary 生成人类可读的计费摘要文本(中文)。
|
||||
// 金额符号取系统本位币(billing_currency),摘要为写入时快照,货币在初始化后不可更改。
|
||||
func GenerateBillingSummary(ctx context.Context, snapshot *BillingSnapshot) string {
|
||||
if snapshot == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 货币符号跟随本位币;money 统一拼接符号 + 价格
|
||||
sym := CurrencySymbol(ctx)
|
||||
money := func(v float64) string { return sym + formatPrice(v) }
|
||||
|
||||
// 价格来源中文映射
|
||||
sourceMap := map[string]string{
|
||||
"base": "基础定价",
|
||||
@@ -212,24 +218,24 @@ func GenerateBillingSummary(snapshot *BillingSnapshot) string {
|
||||
|
||||
// 按次计费特殊处理
|
||||
if snapshot.Pricing.BillingMode == "per_request" {
|
||||
lines = append(lines, fmt.Sprintf("按次单价: $%.6f", snapshot.Pricing.EffectiveInputPrice))
|
||||
lines = append(lines, fmt.Sprintf("按次单价: %s%.6f", sym, snapshot.Pricing.EffectiveInputPrice))
|
||||
} else {
|
||||
// 各类 token 费用明细
|
||||
if tc, ok := snapshot.TokenCosts["input"]; ok && tc.Tokens > 0 {
|
||||
lines = append(lines, fmt.Sprintf("输入: %s tokens × $%s/1M = $%s",
|
||||
formatInt(tc.Tokens), formatPrice(tc.UnitPrice), formatPrice(tc.Cost)))
|
||||
lines = append(lines, fmt.Sprintf("输入: %s tokens × %s/1M = %s",
|
||||
formatInt(tc.Tokens), money(tc.UnitPrice), money(tc.Cost)))
|
||||
}
|
||||
if tc, ok := snapshot.TokenCosts["output"]; ok && tc.Tokens > 0 {
|
||||
lines = append(lines, fmt.Sprintf("输出: %s tokens × $%s/1M = $%s",
|
||||
formatInt(tc.Tokens), formatPrice(tc.UnitPrice), formatPrice(tc.Cost)))
|
||||
lines = append(lines, fmt.Sprintf("输出: %s tokens × %s/1M = %s",
|
||||
formatInt(tc.Tokens), money(tc.UnitPrice), money(tc.Cost)))
|
||||
}
|
||||
if tc, ok := snapshot.TokenCosts["cache_read"]; ok && tc.Tokens > 0 {
|
||||
lines = append(lines, fmt.Sprintf("缓存读取: %s tokens × $%s/1M = $%s",
|
||||
formatInt(tc.Tokens), formatPrice(tc.UnitPrice), formatPrice(tc.Cost)))
|
||||
lines = append(lines, fmt.Sprintf("缓存读取: %s tokens × %s/1M = %s",
|
||||
formatInt(tc.Tokens), money(tc.UnitPrice), money(tc.Cost)))
|
||||
}
|
||||
if tc, ok := snapshot.TokenCosts["cache_creation"]; ok && tc.Tokens > 0 {
|
||||
lines = append(lines, fmt.Sprintf("缓存创建: %s tokens × $%s/1M = $%s",
|
||||
formatInt(tc.Tokens), formatPrice(tc.UnitPrice), formatPrice(tc.Cost)))
|
||||
lines = append(lines, fmt.Sprintf("缓存创建: %s tokens × %s/1M = %s",
|
||||
formatInt(tc.Tokens), money(tc.UnitPrice), money(tc.Cost)))
|
||||
}
|
||||
|
||||
// 小计 × 倍率:展开各项费用明细,乘法链 = 租户倍率 × 时段乘数
|
||||
@@ -242,16 +248,16 @@ func GenerateBillingSummary(snapshot *BillingSnapshot) string {
|
||||
// 收集各项费用明细
|
||||
var costParts []string
|
||||
if tc, ok := snapshot.TokenCosts["input"]; ok && tc.Cost > 0 {
|
||||
costParts = append(costParts, fmt.Sprintf("$%s", formatPrice(tc.Cost)))
|
||||
costParts = append(costParts, money(tc.Cost))
|
||||
}
|
||||
if tc, ok := snapshot.TokenCosts["output"]; ok && tc.Cost > 0 {
|
||||
costParts = append(costParts, fmt.Sprintf("$%s", formatPrice(tc.Cost)))
|
||||
costParts = append(costParts, money(tc.Cost))
|
||||
}
|
||||
if tc, ok := snapshot.TokenCosts["cache_read"]; ok && tc.Cost > 0 {
|
||||
costParts = append(costParts, fmt.Sprintf("$%s", formatPrice(tc.Cost)))
|
||||
costParts = append(costParts, money(tc.Cost))
|
||||
}
|
||||
if tc, ok := snapshot.TokenCosts["cache_creation"]; ok && tc.Cost > 0 {
|
||||
costParts = append(costParts, fmt.Sprintf("$%s", formatPrice(tc.Cost)))
|
||||
costParts = append(costParts, money(tc.Cost))
|
||||
}
|
||||
|
||||
// 构建倍率描述(租户倍率和时段乘数是并列关系,带文字标签)
|
||||
@@ -269,8 +275,8 @@ func GenerateBillingSummary(snapshot *BillingSnapshot) string {
|
||||
}
|
||||
|
||||
if costsExpr != "" {
|
||||
lines = append(lines, fmt.Sprintf("小计: %s × %s = $%s",
|
||||
costsExpr, multDesc, formatPrice(snapshot.Settlement.ActualCost)))
|
||||
lines = append(lines, fmt.Sprintf("小计: %s × %s = %s",
|
||||
costsExpr, multDesc, money(snapshot.Settlement.ActualCost)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,17 +291,17 @@ func GenerateBillingSummary(snapshot *BillingSnapshot) string {
|
||||
s := snapshot.Settlement
|
||||
if s.PreDeductAmount > 0 {
|
||||
if s.RefundAmount > 0 {
|
||||
lines = append(lines, fmt.Sprintf("预扣: $%s → 实际: $%s → 退还: $%s",
|
||||
formatPrice(s.PreDeductAmount), formatPrice(s.ActualCost), formatPrice(s.RefundAmount)))
|
||||
lines = append(lines, fmt.Sprintf("预扣: %s → 实际: %s → 退还: %s",
|
||||
money(s.PreDeductAmount), money(s.ActualCost), money(s.RefundAmount)))
|
||||
} else if s.SupplementAmount > 0 {
|
||||
lines = append(lines, fmt.Sprintf("预扣: $%s → 实际: $%s → 补扣: $%s",
|
||||
formatPrice(s.PreDeductAmount), formatPrice(s.ActualCost), formatPrice(s.SupplementAmount)))
|
||||
lines = append(lines, fmt.Sprintf("预扣: %s → 实际: %s → 补扣: %s",
|
||||
money(s.PreDeductAmount), money(s.ActualCost), money(s.SupplementAmount)))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("预扣: $%s → 实际: $%s(无差额)",
|
||||
formatPrice(s.PreDeductAmount), formatPrice(s.ActualCost)))
|
||||
lines = append(lines, fmt.Sprintf("预扣: %s → 实际: %s(无差额)",
|
||||
money(s.PreDeductAmount), money(s.ActualCost)))
|
||||
}
|
||||
} else if s.ActualCost > 0 {
|
||||
lines = append(lines, fmt.Sprintf("实际费用: $%s", formatPrice(s.ActualCost)))
|
||||
lines = append(lines, fmt.Sprintf("实际费用: %s", money(s.ActualCost)))
|
||||
}
|
||||
|
||||
result := ""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -268,9 +269,9 @@ func TestGenerateBillingSnapshot_WithRelayInfo(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerateBillingSummary_Nil(t *testing.T) {
|
||||
got := GenerateBillingSummary(nil)
|
||||
got := GenerateBillingSummary(context.Background(), nil)
|
||||
if got != "" {
|
||||
t.Errorf("GenerateBillingSummary(nil) = %q, want empty", got)
|
||||
t.Errorf("GenerateBillingSummary(context.Background(), nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +294,7 @@ func TestGenerateBillingSummary_PerRequest(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
text := GenerateBillingSummary(snapshot)
|
||||
text := GenerateBillingSummary(context.Background(), snapshot)
|
||||
if !strings.Contains(text, "按次计费") {
|
||||
t.Error("summary should contain 按次计费")
|
||||
}
|
||||
@@ -330,7 +331,7 @@ func TestGenerateBillingSummary_TokenMode(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
text := GenerateBillingSummary(snapshot)
|
||||
text := GenerateBillingSummary(context.Background(), snapshot)
|
||||
if !strings.Contains(text, "按量计费") {
|
||||
t.Error("summary should contain 按量计费")
|
||||
}
|
||||
@@ -371,7 +372,7 @@ func TestGenerateBillingSummary_SupplementAmount(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
text := GenerateBillingSummary(snapshot)
|
||||
text := GenerateBillingSummary(context.Background(), snapshot)
|
||||
if !strings.Contains(text, "补扣") {
|
||||
t.Error("summary should contain 补扣")
|
||||
}
|
||||
@@ -399,7 +400,7 @@ func TestGenerateBillingSummary_NoDiff(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
text := GenerateBillingSummary(snapshot)
|
||||
text := GenerateBillingSummary(context.Background(), snapshot)
|
||||
if !strings.Contains(text, "无差额") {
|
||||
t.Error("summary should contain 无差额")
|
||||
}
|
||||
@@ -416,7 +417,7 @@ func TestGenerateBillingSummary_UnknownSourcePassthrough(t *testing.T) {
|
||||
RequestMeta: BillingSnapshotRequestMeta{},
|
||||
}
|
||||
|
||||
text := GenerateBillingSummary(snapshot)
|
||||
text := GenerateBillingSummary(context.Background(), snapshot)
|
||||
if !strings.Contains(text, "custom_source") {
|
||||
t.Error("unknown source should be passed through as-is")
|
||||
}
|
||||
@@ -486,7 +487,7 @@ func TestGenerateBillingSummary_TimeRule(t *testing.T) {
|
||||
RequestMeta: BillingSnapshotRequestMeta{RequestedModel: "gpt-4o"},
|
||||
}
|
||||
|
||||
text := GenerateBillingSummary(snapshot)
|
||||
text := GenerateBillingSummary(context.Background(), snapshot)
|
||||
if !strings.Contains(text, "闲时") {
|
||||
t.Errorf("summary should contain time rule name, got:\n%s", text)
|
||||
}
|
||||
|
||||
@@ -301,7 +301,7 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
snapshot := GenerateBillingSnapshot(pricing, breakdown, nil, internalSettlement, nil)
|
||||
snapshot.RequestMeta.RequestedModel = modelName
|
||||
result.BillingSnapshot = SnapshotToJSON(snapshot)
|
||||
result.BillingSummary = GenerateBillingSummary(snapshot)
|
||||
result.BillingSummary = GenerateBillingSummary(ctx, snapshot)
|
||||
result.BillingMode = pricing.BillingMode
|
||||
result.BillingSource = pricing.BillingSource
|
||||
result.RateMultiplier = pricing.DiscountRatio
|
||||
|
||||
@@ -7,7 +7,10 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
)
|
||||
|
||||
// Audit levels
|
||||
@@ -33,6 +36,29 @@ func GetAuditConfig(ctx context.Context) (string, error) {
|
||||
return GetAuditLevel(ctx), nil
|
||||
}
|
||||
|
||||
// LogSensitiveAccess 记录敏感数据访问(查看 Key 明文、审计详情等"读取"行为)。
|
||||
// OperationLog 只覆盖写操作,敏感读取靠本函数留痕;IP/UA 从请求上下文自动提取。
|
||||
// 写入失败只记应用日志不阻断业务 —— 留痕是辅助防线,不影响主流程。
|
||||
func LogSensitiveAccess(ctx context.Context, userID int64, userType, resourceType string, resourceID int64, action, reason string) {
|
||||
ip, ua := "", ""
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
ip = r.GetClientIp()
|
||||
ua = r.GetHeader("User-Agent")
|
||||
}
|
||||
if _, err := AuditModelCtx(ctx, "aud_sensitive_access_logs").Data(do.AudSensitiveAccessLogs{
|
||||
UserId: userID,
|
||||
UserType: userType,
|
||||
ResourceType: resourceType,
|
||||
ResourceId: resourceID,
|
||||
Action: action,
|
||||
Reason: reason,
|
||||
IpAddress: ip,
|
||||
UserAgent: ua,
|
||||
}).Insert(); err != nil {
|
||||
g.Log().Warningf(ctx, "记录敏感数据访问日志失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuditLevels 返回全局审计级别和租户审计级别,两者完全独立。
|
||||
// 全局级别从 sys_options 读取,默认 full。
|
||||
// 租户级别从 tnt_tenants.settings JSONB 读取,未设置时默认 masked。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -10,12 +11,22 @@ var DefaultAuditLogWriter *UsageLogWriter
|
||||
// InitAuditLogWriter 初始化审计日志批量写入器
|
||||
// 审计日志与大模型请求 1:1,量级与 bil_usage_logs 相当,必须走批量通道避免单条 INSERT 打爆审计库。
|
||||
// 配置了 database.audit 独立审计库时经 DB getter 路由过去,否则回退主库(与 AuditModelCtx 一致)。
|
||||
func InitAuditLogWriter() {
|
||||
// 批量参数来自「性能配置-批量写入」(sys_options,重启后生效);未配置或非法时回落内置默认。
|
||||
func InitAuditLogWriter(ctx context.Context) {
|
||||
batchSize := Config().GetInt(ctx, "batch_write_size")
|
||||
if batchSize <= 0 {
|
||||
batchSize = 64
|
||||
}
|
||||
flushInterval := time.Duration(Config().GetInt(ctx, "batch_write_interval_ms")) * time.Millisecond
|
||||
if flushInterval <= 0 {
|
||||
flushInterval = 1 * time.Second
|
||||
}
|
||||
|
||||
DefaultAuditLogWriter = NewUsageLogWriter(UsageLogWriterConfig{
|
||||
Table: "aud_request_logs",
|
||||
QueueSize: 8192,
|
||||
BatchSize: 64,
|
||||
FlushInterval: 1 * time.Second,
|
||||
BatchSize: batchSize,
|
||||
FlushInterval: flushInterval,
|
||||
Workers: 2,
|
||||
Overflow: OverflowSyncFallback,
|
||||
DB: GetAuditDB,
|
||||
|
||||
@@ -2,8 +2,13 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
)
|
||||
|
||||
@@ -65,10 +70,12 @@ func ListPublicCategories(ctx context.Context) ([]*HelpPublicCategoryItem, error
|
||||
countMap[cr.CategoryId] = cr.ArticleCount
|
||||
}
|
||||
|
||||
// 构建所有节点
|
||||
nodeMap := make(map[int64]*HelpPublicCategoryItem)
|
||||
// 构建所有节点(rows 已按 sort_order 排序,同时维护有序切片保证树节点顺序稳定,
|
||||
// 不能遍历 map 组装——Go map 遍历顺序随机,会导致分类顺序每次请求都变)
|
||||
nodeMap := make(map[int64]*HelpPublicCategoryItem, len(rows))
|
||||
orderedNodes := make([]*HelpPublicCategoryItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
nodeMap[r.Id] = &HelpPublicCategoryItem{
|
||||
node := &HelpPublicCategoryItem{
|
||||
Id: r.Id,
|
||||
ParentId: r.ParentId,
|
||||
Name: r.Name,
|
||||
@@ -78,11 +85,13 @@ func ListPublicCategories(ctx context.Context) ([]*HelpPublicCategoryItem, error
|
||||
ArticleCount: countMap[r.Id],
|
||||
Children: make([]*HelpPublicCategoryItem, 0),
|
||||
}
|
||||
nodeMap[r.Id] = node
|
||||
orderedNodes = append(orderedNodes, node)
|
||||
}
|
||||
|
||||
// 组装树结构
|
||||
// 组装树结构(按 sort_order 顺序遍历,roots 与 children 均保持稳定排序)
|
||||
var roots []*HelpPublicCategoryItem
|
||||
for _, node := range nodeMap {
|
||||
for _, node := range orderedNodes {
|
||||
if node.ParentId == 0 {
|
||||
roots = append(roots, node)
|
||||
} else if parent, ok := nodeMap[node.ParentId]; ok {
|
||||
@@ -120,18 +129,19 @@ func ListPublicArticles(ctx context.Context, categorySlug string, page, pageSize
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
if cat == nil {
|
||||
return nil, 0, 0, 0, NewBusinessError(10073, "帮助分类不存在")
|
||||
return nil, 0, 0, 0, NewBusinessError(consts.CodeHelpCategoryNotFound, consts.MsgHelpCategoryNotFound)
|
||||
}
|
||||
|
||||
var total int
|
||||
rows := make([]*HelpPublicArticleItem, 0)
|
||||
// 排序与管理后台列表保持一致:sort_order 优先,其次发布时间倒序
|
||||
err = dao.SptArticles.Ctx(ctx).
|
||||
Where("category_id", cat.Id).
|
||||
Where("status", "published").
|
||||
Where("published_at IS NOT NULL").
|
||||
Where("published_at <=", gtime.Now()).
|
||||
OrderDesc("published_at").
|
||||
OrderAsc("sort_order").
|
||||
OrderDesc("published_at").
|
||||
Page(page, pageSize).
|
||||
ScanAndCount(&rows, &total, false)
|
||||
if err != nil {
|
||||
@@ -177,16 +187,18 @@ func GetPublicArticle(ctx context.Context, slug string) (*HelpPublicArticleDetai
|
||||
return nil, err
|
||||
}
|
||||
if row.Id == 0 {
|
||||
return nil, NewBusinessError(10075, "帮助文章不存在")
|
||||
return nil, NewBusinessError(consts.CodeHelpArticleNotFound, consts.MsgHelpArticleNotFound)
|
||||
}
|
||||
if row.Status != "published" || row.PublishedAt == nil || row.PublishedAt.Timestamp() > gtime.Now().Timestamp() {
|
||||
return nil, NewBusinessError(10075, "帮助文章不存在")
|
||||
return nil, NewBusinessError(consts.CodeHelpArticleNotFound, consts.MsgHelpArticleNotFound)
|
||||
}
|
||||
|
||||
// 增加浏览计数
|
||||
dao.SptArticles.Ctx(ctx).
|
||||
// 增加浏览计数(失败仅记日志,不影响详情返回)
|
||||
if _, err = dao.SptArticles.Ctx(ctx).
|
||||
Where("id", row.Id).
|
||||
Increment("view_count", 1)
|
||||
Increment("view_count", 1); err != nil {
|
||||
g.Log().Warningf(ctx, "帮助文章浏览计数失败 id=%d: %v", row.Id, err)
|
||||
}
|
||||
|
||||
return &HelpPublicArticleDetail{
|
||||
Id: row.Id,
|
||||
@@ -201,18 +213,23 @@ func GetPublicArticle(ctx context.Context, slug string) (*HelpPublicArticleDetai
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SearchPublicArticles 全文搜索已发布文章
|
||||
// SearchPublicArticles 搜索已发布文章
|
||||
// 用 ILIKE 模糊匹配而非 to_tsvector 全文检索:PostgreSQL 'simple' 分词配置不做中文分词,
|
||||
// 连续中文会被切成单个长 token,中文关键词几乎无法命中(帮助文档以中文内容为主)。
|
||||
// 帮助文章量级通常为百级,ILIKE 顺序扫描性能足够。
|
||||
func SearchPublicArticles(ctx context.Context, query string, page, pageSize int) ([]*HelpPublicArticleItem, int, int, int, error) {
|
||||
page, pageSize = NormalizePagination(page, pageSize)
|
||||
|
||||
likePat := "%" + escapeLikePattern(query) + "%"
|
||||
var total int
|
||||
rows := make([]*HelpPublicArticleItem, 0)
|
||||
err := dao.SptArticles.Ctx(ctx).
|
||||
Where("status", "published").
|
||||
Where("published_at IS NOT NULL").
|
||||
Where("published_at <=", gtime.Now()).
|
||||
Where("to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(content,'')) @@ plainto_tsquery('simple', ?)", query).
|
||||
Where("(title ILIKE ? ESCAPE '\\' OR content ILIKE ? ESCAPE '\\')", likePat, likePat).
|
||||
OrderDesc("published_at").
|
||||
OrderAsc("sort_order").
|
||||
Page(page, pageSize).
|
||||
ScanAndCount(&rows, &total, false)
|
||||
if err != nil {
|
||||
@@ -221,3 +238,33 @@ func SearchPublicArticles(ctx context.Context, query string, page, pageSize int)
|
||||
|
||||
return rows, total, page, pageSize, nil
|
||||
}
|
||||
|
||||
// escapeLikePattern 转义 ILIKE 模式中的通配符(配合 ESCAPE '\' 使用),防止用户输入 % _ \ 干扰匹配
|
||||
func escapeLikePattern(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `%`, `\%`)
|
||||
s = strings.ReplaceAll(s, `_`, `\_`)
|
||||
return s
|
||||
}
|
||||
|
||||
// CheckHelpCenterRateLimit 帮助中心公开接口 IP 限流(公开无认证端点,防刷浏览量/搜索滥用)
|
||||
// 默认每 IP 每分钟 60 次,可通过配置 help_center_rate_limit_per_minute 调整;Redis 不可用时放行(帮助中心为公开只读内容)
|
||||
func CheckHelpCenterRateLimit(ctx context.Context, ipAddress string) error {
|
||||
if ipAddress == "" {
|
||||
return nil
|
||||
}
|
||||
limit := Config().GetInt(ctx, "help_center_rate_limit_per_minute")
|
||||
if limit <= 0 {
|
||||
limit = 60
|
||||
}
|
||||
key := fmt.Sprintf("help:center:rl:%s:%d", ipAddress, time.Now().Unix()/60)
|
||||
count, err := incrementWithExpire(ctx, key, 120)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "帮助中心限流检查失败: %v", err)
|
||||
return nil
|
||||
}
|
||||
if count > int64(limit) {
|
||||
return NewBusinessError(consts.CodeHelpRateLimitExceeded, consts.MsgHelpRateLimitExceeded)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
@@ -159,6 +161,36 @@ func GenerateRefreshToken() (string, error) {
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// BindSessionIDToRefreshToken 在刷新令牌尾部附加会话 ID(格式:random.sessionID)。
|
||||
// 附加段不参与哈希存储(库中存的是随机段的哈希),仅用于令牌被轮换后仍能定位
|
||||
// 所属会话 —— 重放检测的前提。随机段为纯 hex,"." 是无歧义分隔符。
|
||||
func BindSessionIDToRefreshToken(token string, sessionID int64) string {
|
||||
return fmt.Sprintf("%s.%d", token, sessionID)
|
||||
}
|
||||
|
||||
// SplitRefreshToken 拆出刷新令牌的随机段与内嵌会话 ID。
|
||||
// ok=false 表示存量旧格式令牌(纯随机段、无会话 ID),调用方需走兼容路径。
|
||||
func SplitRefreshToken(token string) (randomPart string, sessionID int64, ok bool) {
|
||||
idx := strings.LastIndex(token, ".")
|
||||
if idx <= 0 || idx == len(token)-1 {
|
||||
return "", 0, false
|
||||
}
|
||||
sid, err := strconv.ParseInt(token[idx+1:], 10, 64)
|
||||
if err != nil || sid <= 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
return token[:idx], sid, true
|
||||
}
|
||||
|
||||
// HashRefreshTokenForCompare 计算用于与库中哈希比对的值:
|
||||
// 新格式令牌哈希随机段,存量令牌哈希全文(历史版本的存储口径)。
|
||||
func HashRefreshTokenForCompare(token string) string {
|
||||
if randomPart, _, ok := SplitRefreshToken(token); ok {
|
||||
return HashRefreshToken(randomPart)
|
||||
}
|
||||
return HashRefreshToken(token)
|
||||
}
|
||||
|
||||
// GenerateJti generates a new UUID-based JWT ID for session revocation tracking.
|
||||
func GenerateJti() string {
|
||||
return uuid.New().String()
|
||||
|
||||
@@ -65,6 +65,65 @@ func TestGenerateRefreshToken_Unique(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BindSessionIDToRefreshToken / SplitRefreshToken ────────────────
|
||||
|
||||
func TestRefreshTokenSessionIDRoundTrip(t *testing.T) {
|
||||
random, _ := GenerateRefreshToken()
|
||||
bound := BindSessionIDToRefreshToken(random, 42)
|
||||
|
||||
part, sid, ok := SplitRefreshToken(bound)
|
||||
if !ok {
|
||||
t.Fatalf("bound token should parse: %s", bound)
|
||||
}
|
||||
if part != random {
|
||||
t.Fatalf("random part mismatch: %s vs %s", part, random)
|
||||
}
|
||||
if sid != 42 {
|
||||
t.Fatalf("session id mismatch: %d vs 42", sid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitRefreshToken_LegacyToken(t *testing.T) {
|
||||
// 存量令牌为纯 hex 随机段,无 ".",必须走兼容路径(ok=false)
|
||||
legacy, _ := GenerateRefreshToken()
|
||||
if _, _, ok := SplitRefreshToken(legacy); ok {
|
||||
t.Fatalf("legacy token without session id should not parse: %s", legacy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitRefreshToken_Malformed(t *testing.T) {
|
||||
cases := []string{
|
||||
"", // 空
|
||||
".42", // 空随机段
|
||||
"abc.", // 空会话 ID
|
||||
"abc.xid", // 非数字会话 ID
|
||||
"abc.0", // 非正会话 ID
|
||||
"abc.-1", // 负会话 ID
|
||||
"deadbeef.deadbeef", // 随机段误含类数字后缀(hex 段被当作 ID 也应能解析或安全拒绝)
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, _, _ = SplitRefreshToken(c) // 只要不 panic 即可;具体 ok 值由格式决定
|
||||
}
|
||||
// 关键安全约束:解析出的会话 ID 必须为正数,否则视为不可解析
|
||||
if _, sid, ok := SplitRefreshToken("abc.7"); !ok || sid != 7 {
|
||||
t.Fatal("valid bound token failed to parse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashRefreshTokenForCompare(t *testing.T) {
|
||||
random, _ := GenerateRefreshToken()
|
||||
bound := BindSessionIDToRefreshToken(random, 7)
|
||||
|
||||
// 新格式:比对哈希只算随机段(库中存储口径)
|
||||
if got := HashRefreshTokenForCompare(bound); got != HashRefreshToken(random) {
|
||||
t.Fatal("bound token should hash random part only")
|
||||
}
|
||||
// 存量格式:哈希全文
|
||||
if got := HashRefreshTokenForCompare(random); got != HashRefreshToken(random) {
|
||||
t.Fatal("legacy token should hash full token")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── GenerateTokenPair + ParseAccessToken round-trip ────────────────
|
||||
|
||||
func TestTokenPairRoundTrip(t *testing.T) {
|
||||
|
||||
@@ -107,9 +107,19 @@ func incrementWithExpire(ctx context.Context, key string, expireSeconds int) (in
|
||||
return result.Int64(), nil
|
||||
}
|
||||
|
||||
// GetRegisterRateLimitStatus 获取当前IP的注册限流状态(用于前端提示)
|
||||
func GetRegisterRateLimitStatus(ctx context.Context, ipAddress string) map[string]any {
|
||||
status := make(map[string]any)
|
||||
// RegisterRateLimitStatus 当前IP的注册限流状态(注册页提示用)
|
||||
type RegisterRateLimitStatus struct {
|
||||
HourlyLimit int `json:"hourly_limit"` // 每小时注册上限(0=未启用)
|
||||
HourlyRemaining int `json:"hourly_remaining"` // 本小时剩余次数
|
||||
HourlyResetSeconds int `json:"hourly_reset_seconds"` // 小时窗口重置剩余秒数(0=无计数记录)
|
||||
DailyLimit int `json:"daily_limit"` // 每天注册上限(0=未启用)
|
||||
DailyRemaining int `json:"daily_remaining"` // 今日剩余次数
|
||||
DailyResetSeconds int `json:"daily_reset_seconds"` // 天窗口重置剩余秒数(0=无计数记录)
|
||||
}
|
||||
|
||||
// GetRegisterRateLimitStatus 获取当前IP的注册限流状态(用于前端提示,只读不计数)
|
||||
func GetRegisterRateLimitStatus(ctx context.Context, ipAddress string) RegisterRateLimitStatus {
|
||||
var status RegisterRateLimitStatus
|
||||
|
||||
if ipAddress == "" {
|
||||
return status
|
||||
@@ -118,36 +128,30 @@ func GetRegisterRateLimitStatus(ctx context.Context, ipAddress string) map[strin
|
||||
// 查询IP每小时限制
|
||||
hourlyLimit := Config().GetInt(ctx, "register_ip_limit_per_hour")
|
||||
if hourlyLimit > 0 {
|
||||
status.HourlyLimit = hourlyLimit
|
||||
status.HourlyRemaining = hourlyLimit
|
||||
hourlyKey := fmt.Sprintf("register:ip:hourly:%s", ipAddress)
|
||||
hourlyCount, _ := g.Redis().Do(ctx, "GET", hourlyKey)
|
||||
if !hourlyCount.IsNil() {
|
||||
remaining := hourlyLimit - hourlyCount.Int()
|
||||
status["hourly_remaining"] = remaining
|
||||
status["hourly_limit"] = hourlyLimit
|
||||
status.HourlyRemaining = hourlyLimit - hourlyCount.Int()
|
||||
if ttl, err := g.Redis().Do(ctx, "TTL", hourlyKey); err == nil {
|
||||
status["hourly_reset_seconds"] = ttl.Int()
|
||||
status.HourlyResetSeconds = ttl.Int()
|
||||
}
|
||||
} else {
|
||||
status["hourly_remaining"] = hourlyLimit
|
||||
status["hourly_limit"] = hourlyLimit
|
||||
}
|
||||
}
|
||||
|
||||
// 查询IP每天限制
|
||||
dailyLimit := Config().GetInt(ctx, "register_ip_limit_per_day")
|
||||
if dailyLimit > 0 {
|
||||
status.DailyLimit = dailyLimit
|
||||
status.DailyRemaining = dailyLimit
|
||||
dailyKey := fmt.Sprintf("register:ip:daily:%s", ipAddress)
|
||||
dailyCount, _ := g.Redis().Do(ctx, "GET", dailyKey)
|
||||
if !dailyCount.IsNil() {
|
||||
remaining := dailyLimit - dailyCount.Int()
|
||||
status["daily_remaining"] = remaining
|
||||
status["daily_limit"] = dailyLimit
|
||||
status.DailyRemaining = dailyLimit - dailyCount.Int()
|
||||
if ttl, err := g.Redis().Do(ctx, "TTL", dailyKey); err == nil {
|
||||
status["daily_reset_seconds"] = ttl.Int()
|
||||
status.DailyResetSeconds = ttl.Int()
|
||||
}
|
||||
} else {
|
||||
status["daily_remaining"] = dailyLimit
|
||||
status["daily_limit"] = dailyLimit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -158,17 +158,23 @@ func TestGetRegisterRateLimitStatus(t *testing.T) {
|
||||
status := GetRegisterRateLimitStatus(ctx, testIP)
|
||||
|
||||
// 验证状态
|
||||
if hourlyLimit, ok := status["hourly_limit"].(int); !ok || hourlyLimit != 5 {
|
||||
t.Errorf("期望每小时限制为5,得到: %v", status["hourly_limit"])
|
||||
if status.HourlyLimit != 5 {
|
||||
t.Errorf("期望每小时限制为5,得到: %d", status.HourlyLimit)
|
||||
}
|
||||
if hourlyRemaining, ok := status["hourly_remaining"].(int); !ok || hourlyRemaining != 3 {
|
||||
t.Errorf("期望每小时剩余3次,得到: %v", status["hourly_remaining"])
|
||||
if status.HourlyRemaining != 3 {
|
||||
t.Errorf("期望每小时剩余3次,得到: %d", status.HourlyRemaining)
|
||||
}
|
||||
if dailyLimit, ok := status["daily_limit"].(int); !ok || dailyLimit != 20 {
|
||||
t.Errorf("期望每天限制为20,得到: %v", status["daily_limit"])
|
||||
if status.HourlyResetSeconds <= 0 {
|
||||
t.Errorf("期望小时窗口重置秒数大于0,得到: %d", status.HourlyResetSeconds)
|
||||
}
|
||||
if dailyRemaining, ok := status["daily_remaining"].(int); !ok || dailyRemaining != 18 {
|
||||
t.Errorf("期望每天剩余18次,得到: %v", status["daily_remaining"])
|
||||
if status.DailyLimit != 20 {
|
||||
t.Errorf("期望每天限制为20,得到: %d", status.DailyLimit)
|
||||
}
|
||||
if status.DailyRemaining != 18 {
|
||||
t.Errorf("期望每天剩余18次,得到: %d", status.DailyRemaining)
|
||||
}
|
||||
if status.DailyResetSeconds <= 0 {
|
||||
t.Errorf("期望天窗口重置秒数大于0,得到: %d", status.DailyResetSeconds)
|
||||
}
|
||||
|
||||
// 清理测试数据
|
||||
|
||||
@@ -418,6 +418,20 @@ func RegenerateBackupCodes(ctx context.Context, userType string, userID int64, c
|
||||
return plainCodes, nil
|
||||
}
|
||||
|
||||
// LoginLockoutPolicy 返回登录锁定策略(安全配置 sys_options:login_max_attempts / login_lockout_minutes)。
|
||||
// 未配置或非法值(<=0)时回退默认 5 次 / 30 分钟,管理后台与租户控制台共用同一策略。
|
||||
func LoginLockoutPolicy(ctx context.Context) (maxAttempts, lockoutMinutes int) {
|
||||
maxAttempts = Config().GetInt(ctx, "login_max_attempts")
|
||||
if maxAttempts <= 0 {
|
||||
maxAttempts = 5
|
||||
}
|
||||
lockoutMinutes = Config().GetInt(ctx, "login_lockout_minutes")
|
||||
if lockoutMinutes <= 0 {
|
||||
lockoutMinutes = 30
|
||||
}
|
||||
return maxAttempts, lockoutMinutes
|
||||
}
|
||||
|
||||
// Is2FAEnabled checks if 2FA is enabled for a user.
|
||||
func Is2FAEnabled(ctx context.Context, userType string, userID int64) (bool, error) {
|
||||
if userType == "admin" {
|
||||
|
||||
@@ -99,36 +99,6 @@ func CreateSession(ctx context.Context, userType string, userID, tenantID int64,
|
||||
return sessionID, err
|
||||
}
|
||||
|
||||
// RefreshSession rotates a refresh token: invalidates the old one and creates a new session.
|
||||
func RefreshSession(ctx context.Context, sessionID int64, oldRefreshTokenHash, newRefreshTokenHash, ipAddress, deviceInfo string) error {
|
||||
refreshExpire := getRefreshExpire(ctx)
|
||||
expiresAt := gtime.New(time.Now().Add(refreshExpire))
|
||||
|
||||
result, err := dao.SysSessions.Ctx(ctx).
|
||||
Where("id", sessionID).
|
||||
Where("refresh_token_hash", oldRefreshTokenHash).
|
||||
Where("expires_at > NOW()").
|
||||
Data(do.SysSessions{
|
||||
RefreshTokenHash: newRefreshTokenHash,
|
||||
IpAddress: ipAddress,
|
||||
DeviceInfo: deviceInfo,
|
||||
ExpiresAt: expiresAt,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "update session")
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "check rows affected")
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return NewUnauthorizedError("会话不存在或已过期")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeSession revokes a single session by ID (DB only; use MarkSessionRevoked for Redis).
|
||||
// userType 限定会话所属用户体系(admin/tenant),防止跨控制台按 ID 吊销他人会话。
|
||||
func RevokeSession(ctx context.Context, userType string, sessionID int64) error {
|
||||
@@ -225,6 +195,111 @@ func GetSessionByID(ctx context.Context, userType string, sessionID int64) (*ent
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// GetSessionByRefreshToken 按刷新令牌定位活跃会话,并检测"已轮换令牌重放"。
|
||||
//
|
||||
// 新格式令牌内嵌会话 ID(random.sessionID):按 ID 定位会话后与随机段哈希比对,
|
||||
// 命中即正常返回;会话仍在但哈希不匹配 = 该令牌已被轮换过 = 重放(典型场景:
|
||||
// 令牌被盗、攻击者抢先刷新,受害者手里成了旧令牌)。重放直接吊销整个会话
|
||||
// (攻击者手中的新令牌一并作废,迫使用户重新登录)并写登录历史留痕。
|
||||
// 存量旧格式令牌(无会话 ID)退化为按哈希直查,行为与历史版本一致。
|
||||
//
|
||||
// 返回 (session, replayed, err):replayed=true 时会话已在内部吊销,调用方返回 401 即可。
|
||||
func GetSessionByRefreshToken(ctx context.Context, refreshToken string) (*entity.SysSessions, bool, error) {
|
||||
if randomPart, sessionID, ok := SplitRefreshToken(refreshToken); ok {
|
||||
var sess *entity.SysSessions
|
||||
err := dao.SysSessions.Ctx(ctx).
|
||||
Where("id", sessionID).
|
||||
Where("expires_at > NOW()").
|
||||
Scan(&sess)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if sess == nil {
|
||||
// 会话已过期/被清理,按不存在拒绝,无重放可言
|
||||
return nil, false, nil
|
||||
}
|
||||
if sess.RefreshTokenHash == HashRefreshToken(randomPart) {
|
||||
return sess, false, nil
|
||||
}
|
||||
|
||||
// 哈希不匹配 ≠ 重放:只有随机段哈希命中上一代令牌(轮换时记入 Redis)
|
||||
// 才判定为重放。若仅凭"会话存在但哈希不匹配"就吊销,会话 ID 是顺序
|
||||
// bigint,任何人拼 {垃圾串}.{受害者会话ID} 即可恶意吊销他人会话(DoS)。
|
||||
if prev, rErr := g.Redis().Do(ctx, "GET", refreshPrevKey(sessionID)); rErr == nil && !prev.IsNil() && prev.String() == HashRefreshToken(randomPart) {
|
||||
// 重放:吊销会话(Redis jti 黑名单 + 删会话行),留痕后拒绝
|
||||
MarkSessionRevoked(ctx, sess.Jti)
|
||||
if _, delErr := dao.SysSessions.Ctx(ctx).Where("id", sessionID).Delete(); delErr != nil {
|
||||
g.Log().Errorf(ctx, "revoke replayed session %d: %v", sessionID, delErr)
|
||||
}
|
||||
ip, ua := "", ""
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
ip = r.GetClientIp()
|
||||
ua = r.GetHeader("User-Agent")
|
||||
}
|
||||
_ = RecordLoginHistory(ctx, sess.UserType, sess.UserId, sess.TenantId, "refresh", ip, ua, "", false, "刷新令牌重放,会话已吊销")
|
||||
g.Log().Warningf(ctx, "refresh token replay detected: user_type=%s user_id=%d session=%d", sess.UserType, sess.UserId, sessionID)
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
// 既不是当前令牌也不是上一代:普通无效令牌,拒绝但不吊销
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// 存量旧格式令牌:按哈希直查(无法定位轮换来源,退化为普通拒绝)
|
||||
sess, err := GetSessionByRefreshHash(ctx, HashRefreshTokenForCompare(refreshToken))
|
||||
return sess, false, err
|
||||
}
|
||||
|
||||
// refreshPrevKey 是上一代刷新令牌哈希的 Redis key(重放检测用,见 RotateSessionRefreshToken)。
|
||||
func refreshPrevKey(sessionID int64) string {
|
||||
return fmt.Sprintf("session:refresh_prev:%d", sessionID)
|
||||
}
|
||||
|
||||
// RotateSessionRefreshToken 轮换会话刷新令牌:生成新随机段并原子替换库中哈希。
|
||||
// 返回绑定会话 ID 后的新令牌(返回给客户端的最终形态)。
|
||||
// UPDATE 同时校验旧哈希,并发下同一旧令牌只有一个请求能成功。
|
||||
func RotateSessionRefreshToken(ctx context.Context, sessionID int64, oldRefreshToken, ipAddress, deviceInfo string) (string, error) {
|
||||
newRandom, err := GenerateRefreshToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
oldHash := HashRefreshTokenForCompare(oldRefreshToken)
|
||||
|
||||
refreshExpire := getRefreshExpire(ctx)
|
||||
expiresAt := gtime.New(time.Now().Add(refreshExpire))
|
||||
|
||||
result, err := dao.SysSessions.Ctx(ctx).
|
||||
Where("id", sessionID).
|
||||
Where("refresh_token_hash", oldHash).
|
||||
Where("expires_at > NOW()").
|
||||
Data(do.SysSessions{
|
||||
RefreshTokenHash: HashRefreshToken(newRandom),
|
||||
IpAddress: ipAddress,
|
||||
DeviceInfo: deviceInfo,
|
||||
ExpiresAt: expiresAt,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return "", gerror.Wrapf(err, "update session")
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return "", gerror.Wrapf(err, "check rows affected")
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return "", NewUnauthorizedError("会话不存在或已过期")
|
||||
}
|
||||
|
||||
// 记录上一代令牌哈希(TTL 对齐刷新周期):重放检测必须能证明"这个随机段
|
||||
// 曾经属于该会话",否则顺序会话 ID 会被用来恶意吊销他人会话。Redis 不可用
|
||||
// 时仅损失检测能力(降级为普通拒绝),不产生误吊销。
|
||||
if _, rErr := g.Redis().Do(ctx, "SETEX", refreshPrevKey(sessionID), 7*24*3600, oldHash); rErr != nil {
|
||||
g.Log().Warningf(ctx, "record prev refresh hash for session %d: %v", sessionID, rErr)
|
||||
}
|
||||
|
||||
return BindSessionIDToRefreshToken(newRandom, sessionID), nil
|
||||
}
|
||||
|
||||
// GetSessionByRefreshHash retrieves an active session by refresh token hash.
|
||||
func GetSessionByRefreshHash(ctx context.Context, refreshTokenHash string) (*entity.SysSessions, error) {
|
||||
var session *entity.SysSessions
|
||||
@@ -264,6 +339,20 @@ func GetCtxUserID(ctx context.Context) int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetCtxUserRole extracts the admin privilege flag ("super_admin" / "admin") from context.
|
||||
// 注意它是特权标记而非业务角色:业务角色存放在 sys_admin_user_roles,
|
||||
// 这里只用于判断是否走超级管理员短路。
|
||||
func GetCtxUserRole(ctx context.Context) string {
|
||||
val := ctx.Value("role")
|
||||
if val == nil {
|
||||
return ""
|
||||
}
|
||||
if role, ok := val.(string); ok {
|
||||
return role
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetCtxSessionID extracts session ID from context.
|
||||
func GetCtxSessionID(ctx context.Context) int64 {
|
||||
val := ctx.Value("sessionId")
|
||||
|
||||
@@ -70,11 +70,13 @@ var Registry = []SettingDef{
|
||||
{Key: "maintenance_message", Type: SettingTypeString, Default: "", Category: "general",
|
||||
Label: "维护提示信息", Description: "维护模式下显示的提示文字", IsPublic: true},
|
||||
{Key: "maintenance_duration", Type: SettingTypeString, Default: "", Category: "general",
|
||||
Label: "预计维护时长", Description: "维护模式预计持续时间,展示给用户", IsPublic: true},
|
||||
Label: "预计维护时长", Description: "维护模式预计持续时间(Go 时长格式,如 2h、30m、1h30m),展示给用户", IsPublic: true},
|
||||
{Key: "api_maintenance_enabled", Type: SettingTypeBool, Default: "false", Category: "general",
|
||||
Label: "全局 API 维护", Description: "开启后 API 代理返回 503,叠加维护模式使用", IsPublic: true},
|
||||
{Key: "tenant_console_url", Type: SettingTypeString, Default: "", Category: "general",
|
||||
Label: "租户控制台地址", Description: "租户控制台的完整 URL,如 https://console.example.com,用于生成邀请链接等"},
|
||||
{Key: "site_url", Type: SettingTypeString, Default: "", Category: "general",
|
||||
Label: "站点后端地址", Description: "平台后端 API 的对外访问地址,如 https://api.example.com,用于构造 OAuth 登录回调地址(须与 GitHub/Google 应用后台登记的回调域名一致);为空时按当前请求 Host 推断"},
|
||||
{Key: "pricing_time_timezone", Type: SettingTypeString, Default: "Asia/Shanghai", Category: "general",
|
||||
Label: "计费时区", Description: "模型时段定价(峰谷/促销)按此时区解释,IANA 时区名,如 Asia/Shanghai"},
|
||||
|
||||
@@ -113,16 +115,11 @@ var Registry = []SettingDef{
|
||||
Label: "启用 TLS", Description: "587/465 端口建议开启"},
|
||||
|
||||
// ── Security ──
|
||||
{Key: "max_sessions_per_user", Type: SettingTypeInt, Default: "10", Category: "security",
|
||||
Label: "租户用户最大会话数", Validation: "min:1,max:100"},
|
||||
{Key: "admin_max_sessions", Type: SettingTypeInt, Default: "5", Category: "security",
|
||||
Label: "管理员最大会话数", Validation: "min:1,max:50"},
|
||||
// 会话数上限不入注册表:并发会话限制由配置文件 jwt.adminMaxSessions / jwt.tenantMaxSessions 管理
|
||||
{Key: "login_max_attempts", Type: SettingTypeInt, Default: "5", Category: "security",
|
||||
Label: "登录最大尝试次数", Validation: "min:1,max:30"},
|
||||
Label: "登录最大尝试次数", Description: "连续密码错误达到该次数后锁定账号(管理后台 + 租户控制台)", Validation: "min:1,max:30"},
|
||||
{Key: "login_lockout_minutes", Type: SettingTypeInt, Default: "30", Category: "security",
|
||||
Label: "登录锁定时长(分钟)", Validation: "min:1,max:1440"},
|
||||
{Key: "password_min_length", Type: SettingTypeInt, Default: "8", Category: "security",
|
||||
Label: "密码最小长度", Validation: "min:6,max:32"},
|
||||
Label: "登录锁定时长(分钟)", Description: "触发锁定后的持续时长(管理后台 + 租户控制台)", Validation: "min:1,max:1440"},
|
||||
{Key: "turnstile_enabled", Type: SettingTypeBool, Default: "false", Category: "security",
|
||||
Label: "启用 Turnstile 人机验证", IsPublic: true},
|
||||
{Key: "turnstile_site_key", Type: SettingTypeString, Default: "", Category: "security",
|
||||
@@ -131,23 +128,13 @@ var Registry = []SettingDef{
|
||||
Label: "Turnstile Secret Key", Sensitive: true},
|
||||
{Key: "captcha_expire_seconds", Type: SettingTypeInt, Default: "300", Category: "security",
|
||||
Label: "验证码有效期(秒)", Validation: "min:60,max:600"},
|
||||
{Key: "new_device_notification", Type: SettingTypeBool, Default: "true", Category: "security",
|
||||
Label: "新设备登录通知"},
|
||||
{Key: "register_forbidden_words", Type: SettingTypeString, Default: "admin,system,root,api,test,administrator,管理员,系统", Category: "security",
|
||||
Label: "注册禁用词", Description: "组织名称、组织代码、用户名中包含这些词时禁止注册(不区分大小写),多个禁用词用英文逗号分隔"},
|
||||
{Key: "sandbox_enabled", Type: SettingTypeBool, Default: "true", Category: "performance",
|
||||
Label: "启用沙箱模式"},
|
||||
{Key: "sandbox_default_quota", Type: SettingTypeInt, Default: "100", Category: "performance",
|
||||
Label: "沙箱每月默认额度"},
|
||||
|
||||
// ── Audit ──
|
||||
{Key: "audit_level", Type: SettingTypeString, Default: "full", Category: "audit",
|
||||
Label: "全局审计级别", Validation: "enum:full,full_text,masked,question_only,none",
|
||||
Description: "full=完整记录, full_text=全量文本, masked=脱敏记录, question_only=仅提问, none=不记录"},
|
||||
{Key: "audit_retention_days", Type: SettingTypeInt, Default: "90", Category: "audit",
|
||||
Label: "审计日志保留天数", Validation: "min:7,max:3650"},
|
||||
{Key: "operation_log_retention_days", Type: SettingTypeInt, Default: "365", Category: "audit",
|
||||
Label: "操作日志保留天数", Validation: "min:30,max:3650"},
|
||||
|
||||
// ── Payment ──
|
||||
{Key: "payment_amount_options", Type: SettingTypeJSON, Default: "[10,20,50,100,200,500]", Category: "payment",
|
||||
@@ -174,32 +161,26 @@ var Registry = []SettingDef{
|
||||
Label: "用户级 QPS 上限", Validation: "min:0"},
|
||||
{Key: "key_qps_limit", Type: SettingTypeInt, Default: "60", Category: "performance",
|
||||
Label: "Key 级 QPS 上限", Validation: "min:0"},
|
||||
{Key: "global_concurrency_limit", Type: SettingTypeInt, Default: "0", Category: "performance",
|
||||
Label: "系统级并发上限", Validation: "min:0",
|
||||
Description: "全平台同时在途请求的总闸门(多副本共享计数);流式长请求占用连接数分钟,QPS 限流拦不住堆积,须靠并发上限兜底;0=不限制,保存后约 5 秒生效"},
|
||||
{Key: "tenant_concurrency_limit", Type: SettingTypeInt, Default: "0", Category: "performance",
|
||||
Label: "租户级并发上限", Validation: "min:0"},
|
||||
{Key: "request_timeout_seconds", Type: SettingTypeInt, Default: "120", Category: "performance",
|
||||
Label: "请求超时(秒)", Validation: "min:5,max:600"},
|
||||
{Key: "batch_write_size", Type: SettingTypeInt, Default: "64", Category: "performance",
|
||||
Label: "批量写入大小", Validation: "min:1,max:1000"},
|
||||
{Key: "channel_affinity_ttl", Type: SettingTypeInt, Default: "300", Category: "performance",
|
||||
Label: "渠道亲和性TTL(秒)", Validation: "min:0,max:3600"},
|
||||
{Key: "auto_test_interval", Type: SettingTypeInt, Default: "300", Category: "performance",
|
||||
Label: "渠道自动测试间隔(秒)", Validation: "min:60,max:86400"},
|
||||
{Key: "request_timeout_seconds", Type: SettingTypeInt, Default: "180", Category: "performance",
|
||||
Label: "请求超时(秒)", Validation: "min:5,max:600",
|
||||
Description: "非流式请求的全局默认总超时;渠道级超时设置优先,未配置的渠道使用此值,保存后即时生效"},
|
||||
{Key: "streaming_timeout_seconds", Type: SettingTypeInt, Default: "300", Category: "performance",
|
||||
Label: "流式超时(秒)", Validation: "min:30,max:3600",
|
||||
Description: "流式响应最大持续时间"},
|
||||
{Key: "cache_enabled", Type: SettingTypeBool, Default: "true", Category: "performance",
|
||||
Label: "缓存开关", Description: "是否启用 API Key 缓存"},
|
||||
{Key: "cache_ttl_seconds", Type: SettingTypeInt, Default: "300", Category: "performance",
|
||||
Label: "缓存过期时间(秒)", Validation: "min:30,max:86400",
|
||||
Description: "API Key 缓存有效期"},
|
||||
Label: "流式空闲超时(秒)", Validation: "min:30,max:3600",
|
||||
Description: "流式响应两个数据块之间的最大空闲间隔(非总时长)的全局默认;渠道级设置优先,保存后即时生效"},
|
||||
{Key: "batch_write_size", Type: SettingTypeInt, Default: "64", Category: "performance",
|
||||
Label: "批量写入大小", Validation: "min:1,max:1000",
|
||||
Description: "审计日志批量落库条数,重启后生效"},
|
||||
{Key: "batch_write_interval_ms", Type: SettingTypeInt, Default: "1000", Category: "performance",
|
||||
Label: "批量写入间隔(ms)", Validation: "min:100,max:30000",
|
||||
Description: "日志批量写入的间隔时间"},
|
||||
{Key: "global_concurrency_limit", Type: SettingTypeInt, Default: "1000", Category: "performance",
|
||||
Label: "系统级并发上限", Validation: "min:0"},
|
||||
Description: "审计日志批量落库周期,重启后生效"},
|
||||
{Key: "pprof_port", Type: SettingTypeInt, Default: "0", Category: "performance",
|
||||
Label: "pprof 端口", Validation: "min:0,max:65535",
|
||||
Description: "性能分析端口,0=关闭"},
|
||||
Description: "性能分析端口,仅监听本机回环(127.0.0.1),0=关闭,重启后生效"},
|
||||
|
||||
// ── Content Filter ──
|
||||
{Key: "content_filter_mode", Type: SettingTypeString, Default: "off", Category: "content_filter",
|
||||
@@ -221,11 +202,11 @@ var Registry = []SettingDef{
|
||||
{Key: "channel_auto_disable_enabled", Type: SettingTypeBool, Default: "false", Category: "channel",
|
||||
Label: "渠道自动禁用", Description: "渠道熔断持续超过路由策略 breaker.autoDisableAfterSeconds(默认 10 分钟)未恢复时自动禁用"},
|
||||
{Key: "channel_routing_policy", Type: SettingTypeJSON, Default: "", Category: "channel",
|
||||
Label: "路由策略覆盖(JSON)", Description: "渠道调度引擎策略,部分字段覆盖内置默认值,保存后 30 秒内热生效;留空使用全部默认。字段说明见 docs/reference/渠道调度运维手册.md"},
|
||||
Label: "路由策略覆盖(JSON)", Description: "渠道调度引擎策略,部分字段覆盖内置默认值,保存后 30 秒内热生效;留空使用全部默认。高级功能,非必要不要修改;字段说明见管理后台渠道配置页路由策略的说明弹窗"},
|
||||
{Key: "health_snapshot_retention_days", Type: SettingTypeInt, Default: "7", Category: "channel",
|
||||
Label: "健康快照保留天数", Validation: "min:1,max:90"},
|
||||
{Key: "channel_proxy_url", Type: SettingTypeString, Default: "", Category: "channel",
|
||||
Label: "代理地址", Description: "全局代理 URL,支持 http:// 和 socks5://,如 http://127.0.0.1:7890。启用代理的渠道会通过此代理转发请求"},
|
||||
Label: "代理地址", Description: "全局代理 URL,支持 http:// 和 socks5://,如 http://127.0.0.1:7890。启用代理的渠道会通过此代理转发请求;OAuth 出站请求同样走此代理。保存后即时生效,无需重启"},
|
||||
{Key: "sync_image_async_enabled", Type: SettingTypeBool, Default: "false", Category: "channel",
|
||||
Label: "同步图片厂商异步化", Description: "开启后,同步阻塞返回的图片厂商(如 OpenAI/DALL·E)走 /v1/images/generations/async 时由后台 worker 池异步处理,客户端提交即拿 task_id 后轮询取图;关闭则该端点对同步厂商返回不支持。依赖对象存储保存生成结果,开启前需先在「存储配置」中配置对象存储(OSS/S3/COS)"},
|
||||
{Key: "sync_image_rehost_url", Type: SettingTypeBool, Default: "false", Category: "channel",
|
||||
@@ -264,8 +245,6 @@ var Registry = []SettingDef{
|
||||
Label: "临时数据保留天数", Validation: "min:1,max:30"},
|
||||
{Key: "data_export_expiry_days", Type: SettingTypeInt, Default: "7", Category: "data_governance",
|
||||
Label: "数据导出文件保留天数", Validation: "min:1,max:30"},
|
||||
{Key: "data_deletion_completion_days", Type: SettingTypeInt, Default: "30", Category: "data_governance",
|
||||
Label: "GDPR删除请求完成天数", Validation: "min:7,max:90"},
|
||||
{Key: "file_retention_enabled", Type: SettingTypeBool, Default: "true", Category: "data_governance",
|
||||
Label: "启用文件保留期检查"},
|
||||
{Key: "file_image_retention_days", Type: SettingTypeInt, Default: "0", Category: "data_governance",
|
||||
|
||||
@@ -137,6 +137,15 @@ func (s *sOpen) OpenMemberCreate(ctx context.Context, req *v1.OpenMemberCreateRe
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 团队开关与席位上限:控制台的 InviteMember 两者都校验,开放平台此前一项都没有,
|
||||
// 于是买 10 个席位的租户可以从这里建出任意多成员,套餐限制被整条绕开。
|
||||
if err := requireOpenTeamEnabled(ctx, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireOpenMemberSeat(ctx, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
count, _ := dao.TntUsers.Ctx(ctx).Where("tenant_id", tenantID).Where("email", req.Email).Count()
|
||||
if count > 0 {
|
||||
return nil, errOpen(consts.CodeUsernameExists, "邮箱已存在")
|
||||
@@ -173,17 +182,44 @@ func (s *sOpen) OpenMemberUpdate(ctx context.Context, req *v1.OpenMemberUpdateRe
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 必须先取目标成员的角色:同文件的 OpenMemberDelete 挡了 owner,这里此前没挡。
|
||||
// 请求校验 v:"in:admin,member" 只拦住了「把别人提升为 owner」,没拦住「把 owner 降为
|
||||
// member」或「把 owner 置为 disabled」—— 而租户 admin 本就能建带 members:write 的应用,
|
||||
// 于是 admin 可以经由开放平台把组织所有者踢出去,控制台的四处 owner 保护全被绕过。
|
||||
var target *struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
err := dao.TntUsers.Ctx(ctx).
|
||||
Where("id", req.Id).
|
||||
Where("tenant_id", tenantID).
|
||||
Fields("role").
|
||||
Scan(&target)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if target == nil {
|
||||
return nil, errOpen(consts.CodeNotFound, "用户不存在")
|
||||
}
|
||||
|
||||
data := do.TntUsers{}
|
||||
hasUpdate := false
|
||||
roleChanged := false
|
||||
if req.Role != nil {
|
||||
if target.Role == "owner" {
|
||||
return nil, errOpen(consts.CodeForbidden, "不能修改所有者的角色")
|
||||
}
|
||||
data.Role = *req.Role
|
||||
hasUpdate = true
|
||||
roleChanged = *req.Role != target.Role
|
||||
}
|
||||
if req.DisplayName != nil {
|
||||
data.DisplayName = *req.DisplayName
|
||||
hasUpdate = true
|
||||
}
|
||||
if req.Status != nil {
|
||||
if target.Role == "owner" && *req.Status != "active" {
|
||||
return nil, errOpen(consts.CodeForbidden, "不能禁用组织所有者")
|
||||
}
|
||||
data.Status = *req.Status
|
||||
hasUpdate = true
|
||||
}
|
||||
@@ -192,8 +228,19 @@ func (s *sOpen) OpenMemberUpdate(ctx context.Context, req *v1.OpenMemberUpdateRe
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
_, err := dao.TntUsers.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).OmitNil().Data(data).Update()
|
||||
return nil, err
|
||||
if _, err = dao.TntUsers.Ctx(ctx).Where("id", req.Id).Where("tenant_id", tenantID).OmitNil().Data(data).Update(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 与控制台 UpdateMemberRole 对齐:角色变更或停用后撤销该成员的活跃会话。
|
||||
// 租户角色本就每请求从库重读,这一步是纵深防御 —— 失败只记日志,不回滚已生效的变更。
|
||||
if roleChanged || (req.Status != nil && *req.Status != "active") {
|
||||
if revokeErr := common.RevokeAllSessions(ctx, "tenant", req.Id); revokeErr != nil {
|
||||
g.Log().Warningf(ctx, "[OpenMemberUpdate] 撤销成员会话失败 user=%d: %v", req.Id, revokeErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *sOpen) OpenMemberDelete(ctx context.Context, req *v1.OpenMemberDeleteReq) (*v1.OpenMemberDeleteRes, error) {
|
||||
@@ -449,6 +496,30 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 权限范围此前被硬编码为 "full" —— 开放平台签发的每一把 Key 都是最大权限,
|
||||
// 应用方连「只发一把 chat_only 的 Key」都做不到。改为按请求取值(默认仍是 full,
|
||||
// 保持既有调用方行为不变),取值集合与 OpenProjectKeyCreate 对齐。
|
||||
scope := req.Scope
|
||||
if scope == "" {
|
||||
scope = "full"
|
||||
}
|
||||
|
||||
// project_id 可选,填写时必须是本租户下的活跃项目。
|
||||
// relay 的项目状态闸门是 `if k.ProjectID > 0`,只有真正写入 project_id,项目归档/停用
|
||||
// 才会连带吊销这把 Key;此前这里恒不写 project_id,签出的 Key 完全不受项目生命周期约束。
|
||||
// 不填则是租户级集成密钥(无项目、无成员归属),仍按 key_type=project 存储 —— 控制台
|
||||
// 的项目密钥列表正是按 key_type 过滤,改成 personal 反而会因缺少 user_id 而在界面上
|
||||
// 彻底消失、无法吊销。
|
||||
if req.ProjectID > 0 {
|
||||
project, perr := s.getProjectOrError(ctx, req.ProjectID, tenantID)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
if project.Status != "active" {
|
||||
return nil, errOpen(consts.CodeProjectNotActive, "项目状态不可用")
|
||||
}
|
||||
}
|
||||
|
||||
rawKey, prefix, encryptedKey, err := relay.GenerateApiKey(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -460,16 +531,21 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v
|
||||
}
|
||||
quotaLimitDecimal := billing.NewFromFloat(quotaLimit)
|
||||
|
||||
result, err := dao.ApiKeys.Ctx(ctx).Data(do.ApiKeys{
|
||||
insertData := do.ApiKeys{
|
||||
TenantId: tenantID,
|
||||
Name: req.Name,
|
||||
EncryptedKey: encryptedKey,
|
||||
KeyPrefix: prefix,
|
||||
Scope: "full",
|
||||
Scope: scope,
|
||||
Status: "active",
|
||||
TotalQuota: "aLimitDecimal,
|
||||
KeyType: "project",
|
||||
}).Insert()
|
||||
}
|
||||
if req.ProjectID > 0 {
|
||||
insertData.ProjectId = req.ProjectID
|
||||
}
|
||||
|
||||
result, err := dao.ApiKeys.Ctx(ctx).Data(insertData).Insert()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -688,6 +764,41 @@ func (s *sOpen) OpenBillingQuery(ctx context.Context, req *v1.OpenBillingQueryRe
|
||||
return &v1.OpenBillingQueryRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
// requireOpenTeamEnabled 校验租户已启用团队功能。
|
||||
// 与 internal/logic/tenant 的 requireTeamEnabled 同义;那个是包内私有函数,
|
||||
// 此处不便跨包复用(tenant 包体量大且反向依赖会形成环),故按同一判据本地实现。
|
||||
func requireOpenTeamEnabled(ctx context.Context, tenantID int64) error {
|
||||
enabled, err := dao.TntTenants.Ctx(ctx).
|
||||
Where("id", tenantID).Fields("team_enabled").Value()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled.Bool() {
|
||||
return errOpen(consts.CodeTeamNotEnabled, consts.MsgTeamNotEnabled)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireOpenMemberSeat 校验租户仍有可用席位,判据与控制台 InviteMember 完全一致:
|
||||
// 活跃成员数 < 生效上限(上限为 0 表示不限)。
|
||||
func requireOpenMemberSeat(ctx context.Context, tenantID int64) error {
|
||||
memberCount, err := dao.TntUsers.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("status", "active").
|
||||
Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
effectiveMaxMembers, _, err := billing.GetTenantEffectiveLimits(ctx, tenantID)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "查询租户限制信息失败")
|
||||
}
|
||||
if effectiveMaxMembers > 0 && memberCount >= effectiveMaxMembers {
|
||||
return errOpen(consts.CodeMemberLimitReached, consts.MsgMemberLimitReached)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// errOpen is a helper to create open platform errors with proper gerror codes.
|
||||
func errOpen(code int, message string) error {
|
||||
return gerror.NewCode(gcode.New(code, message, nil), message)
|
||||
|
||||
@@ -659,6 +659,9 @@ func (s *sTenant) ApiKeyReveal(ctx context.Context, req *v1.TenantApiKeyRevealRe
|
||||
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "密钥解密失败")
|
||||
}
|
||||
|
||||
// 查看明文属于敏感读取(Key 可长期调用计费接口),落敏感访问日志留痕
|
||||
lcommon.LogSensitiveAccess(ctx, userID, "tenant", "api_key", req.Id, "reveal", "查看 API Key 明文")
|
||||
|
||||
return &v1.TenantApiKeyRevealRes{
|
||||
Key: plainKey,
|
||||
KeyPrefix: info.KeyPrefix,
|
||||
|
||||
@@ -23,6 +23,19 @@ import (
|
||||
"github.com/qianfree/team-api/internal/utility/crypto"
|
||||
)
|
||||
|
||||
// GetRegisterRateLimitStatus 查询当前 IP 的注册限流状态(注册页提示用,公开端点,只读不计数)。
|
||||
func (s *sTenant) GetRegisterRateLimitStatus(ctx context.Context, _ *v1.TenantRegisterRateLimitReq) (*v1.TenantRegisterRateLimitRes, error) {
|
||||
status := common.GetRegisterRateLimitStatus(ctx, g.RequestFromCtx(ctx).GetClientIp())
|
||||
return &v1.TenantRegisterRateLimitRes{
|
||||
HourlyLimit: status.HourlyLimit,
|
||||
HourlyRemaining: status.HourlyRemaining,
|
||||
HourlyResetSeconds: status.HourlyResetSeconds,
|
||||
DailyLimit: status.DailyLimit,
|
||||
DailyRemaining: status.DailyRemaining,
|
||||
DailyResetSeconds: status.DailyResetSeconds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Register handles tenant registration.
|
||||
func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.TenantRegisterRes, error) {
|
||||
// Check if registration is enabled
|
||||
@@ -231,6 +244,7 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "create session")
|
||||
}
|
||||
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
|
||||
|
||||
tokenPair, err := common.GenerateTokenPair(ctx, ownerUserID, "tenant", "owner", tenantID, sessionID, jti)
|
||||
if err != nil {
|
||||
@@ -399,12 +413,13 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
if !crypto.VerifyPassword(req.Password, user.PasswordHash) {
|
||||
_ = common.RecordLoginHistory(ctx, "tenant", user.Id, tenant.Id, "password", ipAddress, ua, deviceFP, false, "密码错误")
|
||||
|
||||
// 原子递增失败次数
|
||||
// 原子递增失败次数;锁定阈值与时长取自安全配置(sys_options)
|
||||
updateData := do.TntUsers{FailedAttempts: gdb.Raw("failed_attempts + 1")}
|
||||
failedAttempts := user.FailedAttempts + 1
|
||||
maxAttempts, lockoutMinutes := common.LoginLockoutPolicy(ctx)
|
||||
|
||||
if failedAttempts >= 5 {
|
||||
lockedUntil := time.Now().Add(30 * time.Minute)
|
||||
if failedAttempts >= maxAttempts {
|
||||
lockedUntil := time.Now().Add(time.Duration(lockoutMinutes) * time.Minute)
|
||||
updateData.LockedUntil = gtime.NewFromTime(lockedUntil)
|
||||
_, err := dao.TntUsers.Ctx(ctx).
|
||||
Where("id", user.Id).
|
||||
@@ -413,7 +428,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
g.Log().Errorf(ctx, "更新账号锁定状态失败: %v", err)
|
||||
}
|
||||
return nil, common.NewBusinessError(consts.CodeAccountLocked,
|
||||
"连续 5 次密码错误,账号已锁定 30 分钟")
|
||||
fmt.Sprintf("连续 %d 次密码错误,账号已锁定 %d 分钟", maxAttempts, lockoutMinutes))
|
||||
}
|
||||
|
||||
_, err := dao.TntUsers.Ctx(ctx).
|
||||
@@ -476,6 +491,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "create session")
|
||||
}
|
||||
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
|
||||
|
||||
tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "tenant", user.Role, tenant.Id, sessionID, jti)
|
||||
if err != nil {
|
||||
@@ -551,12 +567,14 @@ func (s *sTenant) Logout(ctx context.Context, req *v1.TenantLogoutReq) (*v1.Tena
|
||||
|
||||
// Refresh handles token refresh for tenant users.
|
||||
func (s *sTenant) Refresh(ctx context.Context, req *v1.TenantRefreshReq) (*v1.TenantRefreshRes, error) {
|
||||
refreshTokenHash := common.HashRefreshToken(req.RefreshToken)
|
||||
|
||||
session, err := common.GetSessionByRefreshHash(ctx, refreshTokenHash)
|
||||
// 定位会话并做重放检测:已轮换的旧令牌再次出现时,会话在内部被整体吊销
|
||||
session, replayed, err := common.GetSessionByRefreshToken(ctx, req.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, common.NewUnauthorizedError("会话不存在")
|
||||
}
|
||||
if replayed {
|
||||
return nil, common.NewUnauthorizedError("登录状态已失效,请重新登录")
|
||||
}
|
||||
if session == nil {
|
||||
return nil, common.NewUnauthorizedError("会话已过期或不存在")
|
||||
}
|
||||
@@ -578,15 +596,10 @@ func (s *sTenant) Refresh(ctx context.Context, req *v1.TenantRefreshReq) (*v1.Te
|
||||
return nil, common.NewUnauthorizedError(consts.MsgUnauthorized)
|
||||
}
|
||||
|
||||
newRefreshToken, err := common.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newRefreshTokenHash := common.HashRefreshToken(newRefreshToken)
|
||||
|
||||
// 轮换令牌(新令牌内嵌会话 ID,供下次重放检测定位)
|
||||
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
|
||||
deviceInfo := common.ExtractDeviceInfo(ctx)
|
||||
err = common.RefreshSession(ctx, session.Id, refreshTokenHash, newRefreshTokenHash, ipAddress, deviceInfo)
|
||||
newRefreshToken, err := common.RotateSessionRefreshToken(ctx, session.Id, req.RefreshToken, ipAddress, deviceInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -274,6 +274,103 @@ func (s *sTenant) UsageLogs(ctx context.Context, req *v1.TenantUsageLogsReq) (*v
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UsageLogsSummary 用量日志统计汇总(与 UsageLogs 共用筛选口径:强制 tenant_id 隔离,
|
||||
// member 角色只能统计自己的日志;总费用与租户端列表费用列同口径:actual_cost 优先,0/NULL 回退 total_cost)
|
||||
func (s *sTenant) UsageLogsSummary(ctx context.Context, req *v1.TenantUsageLogsSummaryReq) (*v1.TenantUsageLogsSummaryRes, error) {
|
||||
if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
userID := middleware.GetUserID(ctx)
|
||||
role := middleware.GetUserRole(ctx)
|
||||
|
||||
var conditions []string
|
||||
var args []any
|
||||
|
||||
conditions = append(conditions, "u.tenant_id = ?")
|
||||
args = append(args, tenantID)
|
||||
|
||||
// member 角色只能查看自己的用量日志
|
||||
if role == "member" {
|
||||
conditions = append(conditions, "u.user_id = ?")
|
||||
args = append(args, userID)
|
||||
} else if req.Username != "" {
|
||||
conditions = append(conditions, "t.username LIKE ?")
|
||||
args = append(args, "%"+req.Username+"%")
|
||||
}
|
||||
if req.Model != "" {
|
||||
conditions = append(conditions, "u.model_name = ?")
|
||||
args = append(args, req.Model)
|
||||
}
|
||||
if req.Status != "" {
|
||||
conditions = append(conditions, "u.status = ?")
|
||||
args = append(args, req.Status)
|
||||
}
|
||||
if req.RequestType > 0 {
|
||||
conditions = append(conditions, "u.request_type = ?")
|
||||
args = append(args, req.RequestType)
|
||||
}
|
||||
if req.StartDate != "" {
|
||||
conditions = append(conditions, "u.created_at >= ?")
|
||||
args = append(args, common.StartOfRange(req.StartDate))
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
conditions = append(conditions, "u.created_at <= ?")
|
||||
args = append(args, common.EndOfRange(req.EndDate))
|
||||
}
|
||||
|
||||
// 统计聚合不展示名称,仅在按用户名筛选时才 JOIN 用户表,避免大表 SUM 背负无关 JOIN
|
||||
fromClause := "bil_usage_logs u"
|
||||
if role != "member" && req.Username != "" {
|
||||
fromClause += " LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id"
|
||||
}
|
||||
|
||||
summarySQL := `SELECT
|
||||
COALESCE(SUM(COALESCE(NULLIF(u.actual_cost, 0), u.total_cost)), 0) AS total_cost,
|
||||
COALESCE(SUM(u.output_tokens), 0) AS total_output_tokens,
|
||||
COALESCE(SUM(u.input_tokens), 0) AS total_input_tokens,
|
||||
COALESCE(SUM(u.cache_read_tokens), 0) AS total_cache_read
|
||||
FROM ` + fromClause + ` WHERE ` + strings.Join(conditions, " AND ")
|
||||
|
||||
summaryResult, err := g.DB().Ctx(ctx).Query(ctx, summarySQL, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 无 GROUP BY 的聚合必返回一行;空结果兜底为零值
|
||||
if len(summaryResult) == 0 {
|
||||
return &v1.TenantUsageLogsSummaryRes{}, nil
|
||||
}
|
||||
|
||||
var row struct {
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||
TotalInputTokens int64 `json:"total_input_tokens"`
|
||||
TotalCacheRead int64 `json:"total_cache_read"`
|
||||
}
|
||||
if err := summaryResult[0].Struct(&row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 缓存读取占比 = cache_read / input_tokens。
|
||||
// input_tokens 入库口径为「含缓存的总输入」(见 relay/common/usage.go TotalInputTokens),
|
||||
// cache_read 是其子集,分母直接取总和即可,禁止再加 cache_read(会重复计入导致占比偏低)。
|
||||
// 口径与 admin 侧 GetUsageLogSummary 保持一致。
|
||||
var cacheReadRatio float64
|
||||
if row.TotalInputTokens > 0 {
|
||||
cacheReadRatio = float64(row.TotalCacheRead) / float64(row.TotalInputTokens) * 100
|
||||
}
|
||||
|
||||
return &v1.TenantUsageLogsSummaryRes{
|
||||
TotalCost: row.TotalCost,
|
||||
TotalOutputTokens: row.TotalOutputTokens,
|
||||
CacheReadRatio: cacheReadRatio,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExportUsageLogs exports the tenant usage logs as CSV or Excel.
|
||||
func (s *sTenant) ExportUsageLogs(ctx context.Context, req *v1.TenantUsageLogsExportReq) (*v1.TenantUsageLogsExportRes, error) {
|
||||
if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil {
|
||||
|
||||
@@ -3,14 +3,26 @@ package tenant
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
)
|
||||
|
||||
// helpRateLimit 帮助中心公开接口统一入口限流(非 HTTP 上下文跳过)
|
||||
func helpRateLimit(ctx context.Context) error {
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
return common.CheckHelpCenterRateLimit(ctx, r.GetClientIp())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListHelpPublicCategories 公开帮助分类列表(树结构)
|
||||
func (s *sTenant) ListHelpPublicCategories(ctx context.Context, _ *v1.HelpPublicCategoryListReq) (*v1.HelpPublicCategoryListRes, error) {
|
||||
if err := helpRateLimit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := common.ListPublicCategories(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -22,6 +34,9 @@ func (s *sTenant) ListHelpPublicCategories(ctx context.Context, _ *v1.HelpPublic
|
||||
|
||||
// ListHelpPublicArticles 分类下的文章列表
|
||||
func (s *sTenant) ListHelpPublicArticles(ctx context.Context, req *v1.HelpPublicArticleListReq) (*v1.HelpPublicArticleListRes, error) {
|
||||
if err := helpRateLimit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, total, page, pageSize, err := common.ListPublicArticles(ctx, req.CategorySlug, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -50,6 +65,9 @@ func (s *sTenant) ListHelpPublicArticles(ctx context.Context, req *v1.HelpPublic
|
||||
|
||||
// GetHelpPublicArticle 文章详情
|
||||
func (s *sTenant) GetHelpPublicArticle(ctx context.Context, req *v1.HelpPublicArticleGetReq) (*v1.HelpPublicArticleGetRes, error) {
|
||||
if err := helpRateLimit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail, err := common.GetPublicArticle(ctx, req.Slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -75,6 +93,9 @@ func (s *sTenant) GetHelpPublicArticle(ctx context.Context, req *v1.HelpPublicAr
|
||||
|
||||
// SearchHelpPublicArticles 搜索文章
|
||||
func (s *sTenant) SearchHelpPublicArticles(ctx context.Context, req *v1.HelpPublicSearchReq) (*v1.HelpPublicSearchRes, error) {
|
||||
if err := helpRateLimit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, total, page, pageSize, err := common.SearchPublicArticles(ctx, req.Query, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -16,6 +16,14 @@ import (
|
||||
|
||||
// InvitationList returns a paginated list of invitation records for the tenant.
|
||||
func (s *sTenant) InvitationList(ctx context.Context, req *v1.TenantInvitationListReq) (*v1.TenantInvitationListRes, error) {
|
||||
// 邀请码可直接兑换账号,active 邀请的完整 code 会随 invite_url 返回,
|
||||
// member 拿到即可自助注册出邀请角色(可为 admin)的账号 —— 必须与
|
||||
// 邀请创建/撤销同门槛,限 owner/admin。
|
||||
role := middleware.GetUserRole(ctx)
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ import (
|
||||
|
||||
// GetLevelBenefits returns all level configurations and the tenant's current level info.
|
||||
func (s *sTenant) GetLevelBenefits(ctx context.Context, req *v1.TenantLevelBenefitsReq) (*v1.TenantLevelBenefitsRes, error) {
|
||||
// 返回值含组织累计充值额(bil_wallets.cumulative_recharge,财务数据),
|
||||
// member 角色不应看到;组织页面前端本就限 owner,此处与组织管理同门槛
|
||||
if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
// Query all level configs ordered by level
|
||||
|
||||
@@ -428,6 +428,7 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "创建会话失败")
|
||||
}
|
||||
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
|
||||
|
||||
tokenPair, err := common.GenerateTokenPair(ctx, userID, "tenant", invitation.Role, tenantID, sessionID, jti)
|
||||
if err != nil {
|
||||
@@ -815,7 +816,15 @@ func (s *sTenant) UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpda
|
||||
}
|
||||
|
||||
// ResetMemberPassword resets a member's password. Only admins can reset other members' passwords.
|
||||
//
|
||||
// 角色校验此前缺失:只挡了「不能重置自己」和「不能重置 owner」,任意 member 都能改掉
|
||||
// 同组织 admin 的密码并登录该账号,等于组织内横向提权。与相邻的 UnlockMember /
|
||||
// RemoveMember / UpdateMemberRole 保持同一道闸门。
|
||||
func (s *sTenant) ResetMemberPassword(ctx context.Context, req *v1.TenantMemberResetPasswordReq) (*v1.TenantMemberResetPasswordRes, error) {
|
||||
role := middleware.GetUserRole(ctx)
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
if err := requireTeamEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -856,11 +865,17 @@ func (s *sTenant) ResetMemberPassword(ctx context.Context, req *v1.TenantMemberR
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 重置密码时同步清空失败计数与锁定:成员多半是因忘记密码连错被锁才找管理员重置,
|
||||
// 只改哈希不清锁会导致新密码在锁定期内依然登不进(与 admin 端/邮箱自助重置行为对齐)。
|
||||
// 注意必须用 map 而非 do 结构体:do 字段为 interface{},nil 表示「未设置」会被跳过,
|
||||
// locked_until 置 NULL 必须通过 map 显式传递。
|
||||
_, err = dao.TntUsers.Ctx(ctx).
|
||||
Where("id", memberID).
|
||||
Where("tenant_id", tenantID).
|
||||
Data(do.TntUsers{
|
||||
PasswordHash: passwordHash,
|
||||
Data(map[string]interface{}{
|
||||
"password_hash": passwordHash,
|
||||
"failed_attempts": 0,
|
||||
"locked_until": nil,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -909,6 +924,12 @@ func (s *sTenant) UnlockMember(ctx context.Context, req *v1.TenantMemberUnlockRe
|
||||
|
||||
// GetMember returns a single member's detail.
|
||||
func (s *sTenant) GetMember(ctx context.Context, req *v1.TenantMemberGetReq) (*v1.TenantMemberGetRes, error) {
|
||||
// 成员详情含邮箱等 PII,属管理视角数据:member 不得窥视他人信息,
|
||||
// 自身资料走个人设置(GetProfile)
|
||||
if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
var user *struct {
|
||||
@@ -948,6 +969,12 @@ func (s *sTenant) GetMember(ctx context.Context, req *v1.TenantMemberGetReq) (*v
|
||||
|
||||
// GetMemberUsage returns usage statistics for a single member.
|
||||
func (s *sTenant) GetMemberUsage(ctx context.Context, req *v1.TenantMemberUsageReq) (*v1.TenantMemberUsageRes, error) {
|
||||
// 成员用量属于成员管理视角的数据:member 角色不得窥视他人消费明细,
|
||||
// 自身用量走个人工作台/用量日志(已强制过滤本人)
|
||||
if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
// Verify member exists in tenant
|
||||
@@ -1001,6 +1028,12 @@ func (s *sTenant) GetMemberUsage(ctx context.Context, req *v1.TenantMemberUsageR
|
||||
|
||||
// ListMemberApiKeys returns a paginated list of API keys belonging to a specific member.
|
||||
func (s *sTenant) ListMemberApiKeys(ctx context.Context, req *v1.TenantMemberApiKeysReq) (*v1.TenantMemberApiKeysRes, error) {
|
||||
// 同 GetMemberUsage:他人 Key 清单(名称/额度/状态)是管理视角数据,
|
||||
// member 只管理自己的 Key(个人 Key 列表已强制过滤本人),明文另有属主校验
|
||||
if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
@@ -1085,12 +1118,13 @@ var memberPeriodLabels = map[string]string{
|
||||
"month": "按月",
|
||||
}
|
||||
|
||||
// formatMemberUSD 格式化金额为 $ 前缀、最多两位小数(去掉末尾 0),与前端 formatMoney(precision: 2) 输出一致
|
||||
func formatMemberUSD(v float64) string {
|
||||
// formatMemberMoney 格式化金额为「本位币符号 + 最多两位小数(去掉末尾 0)」,与前端 formatMoney(precision: 2) 输出一致。
|
||||
// 成员额度/消费属 bil_ 记账层,币种 = 系统本位币,符号由调用方一次性取好(billing.CurrencySymbol)避免逐行读配置。
|
||||
func formatMemberMoney(sym string, v float64) string {
|
||||
s := strconv.FormatFloat(v, 'f', 2, 64)
|
||||
s = strings.TrimRight(s, "0")
|
||||
s = strings.TrimSuffix(s, ".")
|
||||
return "$" + s
|
||||
return sym + s
|
||||
}
|
||||
|
||||
// ExportMembers exports the tenant member list as CSV or Excel.
|
||||
@@ -1105,6 +1139,10 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
// 额度/消费属 bil_ 记账层,币种 = 系统本位币;符号与币种码取一次供表头和逐行格式化复用
|
||||
moneyCurrency := billing.Currency(ctx)
|
||||
moneySym := billing.CurrencySymbol(ctx)
|
||||
|
||||
columns := []export.Column{
|
||||
{Field: "username", Header: "用户名"},
|
||||
{Field: "display_name", Header: "显示名称"},
|
||||
@@ -1113,7 +1151,7 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR
|
||||
{Field: "status", Header: "状态"},
|
||||
{Field: "quota", Header: "额度限制"},
|
||||
{Field: "model_count", Header: "可用模型"},
|
||||
{Field: "month_cost", Header: "本月消费(USD)"},
|
||||
{Field: "month_cost", Header: "本月消费(" + moneyCurrency + ")"},
|
||||
{Field: "created_at", Header: "加入时间"},
|
||||
{Field: "updated_at", Header: "最后更新"},
|
||||
}
|
||||
@@ -1202,7 +1240,7 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR
|
||||
// 额度限制:不限制 / 金额(周期性追加周期文案),与列表页渲染一致
|
||||
quota := "不限制"
|
||||
if u.QuotaType != "" && u.QuotaType != "none" {
|
||||
quota = formatMemberUSD(u.QuotaLimit)
|
||||
quota = formatMemberMoney(moneySym, u.QuotaLimit)
|
||||
if u.QuotaType == "periodic" && u.QuotaPeriod != "" {
|
||||
quota += "(" + memberPeriodLabels[u.QuotaPeriod] + ")"
|
||||
}
|
||||
@@ -1221,7 +1259,7 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR
|
||||
"status": memberStatusLabels[u.Status],
|
||||
"quota": quota,
|
||||
"model_count": modelCount,
|
||||
"month_cost": formatMemberUSD(monthCostMap[u.Id]),
|
||||
"month_cost": formatMemberMoney(moneySym, monthCostMap[u.Id]),
|
||||
"created_at": u.CreatedAt,
|
||||
"updated_at": u.UpdatedAt,
|
||||
}) {
|
||||
|
||||
@@ -34,7 +34,14 @@ type ImportResult struct {
|
||||
}
|
||||
|
||||
// MemberImport parses CSV content, validates, creates an import record.
|
||||
//
|
||||
// 角色校验此前缺失:批量导入会直接创建租户成员账号(并按套餐占用席位),
|
||||
// 与 MemberCreate / MemberInvite 是同一类动作,必须同为 owner/admin。
|
||||
func (s *sTenant) MemberImport(ctx context.Context, req *v1.TenantMemberImportReq) (*v1.TenantMemberImportRes, error) {
|
||||
role := middleware.GetUserRole(ctx)
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
if err := requireTeamEnabled(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ import (
|
||||
)
|
||||
|
||||
func (s *sTenant) MemberQuota(ctx context.Context, req *v1.TenantMemberQuotaReq) (*v1.TenantMemberQuotaRes, error) {
|
||||
// 成员额度配置是管理员设定的控制线,属管理视角数据:member 不得窥视他人额度,
|
||||
// 自身额度进度走个人工作台(personal-dashboard)。与 MemberQuotaSet 同门槛。
|
||||
if role := middleware.GetUserRole(ctx); role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
var row *struct {
|
||||
|
||||
@@ -32,14 +32,22 @@ func (s *sTenant) ModelComparison(ctx context.Context, req *v1.ModelComparisonRe
|
||||
|
||||
since := gtime.Now().AddDate(0, 0, -req.Days).Format("Y-m-d")
|
||||
|
||||
// Build args for parameterized query
|
||||
// 聚合与趋势两段查询共用同一份 WHERE 和参数(占位符顺序必须一致)。
|
||||
// member 角色只能对比自己的用量,避免普通成员看到组织级成本聚合
|
||||
// —— 与用量日志的 member 过滤口径一致。
|
||||
whereClause := "tenant_id = ? AND created_at >= ?"
|
||||
args := []any{tenantID, since}
|
||||
if middleware.GetUserRole(ctx) == "member" {
|
||||
whereClause += " AND user_id = ?"
|
||||
args = append(args, middleware.GetUserID(ctx))
|
||||
}
|
||||
for _, m := range models {
|
||||
args = append(args, m)
|
||||
}
|
||||
|
||||
// Aggregate per-model stats (parameterized IN clause, no fmt.Sprintf)
|
||||
inClause := strings.Repeat("?,", len(models)-1) + "?"
|
||||
whereClause += " AND model_name IN (" + inClause + ")"
|
||||
aggQuery := `
|
||||
SELECT
|
||||
model_name,
|
||||
@@ -51,7 +59,7 @@ func (s *sTenant) ModelComparison(ctx context.Context, req *v1.ModelComparisonRe
|
||||
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as output_tokens
|
||||
FROM bil_usage_logs
|
||||
WHERE tenant_id = ? AND created_at >= ? AND model_name IN (` + inClause + `)
|
||||
WHERE ` + whereClause + `
|
||||
GROUP BY model_name
|
||||
`
|
||||
|
||||
@@ -132,8 +140,8 @@ func (s *sTenant) ModelComparison(ctx context.Context, req *v1.ModelComparisonRe
|
||||
items[i].IsRecommended = items[i].ModelName == recommended
|
||||
}
|
||||
|
||||
// Daily trends
|
||||
trends := fetchTrends(ctx, tenantID, since, models, args)
|
||||
// Daily trends(与主查询共用 WHERE/参数,member 过滤口径一致)
|
||||
trends := fetchTrends(whereClause, args)
|
||||
|
||||
return &v1.ModelComparisonRes{
|
||||
Summary: v1.ModelComparisonSummary{
|
||||
@@ -209,8 +217,7 @@ func recommendBest(items []v1.ModelComparisonItem) (string, string) {
|
||||
return best, fmt.Sprintf("综合评分 %.1f(费用40%% + 延迟30%% + 成功率30%%)", bestScore)
|
||||
}
|
||||
|
||||
func fetchTrends(ctx context.Context, tenantID int64, since string, models []string, baseArgs []any) []v1.ModelTrendDay {
|
||||
inClause := strings.Repeat("?,", len(models)-1) + "?"
|
||||
func fetchTrends(whereClause string, args []any) []v1.ModelTrendDay {
|
||||
trendQuery := `
|
||||
SELECT
|
||||
DATE(created_at) as day,
|
||||
@@ -219,12 +226,12 @@ func fetchTrends(ctx context.Context, tenantID int64, since string, models []str
|
||||
COALESCE(SUM(total_cost), 0) as cost,
|
||||
COALESCE(AVG(latency_ms) FILTER (WHERE status = 'success'), 0) as latency
|
||||
FROM bil_usage_logs
|
||||
WHERE tenant_id = ? AND created_at >= ? AND model_name IN (` + inClause + `)
|
||||
WHERE ` + whereClause + `
|
||||
GROUP BY DATE(created_at), model_name
|
||||
ORDER BY day
|
||||
`
|
||||
|
||||
records, err := g.DB().Raw(trendQuery, baseArgs...).All()
|
||||
records, err := g.DB().Raw(trendQuery, args...).All()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -68,6 +68,27 @@ func getOAuthSetting(ctx context.Context, key string) string {
|
||||
return common.Config().GetOption(ctx, key)
|
||||
}
|
||||
|
||||
// getOAuthRedirectBase 返回构造 OAuth redirect_uri 的基址。
|
||||
// 优先级:site_url(管理后台「基础配置-站点后端地址」,应与 GitHub/Google 应用后台
|
||||
// 登记的回调域名一致)> 按当前 API 请求自身 Host 推断。
|
||||
// 授权跳转与 token 交换两个阶段的 redirect_uri 必须完全一致(OAuth 规范强制校验),
|
||||
// 因此兜底取后端 API 请求的 Host 而非前端 Origin——授权与回调都是打向后端的请求,
|
||||
// 同一部署下 Host 推断结果天然一致。
|
||||
func getOAuthRedirectBase(ctx context.Context) string {
|
||||
base := strings.TrimRight(getOAuthSetting(ctx, "site_url"), "/")
|
||||
if base != "" {
|
||||
return base
|
||||
}
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
scheme := "https"
|
||||
if r.GetHeader("X-Forwarded-Proto") == "http" || strings.HasPrefix(r.GetHost(), "localhost") || strings.HasPrefix(r.GetHost(), "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, r.GetHost())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// encryptOAuthToken encrypts a token string for secure storage.
|
||||
func encryptOAuthToken(ctx context.Context, token string) string {
|
||||
if token == "" {
|
||||
@@ -113,10 +134,7 @@ func (s *sTenant) GetOAuthAuthorizeURL(ctx context.Context, req *v1.OAuthAuthori
|
||||
return nil, common.NewBusinessError(10059, "该 OAuth 供应商未启用")
|
||||
}
|
||||
|
||||
siteURL := getOAuthSetting(ctx, "site_url")
|
||||
if siteURL == "" {
|
||||
siteURL = "http://localhost:3000"
|
||||
}
|
||||
siteURL := getOAuthRedirectBase(ctx)
|
||||
redirectURI := siteURL + fmt.Sprintf("/api/tenant/oauth/%s/callback", req.Provider)
|
||||
|
||||
state := generateState()
|
||||
@@ -318,6 +336,7 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
|
||||
|
||||
// 生成 JWT token pair
|
||||
tokenPair, err := common.GenerateTokenPair(ctx, userID, "tenant", user.Role, tenantID, sessionID, jti)
|
||||
@@ -599,10 +618,7 @@ func (p *GoogleProvider) ExchangeToken(ctx context.Context, code string) (*OAuth
|
||||
v.Set("code", code)
|
||||
v.Set("client_id", getOAuthSetting(ctx, "oauth_google_client_id"))
|
||||
v.Set("client_secret", getOAuthSetting(ctx, "oauth_google_client_secret"))
|
||||
siteURL := getOAuthSetting(ctx, "site_url")
|
||||
if siteURL == "" {
|
||||
siteURL = "http://localhost:3000"
|
||||
}
|
||||
siteURL := getOAuthRedirectBase(ctx)
|
||||
v.Set("redirect_uri", siteURL+"/api/tenant/oauth/google/callback")
|
||||
v.Set("grant_type", "authorization_code")
|
||||
|
||||
|
||||
@@ -27,6 +27,45 @@ import (
|
||||
// 开放平台应用管理
|
||||
// ============================================================
|
||||
|
||||
// openAppPermissionPoints 是开放平台应用可授予的全部权限点。
|
||||
//
|
||||
// 与 internal/logic/open 中各 handler 实际校验的字符串一一对应(models:read 目前尚无
|
||||
// handler 使用,保留以免存量授权在编辑时被判为非法)。此前创建/更新只校验 required,
|
||||
// 任意字符串都能入库:判定是精确匹配且 fail-closed,写错不会越权,但会静默失效 ——
|
||||
// 写成 members:wirte 的应用在任何接口上都拿不到权限,且排查时毫无线索。
|
||||
var openAppPermissionPoints = map[string]bool{
|
||||
"members:read": true,
|
||||
"members:write": true,
|
||||
"keys:read": true,
|
||||
"keys:write": true,
|
||||
"projects:read": true,
|
||||
"projects:write": true,
|
||||
"models:read": true,
|
||||
"usage:read": true,
|
||||
"billing:read": true,
|
||||
}
|
||||
|
||||
// sanitizeOpenAppPermissions 校验并规整应用权限点:去重去空、拒绝未定义的权限点。
|
||||
func sanitizeOpenAppPermissions(perms []string) ([]string, error) {
|
||||
seen := make(map[string]bool, len(perms))
|
||||
out := make([]string, 0, len(perms))
|
||||
for _, p := range perms {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || seen[p] {
|
||||
continue
|
||||
}
|
||||
if !openAppPermissionPoints[p] {
|
||||
return nil, common.NewBadRequestError("无效的权限点:" + p)
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, common.NewBadRequestError("请至少选择一个权限点")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type opnAppEncryptedSecretData struct {
|
||||
EncryptedSecret any `json:"encrypted_secret"`
|
||||
}
|
||||
@@ -71,7 +110,6 @@ func (s *sTenant) OpenAppList(ctx context.Context, req *v1.OpenAppListReq) (*v1.
|
||||
AppID: app.AppId,
|
||||
Permissions: perms,
|
||||
Status: app.Status,
|
||||
IsSandbox: app.IsSandbox,
|
||||
RateLimit: app.RateLimit,
|
||||
}
|
||||
if app.LastUsedAt != nil {
|
||||
@@ -138,7 +176,11 @@ func (s *sTenant) OpenAppCreate(ctx context.Context, req *v1.OpenAppCreateReq) (
|
||||
return nil, err
|
||||
}
|
||||
|
||||
permsJSON, _ := json.Marshal(req.Permissions)
|
||||
perms, err := sanitizeOpenAppPermissions(req.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
permsJSON, _ := json.Marshal(perms)
|
||||
ipJSON, _ := json.Marshal(req.IPWhitelist)
|
||||
if req.IPWhitelist == nil {
|
||||
ipJSON = []byte("[]")
|
||||
@@ -203,7 +245,11 @@ func (s *sTenant) OpenAppUpdate(ctx context.Context, req *v1.OpenAppUpdateReq) (
|
||||
hasUpdate = true
|
||||
}
|
||||
if req.Permissions != nil {
|
||||
permsJSON, _ := json.Marshal(req.Permissions)
|
||||
perms, err := sanitizeOpenAppPermissions(req.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
permsJSON, _ := json.Marshal(perms)
|
||||
data.Permissions = string(permsJSON)
|
||||
hasUpdate = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// checkOpenPermissionCall 匹配 open 层 handler 里的 middleware.CheckOpenPermission(ctx, "xxx:yyy")。
|
||||
var checkOpenPermissionCall = regexp.MustCompile(`CheckOpenPermission\(ctx,\s*"([^"]+)"\)`)
|
||||
|
||||
// TestOpenAppPermissionWhitelistCoversHandlers 保证「可授予的权限点」覆盖「实际被校验的权限点」。
|
||||
//
|
||||
// 两者分处两个包:白名单在 tenant(配置侧),校验在 open(执行侧)。若有人新增一个
|
||||
// CheckOpenPermission("reports:read") 却忘了加进白名单,租户将永远无法把该权限授予应用 ——
|
||||
// 接口对所有应用恒 403,且因为 sanitizeOpenAppPermissions 会把它判为「无效的权限点」,
|
||||
// 连手工配置这条退路也没有。这类漂移不会有编译错误,只能靠测试兜住。
|
||||
func TestOpenAppPermissionWhitelistCoversHandlers(t *testing.T) {
|
||||
src, err := os.ReadFile("../open/open_api.go")
|
||||
if err != nil {
|
||||
t.Fatalf("读取 open 层源码失败: %v", err)
|
||||
}
|
||||
|
||||
matches := checkOpenPermissionCall.FindAllStringSubmatch(string(src), -1)
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("未在 open 层源码中匹配到任何 CheckOpenPermission 调用,正则可能已失效")
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var missing []string
|
||||
for _, m := range matches {
|
||||
perm := m[1]
|
||||
if seen[perm] {
|
||||
continue
|
||||
}
|
||||
seen[perm] = true
|
||||
if !openAppPermissionPoints[perm] {
|
||||
missing = append(missing, perm)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
t.Errorf("以下权限点被 handler 校验但不在 openAppPermissionPoints 白名单中,租户无法授予:%v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAppPermissions(t *testing.T) {
|
||||
t.Run("去重去空并保留合法权限点", func(t *testing.T) {
|
||||
got, err := sanitizeOpenAppPermissions([]string{"members:read", " ", "members:read", "usage:read"})
|
||||
if err != nil {
|
||||
t.Fatalf("预期通过,实际报错: %v", err)
|
||||
}
|
||||
want := []string{"members:read", "usage:read"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("预期 %v,实际 %v", want, got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("预期 %v,实际 %v", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("拒绝未定义的权限点", func(t *testing.T) {
|
||||
// 拼错一个字母不会有编译错误,但会让该应用在对应接口上永久失效
|
||||
if _, err := sanitizeOpenAppPermissions([]string{"members:wirte"}); err == nil {
|
||||
t.Fatal("预期拒绝拼写错误的权限点,实际通过")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("拒绝空集合", func(t *testing.T) {
|
||||
// 零权限的应用调用任何接口都是 403,配置时就该报错而不是留一个静默失效的应用
|
||||
if _, err := sanitizeOpenAppPermissions([]string{}); err == nil {
|
||||
t.Fatal("预期拒绝空权限集合,实际通过")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// Sandbox(模拟调用,不计费)
|
||||
// ============================================================
|
||||
|
||||
func (s *sTenant) SandboxChat(ctx context.Context, req *v1.SandboxChatReq) (*v1.SandboxChatRes, error) {
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
now := time.Now()
|
||||
quotaKey := fmt.Sprintf("sandbox:quota:%d:%s", tenantID, now.Format("200601"))
|
||||
remaining, err := g.Redis().Do(ctx, "GET", quotaKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defaultQuota := g.Cfg().MustGet(ctx, "sandbox.sandbox_default_quota").Int()
|
||||
if defaultQuota <= 0 {
|
||||
defaultQuota = 100
|
||||
}
|
||||
|
||||
remainInt := defaultQuota
|
||||
if !remaining.IsNil() && !remaining.IsEmpty() {
|
||||
remainInt = remaining.Int()
|
||||
}
|
||||
|
||||
if remainInt <= 0 {
|
||||
return nil, common.NewBusinessError(10056, "本月沙箱额度已用完")
|
||||
}
|
||||
|
||||
_, err = g.Redis().Do(ctx, "DECR", quotaKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if remainInt == defaultQuota {
|
||||
_, _ = g.Redis().Do(ctx, "EXPIRE", quotaKey, 86400*30)
|
||||
}
|
||||
|
||||
content := generateSimulatedResponse(req.Model, req.Messages)
|
||||
|
||||
return &v1.SandboxChatRes{
|
||||
Content: content,
|
||||
IsSandbox: true,
|
||||
RemainingQuota: remainInt - 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *sTenant) SandboxQuota(ctx context.Context, req *v1.SandboxQuotaReq) (*v1.SandboxQuotaRes, error) {
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
defaultQuota := g.Cfg().MustGet(ctx, "sandbox.sandbox_default_quota").Int()
|
||||
if defaultQuota <= 0 {
|
||||
defaultQuota = 100
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
quotaKey := fmt.Sprintf("sandbox:quota:%d:%s", tenantID, now.Format("200601"))
|
||||
remaining, err := g.Redis().Do(ctx, "GET", quotaKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
remainInt := defaultQuota
|
||||
if !remaining.IsNil() && !remaining.IsEmpty() {
|
||||
remainInt = remaining.Int()
|
||||
}
|
||||
|
||||
used := defaultQuota - remainInt
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
|
||||
return &v1.SandboxQuotaRes{
|
||||
TotalQuota: defaultQuota,
|
||||
RemainingQuota: remainInt,
|
||||
UsedQuota: used,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func generateSimulatedResponse(model string, messages []v1.PlaygroundMessage) string {
|
||||
userMsg := ""
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Role == "user" {
|
||||
userMsg = messages[i].Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if userMsg == "" {
|
||||
userMsg = "你的请求"
|
||||
}
|
||||
return fmt.Sprintf("这是来自模型 %s 的模拟响应。您发送的消息是:%q\n\n(沙箱模式:此响应为模拟数据,不会产生实际费用)", model, userMsg)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
"github.com/qianfree/team-api/internal/plugin"
|
||||
|
||||
@@ -76,7 +77,23 @@ func (s *sTenant) TenantPluginDetail(ctx context.Context, req *v1.TenantPluginDe
|
||||
}, nil
|
||||
}
|
||||
|
||||
// assertPluginManager 校验插件的写操作权限。
|
||||
//
|
||||
// 插件配置与启停是【组织级】设置:一次改动对整个租户的全部成员生效,配置里还可能包含
|
||||
// 第三方凭据。此前三个写接口只取了 tenantID 就直接落库,任意 member 都能改掉或关停
|
||||
// 全组织的插件,与 List/Detail(读)同权。读保持全员可见,写收敛到 owner/admin。
|
||||
func assertPluginManager(ctx context.Context) error {
|
||||
role := middleware.GetUserRole(ctx)
|
||||
if role != "owner" && role != "admin" {
|
||||
return common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sTenant) TenantPluginConfigUpdate(ctx context.Context, req *v1.TenantPluginConfigUpdateReq) (*v1.TenantPluginConfigUpdateRes, error) {
|
||||
if err := assertPluginManager(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
if err := plugin.UpdatePluginConfigForTenant(ctx, req.Name, tenantID, req.Config); err != nil {
|
||||
return nil, err
|
||||
@@ -85,6 +102,9 @@ func (s *sTenant) TenantPluginConfigUpdate(ctx context.Context, req *v1.TenantPl
|
||||
}
|
||||
|
||||
func (s *sTenant) TenantPluginEnable(ctx context.Context, req *v1.TenantPluginEnableReq) (*v1.TenantPluginEnableRes, error) {
|
||||
if err := assertPluginManager(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
if err := plugin.EnableForTenant(ctx, req.Name, tenantID); err != nil {
|
||||
return nil, err
|
||||
@@ -93,6 +113,9 @@ func (s *sTenant) TenantPluginEnable(ctx context.Context, req *v1.TenantPluginEn
|
||||
}
|
||||
|
||||
func (s *sTenant) TenantPluginDisable(ctx context.Context, req *v1.TenantPluginDisableReq) (*v1.TenantPluginDisableRes, error) {
|
||||
if err := assertPluginManager(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
if err := plugin.DisableForTenant(ctx, req.Name, tenantID); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -355,7 +355,14 @@ func (s *sTenant) ProjectArchive(ctx context.Context, req *v1.TenantProjectArchi
|
||||
}
|
||||
|
||||
// ProjectUnarchive restores an archived project. Keys are NOT auto-restored.
|
||||
//
|
||||
// 角色校验此前缺失:Create/Update/Archive 都要求 owner/admin,唯独恢复归档没拦,
|
||||
// 任意 member 都能把管理员刚归档的项目恢复回来(归档是治理动作,撤销它同样是)。
|
||||
func (s *sTenant) ProjectUnarchive(ctx context.Context, req *v1.TenantProjectUnarchiveReq) (*v1.TenantProjectUnarchiveRes, error) {
|
||||
role := middleware.GetUserRole(ctx)
|
||||
if role != "owner" && role != "admin" {
|
||||
return nil, common.NewForbiddenError("需要 owner 或 admin 权限")
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
var project *struct {
|
||||
|
||||
@@ -67,6 +67,7 @@ func (s *sTenant) Verify2FA(ctx context.Context, req *v1.Tenant2FAVerifyReq) (*v
|
||||
if err != nil {
|
||||
return nil, gerror.Wrapf(err, "create session")
|
||||
}
|
||||
refreshToken = common.BindSessionIDToRefreshToken(refreshToken, sessionID)
|
||||
|
||||
tokenPair, err := common.GenerateTokenPair(ctx, user.Id, "tenant", user.Role, user.TenantId, sessionID, jti)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
"github.com/qianfree/team-api/internal/utility/export"
|
||||
@@ -227,6 +228,9 @@ func (s *sTenant) ExportTasks(ctx context.Context, req *v1.TenantTaskExportReq)
|
||||
"suno": "Suno",
|
||||
}
|
||||
|
||||
// 预扣/实际费用属 bil_ 记账层,币种 = 系统本位币
|
||||
moneyCurrency := billing.Currency(ctx)
|
||||
|
||||
columns := []export.Column{
|
||||
{Field: "public_task_id", Header: "任务ID"},
|
||||
{Field: "platform_name", Header: "平台"},
|
||||
@@ -235,8 +239,8 @@ func (s *sTenant) ExportTasks(ctx context.Context, req *v1.TenantTaskExportReq)
|
||||
{Field: "model_name", Header: "模型"},
|
||||
{Field: "progress", Header: "进度"},
|
||||
{Field: "username", Header: "用户"},
|
||||
{Field: "pre_deduct_amount", Header: "预扣金额(USD)"},
|
||||
{Field: "actual_cost", Header: "实际费用(USD)"},
|
||||
{Field: "pre_deduct_amount", Header: "预扣金额(" + moneyCurrency + ")"},
|
||||
{Field: "actual_cost", Header: "实际费用(" + moneyCurrency + ")"},
|
||||
{Field: "billing_settled", Header: "是否已结算"},
|
||||
{Field: "fail_reason", Header: "失败原因"},
|
||||
{Field: "submit_time", Header: "提交时间"},
|
||||
|
||||
@@ -104,15 +104,36 @@ func (s *sTenant) TicketGet(ctx context.Context, req *v1.TenantTicketGetReq) (*v
|
||||
return nil, common.NewNotFoundError("工单")
|
||||
}
|
||||
|
||||
var replyRows []*v1.TenantTicketReplyItem
|
||||
// 回复按 user_type 分别关联管理员/租户用户表取作者名
|
||||
type replyRow struct {
|
||||
entity.SptReplies
|
||||
AuthorName string `json:"author_name" orm:"author_name"`
|
||||
}
|
||||
var replyRows []*replyRow
|
||||
err = dao.SptReplies.Ctx(ctx).
|
||||
Where("ticket_id", req.Id).
|
||||
OrderAsc("created_at").
|
||||
LeftJoin("sys_admin_users sa", "spt_replies.user_type = 'admin' AND spt_replies.user_id = sa.id").
|
||||
LeftJoin("tnt_users tu", "spt_replies.user_type = 'tenant' AND spt_replies.user_id = tu.id").
|
||||
Fields("spt_replies.*, CASE WHEN spt_replies.user_type = 'admin' THEN COALESCE(sa.username, '') ELSE COALESCE(tu.display_name, '') END as author_name").
|
||||
Where("spt_replies.ticket_id", req.Id).
|
||||
OrderAsc("spt_replies.created_at").
|
||||
Scan(&replyRows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
replies := make([]*v1.TenantTicketReplyItem, 0, len(replyRows))
|
||||
for _, r := range replyRows {
|
||||
replies = append(replies, &v1.TenantTicketReplyItem{
|
||||
Id: r.Id,
|
||||
TicketId: r.TicketId,
|
||||
UserId: r.UserId,
|
||||
UserType: r.UserType,
|
||||
AuthorName: r.AuthorName,
|
||||
Content: r.Content,
|
||||
CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
var attachRows []*entity.SptAttachments
|
||||
err = dao.SptAttachments.Ctx(ctx).
|
||||
Where("ticket_id", req.Id).
|
||||
@@ -149,7 +170,7 @@ func (s *sTenant) TicketGet(ctx context.Context, req *v1.TenantTicketGetReq) (*v
|
||||
AssignedAdminName: ticket.AssignedAdminName,
|
||||
CreatedAt: ticket.CreatedAt,
|
||||
UpdatedAt: ticket.UpdatedAt,
|
||||
Replies: replyRows,
|
||||
Replies: replies,
|
||||
Attachments: attachments,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -77,11 +77,15 @@ func AdminAuth(r *ghttp.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user still exists and is active
|
||||
// Verify user still exists and is active.
|
||||
// role 一并取出:super_admin 会短路整套鉴权(HasPermission 直接放行、
|
||||
// assertSuperAdmin 与 superAdminOnlyRules 都只看它),是全站最高权级的判据,
|
||||
// 因此必须以数据库为准,不能沿用签发时写进 token 的 claims.Role —— 否则给某人
|
||||
// 临时提为超管再降回来后,其旧 token 在有效期内仍是超管(降权无法即时生效)。
|
||||
var user *entity.SysAdminUsers
|
||||
err = dao.SysAdminUsers.Ctx(r.Context()).
|
||||
Where("id", claims.UserID).
|
||||
Fields("status").
|
||||
Fields("status, role").
|
||||
Scan(&user)
|
||||
if err != nil {
|
||||
response.ErrorMsg(r, consts.CodeUnauthorized, consts.MsgUnauthorized)
|
||||
@@ -95,7 +99,7 @@ func AdminAuth(r *ghttp.Request) {
|
||||
// Set auth context
|
||||
r.SetCtxVar(CtxKeyUserID, claims.UserID)
|
||||
r.SetCtxVar(CtxKeyUserType, claims.UserType)
|
||||
r.SetCtxVar(CtxKeyRole, claims.Role)
|
||||
r.SetCtxVar(CtxKeyRole, user.Role)
|
||||
r.SetCtxVar(CtxKeySessionID, claims.SessionID)
|
||||
r.SetCtxVar(CtxKeyJti, claims.ID)
|
||||
|
||||
|
||||
@@ -43,6 +43,17 @@ func Idempotency(r *ghttp.Request) {
|
||||
tenantID := r.GetCtxVar(CtxKeyTenantID).Int64()
|
||||
userID := r.GetCtxVar(CtxKeyUserID).Int64()
|
||||
userType := r.GetCtxVar(CtxKeyUserType).String()
|
||||
|
||||
// 开放平台不走 TenantAuth,身份存在 OpenPlatformAuth 注入的 typed context key 里,
|
||||
// 上面三个 CtxVar 全是零值。若不单独取,所有应用会共用同一个 ":0:0:" 前缀 ——
|
||||
// 不同租户复用同一个 Idempotency-Key 就会读到彼此缓存的响应体(跨租户数据泄漏)。
|
||||
// 主体取「应用」而非「用户」:开放平台请求由应用发起,重试也以应用为单位。
|
||||
if appID := GetOpenAppID(r.Context()); appID > 0 {
|
||||
userType = "open"
|
||||
tenantID = GetOpenTenantID(r.Context())
|
||||
userID = appID
|
||||
}
|
||||
|
||||
idempotencyKey := buildIdempotencyKey(userType, tenantID, userID, clientKey)
|
||||
|
||||
// Check if this key has been processed before
|
||||
|
||||
@@ -38,7 +38,6 @@ const (
|
||||
ctxKeyOpenAppID openContextKey = "openAppId"
|
||||
ctxKeyOpenTenantID openContextKey = "openTenantId"
|
||||
ctxKeyOpenPermissions openContextKey = "openPermissions"
|
||||
ctxKeyOpenIsSandbox openContextKey = "openIsSandbox"
|
||||
)
|
||||
|
||||
var openAppCache = lcommon.NewCache("open_app", 60*time.Second)
|
||||
@@ -90,6 +89,15 @@ func OpenPlatformAuth(r *ghttp.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 租户状态校验:应用只是租户的一条独立入口,租户被停服/销户后必须一并失效。
|
||||
// 控制台走 ValidateTenantPrincipal、relay 走 ValidateApiKey,两处都会拦下停服租户;
|
||||
// 开放平台此前是唯一缺口 —— 停服后仍可继续建成员、建 Key、读账单,停服动作形同虚设。
|
||||
// 不加缓存:这是一次主键查询,而开放平台是低频管理接口,缓存只会换来「停服延迟生效」的窗口。
|
||||
if !isOpenTenantActive(r.Context(), app.TenantId) {
|
||||
response.Error(r, consts.ErrTenantSuspended)
|
||||
return
|
||||
}
|
||||
|
||||
// Check IP whitelist
|
||||
if err := checkOpenAppIPWhitelist(r, app); err != nil {
|
||||
response.Error(r, err)
|
||||
@@ -132,12 +140,31 @@ func OpenPlatformAuth(r *ghttp.Request) {
|
||||
ctx = context.WithValue(ctx, ctxKeyOpenAppID, app.Id)
|
||||
ctx = context.WithValue(ctx, ctxKeyOpenTenantID, app.TenantId)
|
||||
ctx = context.WithValue(ctx, ctxKeyOpenPermissions, app.Permissions)
|
||||
ctx = context.WithValue(ctx, ctxKeyOpenIsSandbox, app.IsSandbox)
|
||||
r.SetCtx(ctx)
|
||||
|
||||
r.Middleware.Next()
|
||||
}
|
||||
|
||||
// isOpenTenantActive 校验应用所属租户处于 active 状态。
|
||||
// 查询失败或租户不存在时一律 fail-closed —— 鉴权环节宁可误拒,不可误放。
|
||||
func isOpenTenantActive(ctx context.Context, tenantID int64) bool {
|
||||
if tenantID <= 0 {
|
||||
return false
|
||||
}
|
||||
var tenant *struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
err := dao.TntTenants.Ctx(ctx).
|
||||
Where("id", tenantID).
|
||||
Fields("status").
|
||||
Scan(&tenant)
|
||||
if err = lcommon.IgnoreScanNoRows(err); err != nil {
|
||||
g.Log().Warningf(ctx, "[OpenAuth] tenant status lookup failed tenant=%d: %v", tenantID, err)
|
||||
return false
|
||||
}
|
||||
return tenant != nil && tenant.Status == "active"
|
||||
}
|
||||
|
||||
// checkOpenAppNonce enforces replay protection by recording each nonce in Redis.
|
||||
// 用 SET NX 原子写入 nonce:首次出现返回 OK 放行;已存在说明是重放,写 401 并返回 false。
|
||||
// Redis 故障时 fail-open(与限流一致),仅记告警,因为时间戳窗口本身已把可重放时间
|
||||
|
||||
@@ -90,12 +90,19 @@ func OperationLog(r *ghttp.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get auth info
|
||||
// Get auth info;开放平台请求没有用户身份,以应用身份(open_app + 应用ID)留痕,
|
||||
// 否则 /api/open 的建成员/建删 Key 等写操作在三张审计表里都不存在
|
||||
userID := GetUserID(r.Context())
|
||||
userType := GetUserType(r.Context())
|
||||
|
||||
tenantID := GetTenantID(r.Context())
|
||||
if userID == 0 {
|
||||
return
|
||||
if appID := GetOpenAppID(r.Context()); appID > 0 {
|
||||
userID = appID
|
||||
userType = "open_app"
|
||||
tenantID = GetOpenTenantID(r.Context())
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Parse resource info from URL path
|
||||
@@ -134,7 +141,6 @@ func OperationLog(r *ghttp.Request) {
|
||||
logData.ResourceId = resourceID
|
||||
}
|
||||
|
||||
tenantID := GetTenantID(r.Context())
|
||||
if tenantID > 0 {
|
||||
logData.TenantId = tenantID
|
||||
}
|
||||
@@ -169,9 +175,9 @@ func parseResourceFromPath(path string) (resourceType string, resourceID int64)
|
||||
path = strings.TrimPrefix(path, "/api/")
|
||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||
|
||||
// Skip prefix like "admin" or "tenant"
|
||||
// Skip prefix like "admin", "tenant" or "open"
|
||||
idx := 0
|
||||
if len(parts) > 0 && (parts[0] == "admin" || parts[0] == "tenant") {
|
||||
if len(parts) > 0 && (parts[0] == "admin" || parts[0] == "tenant" || parts[0] == "open") {
|
||||
idx = 1
|
||||
}
|
||||
|
||||
|
||||
+136
-55
@@ -10,42 +10,11 @@ import (
|
||||
"github.com/qianfree/team-api/internal/response"
|
||||
)
|
||||
|
||||
// RequirePermission returns middleware that checks if the admin user has the required permission.
|
||||
func RequirePermission(permission string) func(r *ghttp.Request) {
|
||||
return func(r *ghttp.Request) {
|
||||
role := GetUserRole(r.Context())
|
||||
userID := GetUserID(r.Context())
|
||||
|
||||
if role == "super_admin" {
|
||||
r.Middleware.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if !admin.HasPermission(r.Context(), userID, role, permission) {
|
||||
response.ErrorMsg(r, consts.CodeForbidden, "缺少权限:"+permission)
|
||||
return
|
||||
}
|
||||
|
||||
r.Middleware.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireTenantRole returns middleware that checks if the tenant user has the required role.
|
||||
func RequireTenantRole(roles ...string) func(r *ghttp.Request) {
|
||||
roleSet := make(map[string]bool)
|
||||
for _, r := range roles {
|
||||
roleSet[r] = true
|
||||
}
|
||||
|
||||
return func(r *ghttp.Request) {
|
||||
role := GetUserRole(r.Context())
|
||||
if !roleSet[role] {
|
||||
response.ErrorMsg(r, consts.CodeForbidden, consts.MsgForbidden)
|
||||
return
|
||||
}
|
||||
r.Middleware.Next()
|
||||
}
|
||||
}
|
||||
// 说明:曾有 RequirePermission / RequireTenantRole 两个「按路由挂载」的中间件,
|
||||
// 均无任何调用方,已删除。管理后台的授权统一由 AdminPermissionGuard + adminPermissionRules
|
||||
// 集中判定(默认拒绝、规则完整性由 rbac_coverage_test.go 保证);租户控制台的角色校验
|
||||
// 在 logic 层逐方法进行。留着一个从未挂载的 RequireTenantRole 会让人误以为
|
||||
// /api/tenant 有路由级角色闸门,实际并没有。
|
||||
|
||||
// adminPermissionRules defines the mapping from route patterns to permission points.
|
||||
// Unmapped admin routes are denied by default in AdminPermissionGuard.
|
||||
@@ -79,6 +48,7 @@ var adminPermissionRules = []permissionRule{
|
||||
{method: "POST", path: "/api/admin/channels", perm: "channel:create"},
|
||||
{method: "POST", prefix: "/api/admin/channels/", suffix: "/test", perm: "channel:test"},
|
||||
{method: "POST", prefix: "/api/admin/channels/", suffix: "/clone", perm: "channel:create"},
|
||||
{method: "POST", prefix: "/api/admin/channels/", suffix: "/reset-health", perm: "channel:edit"},
|
||||
{method: "POST", prefix: "/api/admin/channels/", suffix: "/keys", perm: "channel:edit"},
|
||||
{method: "DELETE", prefix: "/api/admin/channels/", suffix: "/keys/", perm: "channel:edit"},
|
||||
{method: "GET", prefix: "/api/admin/channels/", suffix: "/keys", perm: "channel:view"},
|
||||
@@ -138,7 +108,7 @@ var adminPermissionRules = []permissionRule{
|
||||
{method: "POST", prefix: "/api/admin/wallets/", suffix: "/offline-recharge", perm: "billing:refund"},
|
||||
{method: "POST", prefix: "/api/admin/wallets/", suffix: "/frozen-items/release", perm: "billing:refund"},
|
||||
{method: "POST", prefix: "/api/admin/wallets/", suffix: "/frozen-items/release-all", perm: "billing:refund"},
|
||||
{method: "PUT", prefix: "/api/admin/wallets/", suffix: "/warning-threshold", perm: "billing:view"},
|
||||
{method: "PUT", prefix: "/api/admin/wallets/", suffix: "/warning-threshold", perm: "billing:refund"},
|
||||
|
||||
// ── plan 套餐管理 ──
|
||||
{method: "GET", path: "/api/admin/plans", perm: "plan:view"},
|
||||
@@ -153,7 +123,9 @@ var adminPermissionRules = []permissionRule{
|
||||
{method: "GET", path: "/api/admin/orders/export", perm: "order:view"},
|
||||
{method: "GET", prefix: "/api/admin/orders/", perm: "order:view"},
|
||||
{method: "POST", prefix: "/api/admin/orders/", suffix: "/refund", perm: "order:refund"},
|
||||
{method: "POST", prefix: "/api/admin/orders/", suffix: "/complete", perm: "order:view"},
|
||||
// 手动完成订单会走 markOrderPaidByAdmin + payment.FulfillOrder:无支付凭证直接履约
|
||||
// (充值入钱包 / 发放套餐),是资金动作而非订单查看,按退款同级管控。
|
||||
{method: "POST", prefix: "/api/admin/orders/", suffix: "/complete", perm: "order:refund"},
|
||||
|
||||
// ── operation 内容运营 ──
|
||||
{method: "GET", path: "/api/admin/changelogs", perm: "operation:view"},
|
||||
@@ -180,6 +152,8 @@ var adminPermissionRules = []permissionRule{
|
||||
{method: "PUT", prefix: "/api/admin/tickets/", suffix: "/assign", perm: "support:edit"},
|
||||
{method: "POST", prefix: "/api/admin/tickets/", suffix: "/reply", perm: "support:reply"},
|
||||
{method: "PUT", prefix: "/api/admin/tickets/", suffix: "/status", perm: "support:edit"},
|
||||
// 顶栏铃铛待办计数(工单 + 反馈聚合,权限收敛在 support:view)
|
||||
{method: "GET", path: "/api/admin/support/pending-summary", perm: "support:view"},
|
||||
|
||||
// ── help center 帮助中心 ──
|
||||
{method: "GET", path: "/api/admin/help-categories", perm: "support:view"},
|
||||
@@ -194,13 +168,17 @@ var adminPermissionRules = []permissionRule{
|
||||
|
||||
// ── audit 审计日志 ──
|
||||
{method: "GET", path: "/api/admin/audit/config", perm: "audit:view"},
|
||||
{method: "PUT", path: "/api/admin/audit/config", perm: "audit:view"},
|
||||
// 写 sys_options.audit_level(全局审计级别,可设为 none 关闭全站审计采集):
|
||||
// 属于系统配置而非审计查看,否则只读档的技术支持可以自行关掉审计留痕。
|
||||
{method: "PUT", path: "/api/admin/audit/config", perm: "system:edit"},
|
||||
{method: "GET", path: "/api/admin/audit/operation-logs", perm: "audit:view"},
|
||||
{method: "GET", path: "/api/admin/audit/operation-logs/export", perm: "audit:export"},
|
||||
{method: "GET", path: "/api/admin/audit/request-logs", perm: "audit:view"},
|
||||
{method: "GET", prefix: "/api/admin/audit/request-logs/", perm: "audit:view"},
|
||||
{method: "GET", prefix: "/api/admin/audit/forwarding-trace/", perm: "audit:view"},
|
||||
{method: "GET", path: "/api/admin/audit/sensitive-logs", perm: "audit:read_sensitive"},
|
||||
{method: "GET", path: "/api/admin/audit/content-filter-logs", perm: "audit:view"},
|
||||
{method: "DELETE", path: "/api/admin/audit/content-filter-logs/clear", perm: "audit:clear"},
|
||||
|
||||
// ── file 文件管理 ──
|
||||
{method: "GET", path: "/api/admin/files", perm: "file:view"},
|
||||
@@ -226,6 +204,7 @@ var adminPermissionRules = []permissionRule{
|
||||
{method: "PUT", prefix: "/api/admin/error-logs/", perm: "monitor:edit"},
|
||||
{method: "PUT", path: "/api/admin/error-logs/batch-resolve", perm: "monitor:edit"},
|
||||
{method: "DELETE", path: "/api/admin/error-logs/clear", perm: "monitor:edit"},
|
||||
{method: "DELETE", path: "/api/admin/monitor/channel-errors/clear", perm: "monitor:edit"},
|
||||
{method: "GET", path: "/api/admin/error-logs/stats", perm: "monitor:view"},
|
||||
{method: "GET", path: "/api/admin/cron-jobs", perm: "system:view"},
|
||||
{method: "POST", prefix: "/api/admin/cron-jobs/", suffix: "/trigger", perm: "system:edit"},
|
||||
@@ -241,15 +220,14 @@ var adminPermissionRules = []permissionRule{
|
||||
{method: "PUT", prefix: "/api/admin/payment-channels/", perm: "system:edit"},
|
||||
{method: "GET", path: "/api/admin/payment-settings", perm: "system:view"},
|
||||
{method: "PUT", path: "/api/admin/payment-settings", perm: "system:edit"},
|
||||
{method: "GET", prefix: "/api/admin/update/", perm: "system:update"},
|
||||
{method: "POST", prefix: "/api/admin/update/", perm: "system:update"},
|
||||
{method: "GET", path: "/api/admin/data-governance/settings", perm: "system:view"},
|
||||
{method: "PUT", path: "/api/admin/data-governance/settings", perm: "system:edit"},
|
||||
{method: "POST", prefix: "/api/admin/data-governance/", perm: "system:edit"},
|
||||
{method: "GET", prefix: "/api/admin/plugins", perm: "system:plugin"},
|
||||
{method: "POST", prefix: "/api/admin/plugins/", perm: "system:plugin"},
|
||||
{method: "PUT", prefix: "/api/admin/plugins/", perm: "system:plugin"},
|
||||
{method: "GET", prefix: "/api/admin/email/", perm: "system:view"},
|
||||
// 邮件发送记录是运营查看通知触达效果的手段,与通知模板同属内容运营,不归系统设置
|
||||
{method: "GET", prefix: "/api/admin/email/", perm: "operation:view"},
|
||||
|
||||
// ── tenant level config 租户等级配置 ──
|
||||
{method: "GET", path: "/api/admin/tenant-level-configs", perm: "tenant:view"},
|
||||
@@ -267,10 +245,12 @@ var adminPermissionRules = []permissionRule{
|
||||
// ── member 成员管理(管理后台维度) ──
|
||||
{method: "GET", path: "/api/admin/members", perm: "member:view"},
|
||||
{method: "POST", path: "/api/admin/members", perm: "member:import"},
|
||||
{method: "PUT", prefix: "/api/admin/members/", suffix: "/disable", perm: "member:view"},
|
||||
{method: "PUT", prefix: "/api/admin/members/", suffix: "/enable", perm: "member:view"},
|
||||
{method: "PUT", prefix: "/api/admin/members/", suffix: "/reset-password", perm: "member:view"},
|
||||
{method: "PUT", prefix: "/api/admin/members/", suffix: "/unlock", perm: "member:view"},
|
||||
// 禁用/启用/改密/解锁都是对租户成员账号的处置(改密等于接管该成员账号,
|
||||
// 进而可用其 API Key 消耗租户余额),必须走写权限点,不能沿用只读的 member:view。
|
||||
{method: "PUT", prefix: "/api/admin/members/", suffix: "/disable", perm: "member:manage"},
|
||||
{method: "PUT", prefix: "/api/admin/members/", suffix: "/enable", perm: "member:manage"},
|
||||
{method: "PUT", prefix: "/api/admin/members/", suffix: "/reset-password", perm: "member:manage"},
|
||||
{method: "PUT", prefix: "/api/admin/members/", suffix: "/unlock", perm: "member:manage"},
|
||||
|
||||
// ── redemption 兑换码管理 ──
|
||||
{method: "GET", path: "/api/admin/redemptions", perm: "redemption:view"},
|
||||
@@ -279,17 +259,33 @@ var adminPermissionRules = []permissionRule{
|
||||
{method: "POST", path: "/api/admin/redemptions", perm: "redemption:create"},
|
||||
{method: "PUT", prefix: "/api/admin/redemptions/", perm: "redemption:edit"},
|
||||
|
||||
// ── role 角色管理(复用 user 组权限点) ──
|
||||
// 账号管理与角色管理是同一件事的两面(谁能进后台、能干什么),拆成两组权限没有实际场景:
|
||||
// 「能建角色但不能分配给人」毫无意义,「能分配角色但不能建角色」用禁用角色即可表达。
|
||||
{method: "GET", path: "/api/admin/roles", perm: "user:view"},
|
||||
{method: "POST", path: "/api/admin/roles", perm: "user:create"},
|
||||
{method: "POST", prefix: "/api/admin/roles/", suffix: "/reset", perm: "user:edit"},
|
||||
{method: "GET", prefix: "/api/admin/roles/", perm: "user:view"},
|
||||
{method: "PUT", prefix: "/api/admin/roles/", suffix: "/status", perm: "user:edit"},
|
||||
{method: "PUT", prefix: "/api/admin/roles/", perm: "user:edit"},
|
||||
{method: "DELETE", prefix: "/api/admin/roles/", perm: "user:delete"},
|
||||
{method: "GET", prefix: "/api/admin/users/", suffix: "/roles", perm: "user:view"},
|
||||
{method: "PUT", prefix: "/api/admin/users/", suffix: "/roles", perm: "user:edit"},
|
||||
|
||||
// ── permission 权限管理(仅 user 组) ──
|
||||
{method: "GET", path: "/api/admin/permissions", perm: "user:view"},
|
||||
{method: "GET", prefix: "/api/admin/users/", suffix: "/permissions", perm: "user:view"},
|
||||
{method: "PUT", prefix: "/api/admin/users/", suffix: "/permissions", perm: "user:edit"},
|
||||
{method: "PUT", prefix: "/api/admin/users/", suffix: "/data-scopes", perm: "user:edit"},
|
||||
|
||||
// ── admin session and security ──
|
||||
{method: "POST", path: "/api/admin/auth/logout", perm: "self:access"},
|
||||
{method: "GET", path: "/api/admin/auth/sessions", perm: "self:access"},
|
||||
{method: "DELETE", prefix: "/api/admin/auth/sessions/user/", perm: "user:edit"},
|
||||
{method: "DELETE", prefix: "/api/admin/auth/sessions/", perm: "self:access"},
|
||||
// 当前用户信息与有效权限:登录即可访问自己的资料,不需要任何业务权限点
|
||||
{method: "GET", path: "/api/admin/auth/me", perm: "self:access"},
|
||||
// 会话管理列出并处置的是【全部管理员】的登录态,属于账号管理(user 域)的操作能力:
|
||||
// 按用户管理模块的操作档位(user:edit)授权,只读档位不可见。
|
||||
// 超管会话的额外保护在 logic 层(assertCanOperateSessions):仅账号本人可操作。
|
||||
{method: "GET", path: "/api/admin/auth/sessions", perm: "user:edit"},
|
||||
{method: "DELETE", prefix: "/api/admin/auth/sessions/", perm: "user:edit"},
|
||||
{method: "PUT", path: "/api/admin/auth/change-password", perm: "self:access"},
|
||||
{method: "POST", prefix: "/api/admin/security/2fa/", perm: "self:access"},
|
||||
{method: "GET", path: "/api/admin/security/login-history", perm: "audit:view"},
|
||||
@@ -319,6 +315,53 @@ var adminPermissionRules = []permissionRule{
|
||||
{method: "POST", prefix: "/api/admin/usage-logs/cleanup/tasks/", suffix: "/cancel", perm: "system:edit"},
|
||||
}
|
||||
|
||||
// superAdminOnlyRules 列出仅超级管理员可访问的路由。
|
||||
//
|
||||
// 角色管理不走「权限点授权」而是硬性限定超管,理由是它是权限体系的元操作:
|
||||
// 谁能改角色,谁就能决定所有人的权限。一旦把它下放出去(哪怕只给 user:edit),
|
||||
// 被下放者就能给自己挂上更高权限的角色——权限体系当场失效。
|
||||
//
|
||||
// 这条限制让提权路径根本不存在,而不是靠「只能授予自己已有的权限」这类兜底去堵。
|
||||
// 普通账号管理(创建/禁用/改密/删除)不受影响,仍按 user:* 授权,可以正常下放。
|
||||
var superAdminOnlyRules = []permissionRule{
|
||||
// 角色的增删改查与启停
|
||||
{method: "GET", path: "/api/admin/roles"},
|
||||
{method: "POST", path: "/api/admin/roles"},
|
||||
{method: "GET", prefix: "/api/admin/roles/"},
|
||||
{method: "POST", prefix: "/api/admin/roles/"},
|
||||
{method: "PUT", prefix: "/api/admin/roles/"},
|
||||
{method: "DELETE", prefix: "/api/admin/roles/"},
|
||||
// 给账号分配角色 —— 这是最直接的提权入口
|
||||
{method: "GET", prefix: "/api/admin/users/", suffix: "/roles"},
|
||||
{method: "PUT", prefix: "/api/admin/users/", suffix: "/roles"},
|
||||
|
||||
// 在线自更新(版本状态 / 检查 / 执行 / 回滚):替换平台二进制并重启服务,
|
||||
// 等同于在服务器上执行代码,是比角色管理更根本的平台级元操作,不下放。
|
||||
// 版本检查(GET)看似只读,但前端每次刷新都会请求,若走权限点授权,
|
||||
// 未授权角色会持续收到 403 —— 硬性限定超管后非超管前端不再发起请求。
|
||||
{method: "GET", prefix: "/api/admin/update/"},
|
||||
{method: "POST", prefix: "/api/admin/update/"},
|
||||
}
|
||||
|
||||
// isSuperAdminOnly 判断路由是否仅限超级管理员。
|
||||
// 任一规则命中即为真,不需要优先级判定。
|
||||
func isSuperAdminOnly(method, path string) bool {
|
||||
for _, rule := range superAdminOnlyRules {
|
||||
if rule.method != method {
|
||||
continue
|
||||
}
|
||||
if rule.path != "" && path == rule.path {
|
||||
return true
|
||||
}
|
||||
if rule.prefix != "" && strings.HasPrefix(path, rule.prefix) {
|
||||
if rule.suffix == "" || suffixMatches(path, rule.suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type permissionRule struct {
|
||||
method string // HTTP method
|
||||
path string // exact path match (mutually exclusive with prefix)
|
||||
@@ -328,10 +371,26 @@ type permissionRule struct {
|
||||
}
|
||||
|
||||
// AdminPermissionGuard enforces RBAC permission checks for admin routes.
|
||||
// 当前策略:未匹配到权限规则的接口默认放行(仅对已认证 admin)。
|
||||
// 已配置规则的接口仍按权限点鉴权;漏配的接口不至于直接 403,降低上线回归风险。
|
||||
// 待 adminPermissionRules 全量补齐后,可将下方 perm=="" 分支改回返回 403 以收紧权限。
|
||||
//
|
||||
// 策略:默认拒绝 —— 未匹配到权限规则的接口一律 403。
|
||||
//
|
||||
// 早期为降低上线回归风险曾采用「未匹配即放行」,那在只有 super_admin / admin 两种可信
|
||||
// 身份时无害;引入运营、技术支持等低权限角色后,放行等同于越权:任何漏配规则的接口对
|
||||
// 所有角色都是敞开的。改为默认拒绝后,遗漏会立刻表现为 403 而不是静默的权限缺口。
|
||||
//
|
||||
// 兜底保证:super_admin 在规则匹配之前短路放行,因此即便规则有遗漏也不会把系统锁死,
|
||||
// 超管始终可以进入并修复。规则的完整性由 rbac_coverage_test.go 在 CI 阶段保证。
|
||||
func AdminPermissionGuard(r *ghttp.Request) {
|
||||
// 路径必须是规范形式:GoFrame 不会归一化重复斜杠,/api/admin//roles 会带着原样路径
|
||||
// 进到 handler,而规则表按精确路径/前缀匹配,同一个 handler 就有了两种匹配结果。
|
||||
// 目前默认拒绝把这类变体都挡在外面,但那是「恰好安全」——只要将来某条前缀规则的
|
||||
// 权限点比精确规则弱,加一个斜杠就能降档。与其依赖这个巧合,不如直接拒收非规范路径:
|
||||
// 正常客户端不会产生它,攻击者才需要它。
|
||||
if !isCanonicalPath(r.URL.Path) {
|
||||
response.ErrorMsg(r, consts.CodeForbidden, "非法的请求路径")
|
||||
return
|
||||
}
|
||||
|
||||
if isAdminPublicPath(r.URL.Path) {
|
||||
r.Middleware.Next()
|
||||
return
|
||||
@@ -343,10 +402,16 @@ func AdminPermissionGuard(r *ghttp.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 角色管理与在线自更新是平台级元操作,硬性限定超管,不参与权限点授权
|
||||
if isSuperAdminOnly(r.Method, r.URL.Path) {
|
||||
response.ErrorMsg(r, consts.CodeForbidden, "该操作仅超级管理员可用")
|
||||
return
|
||||
}
|
||||
|
||||
perm := matchPermission(r.Method, r.URL.Path)
|
||||
if perm == "" {
|
||||
// 默认放行:未配置权限规则的接口,对已认证 admin 放行
|
||||
r.Middleware.Next()
|
||||
// 默认拒绝:未配置权限规则的接口不放行(super_admin 已在上方短路)
|
||||
response.ErrorMsg(r, consts.CodeForbidden, "接口未配置权限规则,请联系管理员")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -364,6 +429,22 @@ func AdminPermissionGuard(r *ghttp.Request) {
|
||||
r.Middleware.Next()
|
||||
}
|
||||
|
||||
// isCanonicalPath 判断路径是否为规范形式:不含空路径段("//")与 "." / ".." 段。
|
||||
//
|
||||
// 只做判定不做重写:重写会把「当前被默认拒绝挡住的变体」变成放行,等于放宽;
|
||||
// 直接拒收既不放宽也不留下将来被利用的缝隙。
|
||||
func isCanonicalPath(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
for _, seg := range strings.Split(strings.TrimPrefix(path, "/"), "/") {
|
||||
if seg == "" || seg == "." || seg == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// matchPermission finds the permission required for the given method and path.
|
||||
// Matching priority: exact path > prefix+suffix > prefix-only (catch-all).
|
||||
// This three-phase approach eliminates rule ordering dependencies.
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/qianfree/team-api/internal/logic/admin"
|
||||
)
|
||||
|
||||
// adminAPIDir 是管理后台 API 定义所在目录(相对本测试文件)。
|
||||
// 路由由 cmd.go 中 g.Bind(adminController.NewV1()) 从这些 g.Meta 注解注册,
|
||||
// 前缀统一为 /api/admin。
|
||||
const adminAPIDir = "../../api/admin/v1"
|
||||
|
||||
// adminRoutePrefix 与 internal/cmd/cmd.go 的 group.Group("/admin", ...) 保持一致。
|
||||
const adminRoutePrefix = "/api/admin"
|
||||
|
||||
// manualAdminRoutes 列出不经 g.Bind(adminController) 注册、但同样挂在
|
||||
// AdminPermissionGuard 之下的路由(见 internal/cmd/cmd.go 的独立路由组)。
|
||||
// 这些路由没有 g.Meta 注解,AST 扫不到,需手工登记。
|
||||
var manualAdminRoutes = []adminRoute{
|
||||
{Method: "GET", Path: "/api/admin/files/{id}/serve"},
|
||||
}
|
||||
|
||||
type adminRoute struct {
|
||||
Method string
|
||||
Path string
|
||||
Source string // 来源文件:行,用于报错定位
|
||||
}
|
||||
|
||||
// collectAdminRoutes 解析 api/admin/v1 下全部 g.Meta 注解,得到已注册的 admin 路由清单。
|
||||
//
|
||||
// 这里刻意用 AST 解析源码而不是 ghttp.Server.GetRoutes():后者需要导入
|
||||
// internal/controller/admin,而它反向依赖 internal/middleware,会造成 import cycle。
|
||||
// 解析源码同样精确(路由本就由这些注解生成),且不需要构造 server 或任何外部依赖。
|
||||
func collectAdminRoutes(t *testing.T) []adminRoute {
|
||||
t.Helper()
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(adminAPIDir, "*.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("扫描 %s 失败: %v", adminAPIDir, err)
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
seen := make(map[string]bool)
|
||||
var out []adminRoute
|
||||
|
||||
add := func(r adminRoute) {
|
||||
key := r.Method + " " + r.Path
|
||||
if seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, r)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if strings.HasSuffix(file, "_test.go") {
|
||||
continue
|
||||
}
|
||||
f, err := parser.ParseFile(fset, file, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("解析 %s 失败: %v", file, err)
|
||||
}
|
||||
|
||||
ast.Inspect(f, func(n ast.Node) bool {
|
||||
st, ok := n.(*ast.StructType)
|
||||
if !ok || st.Fields == nil {
|
||||
return true
|
||||
}
|
||||
for _, field := range st.Fields.List {
|
||||
// g.Meta 是嵌入字段(无字段名),带 path/method 标签
|
||||
if len(field.Names) != 0 || field.Tag == nil {
|
||||
continue
|
||||
}
|
||||
raw, err := strconv.Unquote(field.Tag.Value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tag := reflect.StructTag(raw)
|
||||
path := strings.TrimSpace(tag.Get("path"))
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
pos := fset.Position(field.Pos())
|
||||
for _, m := range strings.Split(tag.Get("method"), ",") {
|
||||
m = strings.ToUpper(strings.TrimSpace(m))
|
||||
if m == "" {
|
||||
m = "GET" // g.Meta 省略 method 时 GoFrame 默认注册 GET
|
||||
}
|
||||
add(adminRoute{
|
||||
Method: m,
|
||||
Path: adminRoutePrefix + path,
|
||||
Source: filepath.Base(pos.Filename) + ":" + strconv.Itoa(pos.Line),
|
||||
})
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
for _, r := range manualAdminRoutes {
|
||||
r.Source = "internal/cmd/cmd.go(手工注册)"
|
||||
add(r)
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("未从 %s 解析到任何路由,测试装置失效", adminAPIDir)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Path != out[j].Path {
|
||||
return out[i].Path < out[j].Path
|
||||
}
|
||||
return out[i].Method < out[j].Method
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// concreteFromPattern 把路由模式里的占位段替换成具体值,
|
||||
// 使其可以喂给 matchPermission(它按具体路径做前缀/后缀匹配,不认识占位符)。
|
||||
//
|
||||
// /api/admin/users/{id}/permissions -> /api/admin/users/1/permissions
|
||||
// /api/admin/channels/:id -> /api/admin/channels/1
|
||||
func concreteFromPattern(pattern string) string {
|
||||
segs := strings.Split(pattern, "/")
|
||||
for i, seg := range segs {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(seg, "{") || strings.HasPrefix(seg, ":") || strings.HasPrefix(seg, "*") {
|
||||
segs[i] = "1"
|
||||
}
|
||||
}
|
||||
return strings.Join(segs, "/")
|
||||
}
|
||||
|
||||
// allPermissionPoints 收集 predefinedPermissionGroups 里声明的全部权限点,
|
||||
// 外加中间件自身消费的伪权限点。
|
||||
func allPermissionPoints() map[string]bool {
|
||||
set := map[string]bool{
|
||||
// self:access 不属于任何模块分组:它表示「登录即可访问自己的资源」,
|
||||
// 在 AdminPermissionGuard 里被直接放行,不参与角色授权。
|
||||
"self:access": true,
|
||||
}
|
||||
for _, g := range admin.PermissionGroups() {
|
||||
for _, p := range g.Permissions {
|
||||
set[p] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// TestEveryAdminRouteHasPermissionRule 保证每个受保护的 admin 接口都能匹配到权限规则。
|
||||
//
|
||||
// AdminPermissionGuard 对未匹配到规则的路由返回 403(默认拒绝)。若新增接口时忘了在
|
||||
// adminPermissionRules 登记,低权限角色会拿到一个无法解释的 403 —— 本测试把这类遗漏
|
||||
// 在 CI 阶段拦下,而不是等到线上有人点开某个页面才发现。
|
||||
func TestEveryAdminRouteHasPermissionRule(t *testing.T) {
|
||||
var missing []string
|
||||
|
||||
for _, r := range collectAdminRoutes(t) {
|
||||
path := concreteFromPattern(r.Path)
|
||||
if isAdminPublicPath(path) {
|
||||
continue
|
||||
}
|
||||
// 超管专属路由(角色管理、在线自更新)由 superAdminOnlyRules 硬性拦截,
|
||||
// 先于权限规则判定,不走权限点授权,因此不要求出现在 adminPermissionRules 里。
|
||||
if isSuperAdminOnly(r.Method, path) {
|
||||
continue
|
||||
}
|
||||
if perm := matchPermission(r.Method, path); perm == "" {
|
||||
missing = append(missing, r.Method+" "+r.Path+" ("+r.Source+")")
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
t.Fatalf("以下 %d 个 admin 接口未配置权限规则(请在 adminPermissionRules 中补齐):\n %s",
|
||||
len(missing), strings.Join(missing, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermissionRulesReferenceKnownPoints 保证规则里引用的权限点都是真实存在的。
|
||||
//
|
||||
// 规则里写错一个字(如 channel:veiw)不会有任何编译期报错,但会造成该接口对所有
|
||||
// 非超管角色永久 403 —— 因为没有任何角色能被授予一个不存在的权限点。
|
||||
func TestPermissionRulesReferenceKnownPoints(t *testing.T) {
|
||||
known := allPermissionPoints()
|
||||
|
||||
for _, rule := range adminPermissionRules {
|
||||
if !known[rule.perm] {
|
||||
target := rule.path
|
||||
if target == "" {
|
||||
target = rule.prefix + "*" + rule.suffix
|
||||
}
|
||||
t.Errorf("规则 %s %s 引用了未定义的权限点 %q", rule.method, target, rule.perm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchedPermissionsAreGrantable 从路由侧再验一次:
|
||||
// 每个受保护接口最终要求的权限点,都必须是可以授予某个角色的。
|
||||
func TestMatchedPermissionsAreGrantable(t *testing.T) {
|
||||
known := allPermissionPoints()
|
||||
|
||||
for _, r := range collectAdminRoutes(t) {
|
||||
path := concreteFromPattern(r.Path)
|
||||
if isAdminPublicPath(path) {
|
||||
continue
|
||||
}
|
||||
perm := matchPermission(r.Method, path)
|
||||
if perm == "" {
|
||||
continue // 由 TestEveryAdminRouteHasPermissionRule 负责报错
|
||||
}
|
||||
if !known[perm] {
|
||||
t.Errorf("接口 %s %s 要求权限点 %q,但该权限点未在 predefinedPermissionGroups 中定义",
|
||||
r.Method, r.Path, perm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeOnViewPermExceptions 列出「写方法但语义上是读」的接口白名单。
|
||||
//
|
||||
// 加白之前先问一句:这个接口有没有改变任何持久状态?只有答案是「没有,用 POST 纯粹是
|
||||
// 因为查询参数太复杂放不进 query string」时才可以加,并写明理由。
|
||||
var writeOnViewPermExceptions = map[string]string{
|
||||
// 导出模型配置为 JSON:只读取并序列化现有配置,不写库。用 POST 是因为筛选条件是
|
||||
// 一个可能很大的模型 ID 数组。
|
||||
"POST /api/admin/models/export-json": "纯导出,不改变任何状态",
|
||||
}
|
||||
|
||||
// TestWriteRoutesDoNotUseViewPermissions 保证写接口不会挂在只读权限点上。
|
||||
//
|
||||
// 这是本次权限审计里出现最多的一类缺陷:接口按 :view 配置,于是「技术支持」这类被明确
|
||||
// 定位成只读的角色,实际拥有了改密、改配置、履约订单的能力。权限点的档位语义
|
||||
// (read/operate/full)只有在规则表遵守它时才成立,靠人工 review 守不住这条线。
|
||||
func TestWriteRoutesDoNotUseViewPermissions(t *testing.T) {
|
||||
for _, r := range collectAdminRoutes(t) {
|
||||
if r.Method == "GET" || r.Method == "HEAD" || r.Method == "OPTIONS" {
|
||||
continue
|
||||
}
|
||||
path := concreteFromPattern(r.Path)
|
||||
if isAdminPublicPath(path) || isSuperAdminOnly(r.Method, path) {
|
||||
continue
|
||||
}
|
||||
if _, ok := writeOnViewPermExceptions[r.Method+" "+r.Path]; ok {
|
||||
continue
|
||||
}
|
||||
perm := matchPermission(r.Method, path)
|
||||
if strings.HasSuffix(perm, ":view") {
|
||||
t.Errorf("写接口 %s %s 挂在只读权限点 %q 上:只读角色会因此获得写能力。"+
|
||||
"请改挂对应的操作/完全档权限点;若该接口确实不改变任何状态,"+
|
||||
"把它加进 writeOnViewPermExceptions 并说明理由", r.Method, r.Path, perm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoleManagementIsSuperAdminOnly 保证角色管理的每个端点都被限定为超级管理员专属。
|
||||
//
|
||||
// 角色管理是权限体系的元操作:谁能改角色,谁就能决定所有人的权限。若某个角色端点漏了这条
|
||||
// 限制,一个被下放了 user:edit 的账号就能给自己挂上更高权限的角色,整套角色体系当场失效。
|
||||
// 新增角色相关路由时,本测试会强制你同步登记 superAdminOnlyRules。
|
||||
func TestRoleManagementIsSuperAdminOnly(t *testing.T) {
|
||||
var leaked []string
|
||||
|
||||
for _, r := range collectAdminRoutes(t) {
|
||||
path := concreteFromPattern(r.Path)
|
||||
isRoleRoute := strings.HasPrefix(r.Path, "/api/admin/roles") ||
|
||||
strings.HasSuffix(r.Path, "/roles")
|
||||
if !isRoleRoute {
|
||||
continue
|
||||
}
|
||||
if !isSuperAdminOnly(r.Method, path) {
|
||||
leaked = append(leaked, r.Method+" "+r.Path+" ("+r.Source+")")
|
||||
}
|
||||
}
|
||||
|
||||
if len(leaked) > 0 {
|
||||
t.Fatalf("以下角色管理接口未限定为超级管理员专属(请补入 superAdminOnlyRules):\n %s",
|
||||
strings.Join(leaked, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuperAdminOnlyRulesDoNotOverreach 反向确认:超管专属清单不应误伤普通账号管理。
|
||||
//
|
||||
// 账号管理(创建/禁用/改密/删除)按 user:* 授权、可以正常下放,只有「分配角色」才是
|
||||
// 权限体系的元操作。两者路径相邻(都在 /api/admin/users/ 下),容易被前缀规则误伤。
|
||||
func TestSuperAdminOnlyRulesDoNotOverreach(t *testing.T) {
|
||||
shouldStayDelegatable := []struct{ method, path string }{
|
||||
{"GET", "/api/admin/users"},
|
||||
{"POST", "/api/admin/users"},
|
||||
{"PUT", "/api/admin/users/1"},
|
||||
{"PUT", "/api/admin/users/1/status"},
|
||||
{"PUT", "/api/admin/users/1/reset-password"},
|
||||
{"PUT", "/api/admin/users/1/unlock"},
|
||||
{"DELETE", "/api/admin/users/1"},
|
||||
{"GET", "/api/admin/users/1/permissions"},
|
||||
}
|
||||
for _, c := range shouldStayDelegatable {
|
||||
if isSuperAdminOnly(c.method, c.path) {
|
||||
t.Errorf("%s %s 属于普通账号管理,不应被限定为超管专属", c.method, c.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,81 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/gclient"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/test/gtest"
|
||||
)
|
||||
|
||||
// TestRequestId_ServerIDGeneration 测试服务端 ID 强制生成
|
||||
// startRequestIdServer 起一个只挂 RequestId 中间件的测试服务,
|
||||
// 回显服务端 ID 与客户端追踪 ID,供本文件的用例复用。
|
||||
//
|
||||
// 注意:RequestId 依赖 ghttp 的响应对象与中间件链,无法脱离 server 直接构造
|
||||
// ghttp.Request 来调用(本文件早期版本曾如此尝试,用的 API 并不存在,从未编译通过)。
|
||||
func startRequestIdServer(t *gtest.T, name string) (*ghttp.Server, *gclient.Client) {
|
||||
s := g.Server(name)
|
||||
s.Group("/", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(RequestId)
|
||||
group.ALL("/test", func(r *ghttp.Request) {
|
||||
r.Response.WriteJson(g.Map{
|
||||
"server_id": r.GetCtxVar("RequestId").String(),
|
||||
"client_id": r.GetCtxVar("ClientTraceId").String(),
|
||||
})
|
||||
})
|
||||
})
|
||||
s.SetAddr("127.0.0.1:0")
|
||||
s.SetDumpRouterMap(false)
|
||||
s.Start()
|
||||
|
||||
client := g.Client()
|
||||
client.SetPrefix(fmt.Sprintf("http://127.0.0.1:%d", s.GetListenedPort()))
|
||||
return s, client
|
||||
}
|
||||
|
||||
// TestRequestId_ServerIDGeneration 服务端 ID 必须每次强制生成且互不相同,
|
||||
// 无论客户端是否传入 X-Request-Id。
|
||||
func TestRequestId_ServerIDGeneration(t *testing.T) {
|
||||
gtest.C(t, func(t *gtest.T) {
|
||||
// 模拟请求上下文
|
||||
r := &ghttp.Request{}
|
||||
r.Request = gtest.NewRequest("GET", "/test")
|
||||
r.Response = ghttp.DefaultServer().GetRouterTree()
|
||||
s, client := startRequestIdServer(t, "request-id-server-gen")
|
||||
defer s.Shutdown()
|
||||
|
||||
// 测试场景1:无客户端 ID
|
||||
r.Request.Header.Set("X-Request-Id", "")
|
||||
RequestId(r)
|
||||
ctx := gctx.New()
|
||||
|
||||
serverID := r.GetCtxVar("RequestId").String()
|
||||
clientID := r.GetCtxVar("ClientTraceId").String()
|
||||
// 场景 1:无客户端 ID
|
||||
body1 := client.GetContent(ctx, "/test")
|
||||
j1 := gjson.New(body1)
|
||||
serverID1 := j1.Get("server_id").String()
|
||||
t.AssertNE(serverID1, "")
|
||||
t.Assert(j1.Get("client_id").String(), "")
|
||||
|
||||
t.Assert(serverID != "", true, "服务端 ID 必须生成")
|
||||
t.Assert(clientID, "", "无客户端 ID 时 ClientTraceId 应为空")
|
||||
// 场景 2:有效客户端 ID —— 服务端 ID 仍独立生成
|
||||
body2 := client.Header(g.MapStrStr{"X-Request-Id": "valid-client-123"}).
|
||||
GetContent(ctx, "/test")
|
||||
j2 := gjson.New(body2)
|
||||
serverID2 := j2.Get("server_id").String()
|
||||
t.AssertNE(serverID2, "")
|
||||
t.Assert(j2.Get("client_id").String(), "valid-client-123")
|
||||
|
||||
// 测试场景2:有效客户端 ID
|
||||
r2 := &ghttp.Request{}
|
||||
r2.Request = gtest.NewRequest("GET", "/test")
|
||||
r2.Request.Header.Set("X-Request-Id", "valid-client-123")
|
||||
r2.Response = ghttp.DefaultServer().GetRouterTree()
|
||||
|
||||
RequestId(r2)
|
||||
|
||||
serverID2 := r2.GetCtxVar("RequestId").String()
|
||||
clientID2 := r2.GetCtxVar("ClientTraceId").String()
|
||||
|
||||
t.Assert(serverID2 != "", true, "服务端 ID 必须生成")
|
||||
t.Assert(clientID2, "valid-client-123", "有效客户端 ID 应保存")
|
||||
t.Assert(serverID != serverID2, true, "两次请求的服务端 ID 必须不同")
|
||||
// 两次请求的服务端 ID 必须不同(幂等键依赖其唯一性)
|
||||
t.AssertNE(serverID1, serverID2)
|
||||
})
|
||||
}
|
||||
|
||||
// TestRequestId_ClientIDValidation 测试客户端 ID 格式校验
|
||||
// TestRequestId_ClientIDValidation 客户端追踪 ID 的格式校验:
|
||||
// 仅接受字母/数字/连字符且长度 1-64,其余一律丢弃,防止日志注入与不可控的下游传播。
|
||||
func TestRequestId_ClientIDValidation(t *testing.T) {
|
||||
gtest.C(t, func(t *gtest.T) {
|
||||
s, client := startRequestIdServer(t, "request-id-client-validation")
|
||||
defer s.Shutdown()
|
||||
|
||||
ctx := gctx.New()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
clientID string
|
||||
@@ -53,106 +84,60 @@ func TestRequestId_ClientIDValidation(t *testing.T) {
|
||||
{"有效ID-字母数字连字符", "client-trace-123", true},
|
||||
{"有效ID-纯数字", "12345678", true},
|
||||
{"有效ID-纯字母", "abcdefgh", true},
|
||||
{"有效ID-64位边界", strings.Repeat("a", 64), true},
|
||||
{"非法ID-含空格", "invalid id", false},
|
||||
{"非法ID-超长", string(make([]byte, 65)), false},
|
||||
{"非法ID-超长65位", strings.Repeat("a", 65), false},
|
||||
{"非法ID-特殊字符", "id@#$%", false},
|
||||
{"非法ID-中文", "客户端ID", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
r := &ghttp.Request{}
|
||||
r.Request = gtest.NewRequest("GET", "/test")
|
||||
r.Request.Header.Set("X-Request-Id", tt.clientID)
|
||||
r.Response = ghttp.DefaultServer().GetRouterTree()
|
||||
|
||||
RequestId(r)
|
||||
|
||||
clientID := r.GetCtxVar("ClientTraceId").String()
|
||||
body := client.Header(g.MapStrStr{"X-Request-Id": tt.clientID}).
|
||||
GetContent(ctx, "/test")
|
||||
clientID := gjson.New(body).Get("client_id").String()
|
||||
|
||||
if tt.shouldAccept {
|
||||
t.Assert(clientID, tt.clientID, tt.name+" 应被接受")
|
||||
t.AssertEQ(clientID, tt.clientID)
|
||||
} else {
|
||||
t.Assert(clientID, "", tt.name+" 应被拒绝")
|
||||
t.AssertEQ(clientID, "")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRequestId_SecurityIsolation 测试安全隔离
|
||||
func TestRequestId_SecurityIsolation(t *testing.T) {
|
||||
gtest.C(t, func(t *gtest.T) {
|
||||
serverIDs := make(map[string]bool)
|
||||
maliciousClientID := "malicious-repeated-id"
|
||||
|
||||
// 模拟恶意客户端:10 次请求使用相同的客户端 ID
|
||||
for i := 0; i < 10; i++ {
|
||||
r := &ghttp.Request{}
|
||||
r.Request = gtest.NewRequest("GET", "/test")
|
||||
r.Request.Header.Set("X-Request-Id", maliciousClientID)
|
||||
r.Response = ghttp.DefaultServer().GetRouterTree()
|
||||
|
||||
RequestId(r)
|
||||
|
||||
serverID := r.GetCtxVar("RequestId").String()
|
||||
serverIDs[serverID] = true
|
||||
}
|
||||
|
||||
// 验证:每次请求的服务端 ID 必须不同
|
||||
t.Assert(len(serverIDs), 10, "10 次请求应生成 10 个不同的服务端 ID")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRequestId_ResponseHeaders 测试响应头设置
|
||||
// TestRequestId_ResponseHeaders 响应头回显规则:
|
||||
// - X-Teamapi-Request-Id 恒为服务端 ID
|
||||
// - X-Request-Id 在客户端 ID 有效时回显客户端 ID,否则回填服务端 ID
|
||||
func TestRequestId_ResponseHeaders(t *testing.T) {
|
||||
gtest.C(t, func(t *gtest.T) {
|
||||
s, client := startRequestIdServer(t, "request-id-response-headers")
|
||||
defer s.Shutdown()
|
||||
|
||||
ctx := gctx.New()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
clientID string
|
||||
expectXTeamapiRequestId bool
|
||||
expectXRequestIdEcho string
|
||||
name string
|
||||
clientID string
|
||||
echoesSrv bool // X-Request-Id 是否应回显服务端 ID
|
||||
}{
|
||||
{
|
||||
name: "无客户端 ID",
|
||||
clientID: "",
|
||||
expectXTeamapiRequestId: true,
|
||||
expectXRequestIdEcho: "server", // 应回显服务端 ID
|
||||
},
|
||||
{
|
||||
name: "有效客户端 ID",
|
||||
clientID: "valid-123",
|
||||
expectXTeamapiRequestId: true,
|
||||
expectXRequestIdEcho: "valid-123", // 应回显客户端 ID
|
||||
},
|
||||
{
|
||||
name: "非法客户端 ID",
|
||||
clientID: "invalid id with spaces",
|
||||
expectXTeamapiRequestId: true,
|
||||
expectXRequestIdEcho: "server", // 应回显服务端 ID
|
||||
},
|
||||
{"无客户端 ID", "", true},
|
||||
{"有效客户端 ID", "valid-123", false},
|
||||
{"非法客户端 ID", "invalid id with spaces", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
r := &ghttp.Request{}
|
||||
r.Request = gtest.NewRequest("GET", "/test")
|
||||
r.Request.Header.Set("X-Request-Id", tt.clientID)
|
||||
r.Response = ghttp.DefaultServer().GetRouterTree()
|
||||
resp, err := client.Header(g.MapStrStr{"X-Request-Id": tt.clientID}).
|
||||
Get(ctx, "/test")
|
||||
t.AssertNil(err)
|
||||
|
||||
RequestId(r)
|
||||
serverID := gjson.New(resp.ReadAllString()).Get("server_id").String()
|
||||
resp.Close()
|
||||
|
||||
serverID := r.GetCtxVar("RequestId").String()
|
||||
xTeamapiRequestId := r.Response.Header().Get("X-Teamapi-Request-Id")
|
||||
xRequestId := r.Response.Header().Get("X-Request-Id")
|
||||
|
||||
// 验证 X-Teamapi-Request-Id
|
||||
if tt.expectXTeamapiRequestId {
|
||||
t.Assert(xTeamapiRequestId, serverID, tt.name+": X-Teamapi-Request-Id 应等于服务端 ID")
|
||||
}
|
||||
|
||||
// 验证 X-Request-Id 回显逻辑
|
||||
if tt.expectXRequestIdEcho == "server" {
|
||||
t.Assert(xRequestId, serverID, tt.name+": X-Request-Id 应回显服务端 ID")
|
||||
t.AssertEQ(resp.Header.Get("X-Teamapi-Request-Id"), serverID)
|
||||
if tt.echoesSrv {
|
||||
t.AssertEQ(resp.Header.Get("X-Request-Id"), serverID)
|
||||
} else {
|
||||
t.Assert(xRequestId, tt.expectXRequestIdEcho, tt.name+": X-Request-Id 应回显客户端 ID")
|
||||
t.AssertEQ(resp.Header.Get("X-Request-Id"), tt.clientID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// TestRequestId_DualIDMechanism 测试双 ID 机制
|
||||
func TestRequestId_DualIDMechanism(t *testing.T) {
|
||||
gtest.C(t, func(t *gtest.T) {
|
||||
s := g.Server(gtest.DataPath("request-id-test"))
|
||||
s := g.Server("mw-request-id-dual")
|
||||
s.Group("/", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(RequestId)
|
||||
group.ALL("/test", func(r *ghttp.Request) {
|
||||
@@ -28,6 +28,7 @@ func TestRequestId_DualIDMechanism(t *testing.T) {
|
||||
})
|
||||
})
|
||||
})
|
||||
s.SetAddr("127.0.0.1:0")
|
||||
s.SetDumpRouterMap(false)
|
||||
s.Start()
|
||||
defer s.Shutdown()
|
||||
@@ -98,7 +99,7 @@ func TestRequestId_SecurityIsolation(t *testing.T) {
|
||||
serverIDs := make(map[string]bool)
|
||||
var mu sync.Mutex
|
||||
|
||||
s := g.Server(gtest.DataPath("request-id-security"))
|
||||
s := g.Server("mw-request-id-isolation")
|
||||
s.Group("/", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(RequestId)
|
||||
group.ALL("/test", func(r *ghttp.Request) {
|
||||
@@ -111,6 +112,7 @@ func TestRequestId_SecurityIsolation(t *testing.T) {
|
||||
})
|
||||
})
|
||||
})
|
||||
s.SetAddr("127.0.0.1:0")
|
||||
s.SetDumpRouterMap(false)
|
||||
s.Start()
|
||||
defer s.Shutdown()
|
||||
@@ -138,7 +140,7 @@ func TestRequestId_IdempotencyKeyUniqueness(t *testing.T) {
|
||||
const concurrency = 100
|
||||
serverIDs := make(chan string, concurrency)
|
||||
|
||||
s := g.Server(gtest.DataPath("request-id-idempotency"))
|
||||
s := g.Server("mw-request-id-idempotency")
|
||||
s.Group("/", func(group *ghttp.RouterGroup) {
|
||||
group.Middleware(RequestId)
|
||||
group.ALL("/test", func(r *ghttp.Request) {
|
||||
@@ -148,6 +150,7 @@ func TestRequestId_IdempotencyKeyUniqueness(t *testing.T) {
|
||||
})
|
||||
})
|
||||
})
|
||||
s.SetAddr("127.0.0.1:0")
|
||||
s.SetDumpRouterMap(false)
|
||||
s.Start()
|
||||
defer s.Shutdown()
|
||||
|
||||
@@ -14,19 +14,21 @@ import (
|
||||
// tenantPublicPaths lists tenant routes that skip JWT auth.
|
||||
// Keep in sync with api/tenant/v1/ structs tagged group:"public" middleware:"-".
|
||||
var tenantPublicPaths = map[string]bool{
|
||||
"/api/tenant/auth/register": true,
|
||||
"/api/tenant/auth/login": true,
|
||||
"/api/tenant/auth/refresh": true,
|
||||
"/api/tenant/auth/2fa/verify": true,
|
||||
"/api/tenant/email/send-code": true,
|
||||
"/api/tenant/email/reset-password": true,
|
||||
"/api/tenant/members/join": true,
|
||||
"/api/tenant/members/invite-info": true,
|
||||
"/api/tenant/agreements/current": true,
|
||||
"/api/tenant/oauth/authorize": true,
|
||||
"/api/tenant/marketplace/models": true,
|
||||
"/api/tenant/help/categories": true,
|
||||
"/api/tenant/help/search": true,
|
||||
"/api/tenant/auth/register": true,
|
||||
// 注册页在提交前展示剩余注册次数,必须匿名可访问(只读不计数)
|
||||
"/api/tenant/auth/register-rate-limit": true,
|
||||
"/api/tenant/auth/login": true,
|
||||
"/api/tenant/auth/refresh": true,
|
||||
"/api/tenant/auth/2fa/verify": true,
|
||||
"/api/tenant/email/send-code": true,
|
||||
"/api/tenant/email/reset-password": true,
|
||||
"/api/tenant/members/join": true,
|
||||
"/api/tenant/members/invite-info": true,
|
||||
"/api/tenant/agreements/current": true,
|
||||
"/api/tenant/oauth/authorize": true,
|
||||
"/api/tenant/marketplace/models": true,
|
||||
"/api/tenant/help/categories": true,
|
||||
"/api/tenant/help/search": true,
|
||||
}
|
||||
|
||||
// tenantPublicPrefixes lists path prefixes that skip JWT auth (for dynamic routes like /current/{code}).
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// =================================================================================
|
||||
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
|
||||
// =================================================================================
|
||||
|
||||
package do
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// SysAdminRolePermissions is the golang structure of table sys_admin_role_permissions for DAO operations like Where/Data.
|
||||
type SysAdminRolePermissions struct {
|
||||
g.Meta `orm:"table:sys_admin_role_permissions, do:true"`
|
||||
Id any // 主键ID
|
||||
RoleId any // 角色ID(逻辑关联 sys_admin_roles.id,无外键,删除角色时由业务层级联清理)
|
||||
PermissionPoint any // 权限点标识(如 tenant:create、channel:edit)
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
}
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user