diff --git a/CLAUDE.md b/CLAUDE.md index ed730ee..79ff840 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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作为仓库贡献者提交 \ No newline at end of file diff --git a/LICENSE b/LICENSE index d0ad6f4..3e75ed5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,11 @@ -Team-API -Copyright (C) 2026 qianfree <406615373@qq.com> +Team-API Community Edition +Copyright (C) 2026 CHENGQIAN WU (qianfree) + +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 diff --git a/README.md b/README.md index d7b87f8..25e0894 100644 --- a/README.md +++ b/README.md @@ -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**。 +商业授权不转让本项目的著作权、专利权、商标权或其他知识产权,具体权利以双方签署的书面协议为准。 + ## 致谢 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..de38c3d --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -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 类许可证。 diff --git a/api/admin/admin.go b/api/admin/admin.go index 444604c..fdec39b 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -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) diff --git a/api/admin/v1/admin_user.go b/api/admin/v1/admin_user.go index f43a024..5d32fa8 100644 --- a/api/admin/v1/admin_user.go +++ b/api/admin/v1/admin_user.go @@ -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 { diff --git a/api/admin/v1/audit.go b/api/admin/v1/audit.go index 2647980..24b53d9 100644 --- a/api/admin/v1/audit.go +++ b/api/admin/v1/audit.go @@ -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:"删除的记录数"` +} diff --git a/api/admin/v1/auth.go b/api/admin/v1/auth.go index 6b8e8d6..0cc4bef 100644 --- a/api/admin/v1/auth.go +++ b/api/admin/v1/auth.go @@ -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 踢出指定会话请求 diff --git a/api/admin/v1/channel_error.go b/api/admin/v1/channel_error.go index dce4a73..66a8292 100644 --- a/api/admin/v1/channel_error.go +++ b/api/admin/v1/channel_error.go @@ -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:"删除的记录数"` +} diff --git a/api/admin/v1/help_center.go b/api/admin/v1/help_center.go index 8c5695d..76e50fa 100644 --- a/api/admin/v1/help_center.go +++ b/api/admin/v1/help_center.go @@ -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:"状态"` diff --git a/api/admin/v1/permission.go b/api/admin/v1/permission.go index ca279c6..1211353 100644 --- a/api/admin/v1/permission.go +++ b/api/admin/v1/permission.go @@ -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 { diff --git a/api/admin/v1/role.go b/api/admin/v1/role.go new file mode 100644 index 0000000..9e85350 --- /dev/null +++ b/api/admin/v1/role.go @@ -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{} diff --git a/api/admin/v1/security.go b/api/admin/v1/security.go index f21f538..9b6d647 100644 --- a/api/admin/v1/security.go +++ b/api/admin/v1/security.go @@ -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"` } // ============================================================ diff --git a/api/admin/v1/support_pending.go b/api/admin/v1/support_pending.go new file mode 100644 index 0000000..7d483b7 --- /dev/null +++ b/api/admin/v1/support_pending.go @@ -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),命中缓存时为缓存写入时刻"` +} diff --git a/api/open/v1/open_api.go b/api/open/v1/open_api.go index 247c02f..425c265 100644 --- a/api/open/v1/open_api.go +++ b/api/open/v1/open_api.go @@ -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=不限)"` } diff --git a/api/tenant/tenant.go b/api/tenant/tenant.go index a67deac..428ac58 100644 --- a/api/tenant/tenant.go +++ b/api/tenant/tenant.go @@ -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) diff --git a/api/tenant/v1/auth.go b/api/tenant/v1/auth.go index 0e19822..617eae1 100644 --- a/api/tenant/v1/auth.go +++ b/api/tenant/v1/auth.go @@ -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:"-"` diff --git a/api/tenant/v1/billing_tenant.go b/api/tenant/v1/billing_tenant.go index 9e67e20..354fa03 100644 --- a/api/tenant/v1/billing_tenant.go +++ b/api/tenant/v1/billing_tenant.go @@ -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:"导出用量日志"` diff --git a/api/tenant/v1/open_platform.go b/api/tenant/v1/open_platform.go index 2462120..a47394d 100644 --- a/api/tenant/v1/open_platform.go +++ b/api/tenant/v1/open_platform.go @@ -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"` diff --git a/api/tenant/v1/playground.go b/api/tenant/v1/playground.go deleted file mode 100644 index c133a74..0000000 --- a/api/tenant/v1/playground.go +++ /dev/null @@ -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"` -} diff --git a/api/tenant/v1/ticket_tenant.go b/api/tenant/v1/ticket_tenant.go index 24fc1eb..3af3e6c 100644 --- a/api/tenant/v1/ticket_tenant.go +++ b/api/tenant/v1/ticket_tenant.go @@ -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 { diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index 7a1498d..ed8809b 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -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\`。名字里带盘符冒号时路径非法,`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 的两条规则。 diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 88a6a1a..0eb76be 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -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) { diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 98c4549..1e73ac2 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -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 = "注册邮箱验证未启用" diff --git a/internal/controller/admin/admin_v1_audit.go b/internal/controller/admin/admin_v1_audit.go index e2346d9..5517f3c 100644 --- a/internal/controller/admin/admin_v1_audit.go +++ b/internal/controller/admin/admin_v1_audit.go @@ -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) +} diff --git a/internal/controller/admin/admin_v1_auth.go b/internal/controller/admin/admin_v1_auth.go index 4c415fe..3f44741 100644 --- a/internal/controller/admin/admin_v1_auth.go +++ b/internal/controller/admin/admin_v1_auth.go @@ -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) +} diff --git a/internal/controller/admin/admin_v1_channel_error.go b/internal/controller/admin/admin_v1_channel_error.go index 51f3ed7..697699f 100644 --- a/internal/controller/admin/admin_v1_channel_error.go +++ b/internal/controller/admin/admin_v1_channel_error.go @@ -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) +} diff --git a/internal/controller/admin/admin_v1_permission.go b/internal/controller/admin/admin_v1_permission.go index aea4afe..c94c44b 100644 --- a/internal/controller/admin/admin_v1_permission.go +++ b/internal/controller/admin/admin_v1_permission.go @@ -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) } diff --git a/internal/controller/admin/admin_v1_role.go b/internal/controller/admin/admin_v1_role.go new file mode 100644 index 0000000..44faabb --- /dev/null +++ b/internal/controller/admin/admin_v1_role.go @@ -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) +} diff --git a/internal/controller/admin/admin_v1_support_pending.go b/internal/controller/admin/admin_v1_support_pending.go new file mode 100644 index 0000000..cd16028 --- /dev/null +++ b/internal/controller/admin/admin_v1_support_pending.go @@ -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) +} diff --git a/internal/controller/tenant/tenant_v1_auth.go b/internal/controller/tenant/tenant_v1_auth.go index be93aec..6d959a8 100644 --- a/internal/controller/tenant/tenant_v1_auth.go +++ b/internal/controller/tenant/tenant_v1_auth.go @@ -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) +} diff --git a/internal/controller/tenant/tenant_v1_billing_tenant.go b/internal/controller/tenant/tenant_v1_billing_tenant.go index 808792f..38efd75 100644 --- a/internal/controller/tenant/tenant_v1_billing_tenant.go +++ b/internal/controller/tenant/tenant_v1_billing_tenant.go @@ -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) +} diff --git a/internal/controller/tenant/tenant_v1_playground.go b/internal/controller/tenant/tenant_v1_playground.go deleted file mode 100644 index 8da7cec..0000000 --- a/internal/controller/tenant/tenant_v1_playground.go +++ /dev/null @@ -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) -} diff --git a/internal/dao/internal/sys_admin_role_permissions.go b/internal/dao/internal/sys_admin_role_permissions.go new file mode 100644 index 0000000..79a7b91 --- /dev/null +++ b/internal/dao/internal/sys_admin_role_permissions.go @@ -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) +} diff --git a/internal/dao/internal/sys_admin_roles.go b/internal/dao/internal/sys_admin_roles.go new file mode 100644 index 0000000..82f3b18 --- /dev/null +++ b/internal/dao/internal/sys_admin_roles.go @@ -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) +} diff --git a/internal/dao/internal/sys_admin_user_roles.go b/internal/dao/internal/sys_admin_user_roles.go new file mode 100644 index 0000000..76bc125 --- /dev/null +++ b/internal/dao/internal/sys_admin_user_roles.go @@ -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) +} diff --git a/internal/dao/sys_admin_role_permissions.go b/internal/dao/sys_admin_role_permissions.go new file mode 100644 index 0000000..2248bc6 --- /dev/null +++ b/internal/dao/sys_admin_role_permissions.go @@ -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. diff --git a/internal/dao/sys_admin_roles.go b/internal/dao/sys_admin_roles.go new file mode 100644 index 0000000..2531b00 --- /dev/null +++ b/internal/dao/sys_admin_roles.go @@ -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. diff --git a/internal/dao/sys_admin_user_roles.go b/internal/dao/sys_admin_user_roles.go new file mode 100644 index 0000000..2b6784e --- /dev/null +++ b/internal/dao/sys_admin_user_roles.go @@ -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. diff --git a/internal/logic/admin/admin_user.go b/internal/logic/admin/admin_user.go index 99a9d92..b960f15 100644 --- a/internal/logic/admin/admin_user.go +++ b/internal/logic/admin/admin_user.go @@ -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 diff --git a/internal/logic/admin/audit.go b/internal/logic/admin/audit.go index 808998c..d7a7db4 100644 --- a/internal/logic/admin/audit.go +++ b/internal/logic/admin/audit.go @@ -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 +} diff --git a/internal/logic/admin/auth.go b/internal/logic/admin/auth.go index 5fb7c01..660083f 100644 --- a/internal/logic/admin/auth.go +++ b/internal/logic/admin/auth.go @@ -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 +} diff --git a/internal/logic/admin/channel_error.go b/internal/logic/admin/channel_error.go index b1fb755..8a831d3 100644 --- a/internal/logic/admin/channel_error.go +++ b/internal/logic/admin/channel_error.go @@ -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 +} diff --git a/internal/logic/admin/help_center.go b/internal/logic/admin/help_center.go index f940437..3099daf 100644 --- a/internal/logic/admin/help_center.go +++ b/internal/logic/admin/help_center.go @@ -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() } diff --git a/internal/logic/admin/order.go b/internal/logic/admin/order.go index 3aed35f..459dd09 100644 --- a/internal/logic/admin/order.go +++ b/internal/logic/admin/order.go @@ -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("充值入账流水金额异常,无法退款") diff --git a/internal/logic/admin/permission.go b/internal/logic/admin/permission.go index 2634ee0..5bec54d 100644 --- a/internal/logic/admin/permission.go +++ b/internal/logic/admin/permission.go @@ -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 diff --git a/internal/logic/admin/privilege_guard_test.go b/internal/logic/admin/privilege_guard_test.go new file mode 100644 index 0000000..343a7b4 --- /dev/null +++ b/internal/logic/admin/privilege_guard_test.go @@ -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) + } +} diff --git a/internal/logic/admin/role.go b/internal/logic/admin/role.go new file mode 100644 index 0000000..b1c79e6 --- /dev/null +++ b/internal/logic/admin/role.go @@ -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 +} diff --git a/internal/logic/admin/role_crud.go b/internal/logic/admin/role_crud.go new file mode 100644 index 0000000..6e4961f --- /dev/null +++ b/internal/logic/admin/role_crud.go @@ -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 +} diff --git a/internal/logic/admin/role_test.go b/internal/logic/admin/role_test.go new file mode 100644 index 0000000..f6463e4 --- /dev/null +++ b/internal/logic/admin/role_test.go @@ -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 +} diff --git a/internal/logic/admin/security.go b/internal/logic/admin/security.go index 5586130..c5f00f0 100644 --- a/internal/logic/admin/security.go +++ b/internal/logic/admin/security.go @@ -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) { diff --git a/internal/logic/admin/settings.go b/internal/logic/admin/settings.go index 31ae47a..e1be102 100644 --- a/internal/logic/admin/settings.go +++ b/internal/logic/admin/settings.go @@ -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"] diff --git a/internal/logic/admin/support_pending.go b/internal/logic/admin/support_pending.go new file mode 100644 index 0000000..8f12224 --- /dev/null +++ b/internal/logic/admin/support_pending.go @@ -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 +} diff --git a/internal/logic/admin/tenant_model.go b/internal/logic/admin/tenant_model.go index cfdc60b..26bc783 100644 --- a/internal/logic/admin/tenant_model.go +++ b/internal/logic/admin/tenant_model.go @@ -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 列出租户已分配的模型 diff --git a/internal/logic/billing/currency.go b/internal/logic/billing/currency.go index f75ef5e..cdd695a 100644 --- a/internal/logic/billing/currency.go +++ b/internal/logic/billing/currency.go @@ -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 { diff --git a/internal/logic/billing/ratelimit.go b/internal/logic/billing/ratelimit.go index da78aaf..64ca3e1 100644 --- a/internal/logic/billing/ratelimit.go +++ b/internal/logic/billing/ratelimit.go @@ -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 秒数 } diff --git a/internal/logic/billing/settlement.go b/internal/logic/billing/settlement.go index 0c6b15a..6f2a522 100644 --- a/internal/logic/billing/settlement.go +++ b/internal/logic/billing/settlement.go @@ -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 diff --git a/internal/logic/billing/snapshot.go b/internal/logic/billing/snapshot.go index 0974d0c..d7aea64 100644 --- a/internal/logic/billing/snapshot.go +++ b/internal/logic/billing/snapshot.go @@ -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 := "" diff --git a/internal/logic/billing/snapshot_test.go b/internal/logic/billing/snapshot_test.go index 7e6640e..f0ef7e2 100644 --- a/internal/logic/billing/snapshot_test.go +++ b/internal/logic/billing/snapshot_test.go @@ -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) } diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index 02534df..67dd1cf 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -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 diff --git a/internal/logic/common/audit.go b/internal/logic/common/audit.go index 96adcc0..721f72e 100644 --- a/internal/logic/common/audit.go +++ b/internal/logic/common/audit.go @@ -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。 diff --git a/internal/logic/common/audit_log_writer.go b/internal/logic/common/audit_log_writer.go index 6e549d4..9f426f9 100644 --- a/internal/logic/common/audit_log_writer.go +++ b/internal/logic/common/audit_log_writer.go @@ -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, diff --git a/internal/logic/common/help_center.go b/internal/logic/common/help_center.go index cd38bfa..d952d21 100644 --- a/internal/logic/common/help_center.go +++ b/internal/logic/common/help_center.go @@ -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 +} diff --git a/internal/logic/common/jwt.go b/internal/logic/common/jwt.go index eee9f3a..c0b8948 100644 --- a/internal/logic/common/jwt.go +++ b/internal/logic/common/jwt.go @@ -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() diff --git a/internal/logic/common/jwt_test.go b/internal/logic/common/jwt_test.go index f7e1f27..8349022 100644 --- a/internal/logic/common/jwt_test.go +++ b/internal/logic/common/jwt_test.go @@ -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) { diff --git a/internal/logic/common/register_rate_limit.go b/internal/logic/common/register_rate_limit.go index f5322a8..816ff61 100644 --- a/internal/logic/common/register_rate_limit.go +++ b/internal/logic/common/register_rate_limit.go @@ -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 } } diff --git a/internal/logic/common/register_rate_limit_test.go b/internal/logic/common/register_rate_limit_test.go index eaab68a..af24383 100644 --- a/internal/logic/common/register_rate_limit_test.go +++ b/internal/logic/common/register_rate_limit_test.go @@ -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) } // 清理测试数据 diff --git a/internal/logic/common/security.go b/internal/logic/common/security.go index db60e1e..bf8f2e3 100644 --- a/internal/logic/common/security.go +++ b/internal/logic/common/security.go @@ -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" { diff --git a/internal/logic/common/session.go b/internal/logic/common/session.go index 437b15a..6866fb9 100644 --- a/internal/logic/common/session.go +++ b/internal/logic/common/session.go @@ -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") diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index f50620a..46e18e4 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -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", diff --git a/internal/logic/open/open_api.go b/internal/logic/open/open_api.go index bbf5d1a..6745cb1 100644 --- a/internal/logic/open/open_api.go +++ b/internal/logic/open/open_api.go @@ -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) diff --git a/internal/logic/tenant/api_key.go b/internal/logic/tenant/api_key.go index 3fcbca4..04b9ba3 100644 --- a/internal/logic/tenant/api_key.go +++ b/internal/logic/tenant/api_key.go @@ -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, diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index b573280..ed05efa 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -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 } diff --git a/internal/logic/tenant/billing.go b/internal/logic/tenant/billing.go index 76ba91b..a4d6991 100644 --- a/internal/logic/tenant/billing.go +++ b/internal/logic/tenant/billing.go @@ -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 { diff --git a/internal/logic/tenant/help_center.go b/internal/logic/tenant/help_center.go index 35d14be..4d51914 100644 --- a/internal/logic/tenant/help_center.go +++ b/internal/logic/tenant/help_center.go @@ -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 diff --git a/internal/logic/tenant/invitation.go b/internal/logic/tenant/invitation.go index c164979..3c3e0e0 100644 --- a/internal/logic/tenant/invitation.go +++ b/internal/logic/tenant/invitation.go @@ -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) diff --git a/internal/logic/tenant/level_benefits.go b/internal/logic/tenant/level_benefits.go index 172ce9c..ce9b6ce 100644 --- a/internal/logic/tenant/level_benefits.go +++ b/internal/logic/tenant/level_benefits.go @@ -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 diff --git a/internal/logic/tenant/member.go b/internal/logic/tenant/member.go index 4851b61..9be1fe9 100644 --- a/internal/logic/tenant/member.go +++ b/internal/logic/tenant/member.go @@ -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, }) { diff --git a/internal/logic/tenant/member_import.go b/internal/logic/tenant/member_import.go index b25f3e1..b990b51 100644 --- a/internal/logic/tenant/member_import.go +++ b/internal/logic/tenant/member_import.go @@ -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 } diff --git a/internal/logic/tenant/member_quota.go b/internal/logic/tenant/member_quota.go index a1c11aa..500ee87 100644 --- a/internal/logic/tenant/member_quota.go +++ b/internal/logic/tenant/member_quota.go @@ -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 { diff --git a/internal/logic/tenant/model_comparison.go b/internal/logic/tenant/model_comparison.go index 7605b26..96da3ef 100644 --- a/internal/logic/tenant/model_comparison.go +++ b/internal/logic/tenant/model_comparison.go @@ -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 } diff --git a/internal/logic/tenant/oauth.go b/internal/logic/tenant/oauth.go index f3a204c..d0482c0 100644 --- a/internal/logic/tenant/oauth.go +++ b/internal/logic/tenant/oauth.go @@ -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") diff --git a/internal/logic/tenant/open_platform.go b/internal/logic/tenant/open_platform.go index f406547..71c7ebc 100644 --- a/internal/logic/tenant/open_platform.go +++ b/internal/logic/tenant/open_platform.go @@ -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 } diff --git a/internal/logic/tenant/open_platform_test.go b/internal/logic/tenant/open_platform_test.go new file mode 100644 index 0000000..f185287 --- /dev/null +++ b/internal/logic/tenant/open_platform_test.go @@ -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("预期拒绝空权限集合,实际通过") + } + }) +} diff --git a/internal/logic/tenant/playground.go b/internal/logic/tenant/playground.go deleted file mode 100644 index 1507fc2..0000000 --- a/internal/logic/tenant/playground.go +++ /dev/null @@ -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) -} diff --git a/internal/logic/tenant/plugin.go b/internal/logic/tenant/plugin.go index add1c79..2f659f8 100644 --- a/internal/logic/tenant/plugin.go +++ b/internal/logic/tenant/plugin.go @@ -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 diff --git a/internal/logic/tenant/project.go b/internal/logic/tenant/project.go index 56e45d1..c4f6045 100644 --- a/internal/logic/tenant/project.go +++ b/internal/logic/tenant/project.go @@ -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 { diff --git a/internal/logic/tenant/security.go b/internal/logic/tenant/security.go index 7074031..58f6fa0 100644 --- a/internal/logic/tenant/security.go +++ b/internal/logic/tenant/security.go @@ -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 { diff --git a/internal/logic/tenant/task.go b/internal/logic/tenant/task.go index 0f40bf7..2bc7a68 100644 --- a/internal/logic/tenant/task.go +++ b/internal/logic/tenant/task.go @@ -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: "提交时间"}, diff --git a/internal/logic/tenant/ticket.go b/internal/logic/tenant/ticket.go index 1268d45..0d3036d 100644 --- a/internal/logic/tenant/ticket.go +++ b/internal/logic/tenant/ticket.go @@ -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 } diff --git a/internal/middleware/admin_auth.go b/internal/middleware/admin_auth.go index 1e2beab..669d9f0 100644 --- a/internal/middleware/admin_auth.go +++ b/internal/middleware/admin_auth.go @@ -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) diff --git a/internal/middleware/idempotency.go b/internal/middleware/idempotency.go index 9a7bc6e..dede4d6 100644 --- a/internal/middleware/idempotency.go +++ b/internal/middleware/idempotency.go @@ -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 diff --git a/internal/middleware/open_auth.go b/internal/middleware/open_auth.go index 7f55e8b..94fb1f3 100644 --- a/internal/middleware/open_auth.go +++ b/internal/middleware/open_auth.go @@ -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(与限流一致),仅记告警,因为时间戳窗口本身已把可重放时间 diff --git a/internal/middleware/operation_log.go b/internal/middleware/operation_log.go index 701d6f1..6499437 100644 --- a/internal/middleware/operation_log.go +++ b/internal/middleware/operation_log.go @@ -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 } diff --git a/internal/middleware/rbac.go b/internal/middleware/rbac.go index 357494b..0025e71 100644 --- a/internal/middleware/rbac.go +++ b/internal/middleware/rbac.go @@ -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. diff --git a/internal/middleware/rbac_coverage_test.go b/internal/middleware/rbac_coverage_test.go new file mode 100644 index 0000000..d9e23a8 --- /dev/null +++ b/internal/middleware/rbac_coverage_test.go @@ -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) + } + } +} diff --git a/internal/middleware/request_id_security_test.go b/internal/middleware/request_id_security_test.go index 9ff0685..6d0c24c 100644 --- a/internal/middleware/request_id_security_test.go +++ b/internal/middleware/request_id_security_test.go @@ -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) } } }) diff --git a/internal/middleware/request_id_test.go b/internal/middleware/request_id_test.go index 1003d89..0bdc95d 100644 --- a/internal/middleware/request_id_test.go +++ b/internal/middleware/request_id_test.go @@ -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() diff --git a/internal/middleware/tenant_auth.go b/internal/middleware/tenant_auth.go index 35d7792..83990c2 100644 --- a/internal/middleware/tenant_auth.go +++ b/internal/middleware/tenant_auth.go @@ -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}). diff --git a/internal/model/do/sys_admin_role_permissions.go b/internal/model/do/sys_admin_role_permissions.go new file mode 100644 index 0000000..6147aff --- /dev/null +++ b/internal/model/do/sys_admin_role_permissions.go @@ -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 // 更新时间 +} diff --git a/internal/model/do/sys_admin_roles.go b/internal/model/do/sys_admin_roles.go new file mode 100644 index 0000000..1f7a29c --- /dev/null +++ b/internal/model/do/sys_admin_roles.go @@ -0,0 +1,24 @@ +// ================================================================================= +// 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" +) + +// SysAdminRoles is the golang structure of table sys_admin_roles for DAO operations like Where/Data. +type SysAdminRoles struct { + g.Meta `orm:"table:sys_admin_roles, do:true"` + Id any // 主键ID + Code any // 角色标识(小写字母/数字/下划线,创建后不可修改,是权限缓存与审计日志的稳定标识;保留字 super_admin 不可占用) + Name any // 角色显示名称(可修改) + Description any // 角色说明,在分配界面展示 + IsBuiltin any // 是否系统预置:仅用于标识来源与支撑「恢复默认权限」,不构成删除保护(预置角色同样可改可删) + IsEnabled any // 是否启用:禁用后该角色的权限对全部关联用户立即失效,无需解除关联 + Sort any // 排序权重(升序) + CreatedAt *gtime.Time // 创建时间 + UpdatedAt *gtime.Time // 更新时间 +} diff --git a/internal/model/do/sys_admin_user_roles.go b/internal/model/do/sys_admin_user_roles.go new file mode 100644 index 0000000..315d375 --- /dev/null +++ b/internal/model/do/sys_admin_user_roles.go @@ -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" +) + +// SysAdminUserRoles is the golang structure of table sys_admin_user_roles for DAO operations like Where/Data. +type SysAdminUserRoles struct { + g.Meta `orm:"table:sys_admin_user_roles, do:true"` + Id any // 主键ID + AdminUserId any // 管理员用户ID(逻辑关联 sys_admin_users.id,无外键) + RoleId any // 角色ID(逻辑关联 sys_admin_roles.id,无外键) + CreatedAt *gtime.Time // 创建时间 + UpdatedAt *gtime.Time // 更新时间 +} diff --git a/internal/model/entity/sys_admin_role_permissions.go b/internal/model/entity/sys_admin_role_permissions.go new file mode 100644 index 0000000..828e33c --- /dev/null +++ b/internal/model/entity/sys_admin_role_permissions.go @@ -0,0 +1,18 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// SysAdminRolePermissions is the golang structure for table sys_admin_role_permissions. +type SysAdminRolePermissions struct { + Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID + RoleId int64 `json:"role_id" orm:"role_id" description:"角色ID(逻辑关联 sys_admin_roles.id,无外键,删除角色时由业务层级联清理)"` // 角色ID(逻辑关联 sys_admin_roles.id,无外键,删除角色时由业务层级联清理) + PermissionPoint string `json:"permission_point" orm:"permission_point" description:"权限点标识(如 tenant:create、channel:edit)"` // 权限点标识(如 tenant:create、channel:edit) + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 + UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间 +} diff --git a/internal/model/entity/sys_admin_roles.go b/internal/model/entity/sys_admin_roles.go new file mode 100644 index 0000000..42481a6 --- /dev/null +++ b/internal/model/entity/sys_admin_roles.go @@ -0,0 +1,22 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// SysAdminRoles is the golang structure for table sys_admin_roles. +type SysAdminRoles struct { + Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID + Code string `json:"code" orm:"code" description:"角色标识(小写字母/数字/下划线,创建后不可修改,是权限缓存与审计日志的稳定标识;保留字 super_admin 不可占用)"` // 角色标识(小写字母/数字/下划线,创建后不可修改,是权限缓存与审计日志的稳定标识;保留字 super_admin 不可占用) + Name string `json:"name" orm:"name" description:"角色显示名称(可修改)"` // 角色显示名称(可修改) + Description string `json:"description" orm:"description" description:"角色说明,在分配界面展示"` // 角色说明,在分配界面展示 + IsBuiltin bool `json:"is_builtin" orm:"is_builtin" description:"是否系统预置:仅用于标识来源与支撑「恢复默认权限」,不构成删除保护(预置角色同样可改可删)"` // 是否系统预置:仅用于标识来源与支撑「恢复默认权限」,不构成删除保护(预置角色同样可改可删) + IsEnabled bool `json:"is_enabled" orm:"is_enabled" description:"是否启用:禁用后该角色的权限对全部关联用户立即失效,无需解除关联"` // 是否启用:禁用后该角色的权限对全部关联用户立即失效,无需解除关联 + Sort int `json:"sort" orm:"sort" description:"排序权重(升序)"` // 排序权重(升序) + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 + UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间 +} diff --git a/internal/model/entity/sys_admin_user_roles.go b/internal/model/entity/sys_admin_user_roles.go new file mode 100644 index 0000000..c6b156f --- /dev/null +++ b/internal/model/entity/sys_admin_user_roles.go @@ -0,0 +1,18 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// SysAdminUserRoles is the golang structure for table sys_admin_user_roles. +type SysAdminUserRoles struct { + Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID + AdminUserId int64 `json:"admin_user_id" orm:"admin_user_id" description:"管理员用户ID(逻辑关联 sys_admin_users.id,无外键)"` // 管理员用户ID(逻辑关联 sys_admin_users.id,无外键) + RoleId int64 `json:"role_id" orm:"role_id" description:"角色ID(逻辑关联 sys_admin_roles.id,无外键)"` // 角色ID(逻辑关联 sys_admin_roles.id,无外键) + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 + UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间 +} diff --git a/internal/service/admin.go b/internal/service/admin.go index 2bd912f..26ce8f7 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -69,6 +69,11 @@ type ( // ContentFilterLogList returns a paginated list of content filter interception logs. // 审计数据从审计库查询,关联信息从主库批量查询后在应用层合并。 ContentFilterLogList(ctx context.Context, req *v1.ContentFilterLogListReq) (*v1.ContentFilterLogListRes, error) + // ContentFilterLogClear 硬删除全部内容过滤拦截日志。 + // 拦截日志为追加型审计流水,堆积后用 TRUNCATE 秒级清空并立即归还磁盘空间 + // (DELETE 需等 VACUUM 回收)。TRUNCATE 不返回行数,故先取删除前条数作为反馈。 + // 表无数据库级外键,TRUNCATE 安全。 + ContentFilterLogClear(ctx context.Context, _ *v1.ContentFilterLogClearReq) (*v1.ContentFilterLogClearRes, error) // Login handles admin login. Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLoginRes, error) // Logout handles admin logout. @@ -83,6 +88,11 @@ type ( ForceLogout(ctx context.Context, req *v1.AdminForceLogoutReq) (*v1.AdminForceLogoutRes, error) // ChangePassword handles admin password change. ChangePassword(ctx context.Context, req *v1.AdminChangePasswordReq) (*v1.AdminChangePasswordRes, error) + // GetMe 返回当前登录用户的信息与有效权限。 + // + // 权限此前只在登录响应里下发一次,之后前端从 localStorage 恢复:管理员改了某人的权限, + // 对方不重新登录就一直按旧权限渲染菜单。前端在应用启动与 token 刷新后调用本接口刷新。 + GetMe(ctx context.Context, _ *v1.AdminMeReq) (*v1.AdminMeRes, error) // CreateChangelog 创建更新日志 CreateChangelog(ctx context.Context, req *v1.ChangelogCreateReq) (*v1.ChangelogCreateRes, error) // ListChangelogs 更新日志列表(管理后台,含草稿) @@ -146,6 +156,11 @@ type ( ChannelErrorTopChannels(ctx context.Context, req *v1.ChannelErrorTopChannelsReq) (*v1.ChannelErrorTopChannelsRes, error) // ChannelErrorCategories 错误分类选项 ChannelErrorCategories(ctx context.Context, req *v1.ChannelErrorCategoriesReq) (*v1.ChannelErrorCategoriesRes, error) + // ChannelErrorClear 硬删除全部渠道错误事件。 + // 错误风暴时事件表堆积很快,用 TRUNCATE 而非逐行 DELETE 秒级清空并立即归还磁盘空间。 + // TRUNCATE 不返回行数,故先取删除前条数作为反馈。表无数据库级外键,TRUNCATE 安全。 + // 注意异步写入器(DefaultChannelErrorWriter)清空后继续写入新事件,属预期行为。 + ChannelErrorClear(ctx context.Context, _ *v1.ChannelErrorClearReq) (*v1.ChannelErrorClearRes, error) // ChannelOAuthAuthURL 生成 OAuth 授权链接 ChannelOAuthAuthURL(ctx context.Context, req *v1.ChannelOAuthAuthURLReq) (*v1.ChannelOAuthAuthURLRes, error) // ChannelOAuthExchange OAuth 授权码换取令牌 @@ -190,7 +205,9 @@ type ( // 流水类型为 recharge,并在描述中携带 CNY 快照(原始人民币 + 汇率 + 入账金额 + 转账流水号), // 供现金对账与开票追溯。 OfflineRecharge(ctx context.Context, req *v1.AdminWalletOfflineRechargeReq) (*v1.AdminWalletOfflineRechargeRes, error) - // GetWalletInfo 获取租户钱包信息(管理后台) + // GetWalletInfo 获取租户钱包信息(管理后台)。 + // balance/frozen_balance 以 Redis 权威值为准(Redis 是资金提交点,调整余额/线下入账后立即可见), + // DB 侧余额是物化器周期覆盖的滞后副本,仅作钱包行存在性校验与 Redis 不可用时的降级展示。 GetWalletInfo(ctx context.Context, req *v1.AdminWalletInfoReq) (*v1.AdminWalletInfoRes, error) // GetWalletTransactions 获取租户钱包交易流水(管理后台) GetWalletTransactions(ctx context.Context, req *v1.AdminWalletTransactionListReq) (*v1.AdminWalletTransactionListRes, error) @@ -400,9 +417,10 @@ type ( GetUserPermissions(ctx context.Context, req *v1.AdminPermissionListReq) (*v1.AdminPermissionListRes, error) // UpdateUserPermissions updates permission points for an admin user. UpdateUserPermissions(ctx context.Context, req *v1.AdminPermissionUpdateReq) (*v1.AdminPermissionUpdateRes, error) - // UpdateUserDataScopes updates data scopes for an admin user. - UpdateUserDataScopes(ctx context.Context, req *v1.AdminDataScopeUpdateReq) (*v1.AdminDataScopeUpdateRes, error) - // GetAllPermissions returns all predefined permission groups. + // GetAllPermissions returns all predefined permission groups plus tier metadata. + // + // 同时返回权限点分组(高级模式用)与「模块 × 档位」元数据(默认配置界面用): + // 二者是同一份数据的两个视图,由后端统一给出,避免前端复刻一份档位定义造成漂移。 GetAllPermissions(ctx context.Context, _ *v1.AdminAllPermissionsReq) (*v1.AdminAllPermissionsRes, error) // ListPlans 获取套餐列表 ListPlans(ctx context.Context, req *v1.PlanListReq) (*v1.PlanListRes, error) @@ -447,6 +465,30 @@ type ( ListRedemptionUsages(ctx context.Context, req *v1.RedemptionUsagesReq) (*v1.RedemptionUsagesRes, error) // ExportRedemptions exports redemption list to CSV or Excel. ExportRedemptions(ctx context.Context, req *v1.RedemptionExportReq) (*v1.RedemptionExportRes, error) + // AssignUserRoles 为管理员分配角色(全量覆盖)。 + AssignUserRoles(ctx context.Context, req *v1.AdminUserRoleAssignReq) (*v1.AdminUserRoleAssignRes, error) + // GetUserRoles 查询管理员已分配的角色。 + GetUserRoles(ctx context.Context, req *v1.AdminUserRoleListReq) (*v1.AdminUserRoleListRes, error) + // ListRoles 返回角色列表(角色数量少,不分页)。 + ListRoles(ctx context.Context, _ *v1.AdminRoleListReq) (*v1.AdminRoleListRes, error) + // GetRoleDetail 返回角色详情,同时给出权限点全集与按模块归纳的档位视图。 + GetRoleDetail(ctx context.Context, req *v1.AdminRoleDetailReq) (*v1.AdminRoleDetailRes, error) + // CreateRole 新建角色。权限可直接给出,也可从现有角色复制。 + CreateRole(ctx context.Context, req *v1.AdminRoleCreateReq) (*v1.AdminRoleCreateRes, error) + // UpdateRole 更新角色的名称、说明、排序与权限(code 不可修改)。 + UpdateRole(ctx context.Context, req *v1.AdminRoleUpdateReq) (*v1.AdminRoleUpdateRes, error) + // UpdateRoleStatus 启用/禁用角色。禁用后该角色的权限对全部关联用户立即失效。 + UpdateRoleStatus(ctx context.Context, req *v1.AdminRoleStatusUpdateReq) (*v1.AdminRoleStatusUpdateRes, error) + // DeleteRole 删除角色,并在同一事务内级联清理其权限行与用户关联。 + // + // 无外键,级联必须由业务层完成,否则会留下悬空的权限行与用户关联。 + // 预置角色同样可删:is_builtin 只标识来源,不构成删除保护。 + // + // 不存在把自己锁死的风险 —— 超级管理员由 sys_admin_users.role 判定并短路鉴权, + // 不依赖任何角色记录,即便角色被删光也能登录并重建,因此不设「至少保留一个角色」的约束。 + DeleteRole(ctx context.Context, req *v1.AdminRoleDeleteReq) (*v1.AdminRoleDeleteRes, error) + // ResetRoleDefaults 把预置角色的权限恢复为出厂默认值。 + ResetRoleDefaults(ctx context.Context, req *v1.AdminRoleResetReq) (*v1.AdminRoleResetRes, error) // Verify2FA handles the 2FA verification step during admin login. Verify2FA(ctx context.Context, req *v1.Admin2FAVerifyReq) (*v1.Admin2FAVerifyRes, error) // Setup2FA starts the 2FA setup process for the current admin user. @@ -485,6 +527,8 @@ type ( // 使用 SendOnce 而非 Send:连通性测试要快速拿到真实 SMTP 错误(认证失败、端口不通重试 // 三次也不会变好),不该让管理员多等十几秒的退避。 TestEmailConfig(ctx context.Context, req *v1.AdminEmailTestReq) (*v1.AdminEmailTestRes, error) + // GetSupportPendingSummary 顶栏铃铛待办计数。 + GetSupportPendingSummary(ctx context.Context, _ *v1.AdminSupportPendingSummaryReq) (*v1.AdminSupportPendingSummaryRes, error) // TaskList 大模型异步任务列表 TaskList(ctx context.Context, req *v1.TaskListReq) (*v1.TaskListRes, error) // TaskDetail 大模型异步任务详情 @@ -549,7 +593,11 @@ type ( UsageLogCleanupCancel(ctx context.Context, req *v1.UsageLogCleanupCancelReq) (*v1.UsageLogCleanupCancelRes, error) // GetWorkbenchSummary 工作台汇总。 GetWorkbenchSummary(ctx context.Context, _ *v1.AdminWorkbenchSummaryReq) (*v1.AdminWorkbenchSummaryRes, error) - // GetWorkbenchBadges 菜单红点计数(与 summary 共用缓存,不额外压库)。 + // GetWorkbenchBadges 工作台菜单角标计数(与 summary 共用缓存,不额外压库)。 + // + // 角标只挂工作台菜单一项:待办的排查线索只存在于工作台的描述文案里, + // 业务菜单里没有对应的定位入口,往各业务菜单挂数字只会带来 + // 「进去了却找不到问题」的困惑(红点引路却无路可走)。 GetWorkbenchBadges(ctx context.Context, _ *v1.AdminWorkbenchBadgeReq) (*v1.AdminWorkbenchBadgeRes, error) } ) diff --git a/internal/service/tenant.go b/internal/service/tenant.go index 3a72490..4b505b0 100644 --- a/internal/service/tenant.go +++ b/internal/service/tenant.go @@ -46,6 +46,8 @@ type ( // TenantRequestAuditLogs 分页查询租户的请求审计日志(不含 body,性能优先) TenantRequestAuditLogs(ctx context.Context, req *v1.TenantRequestAuditLogsReq) (*v1.TenantRequestAuditLogsRes, error) TenantRequestAuditLogDetail(ctx context.Context, req *v1.TenantRequestAuditLogDetailReq) (*v1.TenantRequestAuditLogDetailRes, error) + // GetRegisterRateLimitStatus 查询当前 IP 的注册限流状态(注册页提示用,公开端点,只读不计数)。 + GetRegisterRateLimitStatus(ctx context.Context, _ *v1.TenantRegisterRateLimitReq) (*v1.TenantRegisterRateLimitRes, error) // Register handles tenant registration. Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.TenantRegisterRes, error) // Login handles tenant user login. @@ -66,6 +68,9 @@ type ( WalletTransactions(ctx context.Context, req *v1.TenantWalletTransactionsReq) (*v1.TenantWalletTransactionsRes, error) // UsageLogs 获取租户用量日志 UsageLogs(ctx context.Context, req *v1.TenantUsageLogsReq) (*v1.TenantUsageLogsRes, error) + // UsageLogsSummary 用量日志统计汇总(与 UsageLogs 共用筛选口径:强制 tenant_id 隔离, + // member 角色只能统计自己的日志;总费用与租户端列表费用列同口径:actual_cost 优先,0/NULL 回退 total_cost) + UsageLogsSummary(ctx context.Context, req *v1.TenantUsageLogsSummaryReq) (*v1.TenantUsageLogsSummaryRes, error) // ExportUsageLogs exports the tenant usage logs as CSV or Excel. ExportUsageLogs(ctx context.Context, req *v1.TenantUsageLogsExportReq) (*v1.TenantUsageLogsExportRes, error) // ExportWalletTransactions exports the tenant wallet transactions as CSV or Excel. @@ -258,8 +263,6 @@ type ( PlanCurrent(ctx context.Context, req *v1.TenantPlanCurrentReq) (*v1.TenantPlanCurrentRes, error) // PlanCancelAutoRenew 取消自动续费 PlanCancelAutoRenew(ctx context.Context, req *v1.TenantPlanCancelAutoRenewReq) (*v1.TenantPlanCancelAutoRenewRes, error) - SandboxChat(ctx context.Context, req *v1.SandboxChatReq) (*v1.SandboxChatRes, error) - SandboxQuota(ctx context.Context, req *v1.SandboxQuotaReq) (*v1.SandboxQuotaRes, error) TenantPluginList(ctx context.Context, req *v1.TenantPluginListReq) (*v1.TenantPluginListRes, error) TenantPluginDetail(ctx context.Context, req *v1.TenantPluginDetailReq) (*v1.TenantPluginDetailRes, error) TenantPluginConfigUpdate(ctx context.Context, req *v1.TenantPluginConfigUpdateReq) (*v1.TenantPluginConfigUpdateRes, error) diff --git a/manifest/config/config.example.yaml b/manifest/config/config.example.yaml index 12c1a6d..031998c 100644 --- a/manifest/config/config.example.yaml +++ b/manifest/config/config.example.yaml @@ -83,9 +83,6 @@ demo: enabled: false message: "演示环境,数据不可修改" -# Channel proxy (for accessing Google/Anthropic etc. from China) -channel_proxy_url: "" - # System update source override # Default: GitHub official API (https://api.github.com/repos/qianfree/team-api). # Override to point at a self-hosted release server / local mock release (see diff --git a/migrations/000020_v0_2_17_version.sql b/migrations/000020_v0_2_17_version.sql new file mode 100644 index 0000000..2190d60 --- /dev/null +++ b/migrations/000020_v0_2_17_version.sql @@ -0,0 +1,239 @@ +-- +goose Up +-- 管理后台角色权限管理:引入可自定义的角色实体 +-- +-- 背景:此前管理后台只有 super_admin / admin 两种身份,权限点直接绑在用户上 +-- (sys_admin_role_perms 名为 role 实为 user_perms),无法复用、无法按岗位批量调整, +-- 结果是全团队共用超管账号。本迁移补齐「角色」这一层。 +-- +-- 角色模型: +-- · 超级管理员不入本表 —— 它由 sys_admin_users.role='super_admin' 唯一判定并在鉴权时短路, +-- 特权判定只保留一个真相源;即便角色被删光,超管仍可登录并重建角色体系。 +-- · 三个预置角色(管理员/运营/技术支持)只是安装时的初始数据,不是系统契约: +-- 用户可以改权限、改名、禁用,也可以直接删掉自建一套。 +-- · 有效权限 = ∪(已启用角色的权限) ∪ 用户特批权限(sys_admin_role_perms),只做并集不做否定。 +-- +-- 本迁移另含两笔独立变更:删除帮助中心不再使用的全文检索索引;新增 member:manage 权限点并回填存量授权。 + +-- ── 角色定义 ── +CREATE TABLE IF NOT EXISTS sys_admin_roles ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL, + name VARCHAR(50) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + is_builtin BOOLEAN NOT NULL DEFAULT FALSE, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + sort INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uk_sys_admin_roles_code UNIQUE (code) +); + +COMMENT ON TABLE sys_admin_roles IS '管理后台角色定义(超级管理员不入表,由 sys_admin_users.role 判定)'; +COMMENT ON COLUMN sys_admin_roles.id IS '主键ID'; +COMMENT ON COLUMN sys_admin_roles.code IS '角色标识(小写字母/数字/下划线,创建后不可修改,是权限缓存与审计日志的稳定标识;保留字 super_admin 不可占用)'; +COMMENT ON COLUMN sys_admin_roles.name IS '角色显示名称(可修改)'; +COMMENT ON COLUMN sys_admin_roles.description IS '角色说明,在分配界面展示'; +COMMENT ON COLUMN sys_admin_roles.is_builtin IS '是否系统预置:仅用于标识来源与支撑「恢复默认权限」,不构成删除保护(预置角色同样可改可删)'; +COMMENT ON COLUMN sys_admin_roles.is_enabled IS '是否启用:禁用后该角色的权限对全部关联用户立即失效,无需解除关联'; +COMMENT ON COLUMN sys_admin_roles.sort IS '排序权重(升序)'; +COMMENT ON COLUMN sys_admin_roles.created_at IS '创建时间'; +COMMENT ON COLUMN sys_admin_roles.updated_at IS '更新时间'; + +-- ── 角色 → 权限点 ── +CREATE TABLE IF NOT EXISTS sys_admin_role_permissions ( + id BIGSERIAL PRIMARY KEY, + role_id BIGINT NOT NULL, + permission_point VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uk_sys_admin_role_permissions UNIQUE (role_id, permission_point) +); + +CREATE INDEX IF NOT EXISTS idx_sys_admin_role_permissions_role ON sys_admin_role_permissions (role_id); + +COMMENT ON TABLE sys_admin_role_permissions IS '角色权限点关联'; +COMMENT ON COLUMN sys_admin_role_permissions.id IS '主键ID'; +COMMENT ON COLUMN sys_admin_role_permissions.role_id IS '角色ID(逻辑关联 sys_admin_roles.id,无外键,删除角色时由业务层级联清理)'; +COMMENT ON COLUMN sys_admin_role_permissions.permission_point IS '权限点标识(如 tenant:create、channel:edit)'; +COMMENT ON COLUMN sys_admin_role_permissions.created_at IS '创建时间'; +COMMENT ON COLUMN sys_admin_role_permissions.updated_at IS '更新时间'; + +-- ── 用户 → 角色(多对多,覆盖兼岗场景) ── +CREATE TABLE IF NOT EXISTS sys_admin_user_roles ( + id BIGSERIAL PRIMARY KEY, + admin_user_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT uk_sys_admin_user_roles UNIQUE (admin_user_id, role_id) +); + +CREATE INDEX IF NOT EXISTS idx_sys_admin_user_roles_user ON sys_admin_user_roles (admin_user_id); +CREATE INDEX IF NOT EXISTS idx_sys_admin_user_roles_role ON sys_admin_user_roles (role_id); + +COMMENT ON TABLE sys_admin_user_roles IS '管理员用户与角色关联(多对多:一个账号可同时担任多个角色,有效权限取并集)'; +COMMENT ON COLUMN sys_admin_user_roles.id IS '主键ID'; +COMMENT ON COLUMN sys_admin_user_roles.admin_user_id IS '管理员用户ID(逻辑关联 sys_admin_users.id,无外键)'; +COMMENT ON COLUMN sys_admin_user_roles.role_id IS '角色ID(逻辑关联 sys_admin_roles.id,无外键)'; +COMMENT ON COLUMN sys_admin_user_roles.created_at IS '创建时间'; +COMMENT ON COLUMN sys_admin_user_roles.updated_at IS '更新时间'; + +-- ── 存量表语义调整:sys_admin_role_perms 由「用户权限」改为「用户特批权限」 ── +-- 数据不迁移、不删除。它现在表示「在角色之外额外授予该用户的权限」, +-- 保留「临时给某人单加一个权限」的口子,不必为一个权限点新建角色。 +COMMENT ON TABLE sys_admin_role_perms IS '管理员用户特批权限(角色之外的额外授权,与角色权限取并集)'; +COMMENT ON COLUMN sys_admin_role_perms.admin_user_id IS '关联的管理员用户ID'; +COMMENT ON COLUMN sys_admin_role_perms.permission_point IS '特批的权限点标识(独立于角色,角色禁用不影响特批)'; + +-- ── 预置角色种子 ── +-- ON CONFLICT DO NOTHING 有两层作用:① 迁移可重复执行; +-- ② 用户改过或删过的预置角色,重跑迁移不会被覆盖或复活。 +INSERT INTO sys_admin_roles (code, name, description, is_builtin, is_enabled, sort) VALUES + ('admin', '管理员', '二把手:能碰钱(退款)与系统配置(支付、汇率、存储、邮件),不能管理账号与角色', TRUE, TRUE, 10), + ('operator', '运营', '平台日常运营:渠道、模型、租户、套餐、内容的完整权限,不涉及资金与系统设置', TRUE, TRUE, 20), + ('support', '技术支持', '排查客户问题:除财务数据外全站只读,唯一写权限是工单、反馈与帮助中心', TRUE, TRUE, 30) +ON CONFLICT (code) DO NOTHING; + +-- 管理员:除 user:*(账号与角色)、system:update/plugin 外的全部权限 +INSERT INTO sys_admin_role_permissions (role_id, permission_point) +SELECT r.id, unnest(ARRAY[ + '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' +]) +FROM sys_admin_roles r WHERE r.code = 'admin' +ON CONFLICT (role_id, permission_point) DO NOTHING; + +-- 运营:业务配置的完整权限;不给退款、审计、系统设置、账号管理 +INSERT INTO sys_admin_role_permissions (role_id, permission_point) +SELECT r.id, unnest(ARRAY[ + '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' +]) +FROM sys_admin_roles r WHERE r.code = 'operator' +ON CONFLICT (role_id, permission_point) DO NOTHING; + +-- 技术支持:除财务面(billing/order/promo/redemption/invoice)外全站只读 + 工单完整权限。 +-- 排查主力工具不在财务面:请求审计日志走 audit:view,错误日志与渠道错误监控走 monitor:view。 +INSERT INTO sys_admin_role_permissions (role_id, permission_point) +SELECT r.id, unnest(ARRAY[ + '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' +]) +FROM sys_admin_roles r WHERE r.code = 'support' +ON CONFLICT (role_id, permission_point) DO NOTHING; + + +-- 移除不再使用的配置项 +DELETE FROM sys_options +WHERE "key" IN ( + -- 安全 + 'max_sessions_per_user', + 'admin_max_sessions', + 'new_device_notification', + 'password_min_length', + -- 性能 + 'cache_enabled', + 'cache_ttl_seconds', + 'channel_affinity_ttl', + 'auto_test_interval', + -- 审计/数据治理 + 'audit_retention_days', + 'operation_log_retention_days', + 'data_deletion_completion_days', + -- 沙箱 + 'sandbox_enabled', + 'sandbox_default_quota' +); + + +-- ── 帮助中心搜索:删除不再使用的全文检索索引 ── +-- 搜索已从 to_tsvector 全文检索改为 ILIKE 模糊匹配:PostgreSQL 'simple' 分词配置不做中文分词, +-- 连续中文被切成单个长 token,中文关键词几乎无法命中(帮助文档以中文内容为主)。 +-- 该 GIN 索引不再被查询使用,删除以释放空间。 +DROP INDEX IF EXISTS idx_spt_articles_search; + + +-- ── member:manage 权限点与存量回填 ── +-- 成员禁用/启用/重置密码/解锁四个写接口此前挂在只读的 member:view 上(internal/middleware/rbac.go), +-- 「技术支持」这类只读定位的角色可重置任意租户成员的密码 —— 改密等于接管该成员账号, +-- 进而可用其 API Key 消耗租户余额,档位与动作不匹配,已改为要求 member:manage。 +-- +-- 上方预置角色种子已直接带上 member:manage;下面两条回填覆盖「执行过早期版本本迁移 +-- (种子未含该权限点)」的部署:给【已持有 member:import 的角色/用户】补 member:manage —— +-- member:import 本就在 member 模块的操作档,持有它的角色已经能创建成员,补上「管理成员」 +-- 不扩大其实际能力边界;仅持 member:view 的只读角色(如预置 support)不补。 +-- 幂等:ON CONFLICT DO NOTHING,可重复执行。 + +INSERT INTO sys_admin_role_permissions (role_id, permission_point) +SELECT DISTINCT rp.role_id, 'member:manage' +FROM sys_admin_role_permissions rp +WHERE rp.permission_point = 'member:import' +ON CONFLICT (role_id, permission_point) DO NOTHING; + +-- 用户特批权限同理:特批过 member:import 的账号一并补 member:manage, +-- 否则依赖特批权限工作的账号会在升级后突然失去成员管理能力。 +INSERT INTO sys_admin_role_perms (admin_user_id, permission_point) +SELECT DISTINCT p.admin_user_id, 'member:manage' +FROM sys_admin_role_perms p +WHERE p.permission_point = 'member:import' +ON CONFLICT DO NOTHING; + + +-- +goose Down +-- 移除 member:manage 权限点。回滚后 rbac.go 若仍要求 member:manage,相关接口对非超管一律 403, +-- 因此代码与迁移需要同步回退。 +DELETE FROM sys_admin_role_permissions WHERE permission_point = 'member:manage'; +DELETE FROM sys_admin_role_perms WHERE permission_point = 'member:manage'; + +-- 恢复帮助中心全文检索索引(仅回滚迁移时使用,此时搜索已退回 to_tsvector 方案) +CREATE INDEX idx_spt_articles_search ON spt_articles USING gin (to_tsvector('simple'::regconfig, (((COALESCE(title, ''::character varying))::text || ' '::text) || COALESCE(content, ''::text)))); + +-- 删除角色体系三张表。存量用户权限不受影响:它们本就存放在 sys_admin_role_perms, +-- 回滚后 HasPermission 退回「仅查用户特批权限」的旧语义。 +DROP TABLE IF EXISTS sys_admin_user_roles; +DROP TABLE IF EXISTS sys_admin_role_permissions; +DROP TABLE IF EXISTS sys_admin_roles; + +COMMENT ON TABLE sys_admin_role_perms IS '管理员角色权限关联'; +COMMENT ON COLUMN sys_admin_role_perms.admin_user_id IS '关联的管理员用户ID'; +COMMENT ON COLUMN sys_admin_role_perms.permission_point IS '权限点标识(如 tenant:create、channel:edit)'; diff --git a/relay/common/http_client.go b/relay/common/http_client.go index da5091f..15680a3 100644 --- a/relay/common/http_client.go +++ b/relay/common/http_client.go @@ -62,33 +62,29 @@ var ( // proxiedState 管理带代理的 HTTP 客户端,按需初始化并在代理 URL 变更时重建。 var proxiedState struct { - mu sync.RWMutex - proxyURL string - transport *http.Transport // ResponseHeaderTimeout=defaultResponseHeaderTimeout(普通) - longRun *http.Transport // ResponseHeaderTimeout=0(长耗时同步) - nonStream *http.Client - stream *http.Client - proxyURLCached atomic.Value // string - cacheTime atomic.Int64 // 上次读取配置的 unix 时间戳(秒) + mu sync.RWMutex + proxyURL string + transport *http.Transport // ResponseHeaderTimeout=defaultResponseHeaderTimeout(普通) + longRun *http.Transport // ResponseHeaderTimeout=0(长耗时同步) + nonStream *http.Client + stream *http.Client + proxyURLValue atomic.Value // string,系统代理 URL(sys_options channel_proxy_url,由 internal 注入) } -const proxyCacheTTL = 10 // seconds +// SetSystemProxyURL 注入系统代理 URL(sys_options 的 channel_proxy_url)。 +// relay 包不 import internal(会循环依赖),由 internal 侧在启动时与配置变更时 +// (OnSettingsChanged,免重启)注入,模式同 SetGlobalRequestTimeoutSeconds。 +func SetSystemProxyURL(proxyURL string) { + proxiedState.proxyURLValue.Store(proxyURL) +} -// GetSystemProxyURL 从系统配置读取 channel_proxy_url(带本地缓存)。 +// GetSystemProxyURL 返回系统代理 URL(sys_options 的 channel_proxy_url,空串=未配置)。 // 导出供 WebSocket 拨号器等非 HTTP 客户端调用方使用。 func GetSystemProxyURL() string { - now := time.Now().Unix() - last := proxiedState.cacheTime.Load() - if now-last < proxyCacheTTL { - if v, ok := proxiedState.proxyURLCached.Load().(string); ok { - return v - } + if v, ok := proxiedState.proxyURLValue.Load().(string); ok { + return v } - - proxyURL := g.Cfg().MustGet(context.Background(), "channel_proxy_url").String() - proxiedState.proxyURLCached.Store(proxyURL) - proxiedState.cacheTime.Store(now) - return proxyURL + return "" } // buildProxiedTransport 构建一个带(可选)代理的传输层,ResponseHeaderTimeout 由 rht 决定。 diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 36ba205..e41f707 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -3,6 +3,7 @@ package common import ( "context" "net/http" + "sync/atomic" "time" "github.com/qianfree/team-api/relay/constant" @@ -38,15 +39,60 @@ func (m *ChannelMeta) UpstreamSpeaksResponses() bool { } const ( - // DefaultTimeoutSeconds 非流式请求的默认总超时(http.Client.Timeout)。 + // DefaultTimeoutSeconds 非流式请求的内置默认总超时(http.Client.Timeout)。 // 推理模型(GLM-4.5/4.6 thinking、Claude thinking 等)非流式调用需跑完整段生成才回响应头, // 60s 会误杀;放宽到 180s。个别仍超时的模型建议在渠道级 settings.timeout_seconds 配 >180(自动切 longRun 传输层)。 + // 该常量仅作系统设置 request_timeout_seconds 未配置时的最终兜底。 DefaultTimeoutSeconds = 180 ImagesGenerationTimeoutSecs = 600 + + // DefaultStreamIdleTimeoutSeconds 流式响应的内置默认空闲超时(两个 chunk 之间的最大间隔,非总时长)。 + // 仅作系统设置 streaming_timeout_seconds 未配置时的最终兜底。 + DefaultStreamIdleTimeoutSeconds = 300 ) +// 全局超时配置(sys_options request_timeout_seconds / streaming_timeout_seconds), +// 0 表示未配置、回落内置默认。relay 包不 import internal(会循环依赖), +// 由 internal 侧在启动时与配置变更时(OnSettingsChanged,免重启)注入。 +var ( + globalRequestTimeoutSeconds atomic.Int64 + globalStreamIdleTimeoutSeconds atomic.Int64 +) + +// SetGlobalRequestTimeoutSeconds 注入全局非流式请求超时(<=0 视为未配置)。 +func SetGlobalRequestTimeoutSeconds(seconds int) { + if seconds < 0 { + seconds = 0 + } + globalRequestTimeoutSeconds.Store(int64(seconds)) +} + +// GlobalRequestTimeoutSeconds 返回全局非流式请求超时(未配置返回内置默认 180s)。 +func GlobalRequestTimeoutSeconds() int { + if v := int(globalRequestTimeoutSeconds.Load()); v > 0 { + return v + } + return DefaultTimeoutSeconds +} + +// SetGlobalStreamIdleTimeoutSeconds 注入全局流式空闲超时(<=0 视为未配置)。 +func SetGlobalStreamIdleTimeoutSeconds(seconds int) { + if seconds < 0 { + seconds = 0 + } + globalStreamIdleTimeoutSeconds.Store(int64(seconds)) +} + +// GlobalStreamIdleTimeoutSeconds 返回全局流式空闲超时(未配置返回内置默认 300s)。 +func GlobalStreamIdleTimeoutSeconds() int { + if v := int(globalStreamIdleTimeoutSeconds.Load()); v > 0 { + return v + } + return DefaultStreamIdleTimeoutSeconds +} + // GetTimeoutSeconds 返回请求超时秒数。 -// 图片生成模式强制最低 600s(即使渠道配置了更短的自定义超时),其余模式渠道自定义优先。 +// 优先级:渠道自定义 > 全局配置(系统设置)> 内置默认。图片生成模式强制最低 600s(即使渠道配置了更短的自定义超时)。 func (s ChannelSettings) GetTimeoutSeconds(relayMode int) int { if constant.RelayMode(relayMode) == constant.RelayModeImagesGenerations { if s.TimeoutSeconds > ImagesGenerationTimeoutSecs { @@ -57,7 +103,7 @@ func (s ChannelSettings) GetTimeoutSeconds(relayMode int) int { if s.TimeoutSeconds > 0 { return s.TimeoutSeconds } - return DefaultTimeoutSeconds + return GlobalRequestTimeoutSeconds() } // ChannelSettings 渠道配置(来自 chn_channels.settings JSONB) diff --git a/relay/handler/relay_handler.go b/relay/handler/relay_handler.go index e48167a..aa9d3c4 100644 --- a/relay/handler/relay_handler.go +++ b/relay/handler/relay_handler.go @@ -261,6 +261,14 @@ func extractSessionSignals(rawRequest map[string]json.RawMessage) dispatch.Sessi return sig } +// newSettleContext 创建结算/退款/调度上报专用上下文:脱离请求取消(客户端断开不得影响结算), +// 且超时预算从调用时刻起算。不可在请求发出前创建后跨 DoRequest/DoResponse 复用—— +// 上游请求与流式转发全程可能远超 30s,提前起算的预算会在结算前耗尽, +// 导致计费记录写入、预扣退款、渠道健康上报拿到已过期的 ctx 而失败。 +func newSettleContext(parent context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(parent), 30*time.Second) +} + // settleSuccessfulRequest 成功路径的计费结算、健康度更新和用量记录。 func settleSuccessfulRequest( rc *RelayContext, @@ -289,7 +297,10 @@ func settleSuccessfulRequest( } else { g.Log().Warningf(postCtx, "[RelayHandler] Settlement failed for request=%s model=%s, refunding pre-deduct amount=%.6f", rc.RequestID, v.modelName, preDeductAmount) - _ = billing.SettleFailed(postCtx, rc.TenantID, rc.RequestID, preDeductAmount) + if rErr := billing.SettleFailed(postCtx, rc.TenantID, rc.RequestID, preDeductAmount); rErr != nil { + g.Log().Errorf(postCtx, "[RelayHandler] Refund pre-deduct failed after settlement failure (needs reconciliation): request=%s tenant=%d err=%v", + rc.RequestID, rc.TenantID, rErr) + } } } @@ -668,8 +679,6 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He leaseRefresh := startDispatchLeaseRefresher(d.Channel.ID, rc.RequestID) upstreamCtx := context.WithoutCancel(attemptCtx) - settleCtx, settleCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer settleCancel() // 发送请求到上游 resp, err := adaptor.DoRequest(upstreamCtx, info, convertedBody) @@ -678,6 +687,9 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He // 送达状态标注:连接拒绝/DNS/TLS 建连失败 = 确定未送达; // 写出后 EOF/RST/读超时 = 可能已送达。ReplayUnsafe+MaybeSent 由 FSM 硬规则终止。 delivery := deliveryStateOfRequestErr(err) + // 结算/上报 ctx 此刻新建:上游挂死可耗时数分钟,请求开始时起算的预算早已耗尽 + settleCtx, settleCancel := newSettleContext(ctx) + defer settleCancel() decision, backoff := sess.Report(settleCtx, dispatchStatusCode(err), err, delivery, info.LatencyMs(), 0) trackRetryDecision(dispatchStatusCode(err), err, delivery, decision) @@ -696,7 +708,11 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He } if billing != nil && preDeductAmount > 0 { - _ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount) + // 退款失败意味着预扣冻结需等 2h TTL 清扫才释放,留痕供对账 + if rErr := billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount); rErr != nil { + g.Log().Errorf(settleCtx, "[RelayHandler] Refund pre-deduct failed after upstream request failure (needs reconciliation): request=%s tenant=%d err=%v", + rc.RequestID, rc.TenantID, rErr) + } } recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, info.StreamStatus) recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs()) @@ -720,6 +736,9 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He rc.RequestID, rc.TenantID, selection.ChannelID, selection.ChannelName, adaptor.GetChannelName(), v.modelName, attempt, info.StreamStatus.Summary(), ctx.Err(), usage, preDeductAmount) // 客户端已收到部分流:不可重试(响应已污染),上报健康后按流中断结算 + // 结算 ctx 此刻新建:流式转发全程可能远超 30s,且请求 ctx 已随客户端断开而取消 + settleCtx, settleCancel := newSettleContext(ctx) + defer settleCancel() _, _ = sess.Report(settleCtx, dispatchStatusCode(err), err, dispatch.DeliveryResponseStarted, info.LatencyMs(), 0) streamUsage := usage if streamUsage == nil { @@ -764,6 +783,9 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He // GetEndReason() != "":流已开始传输后出错,字节已提交给客户端,必须终止 delivery = dispatch.DeliveryResponseStarted } + // 结算/上报 ctx 此刻新建:DoResponse(含长流式转发)可能已消耗任意时长 + settleCtx, settleCancel := newSettleContext(ctx) + defer settleCancel() decision, backoff := sess.Report(settleCtx, dispatchStatusCode(err), err, delivery, info.LatencyMs(), retryAfterOf(err)) trackRetryDecision(dispatchStatusCode(err), err, delivery, decision) @@ -790,7 +812,11 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He } if billing != nil && preDeductAmount > 0 { - _ = billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount) + // 退款失败意味着预扣冻结需等 2h TTL 清扫才释放,留痕供对账 + if rErr := billing.SettleFailed(settleCtx, rc.TenantID, rc.RequestID, preDeductAmount); rErr != nil { + g.Log().Errorf(settleCtx, "[RelayHandler] Refund pre-deduct failed after response failure (needs reconciliation): request=%s tenant=%d err=%v", + rc.RequestID, rc.TenantID, rErr) + } } recordFailedUsage(provider, rc, selection, v.modelName, v.relayMode, v.isStream, err, info.StreamStatus) recordChannelError(rc, selection, v.modelName, attempt, true, err, info.LatencyMs()) @@ -801,7 +827,9 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He // 成功路径:绑定续期 + 健康上报由 Finish 完成 dbgAttempt.MarkFinal(nil) - sess.Finish(settleCtx, true, info.LatencyMs()) + finishCtx, finishCancel := newSettleContext(ctx) + defer finishCancel() + sess.Finish(finishCtx, true, info.LatencyMs()) appendHop(trace, hop, true, "", info.LatencyMs()) trace.TotalAttempts = attempt + 1 trace.UpstreamModel = selection.UpstreamModelName diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go index 8a66be7..fdc092e 100644 --- a/relay/helper/stream_scanner.go +++ b/relay/helper/stream_scanner.go @@ -16,15 +16,14 @@ import ( ) const ( - defaultStreamTimeout = 300 * time.Second // 两个 chunk 之间的最大空闲间隔(非总超时) - defaultPingInterval = 15 * time.Second - dataChanSize = 10 - stopChanSize = 3 - shutdownWaitTimeout = 5 * time.Second - minLineLength = 6 - scannerInitialBuf = 64 * 1024 - scannerMaxBuf = 10 * 1024 * 1024 - resetChanSize = 8 + defaultPingInterval = 15 * time.Second + dataChanSize = 10 + stopChanSize = 3 + shutdownWaitTimeout = 5 * time.Second + minLineLength = 6 + scannerInitialBuf = 64 * 1024 + scannerMaxBuf = 10 * 1024 * 1024 + resetChanSize = 8 ) // DataHandlerFunc data 处理回调 @@ -246,15 +245,17 @@ shutdown: } } -// getStreamTimeout 获取流式空闲超时时间(两个 chunk 之间的最大间隔),不低于 defaultStreamTimeout。 +// getStreamTimeout 获取流式空闲超时时间(两个 chunk 之间的最大间隔)。 +// 优先级:渠道自定义(须大于全局值才生效)> 全局配置(系统设置 streaming_timeout_seconds)> 内置默认。 func getStreamTimeout(info *common.RelayInfo) time.Duration { + global := time.Duration(common.GlobalStreamIdleTimeoutSeconds()) * time.Second if info.ChannelMeta != nil && info.ChannelMeta.Settings.TimeoutSeconds > 0 { t := time.Duration(info.ChannelMeta.Settings.TimeoutSeconds) * time.Second - if t > defaultStreamTimeout { + if t > global { return t } } - return defaultStreamTimeout + return global } // safeSendBool 安全发送到 stopChan(recover 避免向已关闭 channel 写入) diff --git a/web/admin/src/components/ModelPricingModal.vue b/web/admin/src/components/ModelPricingModal.vue index bea081e..8887117 100644 --- a/web/admin/src/components/ModelPricingModal.vue +++ b/web/admin/src/components/ModelPricingModal.vue @@ -3,10 +3,9 @@ import { ref, reactive, watch, computed } from 'vue' import { Message } from '@arco-design/web-vue' import { IconCloudDownload, IconRefresh } from '@arco-design/web-vue/es/icon' import request from '@/utils/request' -import { displayCurrency, formatBilling, cnyToUsd } from '@/composables/useCurrency' +import { displayCurrency, currencySymbol, formatBilling, cnyToUsd } from '@/composables/useCurrency' // 本位币符号:定价输入控件后缀跟随本位币,输入值仍为 bil 层存储原值不折算 -const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$')) const props = defineProps<{ visible: boolean diff --git a/web/admin/src/composables/useCurrency.ts b/web/admin/src/composables/useCurrency.ts index 4e2b18d..46f02be 100644 --- a/web/admin/src/composables/useCurrency.ts +++ b/web/admin/src/composables/useCurrency.ts @@ -26,6 +26,12 @@ export const cnyToUsd = computed(() => { return r > 0 ? r : FALLBACK_CNY_TO_USD }) +/** + * 全站显示货币符号(= 本位币符号),供模板直接插值。 + * 仅用于 bil_ 记账层金额与已折算为显示币种的金额;订单层原始 CNY 金额请写死 ¥。 + */ +export const currencySymbol = computed(() => (displayCurrency.value === 'CNY' ? '¥' : '$')) + export interface FormatMoneyOptions { /** 数据存储层币种:'USD' | 'CNY'(按字段所属数据层传入,非显示币种) */ source?: CurrencyCode @@ -48,7 +54,7 @@ export function formatMoney(value: unknown, opts: FormatMoneyOptions = {}): stri if (source === 'CNY' && display === 'USD') { num = num * cnyToUsd.value } - const symbol = display === 'CNY' ? '¥' : '$' + const symbol = currencySymbol.value const p = precision ?? (display === 'CNY' ? 2 : 6) const sign = showSign ? (num > 0 ? '+' : num < 0 ? '-' : '') : '' // 先 toFixed 确保精度,再用 Number 去掉末尾的 0 diff --git a/web/admin/src/main.ts b/web/admin/src/main.ts index 0de17d8..7ec54df 100644 --- a/web/admin/src/main.ts +++ b/web/admin/src/main.ts @@ -14,11 +14,11 @@ console.log( 'background: #115e59; color: white; padding: 4px 8px; border-radius: 0 4px 4px 0;', ) console.log( - '%cAGPL v3.0 开源协议', + '%cAGPL-3.0-or-later 开源协议', 'color: #666;', ) console.log( - '%cCopyright © 2025-2026 Team-API Contributors', + '%cCopyright © 2026 qianfree', 'color: #666;', ) console.log( diff --git a/web/admin/src/router/admin.ts b/web/admin/src/router/admin.ts index 76e9dc8..27a2b8e 100644 --- a/web/admin/src/router/admin.ts +++ b/web/admin/src/router/admin.ts @@ -22,13 +22,21 @@ const adminRoutes: RouteRecordRaw[] = [ path: '', name: 'AdminDashboard', component: () => import('@/views/DashboardPage.vue'), - meta: { title: '仪表盘' }, + meta: { title: '仪表盘', perm: 'dashboard:view' }, }, { path: 'workbench', name: 'AdminWorkbench', component: () => import('@/views/WorkbenchPage.vue'), - meta: { title: '工作台' }, + meta: { title: '工作台', perm: 'dashboard:view' }, + }, + { + // 权限不足的落地页:路由守卫拦截越权访问后跳到这里。 + // 接口层本就会 403,但直接给一个空白页加一片报错的体验很差。 + path: '403', + name: 'AdminForbidden', + component: () => import('@/views/ForbiddenPage.vue'), + meta: { title: '无权访问' }, }, { path: 'profile', @@ -40,55 +48,55 @@ const adminRoutes: RouteRecordRaw[] = [ path: 'users', name: 'AdminUsers', component: () => import('@/views/system/UsersPage.vue'), - meta: { title: '用户管理' }, + meta: { title: '用户管理', perm: 'user:view' }, }, { path: 'plugins', name: 'AdminPlugins', component: () => import('@/views/system/PluginsPage.vue'), - meta: { title: '插件管理' }, + meta: { title: '插件管理', perm: 'system:plugin' }, }, { path: 'models', name: 'AdminModels', component: () => import('@/views/ai/ModelsPage.vue'), - meta: { title: '模型管理' }, + meta: { title: '模型管理', perm: 'model:view' }, }, { path: 'model-groups', name: 'AdminModelGroups', component: () => import('@/views/ai/ModelGroupsPage.vue'), - meta: { title: '模型分组' }, + meta: { title: '模型分组', perm: 'model:view' }, }, { path: 'channels', name: 'AdminChannels', component: () => import('@/views/ai/ChannelsPage.vue'), - meta: { title: '渠道管理' }, + meta: { title: '渠道管理', perm: 'channel:view' }, }, { path: 'channels/:id', name: 'AdminChannelDetail', component: () => import('@/views/ai/ChannelDetailPage.vue'), - meta: { title: '渠道详情' }, + meta: { title: '渠道详情', perm: 'channel:view' }, }, { path: 'usage-logs', name: 'AdminUsageLogs', component: () => import('@/views/tenant/UsageLogsPage.vue'), - meta: { title: '用量日志' }, + meta: { title: '用量日志', perm: 'billing:view' }, }, { path: 'transactions', name: 'AdminTransactions', component: () => import('@/views/finance/TransactionsPage.vue'), - meta: { title: '交易流水' }, + meta: { title: '交易流水', perm: 'billing:view' }, }, { path: 'orders', name: 'AdminOrders', component: () => import('@/views/finance/OrdersPage.vue'), - meta: { title: '订单管理' }, + meta: { title: '订单管理', perm: 'order:view' }, }, { // 旧"支付设置"独立页已并入系统设置(payment 分类),URL 保留重定向兼容 @@ -99,211 +107,211 @@ const adminRoutes: RouteRecordRaw[] = [ path: 'redemptions', name: 'AdminRedemptions', component: () => import('@/views/finance/RedemptionsPage.vue'), - meta: { title: '兑换码管理' }, + meta: { title: '兑换码管理', perm: 'redemption:view' }, }, { path: 'promo-codes', name: 'AdminPromoCodes', component: () => import('@/views/finance/PromoCodesPage.vue'), - meta: { title: '优惠码管理' }, + meta: { title: '优惠码管理', perm: 'promo:view' }, }, { path: 'sessions', name: 'AdminSessions', component: () => import('@/views/security/SessionsPage.vue'), - meta: { title: '会话管理' }, + meta: { title: '会话管理', perm: 'user:edit' }, }, { path: 'permissions', name: 'AdminPermissions', component: () => import('@/views/security/PermissionsPage.vue'), - meta: { title: '权限管理' }, + meta: { title: '角色权限', superOnly: true }, }, { path: 'login-history', name: 'AdminLoginHistory', component: () => import('@/views/security/LoginHistoryPage.vue'), - meta: { title: '登录历史' }, + meta: { title: '登录历史', perm: 'audit:view' }, }, { path: 'tenant-login-history', name: 'AdminTenantLoginHistory', component: () => import('@/views/security/TenantLoginHistoryPage.vue'), - meta: { title: '租户登录历史' }, + meta: { title: '租户登录历史', perm: 'audit:view' }, }, { path: 'task-logs', name: 'AdminTaskLogs', component: () => import('@/views/ai/TaskLogsPage.vue'), - meta: { title: '任务日志' }, + meta: { title: '任务日志', perm: 'task:view' }, }, { path: 'tenants', name: 'AdminTenants', component: () => import('@/views/tenant/TenantsPage.vue'), - meta: { title: '租户列表' }, + meta: { title: '租户列表', perm: 'tenant:view' }, }, { path: 'tenants/:id', name: 'AdminTenantDetail', component: () => import('@/views/tenant/TenantDetailPage.vue'), - meta: { title: '租户详情' }, + meta: { title: '租户详情', perm: 'tenant:view' }, }, { path: 'tenant-levels', name: 'AdminTenantLevels', component: () => import('@/views/tenant/TenantLevelsPage.vue'), - meta: { title: '租户级别' }, + meta: { title: '租户级别', perm: 'tenant:view' }, }, { path: 'members', name: 'AdminMembers', component: () => import('@/views/tenant/MembersPage.vue'), - meta: { title: '成员列表' }, + meta: { title: '成员列表', perm: 'member:view' }, }, { path: 'audit', name: 'AdminAudit', component: () => import('@/views/security/AuditPage.vue'), - meta: { title: '操作日志' }, + meta: { title: '操作日志', perm: 'audit:view' }, }, { path: 'request-audit-logs', name: 'AdminRequestAuditLogs', component: () => import('@/views/security/RequestAuditLogsPage.vue'), - meta: { title: '请求审计日志' }, + meta: { title: '请求审计日志', perm: 'audit:view' }, }, { path: 'notification-templates', name: 'AdminNotificationTemplates', component: () => import('@/views/operation/NotificationTemplatesPage.vue'), - meta: { title: '通知模板' }, + meta: { title: '通知模板', perm: 'operation:view' }, }, { path: 'messages', name: 'AdminMessages', component: () => import('@/views/operation/MessagesPage.vue'), - meta: { title: '消息管理' }, + meta: { title: '消息管理', perm: 'operation:view' }, }, { path: 'email-logs', name: 'AdminEmailLogs', component: () => import('@/views/operation/EmailLogsPage.vue'), - meta: { title: '邮件发送记录' }, + meta: { title: '邮件发送记录', perm: 'operation:view' }, }, { path: 'announcements', name: 'AdminAnnouncements', component: () => import('@/views/operation/AnnouncementsPage.vue'), - meta: { title: '公告管理' }, + meta: { title: '公告管理', perm: 'operation:view' }, }, { path: 'tickets', name: 'AdminTickets', component: () => import('@/views/operation/TicketsPage.vue'), - meta: { title: '工单管理' }, + meta: { title: '工单管理', perm: 'support:view' }, }, { path: 'feedback', name: 'AdminFeedback', component: () => import('@/views/operation/FeedbackPage.vue'), - meta: { title: '反馈管理' }, + meta: { title: '反馈管理', perm: 'support:view' }, }, { path: 'settings', name: 'AdminSettings', component: () => import('@/views/system/SettingsPage.vue'), - meta: { title: '系统设置' }, + meta: { title: '系统设置', perm: 'system:view' }, }, { path: 'files', name: 'AdminFiles', component: () => import('@/views/system/FilesPage.vue'), - meta: { title: '文件管理' }, + meta: { title: '文件管理', perm: 'file:view' }, }, { path: 'help-categories', name: 'AdminHelpCategories', component: () => import('@/views/system/HelpCategoriesPage.vue'), - meta: { title: '帮助分类' }, + meta: { title: '帮助分类', perm: 'support:view' }, }, { path: 'help-articles', name: 'AdminHelpArticles', component: () => import('@/views/system/HelpArticlesPage.vue'), - meta: { title: '帮助文章' }, + meta: { title: '帮助文章', perm: 'support:view' }, }, { path: 'changelogs', name: 'AdminChangelogs', component: () => import('@/views/operation/ChangelogsPage.vue'), - meta: { title: '更新日志' }, + meta: { title: '更新日志', perm: 'operation:view' }, }, { path: 'agreements', name: 'AdminAgreements', component: () => import('@/views/operation/AgreementsPage.vue'), - meta: { title: '用户协议' }, + meta: { title: '用户协议', perm: 'system:view' }, }, { path: 'realtime', name: 'AdminRealtimeMonitor', component: () => import('@/views/monitor/RealtimeMonitorPage.vue'), - meta: { title: '实时监控面板' }, + meta: { title: '实时监控面板', perm: 'monitor:view' }, }, { path: 'monitor', name: 'AdminMonitor', component: () => import('@/views/monitor/MonitorPage.vue'), - meta: { title: '系统监控' }, + meta: { title: '系统监控', perm: 'monitor:view' }, }, { path: 'traffic-flow', name: 'AdminTrafficFlow', component: () => import('@/views/monitor/TrafficFlowPage.vue'), - meta: { title: '流量流向' }, + meta: { title: '流量流向', perm: 'monitor:view' }, }, { path: 'model-performance', name: 'AdminModelPerformance', component: () => import('@/views/monitor/ModelPerformancePage.vue'), - meta: { title: '模型性能' }, + meta: { title: '模型性能', perm: 'monitor:view' }, }, { path: 'alert-rules', name: 'AdminAlertRules', component: () => import('@/views/monitor/AlertRulesPage.vue'), - meta: { title: '告警规则' }, + meta: { title: '告警规则', perm: 'monitor:view' }, }, { path: 'alert-events', name: 'AdminAlertEvents', component: () => import('@/views/monitor/AlertEventsPage.vue'), - meta: { title: '告警记录' }, + meta: { title: '告警记录', perm: 'monitor:view' }, }, { path: 'error-logs', name: 'AdminErrorLogs', component: () => import('@/views/monitor/ErrorLogsPage.vue'), - meta: { title: '系统错误日志' }, + meta: { title: '系统错误日志', perm: 'monitor:view' }, }, { path: 'channel-errors', name: 'AdminChannelErrors', component: () => import('@/views/monitor/ChannelErrorsPage.vue'), - meta: { title: '渠道错误监控' }, + meta: { title: '渠道错误监控', perm: 'monitor:view' }, }, { path: 'content-filter-logs', name: 'AdminContentFilterLogs', component: () => import('@/views/monitor/ContentFilterLogsPage.vue'), - meta: { title: '拦截日志' }, + meta: { title: '拦截日志', perm: 'audit:view' }, }, { path: 'cron-jobs', name: 'AdminCronJobs', component: () => import('@/views/monitor/CronJobsPage.vue'), - meta: { title: '定时任务' }, + meta: { title: '定时任务', perm: 'system:view' }, }, ], }, diff --git a/web/admin/src/router/index.ts b/web/admin/src/router/index.ts index 3f56125..ebf1c0e 100644 --- a/web/admin/src/router/index.ts +++ b/web/admin/src/router/index.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' import adminRoutes from './admin' import { useAuthStore } from '@/stores/auth' +import { hasPermission, isSuperAdmin } from '@/utils/permission' import request, { shouldRefresh, getRefreshToken } from '@/utils/request' import { useSiteName } from '@/composables/useSiteName' import { usePublicSettings } from '@/composables/usePublicSettings' @@ -45,6 +46,10 @@ async function checkSetupStatus(): Promise { return systemInitialized === true } +// 每次页面加载只向服务端同步一次权限:守卫在每次路由跳转都会执行, +// 不加这道闸门会让每次点菜单都多打一个请求。 +let permissionsSynced = false + const { siteName, fetchSiteName } = useSiteName() const { fetchSettings: fetchPublicSettings } = usePublicSettings() const { start, done } = useTopProgress() @@ -87,6 +92,25 @@ router.beforeEach(async (to) => { return { name: 'AdminLogin', query: { redirect: to.fullPath } } } + // 本次会话首次进入受保护页面时,从服务端刷新一次有效权限。 + // 权限只在登录响应里下发过一次,之后由 localStorage 恢复:管理员改了某人的权限, + // 对方不重新登录就会一直按旧权限渲染菜单。这里补上刷新。 + // 注意 await:权限到位后再做下面的 meta.perm 判断,否则首屏可能被误判为越权。 + if (!permissionsSynced) { + permissionsSynced = true + await authStore.fetchMe() + } + + // 页面级权限校验。菜单已按权限过滤,这里拦的是直接输 URL 的情况。 + // 接口层本就会 403,但没有这道守卫,用户会先看到一个空白页再看到一片报错。 + if (to.meta.superOnly && !isSuperAdmin()) { + return { name: 'AdminForbidden' } + } + const requiredPerm = to.meta.perm as string | undefined + if (requiredPerm && !hasPermission(requiredPerm)) { + return { name: 'AdminForbidden' } + } + return true }) diff --git a/web/admin/src/stores/auth.ts b/web/admin/src/stores/auth.ts index 87d9318..bde3448 100644 --- a/web/admin/src/stores/auth.ts +++ b/web/admin/src/stores/auth.ts @@ -10,6 +10,13 @@ export interface AdminUser { role: string } +export interface AdminRoleBrief { + id: number + code: string + name: string + is_enabled: boolean +} + export interface PendingAgreement { id: number code: string @@ -23,6 +30,7 @@ interface LoginResponse { expires_at: number user: AdminUser permissions: string[] + roles?: AdminRoleBrief[] pending_agreements?: PendingAgreement[] } @@ -34,6 +42,7 @@ export const useAuthStore = defineStore('admin-auth', () => { const expiresAt = ref(null) const user = ref(null) const permissions = ref([]) + const roles = ref([]) const pendingAgreements = ref([]) const rememberMe = ref(getRememberMe()) @@ -48,6 +57,7 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt: expiresAt.value, user: user.value, permissions: permissions.value, + roles: roles.value, } localStorage.setItem(STORE_KEY, JSON.stringify(data)) } @@ -62,12 +72,17 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt: number | null user: AdminUser | null permissions: string[] + roles?: AdminRoleBrief[] } token.value = data.token refreshToken.value = data.refreshToken expiresAt.value = data.expiresAt user.value = data.user permissions.value = data.permissions ?? [] + roles.value = data.roles ?? [] + // 恢复 permission.ts 读取的镜像副本:hasPermission() 走 localStorage, + // 只恢复 Pinia 而不同步这份镜像,会让刷新后的按钮级权限全部失效 + setAdminSession(data.user?.role ?? '', permissions.value) } catch { // corrupted data — ignore } @@ -79,6 +94,7 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt.value = loginRes.expires_at user.value = loginRes.user permissions.value = loginRes.permissions ?? [] + roles.value = loginRes.roles ?? [] setTokens({ accessToken: loginRes.access_token, @@ -120,6 +136,7 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt.value = null user.value = null permissions.value = [] + roles.value = [] pendingAgreements.value = [] clearTokens() @@ -152,6 +169,36 @@ export const useAuthStore = defineStore('admin-auth', () => { hydrate() } + /** + * 从服务端拉取当前用户的最新权限。 + * + * 权限此前只在登录响应里下发一次,之后由 localStorage 恢复:管理员改了某人的权限, + * 对方不重新登录就一直按旧权限渲染菜单。在应用启动与 token 刷新后调用本方法即可跟上。 + * + * 失败时保持现有权限不变 —— 后端鉴权以服务端为准,前端权限只影响展示, + * 一次网络抖动不该把用户的菜单清空。 + */ + async function fetchMe(): Promise { + if (!token.value) return + try { + const { data } = await request.get('/admin/auth/me') + const me = data?.data + if (!me) return + user.value = { + id: me.id, + username: me.username, + display_name: me.display_name, + role: me.role, + } + permissions.value = me.permissions ?? [] + roles.value = me.roles ?? [] + setAdminSession(me.role, permissions.value) + persist() + } catch { + // 保持现有权限 + } + } + // Sync Pinia store when Axios interceptor refreshes tokens onTokenRefreshed((tokens) => { token.value = tokens.accessToken @@ -176,6 +223,7 @@ export const useAuthStore = defineStore('admin-auth', () => { expiresAt, user, permissions, + roles, pendingAgreements, rememberMe, isLoggedIn, @@ -186,6 +234,7 @@ export const useAuthStore = defineStore('admin-auth', () => { logoutLocal, refreshTokens, loadFromStorage, + fetchMe, clearPendingAgreements, } }) diff --git a/web/admin/src/utils/permission.ts b/web/admin/src/utils/permission.ts index dbf1c70..dfa7049 100644 --- a/web/admin/src/utils/permission.ts +++ b/web/admin/src/utils/permission.ts @@ -28,6 +28,18 @@ export function hasPermission(permission: string): boolean { return permissions.includes(permission) } +/** + * 是否超级管理员。 + * + * 用于「不参与权限点授权、硬性限定超管」的功能(如角色权限管理): + * 这类功能是权限体系的元操作,不能通过授予权限点下放,否则被下放者 + * 能给自己挂上更高权限的角色。后端有对应的 superAdminOnlyRules 拦截, + * 前端这份判断只用于隐藏入口,不构成安全边界。 + */ +export function isSuperAdmin(): boolean { + return hasRole(ADMIN_ROLES.SUPER_ADMIN) +} + export function hasRole(role: string): boolean { const currentRole = getStoredString(ADMIN_ROLE_KEY) return currentRole === role diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 6250eeb..53c138e 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -1,5 +1,5 @@ + + + + diff --git a/web/admin/src/views/LoginPage.vue b/web/admin/src/views/LoginPage.vue index dfdd753..df6a75f 100644 --- a/web/admin/src/views/LoginPage.vue +++ b/web/admin/src/views/LoginPage.vue @@ -198,7 +198,7 @@ async function handle2FAVerify() { @@ -307,7 +307,7 @@ async function handle2FAVerify() { diff --git a/web/admin/src/views/monitor/ChannelErrorsPage.vue b/web/admin/src/views/monitor/ChannelErrorsPage.vue index eb1ab1d..40d54ff 100644 --- a/web/admin/src/views/monitor/ChannelErrorsPage.vue +++ b/web/admin/src/views/monitor/ChannelErrorsPage.vue @@ -1,11 +1,12 @@ + + diff --git a/web/admin/src/views/security/SessionsPage.vue b/web/admin/src/views/security/SessionsPage.vue index 471bdce..f842a1b 100644 --- a/web/admin/src/views/security/SessionsPage.vue +++ b/web/admin/src/views/security/SessionsPage.vue @@ -8,6 +8,9 @@ import PageHeader from '@/components/PageHeader.vue' import TableStats from '@/components/TableStats.vue' import ResponsiveTable from '@/components/ResponsiveTable.vue' import request from '@/utils/request' +import { useAuthStore } from '@/stores/auth' + +const authStore = useAuthStore() const loading = ref(false) const data = ref([]) @@ -71,6 +74,8 @@ const columns: TableColumnData[] = [ fixed: 'right', render({ record }) { if (record.is_current) return null + // 超管会话仅账号本人可撤销(后端 assertCanOperateSessions 同规则兜底),其他人只能查看 + if (record.role === 'super_admin' && record.user_id !== authStore.user?.id) return null return h(Popconfirm, { content: '确定撤销该会话?', onOk: () => revokeSession(record.id), diff --git a/web/admin/src/views/system/FilesPage.vue b/web/admin/src/views/system/FilesPage.vue index c920ce0..02760d7 100644 --- a/web/admin/src/views/system/FilesPage.vue +++ b/web/admin/src/views/system/FilesPage.vue @@ -415,7 +415,9 @@ async function submitExport() { onMounted(() => { fetchStats() fetchList() - fetchRetention() + // 保留策略读的是系统设置接口(system:view/system:edit 权限域), + // 页面本身只要求 file:view —— 无权限时不请求,避免进页面就弹 403 + if (hasPermission('system:edit')) fetchRetention() }) @@ -442,7 +444,7 @@ onMounted(() => { 手动清理过期文件 - + 触发数据导出 diff --git a/web/admin/src/views/system/HelpArticlesPage.vue b/web/admin/src/views/system/HelpArticlesPage.vue index ff62c43..8445cd2 100644 --- a/web/admin/src/views/system/HelpArticlesPage.vue +++ b/web/admin/src/views/system/HelpArticlesPage.vue @@ -1,11 +1,14 @@