Merge branch 'main' into dev

This commit is contained in:
chengqian committed 2026-09-23 18:26:13 +08:00
commit 225cdd1db1
769 files changed
+69302 -15508

No files matched your search

+12
View File
@@ -16,6 +16,11 @@ build/
# IDE
.idea/
# AI
.claude
.codegraph
.workbuddy
# Environment files with sensitive data
.env
.env.local
@@ -41,3 +46,10 @@ sub2api/
hack/config.yaml
manifest/config/config.yaml
# 防误写保险:语料默认输出在仓库外(../team-api-corpus / TEAM_API_CORPUS_DIR),
# 万一有人 -o 指回仓库内也不至于误提交。见 docs/协议转换语料采集手册.md §4
relaykit/relayconvert/register/golden/real_inputs/
relaykit/relayconvert/register/golden/real_stream_inputs/
relaykit/relayconvert/register/golden/real_responses/
relaykit/relayconvert/register/golden/corpus_manifest.json
+9 -5
View File
@@ -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 = ?`)。新增查询漏写该条件即直接跨租户,评审时必须逐条核对。
### 路由前缀
@@ -271,8 +269,9 @@ cmd(路由注册)
钱包资金状态采用 **Redis 唯一实时权威 + DB 滞后物化视图** 架构(详见 [`docs/钱包Redis权威化设计.md`](docs/钱包Redis权威化设计.md)):
- **Redis 权威**:`wallet:v2:{tenant_id}` hash(balance / frozen_balance / ver,整数 micro-USD)是余额的唯一实时真相。所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)一律通过 `internal/logic/billing/wallet.go` 的 Lua 脚本原子完成,**任何 Go 代码不得直接读改写 Redis 钱包 hash 或从 DB 反向重建它**。
- **DB 物化**:`bil_wallets.balance / frozen_balance` 只是展示/报表/灾难恢复用的滞后副本,由后台物化器每 `billing_wallet_materialize_interval_ms`(默认 5000ms)从 Redis 全量覆盖。**禁止**把 DB 钱包余额当作实时判断依据(如余额门槛、防超扣闸门)。
- **Redis 权威**:`wallet:v2:{tenant_id}` hash(balance / frozen_balance / total_consumed / ver,整数 micro-USD)是余额的唯一实时真相。所有资金变动(预扣冻结、结算扣款、解冻、充值/退款/调账)一律通过 `internal/logic/billing/wallet.go` 的 Lua 脚本原子完成,**任何 Go 代码不得直接读改写 Redis 钱包 hash 或从 DB 反向重建它**。
- **累计消费(total_consumed)只随结算事件变动**:`settleClaimLua` 与扣款同一原子 `HINCRBY` 递增;结算记流水失败的补偿逆转走专用 `ReverseConsumeRedis`(余额与计数同一原子回退)。充值/兑换/调账/退款/预扣/解冻一律**不得**触碰该计数(`CreditWalletRedis`/`DebitWalletRedis` 不感知计数)。字段基线由三层补种保证(boot 种子 `SeedWalletTotalConsumed` / 灾备重建 `rebuildWalletFromDB` / 物化器自愈 `materializeOneWallet`),存量历史从 `bil_transactions(type=consume)` 回填(幂等可重放)。
- **DB 物化**:`bil_wallets.balance / frozen_balance / total_consumed` 只是展示/报表/灾难恢复用的滞后副本,由后台物化器每 `billing_wallet_materialize_interval_ms`(默认 5000ms)从 Redis 全量覆盖。**禁止**把 DB 钱包余额当作实时判断依据(如余额门槛、防超扣闸门)。
- **预扣记录纯 Redis**:`bil_prededuct_tracks` 表已废弃(预扣明细 = `prededuct:v2:{request_id}` hash + `prededuct_active:{tenant}` 集合,TTL 2h),孤儿清扫由 cron `prededuct_sweep` 每 2 分钟按「重算 frozen = Σ 幸存预扣」自愈,不再依赖 DB 表。
- **可用余额** = balance - frozen_balance,预扣/扣款/退款门槛都在 Redis Lua 内原子判断。
- **幂等闸门**:结算幂等靠 `bil_records.request_id` 唯一约束(DB,先行于任何资金变动);预扣/解冻幂等靠 Redis hash 认领即删。
@@ -474,6 +473,7 @@ cmd(路由注册)
| 对比分析 | `docs/new-api-vs-sub2api对比分析报告.md` | 两者差异 |
| 大模型实施方案 | `docs/大模型完整实施方案-v2.1.md` | 大模型代理层设计 |
| API 格式参考 | `docs/reference/api-format-reference.md` | JSON 示例、SSE 格式、错误映射、中间件差异 |
| 协议转换语料采集 | `docs/协议转换语料采集手册.md` | `corpus-extract` 命令用法:从渠道调试日志提取真实流量语料喂给协议转换测试 |
| GoFrame 使用规范 | `docs/reference/goframe-conventions.md` | 框架使用规范 + 已修复的框架 bug 记录 |
@@ -491,3 +491,7 @@ cmd(路由注册)
| `gf gen pb` | 生成 protobuf 文件 | 需要时 |
| `gf run` | 热编译运行开发服务器 | 开发调试 |
| `gf build` | 编译生产二进制 | 构建部署 |
## AI工具要求
不允许在提交内容中加入AI工具相关的身份表示和内容,不允许将AI作为仓库贡献者提交
+8 -2
View File
@@ -1,5 +1,11 @@
Team-API
Copyright (C) 2026 qianfree <406615373@qq.com>
Team-API Community Edition
Copyright (C) 2026 CHENGQIAN WU (qianfree) <wuchengqian5@163.com>
This copy of Team-API is offered under the GNU Affero General Public License
version 3 or any later version. Additional commercial licensing rights, such as
waivers of AGPL obligations or contractual SaaS/OEM/resale rights, are granted
only under a separate written agreement with the copyright holder; this file
does not grant those additional rights.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
+6 -2
View File
@@ -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**。
商业授权不转让本项目的著作权、专利权、商标权或其他知识产权,具体权利以双方签署的书面协议为准。
## 致谢
+180
View File
@@ -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 类许可证。
+15 -1
View File
@@ -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)
@@ -50,6 +52,7 @@ type IAdminV1 interface {
ChangelogDelete(ctx context.Context, req *v1.ChangelogDeleteReq) (res *v1.ChangelogDeleteRes, err error)
ChangelogPublish(ctx context.Context, req *v1.ChangelogPublishReq) (res *v1.ChangelogPublishRes, err error)
ChannelList(ctx context.Context, req *v1.ChannelListReq) (res *v1.ChannelListRes, err error)
ChannelOptions(ctx context.Context, req *v1.ChannelOptionsReq) (res *v1.ChannelOptionsRes, err error)
ChannelCreate(ctx context.Context, req *v1.ChannelCreateReq) (res *v1.ChannelCreateRes, err error)
ChannelUpdate(ctx context.Context, req *v1.ChannelUpdateReq) (res *v1.ChannelUpdateRes, err error)
ChannelDelete(ctx context.Context, req *v1.ChannelDeleteReq) (res *v1.ChannelDeleteRes, err error)
@@ -74,6 +77,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)
@@ -88,6 +92,7 @@ type IAdminV1 interface {
AdminDashboardRecentAlerts(ctx context.Context, req *v1.AdminDashboardRecentAlertsReq) (res *v1.AdminDashboardRecentAlertsRes, err error)
AdminDashboardModelHourly(ctx context.Context, req *v1.AdminDashboardModelHourlyReq) (res *v1.AdminDashboardModelHourlyRes, err error)
AdminUsageLogList(ctx context.Context, req *v1.AdminUsageLogListReq) (res *v1.AdminUsageLogListRes, err error)
AdminUsageLogDetail(ctx context.Context, req *v1.AdminUsageLogDetailReq) (res *v1.AdminUsageLogDetailRes, err error)
AdminUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (res *v1.AdminUsageLogSummaryRes, err error)
AdminBillingRecordList(ctx context.Context, req *v1.AdminBillingRecordListReq) (res *v1.AdminBillingRecordListRes, err error)
AdminWalletList(ctx context.Context, req *v1.AdminWalletListReq) (res *v1.AdminWalletListRes, err error)
@@ -206,7 +211,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 +238,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 +260,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)
+6
View File
@@ -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 {
+11
View File
@@ -71,6 +71,7 @@ type RequestAuditLogListReq struct {
TaskId string `json:"task_id" dc:"异步任务ID(精确匹配)"`
Method string `json:"method" dc:"HTTP 方法"`
Path string `json:"path" dc:"请求路径"`
Model string `json:"model" dc:"模型名称(精确匹配)"`
StatusCode int `json:"status_code" dc:"状态码"`
StartDate string `json:"start_date" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"`
EndDate string `json:"end_date" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"`
@@ -142,3 +143,13 @@ type ContentFilterLogListRes struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// ContentFilterLogClearReq 清空全部内容过滤拦截日志(硬删除,用于日志大量堆积时快速释放数据库空间)
type ContentFilterLogClearReq struct {
g.Meta `path:"/audit/content-filter-logs/clear" method:"delete" mime:"json" tags:"管理后台-审计" summary:"清空内容过滤拦截日志(硬删除全部)"`
}
// ContentFilterLogClearRes 清空内容过滤拦截日志响应
type ContentFilterLogClearRes struct {
Deleted int64 `json:"deleted" dc:"删除的记录数"`
}
+31 -5
View File
@@ -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 踢出指定会话请求
+21
View File
@@ -51,6 +51,24 @@ type ChannelItem struct {
WorstModelScore float64 `json:"worst_model_score"`
}
// ChannelOptionsReq 渠道选项列表请求(下拉选择专用,不分页)
type ChannelOptionsReq struct {
g.Meta `path:"/channels/options" method:"get" mime:"json" tags:"管理后台-渠道" summary:"渠道选项列表(不分页)"`
}
// ChannelOptionsRes 渠道选项列表响应
type ChannelOptionsRes struct {
List []ChannelOptionItem `json:"list"`
}
// ChannelOptionItem 渠道选项项(精简字段)
type ChannelOptionItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type int `json:"type"`
Status string `json:"status"`
}
// ChannelCreateReq 创建渠道请求
type ChannelCreateReq struct {
g.Meta `path:"/channels" method:"post" mime:"json" tags:"管理后台-渠道" summary:"创建渠道"`
@@ -67,6 +85,7 @@ type ChannelCreateReq struct {
Remark string `json:"remark" dc:"备注"`
IsVIP bool `json:"is_vip" d:"false" dc:"是否VIP专属渠道"`
UseProxy bool `json:"use_proxy" d:"false" dc:"启用代理"`
WebSearchToGoogleSearch bool `json:"web_search_to_google_search" d:"false" dc:"web_search→googleSearch 映射(Gemini 渠道,搜索按次另行计价)"`
SharingThreshold float64 `json:"sharing_threshold" d:"0.6" dc:"普通租户借用阈值"`
PreemptionThreshold float64 `json:"preemption_threshold" d:"0.8" dc:"VIP抢占阈值"`
BorrowingCooldownSeconds int `json:"borrowing_cooldown_seconds" d:"30" dc:"被抢占后冷却时间(秒)"`
@@ -95,6 +114,7 @@ type ChannelUpdateReq struct {
StrictCapacity *bool `json:"strict_capacity" dc:"严格容量(fail-closed)"`
IsVIP *bool `json:"is_vip" dc:"是否VIP专属渠道"`
UseProxy *bool `json:"use_proxy" dc:"启用代理"`
WebSearchToGoogleSearch *bool `json:"web_search_to_google_search" dc:"web_search→googleSearch 映射(Gemini 渠道,留空不更新)"`
DebugLogEnabled *bool `json:"debug_log_enabled" dc:"启用渠道调试日志(记录四段完整报文,排障用,用完及时关闭)"`
DebugLogTenantID *int64 `json:"debug_log_tenant_id" dc:"调试目标租户ID过滤(0=不限)"`
DebugLogUserID *int64 `json:"debug_log_user_id" dc:"调试目标成员ID过滤(0=不限)"`
@@ -133,6 +153,7 @@ type ChannelDetailRes struct {
Remark string `json:"remark"`
IsVIP bool `json:"is_vip"`
UseProxy bool `json:"use_proxy"`
WebSearchToGoogleSearch bool `json:"web_search_to_google_search"`
DebugLogEnabled bool `json:"debug_log_enabled"`
DebugLogTenantID int64 `json:"debug_log_tenant_id"`
DebugLogUserID int64 `json:"debug_log_user_id"`
+12 -3
View File
@@ -1,6 +1,10 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
import (
"encoding/json"
"github.com/gogf/gf/v2/frame/g"
)
// 渠道调试日志:调试开关开启的渠道,per-attempt 记录客户端↔系统↔上游四段完整报文。
// 端点全部为 GET/DELETE,路径在 /channels/ 下,自动命中 rbac 前缀规则
@@ -50,9 +54,14 @@ type ChannelDebugLogDetailReq struct {
ID int64 `json:"id" in:"path" v:"required" dc:"记录ID"`
}
// ChannelDebugLogDetailRes 渠道调试日志详情响应
// ChannelDebugLogDetailRes 渠道调试日志详情响应:详情对象平铺进统一响应的 data 字段
// (json:"-" + 自定义 MarshalJSON),避免统一包装后再多包一层 data
type ChannelDebugLogDetailRes struct {
Data map[string]any `json:"data"`
Data map[string]any `json:"-"`
}
func (r *ChannelDebugLogDetailRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
// ChannelDebugLogDeleteReq 删除单条调试日志请求(硬删除)
+22 -2
View File
@@ -1,6 +1,10 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
import (
"encoding/json"
"github.com/gogf/gf/v2/frame/g"
)
// === 渠道错误监控 ===
@@ -61,6 +65,22 @@ type ChannelErrorCategoriesReq struct {
g.Meta `path:"/monitor/channel-errors/categories" method:"get" mime:"json" tags:"管理后台-监控" summary:"错误分类选项"`
}
// ChannelErrorCategoriesRes 分类选项平铺进统一响应的 data 字段(json:"-" + 自定义
// MarshalJSON),避免统一包装后再多包一层 data
type ChannelErrorCategoriesRes struct {
Data []map[string]string `json:"data"`
Data []map[string]string `json:"-"`
}
func (r *ChannelErrorCategoriesRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.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:"删除的记录数"`
}
+71 -8
View File
@@ -1,6 +1,8 @@
package v1
import (
"encoding/json"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
@@ -123,14 +125,16 @@ type AdminUsageLogFilter struct {
ID int64 `json:"id" dc:"用量记录ID"`
TenantID int64 `json:"tenant_id" dc:"租户ID"`
UserID int64 `json:"user_id" dc:"用户ID"`
Username string `json:"username" dc:"用户名(模糊匹配)"`
ApiKeyID int64 `json:"api_key_id" dc:"API Key ID"`
ChannelID int64 `json:"channel_id" dc:"渠道ID"`
Model string `json:"model" dc:"模型名称"`
Status string `json:"status" dc:"状态"`
RequestType int `json:"request_type" dc:"请求类型: 1=同步, 2=流式, 3=异步, 4=WebSocket"`
StartDate string `json:"start_date" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"`
EndDate string `json:"end_date" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"`
UpstreamRequestId string `json:"upstream_request_id" dc:"上游请求ID(精确匹配,排障反查用)"`
StartDate string `json:"start_date" dc:"开始时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"`
EndDate string `json:"end_date" dc:"结束时间(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss)"`
}
type AdminUsageLogListReq struct {
@@ -140,6 +144,54 @@ type AdminUsageLogListReq struct {
AdminUsageLogFilter `json:",inline"` // 嵌入共用筛选条件
}
// AdminUsageLogListItem 用量日志列表项:仅表格列与悬浮明细(Token / 费用分解)所需字段。
// 纯关联 ID(user_id / project_id / channel_id)与排查大字段(billing_snapshot、
// user_agent、error_message、request_id 等)不进列表,统一经详情接口按需获取。
type AdminUsageLogListItem struct {
Id int64 `json:"id"`
TenantId int64 `json:"tenant_id"`
TenantName string `json:"tenant_name"`
Username string `json:"username"`
ProjectName string `json:"project_name"`
ApiKeyId int64 `json:"api_key_id"`
ApiKeyName string `json:"api_key_name"`
ChannelName string `json:"channel_name"`
ModelName string `json:"model_name"`
UpstreamModel string `json:"upstream_model"`
RequestType int `json:"request_type"`
BillingMode string `json:"billing_mode"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationTokens int `json:"cache_creation_tokens"`
CacheReadTokens int `json:"cache_read_tokens"`
CacheCreation5mTokens int `json:"cache_creation_5m_tokens"`
CacheCreation1hTokens int `json:"cache_creation_1h_tokens"`
ReasoningTokens int `json:"reasoning_tokens"`
AudioInputTokens int `json:"audio_input_tokens"`
AudioOutputTokens int `json:"audio_output_tokens"`
ImageOutputTokens int `json:"image_output_tokens"`
InputCost float64 `json:"input_cost"`
OutputCost float64 `json:"output_cost"`
CacheCreationCost float64 `json:"cache_creation_cost"`
CacheReadCost float64 `json:"cache_read_cost"`
TotalCost float64 `json:"total_cost"`
ActualCost float64 `json:"actual_cost"`
RateMultiplier float64 `json:"rate_multiplier"`
LatencyMs int `json:"latency_ms"`
FirstTokenMs int `json:"first_token_ms"`
Status string `json:"status"`
RetryIndex int `json:"retry_index"`
CreatedAt *gtime.Time `json:"created_at"`
}
type AdminUsageLogListRes struct {
List []*AdminUsageLogListItem `json:"list"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// AdminUsageLogItem 用量日志全量字段结构体(详情接口专用,列表用上方的精简 ListItem)
type AdminUsageLogItem struct {
Id int64 `json:"id"`
TenantId int64 `json:"tenant_id"`
@@ -198,14 +250,25 @@ type AdminUsageLogItem struct {
InboundEndpoint string `json:"inbound_endpoint"`
RequestId string `json:"request_id"`
TaskId string `json:"task_id"`
UpstreamRequestId string `json:"upstream_request_id"`
CreatedAt *gtime.Time `json:"created_at"`
}
type AdminUsageLogListRes struct {
List []*AdminUsageLogItem `json:"list"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
// AdminUsageLogDetailReq 用量日志详情(列表只返回表格展示字段,
// billing_snapshot / billing_summary / user_agent / error_message 等大字段经此接口按需获取)
type AdminUsageLogDetailReq struct {
g.Meta `path:"/usage-logs/{id}" method:"get" mime:"json" tags:"管理后台-用量" summary:"用量日志详情"`
Id int64 `json:"id" in:"path" v:"min:1" dc:"用量记录ID"`
}
// AdminUsageLogDetailRes 详情对象平铺进统一响应的 data 字段(json:"-" + 自定义 MarshalJSON,
// 与 RequestAuditLogDetailRes 同一模式),避免统一包装后再多包一层 data
type AdminUsageLogDetailRes struct {
Data *AdminUsageLogItem `json:"-"`
}
func (r *AdminUsageLogDetailRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
// AdminUsageLogSummaryReq 用量日志统计请求(复用筛选条件,不包含分页)
+7 -1
View File
@@ -58,8 +58,14 @@ type ErrorLogStatsReq struct {
g.Meta `path:"/error-logs/stats" method:"get" mime:"json" tags:"管理后台-系统错误" summary:"错误统计"`
}
// ErrorLogStatsRes 统计对象平铺进统一响应的 data 字段(json:"-" + 自定义 MarshalJSON),
// 避免统一包装后再多包一层 data
type ErrorLogStatsRes struct {
Data map[string]any `json:"data"`
Data map[string]any `json:"-"`
}
func (r *ErrorLogStatsRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
// ErrorLogClearReq 清空全部错误日志(硬删除,用于错误大量堆积时快速释放数据库空间)
+4 -4
View File
@@ -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:"状态"`
+117 -58
View File
@@ -1,6 +1,10 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
import (
"encoding/json"
"github.com/gogf/gf/v2/frame/g"
)
// ModelListReq 模型列表请求
type ModelListReq struct {
@@ -8,6 +12,7 @@ type ModelListReq struct {
Page int `json:"page" d:"1" v:"min:1" dc:"页码"`
PageSize int `json:"page_size" d:"20" v:"min:1|max:100" dc:"每页数量"`
Category string `json:"category" dc:"模型分类筛选:chat/embedding/image/audio/rerank/video"`
Vendor string `json:"vendor" dc:"研发厂商筛选:openai/anthropic/google/alibaba/deepseek等,空=不过滤"`
Status string `json:"status" dc:"状态筛选:active/deprecated/offline"`
Search string `json:"search" dc:"搜索关键词(模型名或显示名)"`
PricingStatus string `json:"pricing_status" dc:"定价状态筛选:priced/unpriced"`
@@ -34,6 +39,7 @@ type ModelItem struct {
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Vendor string `json:"vendor"` // 研发厂商(空=未分类)
Status string `json:"status"`
MaxContext int `json:"max_context_tokens"`
MaxOutput int `json:"max_output_tokens"`
@@ -45,11 +51,12 @@ type ModelItem struct {
DeprecatedAt *string `json:"deprecated_at"`
SunsetDate *string `json:"sunset_date"`
ReplacementModel string `json:"replacement_model"`
// 定价摘要(来自 mdl_pricing,min_tokens=0 的基准行)
PricingMode string `json:"pricing_mode"` // "" | "token" | "per_request" | "tiered"
InputPrice float64 `json:"input_price"` // $/1M tokens
OutputPrice float64 `json:"output_price"` // $/1M tokens
PerRequestPrice float64 `json:"per_request_price"` // $/request(按次计费模式)
// 定价摘要(来自 mdl_pricing 的 pricing JSONB)
PricingMode string `json:"pricing_mode"` // "" | "token" | "per_request" | "tiered" | "per_second"
InputPrice float64 `json:"input_price"` // $/1M tokens
PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"` // 规格→$/秒(仅 per_second 模式)
OutputPrice float64 `json:"output_price"` // $/1M tokens
PerRequestPrice float64 `json:"per_request_price"` // $/request(按次计费模式)
// 可用渠道列表(来自 chn_abilities + chn_channels)
Channels []ModelChannelInfo `json:"channels"`
}
@@ -60,6 +67,7 @@ type ModelCreateReq struct {
ModelId string `json:"model_id" v:"required|length:1,100#请输入模型标识|模型标识长度1-100" dc:"模型唯一标识"`
ModelName string `json:"model_name" dc:"模型显示名称"`
Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类必须是 chat/embedding/image/audio/rerank/video" dc:"模型分类"`
Vendor string `json:"vendor" v:"in:openai,anthropic,google,xai,mistral,cohere,meta,alibaba,bytedance,deepseek,zhipu,moonshot,minimax,baidu,tencent,xunfei,kuaishou,midjourney,suno#厂商值不合法" dc:"研发厂商(空=未分类)"`
MaxContext int `json:"max_context_tokens" dc:"最大上下文 token 数"`
MaxOutput int `json:"max_output_tokens" dc:"最大输出 token 数"`
Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"`
@@ -74,10 +82,12 @@ type ModelCreateRes struct {
// ModelUpdateReq 更新模型请求
type ModelUpdateReq struct {
g.Meta `path:"/models/{id}" method:"put" mime:"json" tags:"管理后台-模型" summary:"更新模型"`
ID int64 `json:"id" in:"path" v:"required" dc:"模型ID"`
ModelName string `json:"model_name" dc:"模型显示名称"`
Category string `json:"category" v:"in:chat,embedding,image,audio,rerank,video" dc:"模型分类"`
g.Meta `path:"/models/{id}" method:"put" mime:"json" tags:"管理后台-模型" summary:"更新模型"`
ID int64 `json:"id" in:"path" v:"required" dc:"模型ID"`
ModelName string `json:"model_name" dc:"模型显示名称"`
Category string `json:"category" v:"in:chat,embedding,image,audio,rerank,video" dc:"模型分类"`
// Vendor 研发厂商;指针语义:nil=不更新,空串=清空回未分类,非空=设置(枚举校验在 logic 层做)
Vendor *string `json:"vendor" dc:"研发厂商"`
MaxContext int `json:"max_context_tokens" dc:"最大上下文 token 数"`
MaxOutput int `json:"max_output_tokens" dc:"最大输出 token 数"`
Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"`
@@ -94,19 +104,20 @@ type ModelDeleteReq struct {
ID int64 `json:"id" in:"path" v:"required" dc:"模型ID"`
}
// PricingItem 定价项(支持按次/按量/阶梯)
// PricingItem 定价项(支持按次/按量/阶梯/按秒/特殊方案)
type PricingItem struct {
BillingMode string `json:"billing_mode" v:"required|in:token,per_request,tiered" dc:"计费模式"`
MinTokens int64 `json:"min_tokens" dc:"阶梯起始 token 数"`
MaxTokens *int64 `json:"max_tokens" dc:"阶梯结束 token 数(NULL=无上限)"`
InputPrice float64 `json:"input_price" dc:"每 1M input token 价格"`
OutputPrice float64 `json:"output_price" dc:"每 1M output token 价格"`
PerRequestPrice *float64 `json:"per_request_price" dc:"按次单价(仅 per_request)"`
CacheReadPrice float64 `json:"cache_read_price" dc:"缓存读取每 1M token 价格"`
CacheCreationPrice float64 `json:"cache_creation_price" dc:"缓存创建每 1M token 价格"`
BillingMode string `json:"billing_mode" v:"required|in:token,per_request,tiered,per_second,special" dc:"计费模式(special 仅由特殊计费方案编辑器提交,不进通用模式下拉)"`
MinTokens int64 `json:"min_tokens" dc:"阶梯起始 token 数"`
MaxTokens *int64 `json:"max_tokens" dc:"阶梯结束 token 数(NULL=无上限)"`
InputPrice float64 `json:"input_price" dc:"每 1M input token 价格"`
OutputPrice float64 `json:"output_price" dc:"每 1M output token 价格"`
PerRequestPrice *float64 `json:"per_request_price" dc:"按次单价(仅 per_request)"`
CacheReadPrice float64 `json:"cache_read_price" dc:"缓存读取每 1M token 价格"`
CacheCreationPrice float64 `json:"cache_creation_price" dc:"缓存创建每 1M token 价格"`
PerSecondPrices map[string]float64 `json:"per_second_prices" dc:"按秒单价矩阵(仅 per_second):分辨率规格→每秒单价(本位币),\"*\"为兜底价,如 {\"480p\":0.25,\"720p\":0.5,\"*\":0.5}"`
}
// TimeSegmentItem 时段定价项(mdl_pricing.time_segments JSONB 数组元素)。
// TimeSegmentItem 时段定价项(mdl_pricing.pricing JSONB 顶层 time_segments 数组元素)。
// 语义:按数组顺序先命中先生效(促销时段放前面覆盖常驻时段),未命中=默认价(乘数 1.0);
// 最终费用 = 各项小计 × 租户乘数 × 时段乘数。
type TimeSegmentItem struct {
@@ -119,6 +130,22 @@ type TimeSegmentItem struct {
Multiplier float64 `json:"multiplier" v:"required|min:0.0001|max:10" dc:"价格乘数,0.5=半价"`
}
// ParamConditionItem 参数倍率匹配条件(mdl_pricing.pricing JSONB 顶层 param_multipliers 数组元素)。
// 匹配对象为网关归一化后的任务体({model, prompt, seconds, metadata{...}}),
// 支持数组通配([*],任一元素命中即命中),字符串数字自动强转参与比较。
type ParamConditionItem struct {
Path string `json:"path" v:"required" dc:"任务体 JSON 路径,如 metadata.image / seconds / metadata.content[*].video_url"`
Match string `json:"match" v:"required|in:has_value,equals,gt,gte,lt,lte,between" dc:"匹配方式"`
Value any `json:"value,omitempty" dc:"比对值:equals/gt/gte/lt/lte 单值;between 为 [min,max];has_value 不填"`
}
// ParamMultiplierItem 参数倍率规则:conditions 全部满足(隐式与)时倍率计入连乘;规则间连乘。
type ParamMultiplierItem struct {
Conditions []ParamConditionItem `json:"conditions" v:"required" dc:"条件列表(全部满足才命中)"`
Multiplier float64 `json:"multiplier" v:"required|min:0.0001|max:100" dc:"命中倍率,1.5=加价50%"`
Note string `json:"note" dc:"命中说明(写入计费上下文供对账解释)"`
}
// PricingGetReq 获取模型定价
type PricingGetReq struct {
g.Meta `path:"/models/{model_id}/pricing" method:"get" mime:"json" tags:"管理后台-模型" summary:"获取模型定价"`
@@ -126,24 +153,45 @@ type PricingGetReq struct {
}
type PricingGetRes struct {
List []PricingItem `json:"list"`
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(锚点行 time_segments)"`
// 以下为锚点行展示字段(仅 min_tokens=0 行生效)
List []PricingItem `json:"list"`
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(pricing JSONB 顶层)"`
ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(pricing JSONB 顶层,横切所有计费模式)"`
// 官方参考定价(official_pricing JSONB 还原,结构与计费定价完全一致;OfficialItems nil=未配置)。
// 非计费依据,管理端按官方价 × 折扣快速定价 / 计算现价几折以此为准
OfficialItems []PricingItem `json:"official_items" dc:"官方参考定价项(tiered 展开为多行;nil=未配置)"`
OfficialTimeSegments []TimeSegmentItem `json:"official_time_segments" dc:"官方参考-时段定价"`
OfficialParamMultipliers []ParamMultiplierItem `json:"official_param_multipliers" dc:"官方参考-参数倍率规则"`
// 以下为定价行展示字段
PriceNote string `json:"price_note" dc:"价格说明(仅管理后台可见的内部备注)"`
DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示,如 7折起)"`
PriceChangeNote string `json:"price_change_note" dc:"价格调整说明(对外展示,提示价格有变动)"`
// 计费方案:空 = 通用引擎(前端渲染内置通用表单);非空时前端分发到方案专属编辑器
Scheme string `json:"scheme" dc:"计费方案名(空=通用引擎)"`
// 方案私有配置(不透明容器):schema 由方案自定义,通用表单不理解其内容
SchemeConfig json.RawMessage `json:"scheme_config,omitempty" dc:"计费方案私有配置(随方案编辑器解析)"`
}
// PricingSetReq 设置模型定价(全量替换)
type PricingSetReq struct {
g.Meta `path:"/models/{model_id}/pricing" method:"put" mime:"json" tags:"管理后台-模型" summary:"设置模型定价"`
ModelID int64 `json:"model_id" in:"path" v:"required" dc:"模型ID"`
Items []PricingItem `json:"items" v:"required" dc:"定价列表"`
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价(可选,全量替换;空数组清除时段配置)"`
// 以下为锚点行展示字段(全量替换语义:空=清除;price_note 不透出到租户端)
g.Meta `path:"/models/{model_id}/pricing" method:"put" mime:"json" tags:"管理后台-模型" summary:"设置模型定价"`
ModelID int64 `json:"model_id" in:"path" v:"required" dc:"模型ID"`
Items []PricingItem `json:"items" v:"required" dc:"定价列表"`
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价(可选,全量替换;空数组清除时段配置)"`
ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(可选,全量替换;空数组清除)"`
// 官方参考定价(可选,结构与计费定价完全一致,支持全部四种计费模式 + 时段/倍率):
// OfficialItems nil=本次不动库内官方定价;非 nil=全量替换(三个数组全空=清除)。金额本位币,非计费依据
OfficialItems []PricingItem `json:"official_items" dc:"官方参考定价项(nil=不修改;非 nil=全量替换,与另两个官方数组全空=清除)"`
OfficialTimeSegments []TimeSegmentItem `json:"official_time_segments" dc:"官方参考-时段定价(随 official_items 一并全量替换)"`
OfficialParamMultipliers []ParamMultiplierItem `json:"official_param_multipliers" dc:"官方参考-参数倍率规则(随 official_items 一并全量替换)"`
// 以下为定价行展示字段(全量替换语义:空=清除;price_note 不透出到租户端)
PriceNote string `json:"price_note" dc:"价格说明(仅内部可见)"`
DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示)"`
PriceChangeNote string `json:"price_change_note" dc:"价格调整说明(对外展示)"`
// 计费方案(全量替换语义:空串=回落通用引擎并清除 scheme_config)。
// 必须是已注册方案(后端校验),自定义方案建议 custom: 前缀命名
Scheme string `json:"scheme" dc:"计费方案名(空=通用引擎并清除私有配置)"`
// 方案私有配置:仅 scheme 非空时允许携带,由方案实现的校验规则把关
SchemeConfig json.RawMessage `json:"scheme_config,omitempty" dc:"计费方案私有配置(方案自定义 schema)"`
}
type PricingSetRes struct{}
@@ -179,6 +227,7 @@ type ModelExportReq struct {
g.Meta `path:"/models/export" method:"get" mime:"json" tags:"管理后台-模型" summary:"导出模型列表"`
Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"`
Category string `json:"category" in:"query" dc:"模型分类筛选:chat/embedding/image/audio/rerank"`
Vendor string `json:"vendor" in:"query" dc:"研发厂商筛选,空=不过滤"`
Status string `json:"status" in:"query" dc:"状态筛选:active/deprecated/offline"`
Search string `json:"search" in:"query" dc:"搜索关键词(模型名或显示名)"`
}
@@ -253,22 +302,28 @@ type ModelImportPreviewRes struct {
Models []ModelImportPreviewItem `json:"models"`
}
// ModelImportPreviewItem 导入预览项(模型信息 + 冲突标记)
// ModelImportPreviewItem 导入预览项(模型信息 + 冲突标记)。
// 前端确认导入时把 preview 返回的 models 原样回传,因此该结构必须携带
// 全量可还原字段(含参数倍率与计费方案声明),缺字段会在回传链路被静默丢弃
type ModelImportPreviewItem struct {
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Status string `json:"status"`
MaxContextTokens int `json:"max_context_tokens"`
MaxOutputTokens int `json:"max_output_tokens"`
Capabilities map[string]bool `json:"capabilities"`
Description string `json:"description"`
Tags []string `json:"tags"`
SunsetDate string `json:"sunset_date"`
ReplacementModel string `json:"replacement_model"`
Pricing []PricingItem `json:"pricing"`
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表"`
Conflict string `json:"conflict"` // "" 或 "exists"
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Vendor string `json:"vendor"` // 研发厂商(空=未分类)
Status string `json:"status"`
MaxContextTokens int `json:"max_context_tokens"`
MaxOutputTokens int `json:"max_output_tokens"`
Capabilities map[string]bool `json:"capabilities"`
Description string `json:"description"`
Tags []string `json:"tags"`
SunsetDate string `json:"sunset_date"`
ReplacementModel string `json:"replacement_model"`
Pricing []PricingItem `json:"pricing"`
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表"`
ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(可选)"`
Scheme string `json:"scheme,omitempty" dc:"特殊计费方案名(特殊方案模型导出时携带)"`
SchemeConfig json.RawMessage `json:"scheme_config,omitempty" dc:"方案私有配置(scheme 非空时随方案透传)"`
Conflict string `json:"conflict"` // "" 或 "exists"
}
// ModelImportReq 确认导入模型请求
@@ -277,22 +332,26 @@ type ModelImportReq struct {
Models []ModelImportItem `json:"models" v:"required#请至少导入一个模型"`
}
// ModelImportItem 导入模型项
// ModelImportItem 导入模型项(含定价/时段/参数倍率,全量替换语义)
type ModelImportItem struct {
ModelId string `json:"model_id" v:"required" dc:"模型唯一标识"`
ModelName string `json:"model_name" dc:"模型显示名称"`
Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类无效" dc:"模型分类"`
Status string `json:"status" dc:"状态"`
MaxContextTokens int `json:"max_context_tokens" dc:"最大上下文 token 数"`
MaxOutputTokens int `json:"max_output_tokens" dc:"最大输出 token 数"`
Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"`
Description string `json:"description" dc:"模型描述"`
Tags []string `json:"tags" dc:"标签列表"`
SunsetDate string `json:"sunset_date" dc:"下线日期"`
ReplacementModel string `json:"replacement_model" dc:"推荐替代模型名"`
Pricing []PricingItem `json:"pricing" dc:"定价列表"`
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(可选)"`
ConflictAction string `json:"conflict_action" v:"in:skip,overwrite" dc:"冲突处理策略:skip/overwrite"`
ModelId string `json:"model_id" v:"required" dc:"模型唯一标识"`
ModelName string `json:"model_name" dc:"模型显示名称"`
Category string `json:"category" v:"required|in:chat,embedding,image,audio,rerank,video#请选择分类|分类无效" dc:"模型分类"`
Vendor string `json:"vendor" v:"in:openai,anthropic,google,xai,mistral,cohere,meta,alibaba,bytedance,deepseek,zhipu,moonshot,minimax,baidu,tencent,xunfei,kuaishou,midjourney,suno#厂商值不合法" dc:"研发厂商(空=未分类)"`
Status string `json:"status" dc:"状态"`
MaxContextTokens int `json:"max_context_tokens" dc:"最大上下文 token 数"`
MaxOutputTokens int `json:"max_output_tokens" dc:"最大输出 token 数"`
Capabilities map[string]bool `json:"capabilities" dc:"模型能力特性"`
Description string `json:"description" dc:"模型描述"`
Tags []string `json:"tags" dc:"标签列表"`
SunsetDate string `json:"sunset_date" dc:"下线日期"`
ReplacementModel string `json:"replacement_model" dc:"推荐替代模型名"`
Pricing []PricingItem `json:"pricing" dc:"定价列表"`
TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(可选)"`
ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(可选)"`
Scheme string `json:"scheme,omitempty" dc:"特殊计费方案名(特殊方案模型导出时携带)"`
SchemeConfig json.RawMessage `json:"scheme_config,omitempty" dc:"方案私有配置(scheme 非空时随方案透传)"`
ConflictAction string `json:"conflict_action" v:"in:skip,overwrite" dc:"冲突处理策略:skip/overwrite"`
}
// ModelImportRes 导入结果响应
+42 -8
View File
@@ -1,6 +1,10 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
import (
"encoding/json"
"github.com/gogf/gf/v2/frame/g"
)
// === 监控仪表盘 ===
@@ -9,8 +13,14 @@ type MonitorDashboardReq struct {
Minutes int `json:"minutes" in:"query" d:"5"`
}
// MonitorDashboardRes 数据对象平铺进统一响应的 data 字段(json:"-" + 自定义 MarshalJSON),
// 避免统一包装后再多包一层 data(monitor 包下带 Data 的 Res 均为此模式)
type MonitorDashboardRes struct {
Data map[string]any `json:"data"`
Data map[string]any `json:"-"`
}
func (r *MonitorDashboardRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
type MonitorTrafficReq struct {
@@ -19,7 +29,11 @@ type MonitorTrafficReq struct {
}
type MonitorTrafficRes struct {
Data any `json:"data"`
Data any `json:"-"`
}
func (r *MonitorTrafficRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
type MonitorTrafficFlowReq struct {
@@ -30,7 +44,11 @@ type MonitorTrafficFlowReq struct {
}
type MonitorTrafficFlowRes struct {
Data any `json:"data"`
Data any `json:"-"`
}
func (r *MonitorTrafficFlowRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
type MonitorModelPerformanceReq struct {
@@ -102,7 +120,11 @@ type MonitorLatencyReq struct {
}
type MonitorLatencyRes struct {
Data map[string]any `json:"data"`
Data map[string]any `json:"-"`
}
func (r *MonitorLatencyRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
type MonitorSystemReq struct {
@@ -111,7 +133,11 @@ type MonitorSystemReq struct {
}
type MonitorSystemRes struct {
Data map[string]any `json:"data"`
Data map[string]any `json:"-"`
}
func (r *MonitorSystemRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
// MonitorDispatchReq 渠道调度引擎指标请求
@@ -176,7 +202,11 @@ type MonitorRealtimeReq struct {
}
type MonitorRealtimeRes struct {
Data any `json:"data"`
Data any `json:"-"`
}
func (r *MonitorRealtimeRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
// === 告警规则 ===
@@ -202,7 +232,11 @@ type AlertOptionsReq struct {
}
type AlertOptionsRes struct {
Data map[string]any `json:"data"`
Data map[string]any `json:"-"`
}
func (r *AlertOptionsRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
type AlertRuleCreateReq struct {
+25 -13
View File
@@ -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 {
+9 -1
View File
@@ -1,6 +1,8 @@
package v1
import (
"encoding/json"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
@@ -59,8 +61,14 @@ type PlanDetailReq struct {
Id int64 `json:"id" in:"path" v:"required|min:1"`
}
// PlanDetailRes 详情对象平铺进统一响应的 data 字段(json:"-" + 自定义 MarshalJSON),
// 避免统一包装后再多包一层 data
type PlanDetailRes struct {
Data *PlanItem `json:"data"`
Data *PlanItem `json:"-"`
}
func (r *PlanDetailRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
type PlanUpdateReq struct {
+161
View File
@@ -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{}
+4
View File
@@ -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"`
}
// ============================================================
+24
View File
@@ -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),命中缓存时为缓存写入时刻"`
}
+19 -9
View File
@@ -19,13 +19,15 @@ type TaskListReq struct {
}
type ModelTaskItem struct {
ID int64 `json:"id"`
PublicTaskID string `json:"public_task_id"`
Platform string `json:"platform"`
Action string `json:"action"`
Status string `json:"status"`
Progress string `json:"progress"`
ModelName string `json:"model_name"`
ID int64 `json:"id"`
PublicTaskID string `json:"public_task_id"`
Platform string `json:"platform"`
Action string `json:"action"`
Status string `json:"status"`
Progress string `json:"progress"`
ModelName string `json:"model_name"`
// UpstreamModel 上游供应商实际使用的模型名(仅任务详情返回,排障时用于和请求模型对照)
UpstreamModel string `json:"upstream_model,omitempty"`
FailReason string `json:"fail_reason,omitempty"`
PreDeductAmount float64 `json:"pre_deduct_amount"`
ActualCost float64 `json:"actual_cost"`
@@ -35,8 +37,16 @@ type ModelTaskItem struct {
TenantID int64 `json:"tenant_id"`
UserID int64 `json:"user_id"`
SubmitTime string `json:"submit_time,omitempty"`
FinishTime string `json:"finish_time,omitempty"`
CreatedAt string `json:"created_at"`
// StartTime 上游开始执行任务的时间(仅任务详情返回;详情时间线用它区分「排队耗时」与「执行耗时」)
StartTime string `json:"start_time,omitempty"`
FinishTime string `json:"finish_time,omitempty"`
CreatedAt string `json:"created_at"`
// RequestID 任务提交时的原始请求 ID(req_xxxxx,仅任务详情返回),关联 aud_request_logs.request_id
RequestID string `json:"request_id,omitempty"`
// 上游排障标识(仅任务详情返回;从 private_data 提取,其余内部字段不外露):
// UpstreamTaskID 任务句柄(上游轮询查询用);UpstreamRequestID 提交调用追踪 ID(部分上游返回)
UpstreamTaskID string `json:"upstream_task_id,omitempty"`
UpstreamRequestID string `json:"upstream_request_id,omitempty"`
}
type TaskListRes struct {
+41 -30
View File
@@ -5,13 +5,15 @@ import "github.com/gogf/gf/v2/frame/g"
// TenantCreateReq 创建租户请求
type TenantCreateReq struct {
g.Meta `path:"/tenants" method:"post" mime:"json" tags:"管理后台-租户管理" summary:"创建租户"`
TenantName string `json:"tenant_name" v:"required#请输入租户名称" dc:"租户名称(汉字最多8个,字母最多16个)"`
TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入租户代码|租户代码为3-30位|租户代码仅允许小写字母、数字、中划线" dc:"租户代码"`
Username string `json:"username" v:"required|length:3,50#请输入管理员用户名|用户名长度为3-50位" dc:"管理员用户名"`
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"管理员邮箱"`
Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"管理员密码"`
MaxMembers *int `json:"max_members" dc:"最大成员数(默认10)"`
MaxConcurrency *int `json:"max_concurrency" dc:"并发上限,0不限(默认0)"`
TenantName string `json:"tenant_name" v:"required#请输入租户名称" dc:"租户名称(汉字最多8个,字母最多16个)"`
TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入租户代码|租户代码为3-30位|租户代码仅允许小写字母、数字、中划线" dc:"租户代码"`
Username string `json:"username" v:"required|length:3,50#请输入管理员用户名|用户名长度为3-50位" dc:"管理员用户名"`
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"管理员邮箱"`
Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"管理员密码"`
MaxMembers *int `json:"max_members" dc:"最大成员数(默认10)"`
MaxConcurrency *int `json:"max_concurrency" dc:"并发上限,0不限(默认0)"`
Tags []string `json:"tags" dc:"租户标签(≤10 个,每项 ≤30 字符)"`
Remark *string `json:"remark" dc:"管理员备注(≤1000 字符)"`
}
type TenantCreateRes struct {
@@ -25,6 +27,8 @@ type TenantListReq struct {
PageSize int `json:"page_size" d:"20" dc:"每页数量"`
Keyword string `json:"keyword" dc:"搜索关键词(名称/代码)"`
Status string `json:"status" dc:"状态筛选:active/suspended/closed"`
Tag string `json:"tag" dc:"标签精确筛选"`
Level *int `json:"level" dc:"等级筛选(对应 tnt_tenant_level_configs.level)"`
}
type TenantListRes struct {
@@ -35,24 +39,27 @@ type TenantListRes struct {
}
type TenantItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
LogoURL string `json:"logo_url"`
OwnerUserID int64 `json:"owner_user_id"`
OwnerName string `json:"owner_name"`
Status string `json:"status"`
MaxMembers *int `json:"max_members" dc:"最大成员数上限,NULL表示跟随等级配置"`
MaxConcurrency *int `json:"max_concurrency" dc:"并发上限,NULL表示跟随等级配置"`
EffectiveMaxMembers int `json:"effective_max_members" dc:"实际生效的成员数上限"`
EffectiveMaxConcurrency int `json:"effective_max_concurrency" dc:"实际生效的并发上限"`
DefaultChannelScope string `json:"default_channel_scope"`
MemberCount int `json:"member_count"`
WalletBalance string `json:"wallet_balance"`
Level int `json:"level"`
LevelName string `json:"level_name"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
LogoURL string `json:"logo_url"`
OwnerUserID int64 `json:"owner_user_id"`
OwnerName string `json:"owner_name"`
Status string `json:"status"`
MaxMembers *int `json:"max_members" dc:"最大成员数上限,NULL表示跟随等级配置"`
MaxConcurrency *int `json:"max_concurrency" dc:"并发上限,NULL表示跟随等级配置"`
EffectiveMaxMembers int `json:"effective_max_members" dc:"实际生效的成员数上限"`
EffectiveMaxConcurrency int `json:"effective_max_concurrency" dc:"实际生效的并发上限"`
DefaultChannelScope string `json:"default_channel_scope"`
MemberCount int `json:"member_count"`
WalletBalance string `json:"wallet_balance"`
Tags []string `json:"tags" dc:"租户标签(管理后台运营标注)"`
Remark string `json:"remark" dc:"管理员备注"`
TotalConsumed string `json:"total_consumed" dc:"累计消费(本位币,decimal 字符串;Redis 权威计数的物化副本,≤5s 滞后)"`
Level int `json:"level"`
LevelName string `json:"level_name"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// TenantGetReq 获取租户详情
@@ -85,11 +92,13 @@ type TenantUpdateStatusRes struct{}
// TenantUpdateReq 更新租户信息
type TenantUpdateReq struct {
g.Meta `path:"/tenants/{id}" method:"put" mime:"json" tags:"管理后台-租户管理" summary:"更新租户"`
Id int64 `json:"id" in:"path" v:"required" dc:"租户ID"`
Name string `json:"name" dc:"租户名称"`
MaxMembers *int `json:"max_members" dc:"最大成员数"`
MaxConcurrency *int `json:"max_concurrency" dc:"租户总并发上限(0表示不限制)"`
Level *int `json:"level" dc:"租户等级(调整等级会同步更新成员数和并发数为该等级的配置值)"`
Id int64 `json:"id" in:"path" v:"required" dc:"租户ID"`
Name string `json:"name" dc:"租户名称"`
MaxMembers *int `json:"max_members" dc:"最大成员数"`
MaxConcurrency *int `json:"max_concurrency" dc:"租户总并发上限(0表示不限制)"`
Level *int `json:"level" dc:"租户等级(调整等级会同步更新成员数和并发数为该等级的配置值)"`
Tags []string `json:"tags" dc:"租户标签(≤10 个,每项 ≤30 字符,传空数组清空标签)"`
Remark *string `json:"remark" dc:"管理员备注(≤1000 字符)"`
}
type TenantUpdateRes struct{}
@@ -138,6 +147,8 @@ type TenantExportReq struct {
Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"`
Keyword string `json:"keyword" in:"query" dc:"搜索关键词(名称/代码)"`
Status string `json:"status" in:"query" dc:"状态筛选:active/suspended/closed"`
Tag string `json:"tag" in:"query" dc:"标签筛选"`
Level *int `json:"level" in:"query" dc:"等级筛选"`
}
type TenantExportRes struct{}
+2
View File
@@ -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=不限)"`
}
+3 -2
View File
@@ -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,8 @@ 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)
TenantUsageLogDetail(ctx context.Context, req *v1.TenantUsageLogDetailReq) (res *v1.TenantUsageLogDetailRes, 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 +135,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)
+10 -1
View File
@@ -1,6 +1,8 @@
package v1
import (
"encoding/json"
"github.com/gogf/gf/v2/frame/g"
)
@@ -44,6 +46,7 @@ type TenantRequestAuditLogsReq struct {
RequestId string `json:"request_id" in:"query" dc:"Request ID(精确匹配)"`
TaskId string `json:"task_id" in:"query" dc:"异步任务ID(精确匹配)"`
Path string `json:"path" in:"query" dc:"请求路径(模糊匹配)"`
Model string `json:"model" in:"query" dc:"模型名称(精确匹配)"`
StatusCode int `json:"status_code" in:"query" dc:"HTTP 状态码"`
StartDate string `json:"start_date" in:"query" dc:"开始日期"`
EndDate string `json:"end_date" in:"query" dc:"结束日期"`
@@ -61,6 +64,12 @@ type TenantRequestAuditLogDetailReq struct {
Id int64 `json:"id" in:"path" v:"required|min:1"`
}
// TenantRequestAuditLogDetailRes 详情对象平铺进统一响应的 data 字段(json:"-" + 自定义
// MarshalJSON),避免统一包装后再多包一层 data(与 admin 端审计详情接口同一模式)
type TenantRequestAuditLogDetailRes struct {
Data map[string]any `json:"data"`
Data map[string]any `json:"-"`
}
func (r *TenantRequestAuditLogDetailRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
+14
View File
@@ -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:"-"`
+41 -1
View File
@@ -1,6 +1,10 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
import (
"encoding/json"
"github.com/gogf/gf/v2/frame/g"
)
// === 租户用量与账单 ===
@@ -23,6 +27,42 @@ type TenantUsageLogsRes struct {
PageSize int `json:"page_size"`
}
// TenantUsageLogDetailReq 用量日志详情:列表只返回表格展示字段,
// billing_summary / error_message / user_agent 等大字段经此接口按需获取。
// 租户隔离:member 角色仅能查看自己的记录。
type TenantUsageLogDetailReq struct {
g.Meta `path:"/usage-logs/{id}" method:"get" mime:"json" tags:"租户控制台-用量" summary:"用量日志详情"`
Id int64 `json:"id" in:"path" v:"min:1" dc:"用量记录ID"`
}
// TenantUsageLogDetailRes 详情对象平铺进统一响应的 data 字段(json:"-" + 自定义
// MarshalJSON),避免统一包装后再多包一层 data(与 admin 端详情接口同一模式)
type TenantUsageLogDetailRes struct {
Data map[string]any `json:"-"`
}
func (r *TenantUsageLogDetailRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
// 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:"导出用量日志"`
+23 -17
View File
@@ -8,6 +8,7 @@ type MarketplaceListReq struct {
g.Meta `path:"/marketplace/models" method:"get" tags:"公开-模型广场" summary:"获取模型广场列表" group:"public" middleware:"-"`
Keyword string `json:"keyword" dc:"搜索关键词"`
Category string `json:"category" dc:"模型类别筛选(chat/embedding/image/audio等)"`
Vendor string `json:"vendor" dc:"研发厂商筛选(openai/anthropic/alibaba等),空=不过滤"`
Page int `json:"page" d:"1" dc:"页码"`
PageSize int `json:"page_size" d:"20" v:"between:1,100" dc:"每页数量"`
}
@@ -17,26 +18,31 @@ type MarketplaceListRes struct {
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
// Vendors 当前筛选条件(关键词/分类,忽略厂商筛选本身)下有模型的厂商去重列表,
// 供前端只渲染实际有模型的厂商筛选项;顺序不保证,前端按本地字典序展示
Vendors []string `json:"vendors"`
}
type MarketplaceModelItem struct {
ModelId string `json:"model_id"` // 模型标识
ModelName string `json:"model_name"` // 模型显示名称
Category string `json:"category"` // 分类(chat/embedding/image等)
Description string `json:"description"` // 描述
MaxContextTokens int `json:"max_context_tokens"` // 最大上下文 tokens
MaxOutputTokens int `json:"max_output_tokens"` // 最大输出 tokens
BillingMode string `json:"billing_mode"` // 计费模式:token/per_request/tiered
InputPrice float64 `json:"input_price"` // 输入价格(每百万 token,USD;tiered=首档)
OutputPrice float64 `json:"output_price"` // 输出价格(每百万 token,USD;tiered=首档)
PerRequestPrice float64 `json:"per_request_price"` // 按次单价(USD/次,仅 per_request)
CacheReadPrice float64 `json:"cache_read_price"` // 缓存读取价格(每百万 token,USD)
CacheCreationPrice float64 `json:"cache_creation_price"` // 缓存创建价格(每百万 token,USD)
DiscountLabel string `json:"discount_label"` // 折扣标签(营销展示,空=不展示)
PriceChangeNote string `json:"price_change_note"` // 价格调整说明(对外提示,空=不展示)
TimePrices []TimePriceItem `json:"time_prices"` // 时段价目(平台基础价 × 时段乘数,无配置=空)
Tags []string `json:"tags"` // 标签
Capabilities g.Map `json:"capabilities"` // 能力标签
ModelId string `json:"model_id"` // 模型标识
ModelName string `json:"model_name"` // 模型显示名称
Category string `json:"category"` // 分类(chat/embedding/image等)
Vendor string `json:"vendor"` // 研发厂商(openai/anthropic/alibaba等,空=未分类)
Description string `json:"description"` // 描述
MaxContextTokens int `json:"max_context_tokens"` // 最大上下文 tokens
MaxOutputTokens int `json:"max_output_tokens"` // 最大输出 tokens
BillingMode string `json:"billing_mode"` // 计费模式:token/per_request/tiered/per_second
InputPrice float64 `json:"input_price"` // 输入价格(每百万 token,USD;tiered=首档)
OutputPrice float64 `json:"output_price"` // 输出价格(每百万 token,USD;tiered=首档)
PerRequestPrice float64 `json:"per_request_price"` // 按次单价(USD/次,仅 per_request)
PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"` // 按秒单价矩阵(规格→USD/秒,仅 per_second),"*"为兜底价
CacheReadPrice float64 `json:"cache_read_price"` // 缓存读取价格(每百万 token,USD)
CacheCreationPrice float64 `json:"cache_creation_price"` // 缓存创建价格(每百万 token,USD)
DiscountLabel string `json:"discount_label"` // 折扣标签(营销展示,空=不展示)
PriceChangeNote string `json:"price_change_note"` // 价格调整说明(对外提示,空=不展示)
TimePrices []TimePriceItem `json:"time_prices"` // 时段价目(平台基础价 × 时段乘数,无配置=空)
Tags []string `json:"tags"` // 标签
Capabilities g.Map `json:"capabilities"` // 能力标签
}
type MarketplaceDetailReq struct {
+7
View File
@@ -6,6 +6,7 @@ import "github.com/gogf/gf/v2/frame/g"
type TenantAvailableModelsReq struct {
g.Meta `path:"/models" method:"get" mime:"json" tags:"租户控制台-模型" summary:"租户可用模型列表"`
Category string `json:"category" dc:"模型分类筛选:chat/embedding/image/audio/rerank"`
Vendor string `json:"vendor" dc:"研发厂商筛选(openai/anthropic/alibaba等),空=不过滤"`
Search string `json:"search" dc:"搜索关键词(模型名或显示名)"`
// ApiKeyID 按指定 API Key 的模型范围过滤(可选;不传或为 0 时返回租户全部可用模型)
ApiKeyID int64 `json:"api_key_id" dc:"API Key ID(可选,传入时按该 Key 的模型范围过滤)"`
@@ -36,6 +37,8 @@ type TimePriceItem struct {
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
// per_second 模型填 PerSecondPrice(兜底档 "*" 的换算价,无 "*" 取矩阵最低档)
PerSecondPrice *float64 `json:"per_second_price"`
}
// TenantAvailableModelItem 租户可用模型信息
@@ -44,6 +47,7 @@ type TenantAvailableModelItem struct {
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Vendor string `json:"vendor"` // 研发厂商(空=未分类)
MaxContext int `json:"max_context_tokens"`
MaxOutput int `json:"max_output_tokens"`
Description string `json:"description"`
@@ -58,6 +62,9 @@ type TenantAvailableModelItem struct {
CacheReadPrice *float64 `json:"cache_read_price"`
CacheCreationPrice *float64 `json:"cache_creation_price"`
PricingTiers []PricingTierItem `json:"pricing_tiers"`
// PerSecondPrices 按秒计费矩阵(billing_mode=per_second):分辨率规格 → 每秒单价(本位币),
// "*" 为兜底价。租户倍率/折扣在计费时作用,展示为平台原价。
PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"`
// TimePrices 时段价目(平台配置了时段定价的模型才有;价格为换算后的展示价)
TimePrices []TimePriceItem `json:"time_prices"`
// DiscountLabel 折扣标签(平台定价锚点行配置的营销展示文案,如"7折起";NULL=不展示)
+6 -1
View File
@@ -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"`
-40
View File
@@ -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"`
}
+9 -1
View File
@@ -1,6 +1,8 @@
package v1
import (
"encoding/json"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
@@ -124,8 +126,14 @@ type TenantProjectUsageStatsReq struct {
Id int64 `json:"id" in:"path" v:"required|min:1"`
}
// TenantProjectUsageStatsRes 统计对象平铺进统一响应的 data 字段(json:"-" + 自定义
// MarshalJSON),避免统一包装后再多包一层 data
type TenantProjectUsageStatsRes struct {
Data map[string]any `json:"data"`
Data map[string]any `json:"-"`
}
func (r *TenantProjectUsageStatsRes) MarshalJSON() ([]byte, error) {
return json.Marshal(r.Data)
}
type TenantProjectUsageLogsReq struct {
+7 -6
View File
@@ -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 {
+24 -1
View File
@@ -44,7 +44,8 @@ CREATE TABLE IF NOT EXISTS aud_request_logs (
task_status VARCHAR(20),
task_result TEXT,
task_upstream_headers JSONB,
task_completed_at TIMESTAMPTZ
task_completed_at TIMESTAMPTZ,
model_name VARCHAR(100)
);
CREATE INDEX IF NOT EXISTS idx_aud_request_logs_created_brin
@@ -55,6 +56,11 @@ CREATE INDEX IF NOT EXISTS idx_aud_request_logs_project
ON aud_request_logs USING btree (project_id) WHERE project_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_aud_request_logs_task_id
ON aud_request_logs (task_id) WHERE task_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_aud_request_logs_model
ON aud_request_logs (model_name, created_at);
-- created_at 单列 btree(对应迁移 000024):服务无租户条件的全局倒序分页
CREATE INDEX IF NOT EXISTS idx_aud_request_logs_created_bt
ON aud_request_logs USING btree (created_at);
COMMENT ON TABLE aud_request_logs IS '请求审计日志(记录所有 API 代理请求)';
COMMENT ON COLUMN aud_request_logs.id IS '主键ID';
@@ -87,3 +93,20 @@ COMMENT ON COLUMN aud_request_logs.task_status IS '异步任务终态:SUCCESS
COMMENT ON COLUMN aud_request_logs.task_result IS '异步任务完成时上游返回的原始响应体';
COMMENT ON COLUMN aud_request_logs.task_upstream_headers IS '异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)';
COMMENT ON COLUMN aud_request_logs.task_completed_at IS '异步任务达到终态的时间';
COMMENT ON COLUMN aud_request_logs.model_name IS '请求使用的模型名(用户请求体中的原始模型,未经渠道映射)';
-- ============================================================
-- 增量变更(存量审计库升级用)
--
-- 本脚本整体幂等(IF NOT EXISTS),存量审计库升级时直接重跑全脚本即可;
-- 也可只执行下方对应版本的增量语句。主库的同等变更由 goose 迁移
-- migrations/000022、000024 管理,两边需保持同步。
-- ============================================================
-- 000022:补记模型名(存量行无法回填,模型名自升级后开始记录)
ALTER TABLE aud_request_logs ADD COLUMN IF NOT EXISTS model_name VARCHAR(100);
COMMENT ON COLUMN aud_request_logs.model_name IS '请求使用的模型名(用户请求体中的原始模型,未经渠道映射)';
CREATE INDEX IF NOT EXISTS idx_aud_request_logs_model
ON aud_request_logs (model_name, created_at);
CREATE INDEX IF NOT EXISTS idx_aud_request_logs_created_bt
ON aud_request_logs USING btree (created_at);
+54 -1
View File
@@ -69,7 +69,7 @@
| 10003 | `CodeChannelUnavailable` | 没有可用的渠道 |
| ... | 更多见 `consts.go` | ... |
## 大模型代理接口格式(`/v1/*`、`/v1beta/*`、`/suno/*`)
## 大模型代理接口格式(`/v1/*`、`/v1beta/*`、`/v2/*`、`/suno/*`)
### 已注册的代理端点
@@ -92,12 +92,65 @@
| GET | `/v1/realtime` | 实时对话(WebSocket) | OpenAI | WebSocket |
| POST | `/v1/video/generations` | 视频生成(异步任务) | 自定义 | 否 |
| GET | `/v1/video/generations/{task_id}` | 视频生成任务查询 | — | 否 |
| POST | `/v1/videos` | OpenAI Videos 视频生成(multipart/JSON) | OpenAI | 否 |
| GET | `/v1/videos/{video_id}` | OpenAI Videos 任务查询 | — | 否 |
| GET | `/v1/videos/{video_id}/content` | OpenAI Videos 成品下载 | — | 否 |
| DELETE | `/v1/videos/{video_id}` | OpenAI Videos 任务删除(终态软删) | — | 否 |
| GET | `/v1beta/models` | Gemini 模型列表 | Gemini | 否 |
| GET | `/v1beta/models/{model}` | Gemini 模型详情 | Gemini | 否 |
| POST | `/v1beta/models/{model}` | Gemini 内容生成 | Gemini | SSE |
| POST | `/suno/submit/{action}` | Suno 音乐生成提交 | 自定义 | 否 |
| POST | `/suno/fetch` | Suno 批量查询 | 自定义 | 否 |
| GET | `/suno/fetch/{task_id}` | Suno 任务查询 | — | 否 |
| POST | `/v2/video_generation` | MiniMax H3 视频生成(官方协议 v2) | MiniMax | 否 |
| GET | `/v2/query/video_generation/{task_id}` | MiniMax H3 任务查询(v2) | — | 否 |
| DELETE | `/v2/video_generation/{task_id}` | MiniMax H3 任务取消(仅排队中,v2) | — | 否 |
| POST | `/v1/video_generation` | MiniMax Hailuo 视频生成(官方协议 v1,四形态共用) | MiniMax | 否 |
| GET | `/v1/query/video_generation?task_id=` | MiniMax Hailuo 任务查询(v1,query 参数) | — | 否 |
### MiniMax 官方视频协议(`/v2/*`,MiniMax-H3/H3-Max)
对 MiniMax v2 视频协议的原生接入:官方 SDK 把 base_url 指向网关即可直连。协议规格见 `docs/modeldocs/minimax/video/`。
**提交** `POST /v2/video_generation`(Bearer 平台 API Key):
```json
{
"model": "MiniMax-H3",
"content": [{ "type": "text", "text": "海浪拍打礁石,慢镜头。" }],
"resolution": "768P",
"duration": 6,
"ratio": "16:9"
}
```
- `model` / `content`(含非空 text 项)/ `resolution` / `duration` 四项必填;`ratio` 文生视频时必填(不能为 `adaptive`)
- `callback_url` **会被剥离**:网关按自身任务体系轮询计费,上游回调携带的上游 task_id 与网关公开 ID 不互通
- 提交响应为官方形态:`{"task_id": "task_xxx"}`(网关公开任务 ID,非上游 ID)
**查询** `GET /v2/query/video_generation/{task_id}` → `{"task": {官方 VideoTask}}`:
状态机 `queued/running/succeeded/failed`(上游取消的任务回放为 `failed`)。`content.url` 为上游限时直链;
`usage/resolution/duration/ratio` 从最近一次上游查询响应回放,任务提交后首拍轮询前(≤15s)可能缺失。
**取消** `DELETE /v2/video_generation/{task_id}`:仅支持取消**排队中**(queued)的任务,取消结果以远程返回为准——
网关调用上游 DELETE,上游确认 `action=cancelled` 才向客户端返回成功
(`{"task_id": "...", "action": "cancelled", "status": "cancelled"}`);上游拒绝时(如任务已进入 running)
原样透传上游错误。**不支持删除任务记录**(终态任务不可删,软删也不做,任务数据保留用于计费与审计;错误码 `task_not_cancellable`)。
取消确认后本地状态由轮询循环在下一拍(≤15s)对账收敛为 failed,预扣随之退款。
错误响应为 OpenAI 风格 OaiError(`{"error": {"type": "...", "message": "..."}}`),与官方 v2 一致。
**OpenAI 协议互通**:同一渠道下 `POST /v1/videos`(OpenAI Videos 协议)与 `POST /v1/video/generations`(通用任务体)可直接调用 MiniMax 视频模型(H3 与 Hailuo 系列按模型自动分派 v2/v1 上游协议)。`size` 只借 OpenAI 协议的字段形态、**值原样透传不换算**——请直接传目标供应商的原生分辨率词汇(H3:`768P`/`2K`/`480P`;Hailuo 2.x:`768P`/`1080P`);非官方档位值会原样送达上游(上游 400 则任务提交失败、预扣全额退款)。时长取 `seconds`/`duration`/`metadata.duration`(缺省 6 秒),`input_reference`/`images[0]` 作为首帧图(v1 转为 `first_frame_image`),纯文生视频时 v2 ratio 默认 `16:9`。
### MiniMax 官方视频协议 v1(`/v1/video_generation`,Hailuo 系列)
Hailuo 2.x 及旧型号(T2V/I2V/S2V-01,传入即转发不拦截)走 v1 扁平字段协议:
- **提交** `POST /v1/video_generation`:文生(`prompt` 必填)/ 图生(`first_frame_image` 必填,支持 URL 或 data URI)/ 首尾帧(`last_frame_image`,仅 Hailuo-02)/ 主体参考(`subject_reference`,S2V-01)四形态共用;`duration`(默认 6,768P 支持 10)/`resolution`(Hailuo 2.x 默认 `768P`,旧型号默认 `720P`)可选;`prompt_optimizer`/`fast_pretreatment`/`aigc_watermark` 透传;`callback_url` 剥离。响应为官方 `{task_id, base_resp}` 形态(task_id 为网关公开 ID)
- **查询** `GET /v1/query/video_generation?task_id=`:官方 `{task_id, status, file_id, video_width, video_height, base_resp}` 形态,状态机 `Preparing/Queueing/Processing/Success/Fail`;**增补字段** `download_url`(网关轮询时经官方 files/retrieve 二跳解析的限时直链,官方查询响应无此字段)与 `error`(失败原因)
- **成品二跳由网关代取**:官方流程「file_id → GET /v1/files/retrieve → download_url」在轮询时链式完成,`download_url` 落任务记录并回放到查询响应;不暴露 files/retrieve 代理端点
- **取消/删除**:v1 上游无此能力,端点不提供(v2 的 DELETE 端点对 v1 任务返回 400)
- v1 无 usage 返回,计费按请求规格(per_second 矩阵 × 时长),与 v2 口径一致
### OpenAI 格式端点响应
+135
View File
@@ -841,3 +841,138 @@ sender := common.NewEmailSender(&common.EmailConfig{
- 同一份配置有多个消费方时,**必须收敛到同一个加载函数**(本例为 `EmailConfigFromOptions`),禁止各处自行拼装配置结构体。
排查信号:某个功能"配置明明填了却不生效",而同类功能正常;`grep -rn "g.Cfg()" ` 命中的键在 `manifest/config/*.yaml` 中搜不到。
### 2026-09-01:测试里用 `gtest.DataPath()` 当 server 名,Windows 下 `Start()` 静默失败
**问题**:`internal/middleware` 的 `request_id_test.go` 三个用例在 Windows 上全部失败,断言形如 `EXPECT 0 == 10`、`EXPECT 0 == 100`,或客户端报 `EOF`——看起来像中间件没生成 RequestId,实则请求根本没发出去。
**原因**:测试用路径当服务器名:
```go
s := g.Server(gtest.DataPath("request-id-test")) // 名字是 D:\...\testdata\request-id-test
s.Start() // 返回值被丢弃
```
GoFrame 用**服务器名**拼 session 存储目录:`%TEMP%\gsessions\<server-name>`。名字里带盘符冒号时路径非法,`os.MkdirAll` 失败,`Start()` 返回 error——但测试没接这个返回值,于是服务器没监听,`GetListenedPort()` 返回 `-1`,客户端请求 `http://127.0.0.1:-1` 直接 EOF。Linux 下 `gtest.DataPath` 返回的路径不含冒号,同样的代码能跑通,所以问题只在 Windows 暴露。
同一文件还继承了 `manifest/config/config.yaml` 的 `server.address: :18888`(真实应用端口),即便名字合法也会与本机运行中的服务抢端口。
**修复**:服务器名改用普通标识符,并显式绑定空闲端口:
```go
s := g.Server("mw-request-id-dual") // 纯名字,不是路径
s.SetAddr("127.0.0.1:0") // 不继承配置文件端口,让系统分配
s.SetDumpRouterMap(false)
s.Start()
```
**正确做法(通用规则)**:
- **`g.Server(name)` 的参数是「服务器名」不是「路径」**:它会参与 session 目录等文件路径拼接,只能用普通标识符(字母/数字/连字符)。`gtest.DataPath()` 是用来定位测试数据文件的,不要拿来当名字。
- **测试里起 server 必须 `SetAddr("127.0.0.1:0")`**:`g.Server()` 会读取项目配置,默认继承生产监听端口,在本机跑着服务时必然冲突。
- **不要丢弃 `s.Start()` 的 error**:Start 失败后测试仍会继续跑,症状表现为断言数值不对或 EOF,与真正原因(服务器没起来)完全无关,极易误导排查方向。
排查信号:测试断言"期望 N 实际 0"且伴随 `EOF`;`GetListenedPort()` 返回 `-1`;同一测试在 Linux CI 通过但本地 Windows 失败。
### 2026-09-01(二):channel_proxy_url 双数据源分裂——设置页保存的代理对 relay 渠道转发不生效
**问题**:管理后台「系统设置 → 渠道配置 → 代理地址」保存后,渠道编辑中开启「使用代理」的请求并不走该代理。设置页把 `channel_proxy_url` 写入 `sys_options`,但 relay 层(`relay/common/http_client.go` 的 `GetSystemProxyURL`)读的是 `g.Cfg().MustGet(ctx, "channel_proxy_url")`——即 `manifest/config/config.yaml` 配置文件里的**同名 key**。两套数据源互不相通:设置页的值只被 `oauth/client.go`(OAuth 出站)读到,它宣称的主要用途(渠道转发代理、Realtime WebSocket 拨号)从未读到过。
**原因**:同一配置 key 被两个模块各自实现了读取逻辑,一个走设置注册表(`sys_options`),一个走 `g.Cfg()`(配置文件),没有收敛到同一加载入口。`g.Cfg().MustGet` 对键不存在不报错、静默返回空串(见 2026-08-31 记录),掩盖了分裂——渠道一直"代理未配置",无人察觉。
**修复**:
1. `relay/common/http_client.go`:`GetSystemProxyURL` 不再读 `g.Cfg()`(含 10s TTL 轮询缓存一并删除),改为读包内 atomic 值,新增 `SetSystemProxyURL` setter;
2. `internal/cmd/cmd.go`:沿用 `SetGlobalRequestTimeoutSeconds` 的桥接注入模式(relay 不反向 import internal,会循环依赖)——启动时 + `OnSettingsChanged("channel_proxy_url")` 时从 `sys_options` 读取并注入,免重启、多实例经 Redis pub/sub 各自刷新;
3. `config.example.yaml` 删除已失效的 `channel_proxy_url` key,配置文件不再承载该配置。
**正确做法(通用规则)**:
- **一个配置 key 只能有一个数据源、一个加载入口**。凡注册进设置注册表(`sys_options`,管理员可改)的 key,所有消费方——包括 relay 这类不 import internal 的独立模块——都必须经同一加载函数取值;独立模块用「setter 注入 + OnSettingsChanged 刷新」桥接(本项目既有模式:全局超时 `SetGlobalRequestTimeoutSeconds`)。
- **禁止用 `g.Cfg()` 读取任何注册表里的 key**:同名 key 在配置文件里恰好存在时会造成"双真相源",配置文件的值会静默覆盖管理员在后台设置的值(或反之),且 `MustGet` 对键缺失不报错,分裂长期不可见。
排查信号:设置页某配置"保存了但不生效";`grep -rn "g.Cfg()"` 命中的 key 同时出现在 `settings_registry.go` 中。
### 2026-09-03(三):ghttp 归一化尾部斜杠但不归一化中间的重复斜杠
**问题**:管理后台的授权(`AdminPermissionGuard`)按 `r.URL.Path` 做精确路径 / 前缀 / 前缀+后缀三段匹配。若框架对路径的归一化范围与预期不符,同一个 handler 就可能有多种匹配结果——加一个斜杠就绕到另一条(可能更弱的)规则上。
**框架实测行为**(GoFrame v2,`ghttp` 路由,实测端口探针验证):
| 请求路径 | 是否匹配到路由 | handler 里的 `r.URL.Path` |
|----------|----------------|---------------------------|
| `/api/admin/roles` | ✅ | `/api/admin/roles` |
| `/api/admin/roles/` | ✅ | `/api/admin/roles`(**尾部斜杠已被去掉**) |
| `/api/admin/roles//` | ✅ | `/api/admin/roles`(同上) |
| `/api/admin//roles` | ✅ | `/api/admin//roles`(**中间的重复斜杠原样保留**) |
| `/api/admin/./roles`、`/api/x/../admin/roles` | ❌ 404 | — |
| `/api/admin/ROLES` | ❌ 404 | — |
即:**尾部斜杠会被归一化,路径中间的空段不会**;`.` / `..` 段与大小写变体压根匹配不到路由。
**正确做法(通用规则)**:
- **任何按 `r.URL.Path` 做字符串匹配的中间件(鉴权、限流、审计采样、灰度路由),都不能假设路径已规范化**。中间的 `//` 会带着原样路径进到 handler,字符串规则却按规范形式书写,两者对不上。
- 处理方式选**拒收**而非**重写**:重写会把原本因规则不匹配而被拒的变体变成放行(等于放宽授权面),拒收既不放宽也不留缝隙。本项目在 `AdminPermissionGuard` 入口用 `isCanonicalPath` 直接 403,正常客户端不会产生这类路径。
- 尾部斜杠已由框架处理,无需自行 trim,**也不要因此把 `/xxx/` 当作合法输入去写规则**——handler 永远看不到它。
排查信号:某接口"多加一个斜杠就能绕过权限/限流";中间件按路径匹配的规则表里出现了同一 handler 的两条规则。
### 2026-09-05:`Model.Value()` 不带 Fields 返回结果集第一列(id),不是想要的业务列
**问题**:`internal/logic/common/config.go` 的 `SetOption` 中,本位币只读保护用 `dao.SysOptions.Ctx(ctx).Where("key", key).Value()` 读取已存的 `billing_currency` 值,与提交值比对实现"相同值放行"。但 `Model.Value()` 不指定字段时,GoFrame 生成 `SELECT * ... LIMIT 1` 并取**结果集第一列**(`gdb_core_underlying.go` 中 `FirstResultColumn = columnTypes[0].Name()`),对 `sys_options` 来说是 `id`。于是 `old` 永远是行 id(如 `"42"`),与 `"USD"/"CNY"` 永不相等——系统初始化后,设置页保存整个 payment 分类必报"本位币在系统初始化后不可更改",即使用户根本没改本位币。
**原因**:`Value()` 的语义是"返回查询结果第一行第一列的值",取哪一列完全由 `Fields()`(或 `Value(fieldsAndWhere...)` 参数)决定;不带字段调用时落到表的第一列(通常是 `id`),且不报错,属于静默取错列。
**修复方式**:显式指定列 `dao.SysOptions.Ctx(ctx).Where("key", key).Fields("value").Value()`,与代码库其余 5 处 `Fields("xxx").Value()` 的既有写法保持一致。
**正确做法(通用规则)**:`Value()` / `Array()` 这类取单列的快捷方法**必须**显式 `Fields("列名")`,否则取到的是 `id`。评审信号:`grep -rn '\.Value()'` 命中且前面没有 `.Fields(` 的调用。
### 2026-09-05:Scan 进无 orm 标签的匿名结构体——TableFields 元数据查询失败时帕斯卡列名裸进 SQL
**问题**:管理后台租户列表接口 `/api/admin/tenants` 偶发报 `pq: column "Id" does not exist`,SQL 里出现 `"Id","Name","LogoURL"` 等帕斯卡命名列。PostgreSQL 引号标识符大小写敏感,真实列是 `id`/`logo_url`,查询必然失败。
**原因**:`internal/logic/admin/tenant.go` 的 `ListTenants` 把查询结果 `Scan` 进一个**只有 json 标签、没有 orm 标签的匿名结构体**。gf 的 `Model.Scan`(`doStructs`)有个内置行为 *"Auto selecting fields by struct attributes"*:模型未显式 `Fields()` 时,会用**扫描结构体的字段名**自动生成 SELECT 字段列表。字段解析链路(`gdb_func.go` 的 `getFieldsFromStructOrMap`):有 orm 标签取标签值,**没有则取 Go 字段名原样**;随后 `mappingAndFilterToTableFields` 尝试用真实表结构把 `Id→id`、`LogoURL→logo_url` 纠偏,但它对元数据查询失败的容忍方式是(`gdb_model_utility.go`):
```go
fieldsMap, _ := m.TableFields(fieldsTable) // 错误被静默吞掉
if len(fieldsMap) == 0 {
return fields // 元数据拿不到就原样透传 Go 字段名
}
```
pgsql 驱动的 `TableFields` 要实时查 `pg_attribute` 系统表(`'表名'::regclass`)。远程数据库瞬时抖动一次、这条目录查询失败,帕斯卡字段名就直接进了 SQL。**平时不炸的原因**:TableFields 成功结果进程内永久缓存(`gcache.DurationNoExpire`),启动后第一次查成功就一直复用;失败不缓存,所以重试通常即恢复。同请求内先执行的 `Count()` 走数据链路成功,进一步印证只有目录查询那一步挂了。
**修复方式**:给匿名结构体补上 orm 标签(`json:"id" orm:"id"` 等 13 个字段)。orm 标签值直接成为 SELECT 字段名——即使 TableFields 失败、字段名原样透传,透传出去的也是正确的小写蛇形列名,SQL 依然成立,彻底消除对运行时元数据的依赖。随后用 go/AST 脚本全仓排查同款写法,批量修复 24 处(幂等中间件、admin/tenant/open 列表与导出、成员模型范围、预算巡检等)。
**不受影响的两种同貌写法(排查时排除)**:
- **模型链上有显式 `.Fields(...)`**:`doStructs` 的自动投影有前置条件 `len(fields)==0 && len(fieldsEx)==0`,显式 Fields 时不触发,SELECT 列已固定。
- **`Raw(sql).Scan(&x)` 与 `result[0].Struct(&x)`**:Raw 路径在 `getFormattedSqlAndArgs` 里直接透传 rawSql、不拼接 Fields;`Record.Struct` 是内存行→结构体转换,根本不生成 SQL。
**正确做法(通用规则)**:
- **凡 `Scan`/`Struct`/`Structs` 进非 `model/entity` 生成的结构体(匿名结构体、手写 DTO),每个字段必须带 `orm` 标签**,列名写死不依赖运行时映射;`entity.Xxx` 自带 orm 标签天然安全。
- 判断风险的方法:`grep -rn 'var .* \[\]struct {' internal/` 找到匿名结构体,再看它是否被传给 `dao.Xxx.Ctx(ctx)...Scan(&x)` 链路且链上无 `Fields(...)`/`Raw(...)`——是则全部依赖这条元数据映射,远程 DB 抖动哪个请求撞上哪个接口炸,且报错完全看不出是网络问题。
排查信号:`pq: column "Xxx" does not exist` 且 SQL 列名是帕斯卡命名;偶发、重试即好、与网络抖动时间点吻合。
### 2026-09-18:请求体超过 ClientMaxBodySize 时 gf 直接 panic,客户端收到裸 500 "Internal Error"
**问题**:线上 ZCode 客户端 POST `/v1/messages` 报 500 "Internal Error"(访问日志错误码 `50, "Internal Error"`,错误栈 `Read from request Body failed` → `http: request body too large`),请求耗时 53 秒——客户端慢速上传超大请求体(agent 长上下文 + 内联 base64 媒体),读满上限后被拒。
**原因**:三层机制叠加:
1. gf 在 `ServeHTTP` 里给每个请求体包 `http.MaxBytesReader`,上限取 `server.clientMaxBodySize`,**默认 8MB**(`ghttp_server_config.go`),未配置时即 8MB 生效。
2. 任何代码调 `r.GetBody()`(内部 `MakeBodyRepeatableRead`)读取超限时,gf **直接 `panic(gerror.WrapCode(gcode.CodeInternalError(50, "Internal Error"), err, "Read from request Body failed"))`**——不是返回错误。
3. 该 panic 会被 gf `middleware.Next()` 里逐层包裹的 `gutil.TryCatch` **先于自定义 Recovery 中间件**捕获(`ghttp_request_middleware.go` 的 catch 分支 `WriteStatus(500, exception)`),所以项目统一的 JSON 错误格式与 Recovery 兜底全都接不到,客户端拿到框架裸 500 文本。
**修复方式**:`manifest/config/config.yaml` 显式配置 `server.clientMaxBodySize: "32MB"`(支持 "32MB"/"512KB" 字符串,gf 用 `gfile.StrToSize` 解析);并新增 `middleware.RelayBodyLimit`(`internal/middleware/body_limit.go`)挂在 relay 路由组 `ApiKeyAuth` 之后、`ContentFilter` 之前:按 `Content-Length` 预检,超限先 `io.Copy(io.Discard, r.Body)` 排空再返回 413 + 协议原生格式错误(`/v1/messages` 前缀走 Claude 格式 `request_too_large`,其余走 OpenAI 格式 `invalid_request_error`)。预检阈值从同一配置键读取,与 `MaxBytesReader` 实际上限天然一致。
**正确做法(通用规则)**:
- **调大上限 ≠ 响应体送达**:拒绝超大请求时必须**先排空请求体再写响应**。客户端上传大 body 往往耗时几十秒,服务端抢先响应并关连接,响应体会在半路被掐掉(实测 body 为空、响应头齐全的竞态);`r.Body` 已被 `MaxBytesReader` 封顶,排空量有界,不会被恶意大包拖死。
- 无 `Content-Length` 的分块传输无法预检,超限时仍走 gf 的裸 500 兜底(主流 SDK 均携带 Content-Length,属可接受残留)。
- 自定义 Recovery 中间件**不是**万能兜底:gf 中间件链每层 handler 自带 TryCatch,handler 内的 panic 到不了最外层自定义中间件。
排查信号:访问日志出现 `50, "Internal Error"` + `Read from request Body failed` + `http: request body too large` 栈;耗时数万毫秒的 500。
+305
View File
@@ -0,0 +1,305 @@
# relay → relaykit 协议转换迁移 · 工作状态
> 记录时间:2026-09-10(上一版 2026-09-09)
> 分支:`feat/relaykit`(基于 main,领先 2 个已提交 commit + 大量未提交工作区改动)
> 状态:**未提交**。全仓 `go build ./...` + `go vet` 通过;relaykit 模块测试全绿;
> relay 侧仅剩既有失败(见 §四)。§5.1 已修,另发现并修复 **2 个本次迁移引入的回归**(见 §3.6)。
---
## 一、本次任务目标
把 relay 层残留的「纯协议转换」代码全部迁进独立模块 `relaykit/`,让 relaykit 成为唯一转换路径,
宿主 `relay/` 只保留 HTTP/SSE/计费/调度等宿主职责。
## 二、开工前定下的 4 个决策(用户已拍板)
| 决策点 | 选定方案 |
|--------|----------|
| 注册表改造 | **允许单侧注册**(请求侧或响应侧可单独为空),反向方向才注册得进去 |
| 跨原生方向表达 | **中枢链式 + 直连覆盖**:请求走 `A→OpenAI→B` 步骤链,响应用直连转换器(保真度高)。<br>后修订:Claude→Gemini 请求侧改为直连(见 §六.11) |
| legacy 兜底策略 | **立即 hard-fail**:不再"解析失败回退旧实现",legacy 随迁随删 |
| 迁移范围 | 全选:4 个跨原生响应桥 + openai/converter.go 反向方向 + o2c/o2g 双实现清理 + 小方向补全 |
## 三、已完成的工作
### 3.1 relaykit 注册表基建(新增/改造)
| 文件 | 说明 |
|------|------|
| `relaykit/relayconvert/text_converter_registry.go` | 放宽双侧非空约束 → 单侧注册(两侧都空才 panic),只把已配置的一侧写进对应注册表 |
| `relaykit/relayconvert/engine.go`(新增) | `ConvertRequestByID` / `ExecuteRequestConversion`:统一执行直连转换器与 StepConverters 步骤链,调用方无需区分 |
| `relaykit/relayconvert/stream_event.go`(新增) | `StreamEvent{Event, Data, Usage *dto.UsageWithDetails}`:非 OpenAI 客户端方向的通用流式帧封装(Event 非空=事件帧,空=纯 data 帧) |
| `relaykit/relayconvert/errors.go`(新增) | `ErrStatefulResponsesUnsupported` 哨兵(顶层包,宿主可 `errors.Is`) |
| `relaykit/relayconvert/convmeta/responses_stash.go`(新增) | `ResponsesStash` 能力接口:Responses 请求快照 stash/读取 |
| `request_registry.go` / `response_registry.go` | 补齐全部方向的 converter ID 常量(含跨原生、反向、Responses 双向、流式) |
**注意**:`StreamEvent.Usage` 中途从 `*dto.Usage` 升级为 `*dto.UsageWithDetails`(瘦类型会丢缓存明细 → 影响计费),
所有转换器已按新契约适配。
### 3.2 转换器迁移(4 个并行子任务,全部完成且测试通过)
| 子任务 | 产出包 | 内容 |
|--------|--------|------|
| A | `relaykit/relayconvert/internal/oai_chat/` | Claude 入站方向:`ClaudeToOpenAIRequestConverter`、`OpenAIToClaudeResponseConverter`、`OpenAIToClaudeStreamConverter` |
| B | `relaykit/relayconvert/internal/oai_gemini/` | Gemini 入站方向:`GeminiToOpenAIRequestConverter`、`OpenAIToGeminiResponseConverter`、`OpenAIToGeminiStreamConverter` |
| C | `relaykit/relayconvert/internal/oai_responses/`(新包)+ `relaykit/dto/openai_responses.go`(新) | Responses 双向 6 个转换器;Responses DTO 下沉到 relaykit,`relay/dto/openai_responses.go` 改为纯别名文件 |
| D | `internal/claude_gemini/` + `internal/native_responses/`(新包) | 8 个跨原生响应转换器(Claude↔Gemini、Claude/Gemini→Responses,各含流式) |
### 3.3 注册接线
`relaykit/relayconvert/register/register.go` 现注册 **12 个方向**:
- OpenAI 入站 → Claude / Gemini / Ollama(chat)(原有)
- 反向:Claude / Gemini / Responses 入站 → OpenAI 上游(新)
- OpenAI chat → Responses 上游(ChatViaResponses 桥接,新)
- 跨原生:Claude→Gemini、Gemini→Claude、Responses→Claude、Responses→Gemini
(新;响应侧全部直连,请求侧 Claude→Gemini 已改直连,其余仍为步骤链)
### 3.4 宿主桥接层改造
| 文件 | 说明 |
|------|------|
| `relay/relaykit_bridge/route.go`(**新增**) | **唯一权威方向矩阵**:`KitFormat`(宿主↔relaykit 格式名映射,Responses 命名不同需显式转)、`EffectiveUpstreamFormat`(含 UseResponsesAPI / UpstreamSpeaksResponses 覆盖)、`RequestConverterIDForRoute`、`ResponseConverterIDForRoute`。另在此包 blank-import `register` 触发注册(放桥接包而非某调用方,保证所有 channel 包及其单测都能拿到注册结果) |
| `relay/relaykit_bridge/response.go` | 签名改 4 值 `(converted, usage, handled, err)`,hard-fail 语义;按格式解析上游响应(新增 OpenAI/Responses 两种) |
| `relay/relaykit_bridge/stream.go` | chunkWriter 支持 `StreamEvent`(事件帧/data 帧分派 + usage 明细捕获);**收尾策略按客户端格式**:OpenAI/Gemini 客户端写 `[DONE]`(OpenAI 另补终止 chunk),Claude/Responses 客户端不写(终止语义由 message_stop / response.completed 承载) |
| `relay/handler/relaykit_bridge.go` | 重写:走统一矩阵、按入站格式解析请求、hard-fail、OpenAI 上游后处理(stream_options / reasoning_effort 注入,均"客户端未显式设置时"语义)、哨兵错误映射 |
| `relay/handler/relay_handler.go` | `convertRequestBody` 改三态处理(handled+err / handled / 未接管走 adaptor) |
| `relay/common/relay_info.go` | `*RelayInfo` 实现 `convmeta.ResponsesStash` |
### 3.5 宿主 legacy 删除(hard-fail 接线)
已改为纯桥接外壳并删除旧转换实现:
- `relay/channel/claude/response.go`(handleNonStreamToOpenAI / handleStreamToOpenAI,删约 270 行流式循环)
- `relay/channel/gemini/response.go`(同上,删约 300 行)
- `relay/channel/ollama/adaptor.go`(chat 非流式+流式,删约 150 行)
- `relay/channel/openai/claude_response.go`(重写为桥接外壳)
- `relay/channel/openai/gemini_response.go`(重写为桥接外壳,保留 `writeOpenAIErrorAsGemini` 等错误映射)
- 4 个跨原生桥 `claude/gemini_bridge.go`、`claude/responses_bridge.go`、`gemini/claude_bridge.go`、`gemini/responses_bridge.go`(由子任务改造,**见待办**)
---
## 四、当前验证状态
```
GOTOOLCHAIN=go1.25.8 go build ./... ✅ 通过
GOTOOLCHAIN=go1.25.8 go vet ./relay/... ✅ 通过
cd relaykit && GOWORK=off go test -count=1 ./... ✅ 全绿(14 个包)
GOTOOLCHAIN=go1.25.8 go test ./relay/... ✅ 仅 relay/helper 既有失败(见 §六.7)
GOTOOLCHAIN=go1.25.8 go test ./internal/... ⚠️ 3 个既有失败,与本次迁移无关:
· internal/logic/admin TestBuiltinRoleDefaultsMatchMigration
—— 找不到 000020_admin_role_management.sql(迁移已重编号为 000020_v0_2_17_version.sql)
· internal/dispatchadapter TestProbeFailure_DoesNotDecayHealthEwma —— 健康分 EWMA
· internal/logic/billing TestGenerateBillingSummary_TimeRule —— 账单文案时段行
(三处工作区均无改动,勿误当回归)
gofmt -l relay/ relaykit/ ⚠️ 剩 10 个**存量**未格式化文件
(ali/volcengine converter_test、relaykit dto/audio、goldentest、benchmark_test 等,
本次迁移触及的 5 个文件已 gofmt -w)
```
---
## 五、重启后的待办
### 5.1 ~~修复 3 个流式错误传播测试~~ ✅ 已完成(2026-09-10)
**上一版留下的坑**:这 3 个测试当时是被「反转断言」改绿的——把「应返回错误」改成
「不返回错误、只查 StreamStatus」。但 `StreamEndReasonError` **不在** `IsPartialStreamEnd()`
集合内(`relay/common/stream_status.go:79`),于是 `DoResponse` 返回 `(usage, nil)`,
落到 `relay_handler.go` 的**成功路径**:`sess.Finish(true)` 让调度 FSM 收到「成功」、
熔断/健康分拿不到上游故障信号,并按正常成功结算(usage 近零)。旧实现是返回 error 的。
**已按原文档倾向的方案 1 修正**:
- `TryConvertStreamViaRelaykit` 签名改三态 `(*common.Usage, bool, error)`;
`ok=true,err!=nil` = 已写出 SSE 但流以错误/中断结束,调用方原样 `return usage, err`。
- 新增 `streamConvertError`(`relay/relaykit_bridge/stream.go`)做错误分类,
一律置 `ResponseWritten`(SSE 头与 200 已提交,不得改状态码/换渠道重试)。
- 客户端断开分支直接透出 `common.ErrStreamInterrupted`,9 个调用点不再各自
读 `IsPartialStreamEnd()` 判定,统一收敛到桥接层。
- 3 个测试断言改回「应返回错误」,并加断分类与收尾事件。
**顺带修正的分类错误**:安全拦截(Gemini `promptFeedback.blockReason`)原先会被映射成
502 上游错误 → 正常渠道因用户发违规提示词被扣健康分乃至熔断。现新增
`relaykit/relayconvert.ErrContentBlocked` 哨兵(6 个 Gemini 出站转换器 wrap 返回),
宿主 `errors.Is` 识别后映射为请求类 4xx。非流式侧同理,新增
`relaykit_bridge.ResponseConvertError` 供 3 个 Gemini 上游非流式站点统一分类。
6 个方向(→Claude / →Responses / →OpenAI × 流式/非流式)均已有测试覆盖。
### 5.2 ~~核对子任务 D 的跨原生桥接线成果~~ ✅ 已核对(结论与担心相反)
`geminiToClaudeResponse` / `buildResponsesBodyFromGemini` **仍在且不能删**:
它们是 Code Assist 强制流式聚合路径(`gemini/adaptor.go:514,520`
`handleCodeAssistAggregatedStream`)复用的非流式装配函数,各文件头注释已写明。
计费 usage 已确认仍从**原始上游响应体**提取。
### 5.3 ~~收尾清理~~ ✅ 已完成(2026-09-10,legacy 删除落地)
删除过程中发现并补齐了 2 处迁移遗漏(见 §七的「删除时补的接线」)。
### 5.4 提交前检查
- `gofmt -l` 本次触及文件已清空(存量 10 个未动,避免污染本次 diff)
- `go vet ./relay/... ./internal/...` ✅
- 提交信息不得包含 AI 工具身份标识(项目规范)
- 建议拆 commit:注册表基建 / 转换器迁移 / 宿主接线 / legacy 删除 /
**本次新增:流式错误传播 + 安全拦截分类 / Anthropic 端点与 provider 后处理回归修复**
---
## 3.6 本次新发现并修复的 2 个回归(重点,评审必看)
两者同一个根因:**relaykit 的转换矩阵按「协议格式」裁决、与供应商无关**,
命中后 `adaptor.ConvertRequest` 整个不再被调用。而 §3.3 新增的「非 OpenAI 入站 →
OpenAI 上游」三个反向方向,把原本归 adaptor 的路径整片接管走了。
### 回归 A:Anthropic 兼容端点渠道的 Claude 入站双向断链(严重)
ali / zhipu / deepseek / moonshot / volcengine / minimax 这 6 家主协议是 OpenAI,
但对 `RelayModeClaudeMessages` **另挂独立的 Anthropic 兼容端点**
(`/anthropic/v1/messages`、`/apps/anthropic/v1/messages`、`/api/coding/v1/messages`),
Claude 入站时上游**实际说 Claude**:请求体原样发、响应体也是 Claude
(其 `DoResponse` 正是委托给 `claude.Adaptor`)。
但 `helper.ProviderNativeFormat` 只按渠道类型返回单一格式(这几家 → openai),
且 `inboundMatchesChannelNative` 因此判 false 使 `canPassThrough` 也不兜底,于是:
- 请求侧:Claude 体被 relaykit 转成 OpenAI chat,POST 到 Anthropic 端点 → 上游 400;
- 响应侧:返回的 Claude 响应体被当 OpenAI 解析 → 转换失败。
**修复**:新增 `constant.HasNativeClaudeEndpoint(providerType)`,在
`relaykit_bridge.EffectiveUpstreamFormat` 与 `handler.inboundMatchesChannelNative`
两处叠加判定 —— Claude 入站 + 该供应商 ⇒ 有效上游格式 = Claude ⇒ 同格式、矩阵返回空串
⇒ 不转换,回到 adaptor / 直连原路径。
> 登记判据:`GetRequestURL` 对 `ClaudeMessages` 返回了与 chat 端点**不同**的地址。
> 共用同一端点的(aws / baidu_v2 / mistral / openai / xai / xunfei)上游只说 OpenAI,
> 不属于本集合,Claude 入站仍须转换。新增此类供应商时必须同步登记。
### 回归 B:5 个供应商的私有请求后处理被绕过
xai / baidu_v2 / zhipu / ali / deepseek 在格式转换之外还有私有请求适配,
relaykit 只补了通用的 `model` / `stream_options` / `reasoning_effort`:
| provider | 丢失的后处理 | 后果 |
|---|---|---|
| xai | `-search`/`-high`/`-low` 后缀剥离 + `search_parameters`/`reasoning_effort` | 未配映射时 `grok-4-search` 原样发上游 → 400 |
| baidu_v2 | `-search` 后缀剥离 + `web_search` 注入 | 同上 |
| zhipu | `applyGLMCompatibility`(top_p 裁剪、图片前缀剥离)+ 思考参数方言 | GLM 参数越界、思考失效 |
| ali | DashScope 按上游模型剥离 `thinking_budget` | 上游拒绝不支持该参数的模型 |
| deepseek | `thinking` 对象(V3 `type` + R1 `reasoning_effort:"none"`) | `-thinking`/`-nothinking` 静默失效 |
模型名那条最实际:未配映射时 `UpstreamModel = row.ModelName`
(`internal/dispatchadapter/catalog.go:443`)**带着后缀**,relaykit 无条件写
`GetUpstreamModelName()`,而 adaptor 本来会剥掉后缀再写回。
**修复**:新增可选能力接口 `common.RequestPostProcessor`
(`PostProcessConvertedRequest(ctx, info, body) ([]byte, error)`),
`handler.convertRequestBody` **仅在 relaykit 接管时**调用(走 adaptor 那条路径的
`ConvertRequest` 内部已含同一段,重复执行会双写)。5 家各把自己 `ConvertRequest`
的尾段抽成 `postProcessRequest`,与钩子共用同一实现,杜绝两份漂移。
已核对**不受影响**:minimax / moonshot 的 Claude 入站分支只做模型名映射(relaykit 已覆盖);
其余约 15 个 OpenAI 兼容 provider 的 `ConvertRequest` 无格式外后处理。
---
## 七、legacy 删除完成记录(2026-09-10 已全部落地)
净删约 **6400 行**(git diff --stat:+530 / -6907)。逐项:
| 项 | 结果 |
|----|------|
| `openai/converter.go` o2r 段(`ConvertOpenAIToResponses` + c2r 助手) | ✅ 删除(chat→Responses 请求转换由 relaykit `ConverterOpenAIChatToOpenAIResponses` 接管,handler 桥注入 reasoning_effort) |
| `openai/converter.go` c2o/g2o/r2o(`ConvertClaudeToOpenAI` 等) | ⚠️ **保留**:Ollama 渠道的 Claude/Gemini/Responses 入站走 `ConvertToOpenAI`→OpenAI→Ollama 链(矩阵未注册 Claude→Ollama 方向,属「未迁方向保留」设计) |
| `openai/responses.go` `handleResponsesInboundStream`/`NonStream`(约 600 行) | ✅ 改为 relaykit 桥接壳(**这是迁移漏掉的接线**——转换器早注册了但 openai adaptor 一直在调 legacy 实现);孤儿助手 `chatCompletionToResponsesResponse`/`extractStreamEmbeddedError` 删除,`extractResponsesRequestEcho` 保留(`BuildResponsesObjectMap` 导出依赖) |
| `claude/converter.go`(446 行)+ `converter_test.go` | ✅ 整文件删除(o2c 请求侧全套) |
| `gemini/converter.go`(686 行)+ `converter_test.go` | ✅ 整文件删除(o2g 请求侧全套) |
| 18 个 adaptor 的 `ConvertToOpenAI` 守卫 | ✅ 删 17 个(死代码:非 OpenAI 入站→OpenAI 上游已全由矩阵接管);**ollama 保留**(见上) |
| claude/gemini/openai adaptor 的入站格式 switch | ✅ 删除,改为同格式路径 + **hard-fail 守卫**(矩阵意外未接管时显式报错,不静默错格式透传) |
| `deepseek` `convertResponsesToChatForDeepSeek` | ✅ 删除,分支改 hard-fail(relaykit Responses→OpenAI + PostProcess 钩子接管) |
| stale 注释(passthrough/bridge 头注释中的旧函数名) | ✅ 更新为 relaykit 表述 |
**删除时补的接线**(不做这两步直接删会断链):
1. **openai adaptor responses 入站桥接**:迁移只注册了转换器、没改 adaptor 调用点。已把两个 inbound 处理器改为调 `TryConvertStream/ResponseViaRelaykit`(非 200 透传语义保留,计费仍取上游原始 chat 体口径)。
2. **Responses 客户端流错误收尾**:桥接层在转换器以错误退出且未产出任何终止态事件时补发 `response.failed`(否则 200 SSE 静默结束、codex 等客户端挂起);转换器已自行收尾的(如 Gemini SAFETY 发 completed 拒答)不追加第二终止事件。相关测试已按新契约改写。
**验证**:`go build ./...` ✅、`go vet ./relay/... ./internal/...` ✅、relaykit 模块全绿 ✅、
relay 测试仅剩 §六.7 既有失败 ✅、触及文件 gofmt 干净 ✅。
## 六、重要设计约束备忘(重启后容易忘)
1. **格式名两侧不同**:宿主 `constant.RelayFormatResponses = "responses"`,relaykit `types.RelayFormatOpenAIResponses = "openai_responses"`。
跨模块必须走 `relaykit_bridge.KitFormat()`,**不能直接强转**(其余格式字符串值相同可强转)。
2. **计费 usage 一律取上游原始口径**,不从转换后的响应体取。OpenAI 上游方向需置
`CacheIncludedInPrompt = true`(prompt 含 cached,计费按明细扣减,避免双重计费)。
3. **转换器只能依赖 `convmeta.Meta` 接口**,禁止 import 宿主 `relay/...` 或 GoFrame。
需要宿主状态时走能力接口(如 `ResponsesStash`)+ 类型断言,断言失败静默降级。
4. **`relay/dto` 是 `relaykit/dto` 的类型别名**(`relaykit_types.go` + `openai_responses.go`),两侧同型,边界零转换。
5. **转换器 ID 元数据方法(ID/From/To/Quality)与注册用的配对 spec ID 不是一回事**:
注册以 `RegisterTextConverter` 传入的 spec.ID 为准,元数据方法只是自描述。
6. relaykit 是独立 module:测试要 `cd relaykit && GOWORK=off go test ./...`;
宿主构建要 `GOTOOLCHAIN=go1.25.8`(否则 stdlib 重编译失败)。
7. `relay/helper` 的 `TestSafeUpstreamErrorMessage_*` 失败是**既有问题**(中英文案期望不一致),
与本次迁移无关,不要误当回归去修。
8. **矩阵按格式裁决、与供应商无关**——这是本次两个回归的共同根因。新增/修改方向时必须自问:
① 该供应商对这个 relay mode 是否另有原生端点(→ `constant.HasNativeClaudeEndpoint` 类判定)?
② 该供应商的 `ConvertRequest` 里除格式转换外是否还有私有适配
(→ 实现 `common.RequestPostProcessor`)?漏掉任一条都是**静默**降级或断链。
9. **`ProviderNativeFormat` 是「按渠道类型的单一格式」,不是全部真相**:多协议供应商的
有效上游格式取决于 (供应商, relay mode),权威计算在 `relaykit_bridge.EffectiveUpstreamFormat`,
`handler.inboundMatchesChannelNative`(直连判定)必须与它保持同步,否则直连与转换两条路会对同一
请求给出不同格式结论。
10. **provider 后处理钩子只在 relaykit 路径调用**:adaptor 路径的 `ConvertRequest` 内部已含同一段,
两处都调会双写参数。5 家的实现均把尾段抽成 `postProcessRequest` 与钩子共用,改动时不要只改一处。
11. **跨原生请求侧优先直连,不要新增中枢步骤链**:Claude→Gemini 已由
`internal/claude_gemini/ClaudeToGeminiRequestConverter` 直连。链式(`StepConverters`)会让
中间格式承载不了的字段静默消失——实测丢 thinking 签名、精确 thinking budget(5000 被放大成 8192)、
`top_k`、`tool_use.id` 四项,见 `claude_to_gemini_request_test.go` 的
`TestClaudeToGeminiRequest_PreservesWhatChainLoses`。新增跨原生方向时先写直连转换器,
链式只作过渡手段。Gemini 的 JSON Schema 关键字白名单在 `internal/shared.CleanGeminiToolParams`,
OpenAI→Gemini 与 Claude→Gemini 两条路径共用一处,改白名单是两边同时生效。
---
## 八、模型名后缀(虚拟模型)机制移除(2026-09-10)
经用量核实无任何带后缀模型(`-thinking`/`-nothinking`/`-none`/`-minimal`/`-low`/`-medium`/
`-high`/`-xhigh`/`-max`/`-search`)在用,整套「模型名后缀 → 虚拟模型」机制作为技术债务移除。
思考/搜索能力此后一律由**客户端请求参数**表达(`thinking` 对象、`reasoning_effort`、
`reasoning.effort` 等),网关只做协议转换不做能力注入。
### 删除清单
**宿主侧:**
- `relay/helper/thinking.go`(`ParseThinkingSuffix` + `ThinkingInfo`)整文件删
- `resolveRelayModel` 简化为纯目录校验(不再「字面优先 + 剥后缀回退」两段查找)
- `RelayInfo` 删 `ThinkingEnabled`/`ThinkingDisabled`/`ReasoningEffort` 三字段
及 `Get/SetReasoningEffort`(convmeta.Meta 相应方法一并删,本就零调用方)
- 各注入器删除:claude `injectClaudeThinking/Effort`、gemini `injectGeminiThinking`、
openai `injectReasoningEffort/injectResponsesReasoning`、deepseek 三个 inject* +
`isDeepSeekV4Model`、zhipu `injectThinkingParams`
- `canPassThrough` 删 thinking 后缀排除条件;handler 桥删两处 effort 注入(stream_options 保留)
- xai/baidu_v2 的 `-search`/`-high`/`-low` 剥离删除,两家 + deepseek 的
`RequestPostProcessor` 钩子实现删除(剩余职责 relaykit 已覆盖);**钩子接口保留**,
现存实现方仅 zhipu(GLM 参数兼容)与 ali(DashScope 参数裁剪)
**relaykit 侧:**
- `internal/shared/thinking_adapter.go`、`reasoning/` 包整体删除
- `convmeta.Options` 删 `PreserveThinkingSuffix`;Claude/Gemini Options 删
`ThinkingAdapter*` 字段(宿主侧写死 TODO 的配置一并清)
- oai_chat 请求转换器不再解析上游模型名后缀
### 保留(勿误删)
- **客户端参数转换线**:`c2oConvertThinkingToReasoningEffort` / `g2oConvertThinkingConfig`
(Claude thinking 对象 / Gemini thinkingConfig → chat `reasoning_effort` 字段)——
这是协议转换不是后缀注入,宿主 converter.go 与 relaykit oai_chat 各一份
- `bil_usage_logs.reasoning_effort` 列与 `UsageRecord.ReasoningEffort` 字段保留
(dao 生成 + 历史数据),此后恒为空串
- `RelayInfo.BaseModelName` 字段保留(responses 路由记录在用),现恒等于 OriginModelName
### 行为变化
- 请求带旧后缀名(如 `gpt-4o-thinking`)→ 目录查不到 → **404 model not found**
(原为剥后缀回退命中 + 注入思考参数)
- 名字本身含 `-max` 等字样的真实目录模型(qwen-max)不受影响(本就字面优先)
File diff suppressed because it is too large. Load diff
+722
View File
@@ -0,0 +1,722 @@
# 协议转换语料采集手册
`corpus-extract` 命令的使用说明:如何把线上真实流量转成协议转换层的测试语料。
---
## 1. 这个工具解决什么问题
协议转换层(`relaykit/relayconvert`)有六层测试防线——wire 保真、能力守恒、金样本、
矩阵守卫、E2E 集成、模糊测试。但这些防线的**输入语料全部是手写的**,而手写语料有一个
无法自我察觉的盲区:**写的人想不到的形状**。
真实客户端(Claude Code、Cursor、各家官方 SDK、自研网关)发出的请求,在嵌套深度、
字段组合、多轮结构上远比人工构造的刁钻。历史上多个「静默能力丢失」bug——多模态内容被吞、
链式 ContentPart 丢失、工具循环断链——都出在手写语料覆盖不到的形状上。
首轮采集(2026-09-12)当天就从语料里抓到一个:Claude Code 的 Grep 工具使用 JSON Schema
2020-12 的 `prefixItems` 元组语法,Gemini 丢弃该关键字后留下无 `items` 的裸数组直接 400
(`relayconvert/internal/shared/gemini_schema.go` 已修复并加回归测试)。
本工具把 `chn_debug_logs`(渠道调试日志)里已经记录的真实报文转成语料文件,
让各层防线用真实形状跑一遍。
### 数据从哪来
渠道调试功能对开启的渠道**逐次尝试**记录四段完整报文:
| 段 | 内容 | 本工具用途 |
|----|------|-----------|
| 段1 | 客户端原始请求 | ✅ **请求侧语料**(转换器的输入) |
| 段2 | 网关发给上游的请求 | ❌ 不用(这是转换的**输出**,见 §6.4) |
| 段3 | 上游原始响应(流式为完整 SSE) | ✅ **响应侧语料**(转换器的输入) |
| 段4 | 客户端最终收到的字节 | ❌ 不用(同上) |
关键点:**四段里只有段1和段3是语料**。段2/段4 是「当时那次转换的输出」,
属于另一种用法(生产一致性校验),本工具不处理。
---
## 2. 快速开始
```bash
# 1) 在管理后台给目标渠道打开「调试日志」,跑一批测试流量
# 2) 先空跑,看采到了什么
team-api corpus-extract --dry-run
# 3) 确认统计合理后正式提取
team-api corpus-extract
# 4) 抽查几个语料文件,确认脱敏效果符合预期
# 5) 采集完毕,关掉渠道的调试开关
```
开发环境用 `go run main.go corpus-extract ...` 等价。
---
## 3. 第一步:采集调试流量
### 开启调试
管理后台 → 渠道详情 → 编辑,勾选 **启用渠道调试日志**(`debug_log_enabled`)。
配套三个过滤器,可缩小采集范围、避免录下无关流量:
| 字段 | 含义 |
|------|------|
| `debug_log_tenant_id` | 只记录指定租户的请求(0 = 不限) |
| `debug_log_user_id` | 只记录指定成员的请求(0 = 不限) |
| `debug_log_api_key_id` | 只记录指定密钥的请求(0 = 不限) |
三者是 **AND** 关系,未设置(0)的维度不参与过滤。**建议用专用测试密钥采集**,
既精准又避免混入其他流量。
### 采集什么流量
#### 首要原则:别手搓请求
这个工具的**全部价值**在于跳出「写测试的人能想到的形状」。如果用 curl 手搓请求去采集,
形状依然来自我们自己的想象力,与手写语料是**同源盲区**——只是把手写从测试文件
挪到了采集脚本里。
优先级:**真实客户端 > 官方 SDK 脚本 > 手搓 curl**,最后一档只用来补冷门方向。
| 客户端 | 入站格式 | 能采到的特征 |
|--------|----------|--------------|
| Claude Code | claude | 重度工具循环、thinking、长多轮、大 system prompt、prompt caching |
| Codex CLI | responses | Responses 原生形态(其 serde 极严,`function_call` 必带 `arguments` 键) |
| Cursor / Cline / Continue | openai 或 claude | 代码上下文、并行工具调用、结构化输出 |
| Gemini CLI / `@google/genai` | gemini | Gemini 原生 parts 形态、thoughtSignature |
| 官方 SDK(openai-python / anthropic / google-genai) | 各自 | 精确控制特征矩阵,适合补缺口 |
项目历史上那几条 wire 契约坑(Gemini parts 禁 null、SSE 禁 `[DONE]`、codex 严格 serde)
**全部是官方 SDK 挑出来的**,手搓请求根本碰不到。
#### 渠道怎么配才能采全方向
路由是**按模型名选渠道**的。所以要让「Claude 格式的请求打到 Gemini 渠道」,
必须在 Gemini 渠道上挂一个模型,再用 Claude 格式去请求那个模型名。
最小渠道集:OpenAI / Claude / Gemini / Ollama / Responses 各一个,
然后四种客户端格式分别打过去:
| 客户端格式 ↓ \ 上游 → | OpenAI | Claude | Gemini | Ollama | Responses |
|---|---|---|---|---|---|
| openai | 直连 | ✓ | ✓ | ✓ | ✓ |
| claude | ✓ | 直连 | ✓ | — | — |
| gemini | ✓ | ✓ | 直连 | — | — |
| responses | ✓ | ✓ | ✓ | — | 直连 |
打勾的 11 格是转换方向,「直连」也要采(透传路径同样有测试价值)。
**跨原生方向(claude→gemini、gemini→claude、responses→claude/gemini)最容易漏**——
日常没人这么配,必须专门造。
#### 请求侧特征清单
请求侧你能精确控制。建议**先各特征单独一条,再来几条组合**——
线上两种都存在,而结构指纹会把它们算作不同形状。
| 特征 | 怎么触发 | 提示词示例 |
|------|----------|------------|
| 基础对话 | 什么都不加 | 「你好」 |
| 工具定义 | 挂一个 `get_weather` 工具 | 「北京今天天气怎么样」 |
| **多轮工具循环** | 上一条的**第二轮**:带着 `tool_result` 再发一次 | — |
| 并行工具调用 | 挂两个工具,问需要同时查的 | 「北京和上海天气分别怎么样」 |
| 强制工具 | `tool_choice` 指定函数名 | 任意 |
| 图片(data URL) | 上传本地图片 | 「这张图里有什么」 |
| 图片(远程 URL) | 传 http 图片链接 | 同上 |
| 思考 | Claude 开 `thinking` / OpenAI 用 o 系列 `reasoning_effort` / Gemini 开 `thinkingConfig` | 「笼子里鸡兔共 35 个头 94 只脚,各几只」 |
| **联网搜索** | 开 web_search 工具,且问题**必须有时效性** | 「今天有什么 AI 领域的新闻」 |
| 结构化输出 | `response_format` 给 json_schema | 「提取这段文字里的人名和公司」 |
| 停止序列 | 设 `stop` | 任意 |
| 长多轮 | 连续对话 5–6 轮 | 任意 |
| 大 system prompt | 塞几千 token 的系统提示 | 任意 |
两个容易做错的点:
- **多轮工具循环必须真跑两轮**。只发第一轮拿到 `tool_call` 是不够的——真正有价值的
语料是**第二轮请求**,它包含完整的 `assistant.tool_calls` + `tool_result` 历史结构,
而工具循环断链的 bug 正出在这个结构的转换上。
- **联网搜索要问有时效性的问题**。问「什么是快速排序」模型不会去搜,
工具挂了也白挂,采不到 `groundingMetadata`。
#### 响应侧特征:只能诱导
响应形状由模型决定,你只能间接诱导:
| 想采到 | 怎么诱导 |
|--------|----------|
| `finish_reason = length` | `max_tokens` 设成 10 |
| `finish_reason = tool_calls` | 上面的工具调用 |
| 缓存命中(cache_read) | 同一个长 system prompt 连发两次(Claude 需显式 `cache_control`) |
| reasoning tokens | 思考类请求 |
| grounding 元数据 | 时效性问题 + 搜索工具 |
内容过滤与安全拦截不建议刻意触发。
#### 最容易漏的五件事
1. **非流式**。绝大多数客户端默认 `stream=true`,而非流式转换器是**完全独立的代码路径**,
必须专门发 `stream=false` 的请求。这条排第一。
2. **跨原生方向**(见上面的矩阵)。
3. **Ollama 上游**——要本地起一个 ollama。
4. **Responses 入站**——需要 codex 或专门的 Responses 客户端。
5. **同一特征在不同入站格式下都要采**:`openai__tools` 和 `claude__tools`
是两份语料、驱动的是不同转换器。
#### 建议顺序
先用真实客户端(Claude Code / Cursor / codex)自然跑一段时间,把主干路径和
「想不到的形状」捞进来;再用官方 SDK 脚本按上面的特征表补缺口;
最后跑一次 `--dry-run` 看方向覆盖表,缺哪补哪。
配一个**专用测试密钥**,用 `debug_log_api_key_id` 过滤,避免混入其他流量。
### 覆盖清单
提取完成后命令会打印**转换方向覆盖表**,对照它补采即可。核对四个维度:
- **四种入站格式**:OpenAI chat(`/v1/chat/completions`)、Claude(`/v1/messages`)、
Gemini(`/v1beta/...`)、Responses(`/v1/responses`)
- **各上游协议**:至少覆盖 OpenAI / Claude / Gemini / Ollama 各一个渠道
- **流式与非流式**:两者的转换器是完全不同的代码路径,都要采
- **能力维度**:工具调用、多轮工具循环、图片、思考、联网搜索
### 注意事项
- **体积**:`chn_debug_logs` 的 body 字段**完整不截断、无自动过期**。带 base64 图片的
请求单条可达数 MB。这是一次**时间盒式的采集战役**(比如几小时或到 N 条为止),
不是常开功能。
- **采集完及时关闭调试开关**,并按需清理表数据(无自动清理机制)。
- **脱敏默认开启**:报文体会做内容脱敏(详见 §7)。`--no-scrub` 可关闭,
但那样落盘的是原文,仅限确知无敏感数据的测试流量。
---
## 4. 第二步:提取语料
### 命令与参数
```bash
team-api corpus-extract [选项]
```
| 选项 | 默认值 | 说明 |
|------|--------|------|
| `-o, --output` | `relaykit/relayconvert/register/golden` | 语料输出根目录 |
| `--since` | 无 | 起始时间,支持 `2026-09-01` / `2026-09-01 10:00:00` / RFC3339 |
| `--until` | 无 | 结束时间,格式同上 |
| `--channel` | 无 | 渠道 ID 过滤,逗号分隔,如 `3,7,12` |
| `--format` | 无 | 客户端格式过滤:`openai` / `claude` / `gemini` / `responses` |
| `--limit` | `5000` | 最多扫描的日志行数 |
| `--per-shape` | `1` | 每种结构形状保留几条样本 |
| `--max-inline` | `8192` | 内联 base64 超过该字节数则替换为占位(**仅 `--no-scrub` 时生效**;脱敏开启时所有内联数据都会被换掉) |
| `--no-scrub` | 关 | 关闭脱敏,落盘报文原文(仅限确知无敏感数据的测试流量) |
| `--keep-tool-names` | 关 | 脱敏时保留自定义工具名原值(内置工具名始终保留) |
| `--include-errors` | 关 | 包含失败尝试(默认只取上游 200 的最终尝试) |
| `--dry-run` | 关 | 只统计不落盘 |
默认只取 `is_final = true`(产生客户端响应的最终尝试)且上游返回 200 的记录——
中间失败尝试的段1 与最终尝试相同,段3 则是错误体,不是可用的响应语料。
### 先 dry-run
```bash
team-api corpus-extract --dry-run --since 2026-09-11
```
输出示例:
```
扫描 3271 条日志 → 47 种结构形状 → 61 个语料文件
跳过 12 条(缺 conversion 协议元数据,可能是非 relay 路径)
转换方向覆盖:
openai→claude 18
openai→gemini 14
claude→openai 9
gemini→openai 7
responses→openai 13
(方向覆盖不全时,补充对应渠道的调试采集再跑一次)
[dry-run] 未写入任何文件
```
**怎么读这个统计**:
- `3271 条 → 47 种形状` 说明去重生效了(真实流量冗余极大,这个压缩比是正常的)。
如果形状数接近日志条数,说明指纹没起作用,需要排查。
- **方向覆盖表是最重要的一行**。上例缺 `claude→gemini`、`responses→claude` 等跨原生方向,
说明没有配置对应的渠道组合,需要补采。
- `跳过(缺 conversion)` 少量正常(非 relay 路径的请求);如果占比很高,
说明采到的多是非转换流量。
### 输出结构
```
relaykit/relayconvert/register/golden/
├── real_inputs/ # 段1:客户端请求语料
│ ├── openai__image-tools_a1b2c3d4.json
│ ├── claude__basic_e5f6a7b8.json
│ └── ...
├── real_stream_inputs/ # 段3:上游流式响应(SSE / NDJSON 原文)
│ ├── claude__thinking-toolloop_c9d0e1f2.txt
│ └── ...
├── real_responses/ # 段3:上游非流式响应
│ └── gemini__websearch_b3c4d5e6.json
└── corpus_manifest.json # 清单与统计
```
文件名格式:`<格式>__<特征>_<结构指纹前8位>.<扩展名>`,与手写语料的
`<格式>__<场景>` 约定保持形似,便于人工浏览归类。
> **为什么与手写语料分目录**:真实语料量大且会持续增长,混进 `inputs/` 会让精选集难以
> 阅读;而且金样本套件对手写目录有「文件名必须匹配已知场景」的校验,
> 真实语料的自动命名会触发失败。
### 读懂 manifest
`corpus_manifest.json` 记录每条语料的来源与统计:
```json
{
"generated_at": "2026-09-11T23:30:00+08:00",
"source": { "since": "2026-09-11", "limit": 5000 },
"stats": {
"scanned": 3271, "skipped_no_conv": 12, "skipped_empty": 3,
"shapes": 47, "written": 61
},
"directions": { "openai→claude": 18, "openai→gemini": 14 },
"entries": [
{
"file": "real_inputs/openai__image-tools_a1b2c3d4.json",
"kind": "request",
"format": "openai",
"features": ["image", "tools"],
"shape_hash": "a1b2c3d4...",
"occurrences": 137,
"client_format": "openai",
"upstream_format": "claude",
"relay_mode": "chat_completions",
"is_stream": false,
"model_name": "gpt-4o",
"source_request_id": "...",
"source_log_id": 88123,
"captured_at": "2026-09-11T14:22:31+08:00"
}
]
}
```
**`occurrences` 是最有价值的字段**:该结构形状在采集窗口内出现了多少次,
反映真实流量权重。挑选哪些语料提升进精选金样本时,优先看这个数——
高频形状是线上主干路径,最值得锁死。
`source_log_id` / `source_request_id` 可回溯到 `chn_debug_logs` 原始记录。
---
## 5. 第三步:使用语料(**待实现**)
> **当前状态**:工具链(采集 → 提取 → 脱敏 → 去重 → 落盘)已完成并有单元测试覆盖;
> **测试接线尚未实现**。本节是可直接照做的实施计划,续作从这里开始。
>
> 前置条件:先按 §3 采一批语料、跑一次 `corpus-extract`,**看过真实语料长什么样**
> 再动手——语料的实际形状会影响下面几个断言的写法。
三项工作,按价值排序。建议顺序 (a) → (b) → (c)。
### (a) 不变量测试(主力价值,先做这个)
**目标**:把能力守恒**从「一条手写满配请求」推广到「几百个真实形状」**。
现有 `capExpectations`(`register/capability_invariant_test.go`)只对着一条人工构造的
满配请求验证过,覆盖的是「写的人想得到的组合」。
**落点**:新建 `relaykit/relayconvert/register/realcorpus_test.go`
**实现要点**:
1. 枚举 `golden/real_inputs/*.json`,文件名前缀即客户端格式(`openai__...` → openai)。
用 `parseWireRequest`(该文件已有,与宿主 `parseInboundRequest` 同款裸 unmarshal)解析。
2. 对每条语料 × 每个 `From` 匹配的已注册请求转换器,断言:
- 不 panic;转换失败**允许**(真实流量含畸形请求),但不得返回 `(nil, nil)`
- 成功时结果必须可 `json.Marshal`(宿主紧接着就要序列化发上游)
3. **能力守恒**:复用 `extractProfile` 提取输入与输出画像,逐维比对:
- 自定义工具名集合:输出必须是输入的超集(目标格式支持工具时)
- 图片数、工具循环轮次:不得减少
- 目标格式表达不了的维度按 `capExpectations` 的既有缺口表跳过
4. 流式同理:枚举 `golden/real_stream_inputs/*.txt`,
复用 `goldensuite_stream_test.go` 的 `runStreamConverter`。
**注意**:真实语料的转换失败率不会是 0(畸形请求、罕见字段组合)。
不要把「转换失败」当断言失败——**要断言的是「不崩溃」和「成功时不丢能力」**。
如果失败率异常高(比如 >5%),那本身是个信号,值得逐条看。
**验收**:`go test -run TestRealCorpus ./relayconvert/register/` 通过,
且能打印出「N 条语料 × M 个方向,K 条转换失败」的统计。
### (b) 模糊测试种子
**目标**:真实形状作为变异起点,大幅提升 fuzz 的有效覆盖。
目前 fuzz 从手写对抗种子出发,起点仍受限于想象力。
**落点**:`relaykit/relayconvert/register/fuzz_test.go`
**实现**:`addSeedsFromDir` 机制现成,加目录即可:
```go
addSeedsFromDir(f, filepath.Join("golden", "real_inputs"), ".json")
```
注意 `addSeedsFromDir` 按 `<格式>__<场景>` 解析文件名取格式前缀,
真实语料的命名(`<格式>__<特征>_<指纹>`)第一段同样是格式,可直接复用。
流式目标同理加 `real_stream_inputs`。
**验收**:`go test -run XXX -fuzz FuzzRequestConversion -fuzztime 60s ./relayconvert/register/`
无失败;`gathering baseline coverage` 的种子数应明显上升。
### (c) 精选金样本
**目标**:把最有代表性的真实形状锁进字节级金样本。
**做法**:按 manifest 的 `occurrences` 降序挑 **10–20 条**,重命名为
`<格式>__<场景>.json` 放进手写语料目录 `golden/inputs/`,
在 `goldenScenarios`(`goldensuite_test.go`)登记新场景名,
然后 `go test -run TestGoldenSuite -update-golden ./relayconvert/register/` 生成 expected。
> ⚠️ **不要给全部真实语料生成字节级金样本**。几百个 expected 文件会让任何一次有意改动
> 产生巨量 diff,而「审查变化面、确认改 A 方向没有波及 B 方向」正是金样本套件的核心价值,
> 那样就废了。这也是真实语料与手写语料**分目录**的原因。
### (d) 可选:生产一致性校验
段2/段4 目前未被使用(见 §6.4)。若要做,用法是**重构前采集、重构后回放**:
段1 → 当前转换器 → 与段2 比对。需要先给 `Record` 补段2/段4 字段,
并在 manifest 里记录渠道配置快照(系统提示注入、参数覆盖、模型映射都会造成合理差异)。
优先级低于 (a)(b)(c),等前面跑顺再说。
---
## 6. 工作原理
### 6.1 结构指纹去重
真实流量冗余极大——一万条记录往往只有几十种结构。直接落盘既臃肿又无益,
因此按**结构指纹**去重,每种形状只留代表样本。
指纹算法(`internal/utility/corpus/shape.go`)递归归一化 JSON:
- **对象**:保留排序后的 key,值递归求形状
- **数组**:逐元素求形状后**游程压缩**,连续相同形状折叠为 `{shape, n}`,
`n` 按 `1 / 2 / 3 / many` 分桶
- **字符串**:判别式值(`type`、`role`、`finish_reason` 等,决定转换分支)保留原值;
自由文本(`content`、`text`、`model`、`arguments` 等,见 `freeTextKeys`)折叠为 `#str`
- **数字 / 布尔 / null**:折叠为类型占位
**分桶的意义**:「1 条消息」与「2 条消息」是结构差异要区分;
「50 条」与「51 条」是长度噪音要折叠。
**流式另有一套**(`FingerprintStream`):逐帧取 `data` 负载的形状,
再对帧序列做游程压缩。不折叠的话,「一次回答 3 个 token」和「300 个 token」
会被算作两种形状,去重完全失效。
两个按**值/键形态**兜底的折叠(key 名不可枚举,只能按形态识别):
- **裸 UUID 字符串**折叠为 `#str`:客户端把会话标识塞进任意命名的字段
(codex 的 `prompt_cache_key` / `session_id` / `thread_id` / `x-codex-*` 镜像等),
值随会话变化——当枚举保留会让跨会话的同形状请求指纹互异。
- **按 id 索引的对象 key**(`msg_<uuid>`、32 位十六进制等)折叠进 `#id-keyed` 桶
(保留条目数分桶与值形状):Responses 的 usage 明细按消息 id 建 map,
id 由服务端逐次生成——原样进指纹的话这类报文每次都是新形状。
> **重要**:指纹**只用于去重判等**——把自由文本折叠成 `#str` 不代表语料里就没有内容,
> 它只是让「内容不同、结构相同」的记录被认作同一形状。所以指纹可以折叠得很彻底。
>
> 指纹在**脱敏之后**计算。脱敏保持结构不变,但会重排 key 顺序、归一化数值格式,
> 在原文上算指纹会让同形状的记录因序列化差异被判成不同形状、去重失效。
### 6.2 特征识别
用于文件命名归类,让人浏览目录时一眼看出语料覆盖了什么能力。
采用**跨格式的通用扫描**——遍历 JSON 的全部 key 与判别式字符串值,
对照标记表(`featureMarkers`)匹配,而非按格式写四套解析器。
各协议表达同一能力的字段名不同但都在这张表里(例如 image 同时认
`image_url` / `inlineData` / `input_image`),新增格式无需改代码。
识别的特征:`tools`、`toolloop`、`image`、`thinking`、`websearch`、`audio`、`file`、
`multiturn`(消息数 > 2)。无特征时命名为 `basic`。
特征只影响文件名,识别偏差不影响语料本身。
### 6.3 体积控制
`--max-inline`(默认 8KB):超过阈值的 base64 串替换为 1×1 透明 PNG 的 base64。
此选项**仅在 `--no-scrub` 时生效**——脱敏开启时所有内联数据都会被换成占位图,
体积问题一并解决。
**这是体积控制,不是脱敏**。图片/音频的**内容**对协议转换毫无影响(转换器只搬运
`mimeType` 与 `data` 字段),但一张截图的 base64 动辄数 MB,原样落盘会让语料仓库失控。
选择真实可解码的 PNG 而非任意字符串,是为了让图片处理路径(尺寸探测、格式校验)
拿到替身后仍能正常工作,语料不会因替身而走进异常分支。
### 6.4 段2/段4 为什么不用
段2(发给上游的请求)和段4(返回客户端的字节)是**当时那次转换的输出**,
不是输入。它们的用途是另一件事——**生产一致性校验**:
段1 → 当前转换器 → 与段2 比对,差异即行为变化。
但**差异不等于 bug**:渠道配置(系统提示注入、参数覆盖、模型映射)和代码版本
都会造成合理差异。正确用法是**重构前采集、重构后回放**,当作重构的回归网,
而且需要同时记录渠道配置快照才能可靠比对。
本工具目前不做这件事。
---
## 7. 注意事项
### 脱敏(默认开启)
核心判断:**协议转换器只关心结构,不关心内容**。所以自由文本可以激进替换,
不损失任何测试价值。但「激进」必须止步于两类东西,否则语料会变成**假阳性的来源**——
测试失败了,却不是代码的错,是语料自己坏了。
#### 会被替换的
| 类别 | 处理方式 |
|------|----------|
| 自由文本(`content`/`text`/`instructions` 等) | **等长**填充文本替换 |
| 工具参数(`arguments`/`args`/`input` 子树) | 递归替换值,**保持仍是合法 JSON** |
| 自定义工具名与描述 | 一致映射为 `tool_1`/`tool_2`…(`--keep-tool-names` 可保留) |
| 标识符(`id`/`call_id`/`tool_call_id`/`tool_use_id` 等) | 一致映射,**保留前缀**(`toolu_0001`) |
| 会话级标识符(`session_id`/`thread_id`/`prompt_cache_key`/`x-codex-*` 等) | 一致映射;**裸 UUID 形态的值无论挂在哪个 key 下都兜底映射**(key 名不可枚举,codex 曾把同一会话 UUID 塞进 7 个字段) |
| 内联 base64 数据 | 换成可解码的 1×1 PNG,保留 `data:<mime>;base64,` 前缀 |
| 远程 URL | 换成 `https://scrubbed.example/resource` |
| 思考签名、加密块 | 固定标记 |
| 凭证形态(`sk-`/`AIza`/`ghp_`/JWT 等) | 无条件替换,**不管出现在哪个字段** |
#### 必须保留的(否则语料失效)
**① 引用一致性** —— 这是最容易做错、也是后果最严重的一条:
- OpenAI:`tool_calls[].id` ←→ `messages[].tool_call_id`
- Claude:`tool_use.id` ←→ `tool_result.tool_use_id`
- Responses:`function_call.call_id` ←→ `function_call_output.call_id`
- **Gemini 没有 id,`functionCall` ↔ `functionResponse` 靠函数名关联** ——
所以工具名也必须一致映射
打断任何一条,工具循环链就断了。而工具循环恰是协议转换最容易出 bug 的地方,
语料坏掉会让测试报「工具循环丢失」,排查半天发现是语料的锅。
实现上是按原值建映射表、整条记录内全局一致替换;**同一记录的请求与响应共用同一张表**,
保证两个语料文件之间的引用也对得上。
**② 判别式与行为相关字段** —— 原样保留:
- `type` / `role` / `finish_reason` / `stop_reason`:决定转换分支
- `model`:决定模型相关分支(Claude 默认 `max_tokens` 按模型名分档、
Vertex 按名字是否含 `claude` 选端点)
- 协议内置工具名(`web_search`、`code_execution`、`bash` 等):
转换器按名识别服务端工具,改掉语料就不再触发那些能力分支
- 数字与布尔(`max_tokens`、`temperature`、`usage`):影响转换且不含隐私
**③ 形态约束**:
- tool 的 `arguments` 是**JSON 字符串**,替换后必须仍是合法 JSON——
否则转换器走进解析失败分支,语料反映的就不是线上行为
- 自由文本按**字节长度**等长替换——token 估算(2 字符/token)、截断阈值、
grounding 的字节偏移校正都与长度相关,长度塌缩会丢掉这些路径的覆盖
#### 已知代价
脱敏经过「解析 → 改写 → 重新序列化」,输出的 key 顺序与空白和原文不同。
语料本就要被转换器重新解析,语义等价即可;但这意味着**脱敏后的语料不适合
做字节级的 wire 保真校验**(重复 key、异常转义之类的原始痕迹会丢失)。
需要那种校验时用 `--no-scrub` 单独采一批。
#### 仍需人工确认
脱敏是启发式的,不是保证。**提交前抽查几个文件**——尤其注意自定义字段:
落在未知 key 下、短且无空格的字符串会按「疑似枚举值」放行。
### 定期刷新
协议在演进(新字段、新能力),旧语料会逐渐偏离现实。建议在大版本升级或
接入新客户端后重新采集一轮。
---
## 8. 故障排查
**查不到日志**
命令会提示三个检查点:目标渠道是否已开启 `debug_log_enabled`、采集窗口内是否确有流量、
`--since`/`--until`/`--channel` 是否过严。另外注意三个过滤器
(tenant/user/api_key)是 AND 关系,配错会导致一条都录不上。
**形状数接近日志条数(去重失效)**
说明指纹把本应相同的结构判成了不同。常见原因是某个自由文本字段没进
`freeTextKeys`(`internal/utility/corpus/shape.go`)——比如新增的协议字段。
把它加进表即可。
**方向覆盖不全**
覆盖表缺某个方向,说明没有对应的(客户端格式 × 渠道协议)组合流量。
跨原生方向(如 `claude→gemini`、`responses→claude`)尤其容易漏——
需要专门配置一个 Gemini 渠道并用 Claude 格式去请求它。
**某类语料一条都没有**
- `real_responses/` 为空:可能全部流量都是流式的(正常),或上游都没返回 200
- `real_stream_inputs/` 为空:采集的全是非流式请求
- 全部为空但 `scanned` 不为 0:检查 `skipped_no_conv` 是否很高,
说明采到的是非 relay 路径流量
---
## 9. 代码位置
| 路径 | 职责 |
|------|------|
| `internal/cmd/cmd_corpus_extract.go` | CLI 命令:参数解析、数据库查询、报文解码 |
| `internal/utility/corpus/shape.go` | 结构指纹(含流式) |
| `internal/utility/corpus/sample.go` | 特征识别、样本构建、manifest 结构 |
| `internal/utility/corpus/collect.go` | 去重收集、落盘 |
| `internal/utility/corpus/scrub.go` | 脱敏:一致映射、字段分类、等长替换 |
| `internal/utility/corpus/shrink.go` | 内联数据体积控制(`--no-scrub` 路径) |
| `internal/utility/corpus/corpus_test.go` | 指纹/特征/去重单元测试 |
| `internal/utility/corpus/scrub_test.go` | 脱敏单元测试(重点:引用一致性) |
> 纯逻辑放在 `internal/utility/` 而非 `internal/logic/`,是因为后者会被
> `gf gen service` 扫描并产生噪音文件(`internal/dispatchadapter` 当年正因此被移出
> `internal/logic`)。
**扩展点**:
- 新增协议格式 → 在 `featureMarkers` 补标记词;如有新的自由文本字段,
同步加进 `freeTextKeys`
- 调整去重粒度 → 改 `bucketCount` 的分桶策略或 `freeTextKeys` 的范围
- 新增语料类别 → 加 `SampleKind` 并实现 `dirOf()` / `ext()`
- **新增协议字段时检查脱敏分类**(`scrub.go`):承载标识符的加进 `idKeys`
(否则引用会断)、影响转换分支的加进 `behaviorKeys`(否则语料不再触发该分支)、
承载用户数据的加进 `userDataKeys`(否则可能漏脱敏)
---
## 10. 当前进度与续作指引
> 本章是「下次接着做」的入口。改动本功能后请同步更新。
### 已完成(2026-09-11)
工具链全部打通,全仓测试通过:
- `corpus-extract` CLI 命令,已注册进 `main`(`team-api corpus-extract -h` 可见)
- 结构指纹去重(含流式游程压缩与计数分桶)
- 跨格式特征识别与文件命名
- 脱敏(默认开启):一致映射、字段分类、等长替换、凭证兜底
- manifest 生成(含 `occurrences` 流量权重)
- 单元测试:`corpus_test.go`(指纹/特征/去重)+ `scrub_test.go`(脱敏,重点是引用一致性)
### 首轮真实提取(2026-09-12)
用 Claude Code(claude 入站)+ codex(responses 入站)采集了 200+ 条调试日志,
正式提取出 236 个语料文件(claude→responses 80 / claude→gemini 25 / claude→openai 17 /
responses→responses 36 / claude→claude 78)。过程中发现并修复了四个工具链问题:
1. **会话标识符泄漏**:codex 把同一会话 UUID 塞进 `prompt_cache_key` / `session_id` /
`thread_id` / `turn_id` / `root_turn_id` / `x-codex-window-id` / `x-codex-installation-id`
七个字段,key 名不可枚举——脱敏与指纹都改为按裸 UUID **值形态**兜底(`scrub.go` ①、
`shape.go` isEnumLike),跨会话去重不再碎片化。
2. **usage 明细的 id-keyed map 撕裂去重**:Responses 上游按消息 id 建 usage map,
指纹把对象 key 原样保留导致这类流永不判等——`shapeOf` 将 id 形态的 key 折叠进
`#id-keyed` 桶(保留数量分桶与值形状)。
3. **重复提取产生孤儿文件**:脱敏/指纹语义变化会改文件名,旧文件残留且被目录枚举型
测试误读——`Write` 现在每次先清空三个 `real_*` 目录再重建。
4. **CLI 摘要与 Brief 谎报「未脱敏」**:修正为按实际状态提示。
同一批语料还直接定位并修复了第一个线上 bug:**Gemini 工具 schema 对 `prefixItems`
元组语法的 400**(见 §1),回归测试用的就是语料里的真实 schema。
**剩余缺口**(对照 §3 的矩阵):openai / gemini 入站完全缺失、全部方向的非流式为 0、
`real_responses/` 为空、image / audio / file 特征为 0、ollama 上游未接。
### 第二轮采集与修复(2026-09-12,codex 补充语料)
补充了 codex 打 claude / deepseek / gemini 模型的流量(424 条日志 → 488 个语料),
据此定位并修复了两个转换层 bug、一个语料工具 bug:
1. **DeepSeek thinking 模式 400**(`reasoning_content must be passed back`):
codex 多轮历史里的 reasoning 项被 responses→chat 转换直接丢弃。修复为回传——
明文思考文本(summary / content 两种形态)挂到下一条 assistant 消息的
`reasoning_content`(relaykit 与 relay/channel/openai 两处镜像同步);
同时 chat→responses 流式合成器补齐 reasoning 输出项(此前只发孤儿 delta,
客户端无法在下一轮回传,纯 chat 链路的多轮会话必 400)。
2. **短中文脱敏泄漏**:中文不含空格,「短且无空格即枚举」的启发式对 CJK 失效,
用户输入与思考内容原样落进语料。修复:值含 CJK 表意字符一律按自由文本处理
(脱敏等长替换 + 指纹折叠)。
3. **乱码排查(未定位到网关侧成因)**:codex→gpt(responses 直连)流式中文乱码。
全链路审计结论:上游字节(段3)全部合法 UTF-8、delta 无 rune 截断、内容语义正常;
网关直连路径逐行字节透传(tee/帧化/保活均不改写字节)。**下次复现时保持调试开启**,
段4(客户端最终收到的字节)会揭示污染发生在哪一段。
### 待做
| 优先级 | 事项 | 说明 |
|--------|------|------|
| 1 | **采一批真实语料** | 前置条件。按 §3 的打法采集,跑 `--dry-run` 看方向覆盖 |
| 2 | **(a) 不变量测试** | 见 §5(a),落点 `register/realcorpus_test.go`。主力价值 |
| 3 | **(b) fuzz 种子** | 见 §5(b),改一行即可 |
| 4 | **(c) 精选金样本** | 见 §5(c),按 `occurrences` 挑 10–20 条 |
| 5 | (d) 生产一致性校验 | 见 §5(d),可选,优先级最低 |
**下一步具体动作**:开渠道调试 → 按 §3 采集 → `corpus-extract --dry-run` →
确认方向覆盖 → 正式提取 → 抽查语料 → 开始 §5(a)。
### 已定决策(不必重新讨论)
这些是实现时权衡过的,改动前先看理由:
| 决策 | 理由 |
|------|------|
| 做成 CLI 而非管理端接口 | 四段报文含请求体原文,不该经 HTTP 出网 |
| 纯逻辑放 `internal/utility/` 而非 `internal/logic/` | 后者会被 `gf gen service` 扫描产生噪音文件 |
| 真实语料与手写语料**分目录** | 真实语料量大且增长;手写目录有「文件名必须匹配已知场景」的校验 |
| 只用段1/段3,不用段2/段4 | 段2/4 是转换的**输出**不是输入;用它们是另一种玩法(§6.4) |
| 指纹在**脱敏之后**计算 | 脱敏会重排 key 顺序,在原文上算会导致去重失效 |
| 脱敏默认**开启** | 安全默认;`--no-scrub` 显式选择原文 |
| 工具名默认**替换**、内置名保留 | 自定义名可能泄漏业务逻辑;内置名是转换器的识别依据 |
| `model` 字段**保留原值** | 决定 Claude 默认 max_tokens 分档与 Vertex 端点选择 |
| 不给全部真实语料生成金样本 | 会让「审查变化面」的能力失效(§5(c)) |
### 踩过的坑
- **引用一致性**是脱敏最容易做错的地方,且后果隐蔽:语料坏掉会让测试报
「工具循环丢失」,排查半天才发现是语料的锅。四种协议的关联方式各不相同,
Gemini 甚至没有 id、靠函数名关联(§7)。
- **流式指纹必须折叠帧数**。不折叠的话每条流都是独一无二的形状,去重完全失效。
- **tool `arguments` 是 JSON 字符串**,脱敏后必须仍是合法 JSON,
否则转换器走进解析失败分支,语料反映的不是线上行为。
- **脱敏后的语料不适合做字节级 wire 保真校验**(重新序列化丢失了 key 顺序等原始痕迹),
需要时用 `--no-scrub` 单独采一批。
- **启发式要考虑 CJK**:「无空格即枚举」「值形态」这类判断对中文全部失效过一遍——
短中文被当枚举放行(泄漏)、会话 UUID 要按值形态兜底(key 不可枚举)、
usage 明细按 id 建 map(对象 key 也会撕裂去重)。凡是按字符串形态做判断的地方,
先想一遍 CJK 和动态标识符两个反例。
### 相关文档
- `docs/reference/api-format-reference.md` —— 各协议的 JSON / SSE 格式参考
- `docs/Claude-Code请求问题排查记录.md` —— 首轮采集(Claude Code 流量)暴露的问题与修复
- `docs/codex请求问题排查记录.md` —— 第二轮采集(codex 流量)暴露的问题与修复
- `relaykit/relayconvert/register/` —— 六层测试防线的实现(金样本、能力守恒、
矩阵守卫、模糊测试)
+97 -9
View File
@@ -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,36 @@ 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)
// 累计消费基线种子:为存量钱包 hash 补种 total_consumed 历史基线(幂等),
// 必须在 relay 流量进入前完成,防止结算 HINCRBY 从 0 起算丢历史
billing.SeedWalletTotalConsumed(ctx)
// 启动钱包物化器(boot goroutine,秒级刷新 Redis 权威钱包状态到 DB;不走 cron)
billing.StartWalletMaterializer(ctx)
@@ -223,7 +249,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 +284,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 +366,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 +397,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) {
@@ -462,7 +520,7 @@ func registerPaymentCallbacks(group *ghttp.RouterGroup) {
// registerRelayRoutes registers AI proxy routes (/v1/xxx).
func registerRelayRoutes(server *ghttp.Server) {
server.Group("/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter)
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter)
group.POST("/chat/completions", relay.HandleChatCompletions)
group.GET("/models", relay.HandleModels)
@@ -477,6 +535,7 @@ func registerRelayRoutes(server *ghttp.Server) {
group.POST("/responses/{id}/cancel", relay.HandleResponsesCancel)
group.DELETE("/responses/{id}", relay.HandleResponsesDelete)
group.POST("/messages", relay.HandleMessages)
group.POST("/messages/count_tokens", relay.HandleCountTokens)
group.POST("/audio/speech", relay.HandleAudioSpeech)
group.POST("/audio/transcriptions", relay.HandleAudioTranscription)
group.POST("/audio/translations", relay.HandleAudioTranslation)
@@ -484,11 +543,16 @@ func registerRelayRoutes(server *ghttp.Server) {
group.POST("/moderations", relay.HandleModerations)
group.POST("/images/edits", relay.HandleImagesEdits)
group.GET("/realtime", relay.HandleRealtime)
// OpenAI Videos 协议端点(官方 SDK 兼容,对接第三方视频模型)
group.POST("/videos", relay.HandleVideoCreate)
group.GET("/videos/{video_id}", relay.HandleVideoRetrieve)
group.GET("/videos/{video_id}/content", relay.HandleVideoContent)
group.DELETE("/videos/{video_id}", relay.HandleVideoDelete)
})
// Gemini 兼容路由(/v1beta/models/{model}:generateContent)
server.Group("/v1beta", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter)
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter)
group.GET("/models", relay.HandleGeminiModels)
group.GET("/models/{model}", relay.HandleGeminiModelDetail)
group.POST("/models/{model}", relay.HandleGeminiGenerateContent)
@@ -496,25 +560,49 @@ func registerRelayRoutes(server *ghttp.Server) {
// 异步任务端点(视频/音乐生成)
server.Group("/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter)
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter)
group.POST("/video/generations", relay.HandleTaskSubmit)
group.GET("/video/generations/{task_id}", relay.HandleTaskFetch)
})
// 异步图片生成端点(阿里云 DashScope 等)
server.Group("/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter)
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter)
group.POST("/images/generations/async", relay.HandleAliImageSubmit)
group.GET("/images/generations/async/{task_id}", relay.HandleTaskFetch)
})
// Suno 端点
server.Group("/suno", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.ContentFilter)
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter)
group.POST("/submit/{action}", relay.HandleTaskSubmit)
group.POST("/fetch", relay.HandleSunoFetchBatch)
group.GET("/fetch/{task_id}", relay.HandleTaskFetch)
})
// MiniMax 官方视频协议端点(H3 v2,官方 SDK 换 base_url 直连;协议文档 docs/modeldocs/minimax/video/)
server.Group("/v2", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter)
group.POST("/video_generation", relay.HandleMiniMaxVideoSubmit)
group.GET("/query/video_generation/{task_id}", relay.HandleMiniMaxVideoRetrieve)
group.DELETE("/video_generation/{task_id}", relay.HandleMiniMaxVideoCancel)
})
// MiniMax 官方视频协议端点(Hailuo 系列 v1,扁平字段协议;查询走 query 参数,
// 成品经 files/retrieve 二跳由网关轮询时代取,官方无取消/删除端点)
server.Group("/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter)
group.POST("/video_generation", relay.HandleMiniMaxVideoV1Submit)
group.GET("/query/video_generation", relay.HandleMiniMaxVideoV1Retrieve)
})
// 阿里 DashScope 官方视频协议端点(wan2.x / wan3.0 通用,阿里 SDK 换 base_url 直连;
// 查询回放官方响应形态,轮询计费由网关后台完成)
server.Group("/v1", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.ApiMaintenance, middleware.MaintenanceMode, middleware.ApiKeyAuth, middleware.RelayBodyLimit, middleware.ContentFilter)
group.POST("/services/aigc/video-generation/video-synthesis", relay.HandleAliVideoSubmit)
group.GET("/tasks/{task_id}", relay.HandleAliVideoRetrieve)
})
}
// registerLandingPage 在未内嵌前端(非 embedweb 构建)时,为后端根路径注册项目介绍页,
+439
View File
@@ -0,0 +1,439 @@
package cmd
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/gogf/gf/v2/os/gcmd"
"github.com/gogf/gf/v2/os/gtime"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/utility/corpus"
)
// corpus-extract:从 chn_debug_logs 提取协议转换测试语料。
//
// 为什么做成 CLI 而不是管理端接口:调试日志是四段完整报文(含请求体原文),
// 不该经 HTTP 出网;CLI 走应用自身配置连库,查询、去重、落盘一次完成。
//
// 默认对报文体做脱敏:自由文本等长替换、内联数据换占位图,同时**保持引用一致性**
// (tool_call_id / 工具名的映射在一条记录内前后一致)与判别式字段原值。
// --no-scrub 可关闭,但那样落盘的是报文原文,仅限确知无敏感数据的测试流量。
const (
// defaultCorpusOutput 语料输出根目录:**仓库外的独立目录**(语料体积由结构决定、
// 动辄数百 MB,不进 team-api 仓库)。可用环境变量 TEAM_API_CORPUS_DIR 覆盖,
// 下游测试按同一变量发现语料(缺失即跳过)。
defaultCorpusOutput = "../team-api-corpus"
defaultScanLimit = 5000
defaultMaxInline = 8192
defaultMaxText = 16384
)
var corpusExtractCmd = gcmd.Command{
Name: "corpus-extract",
Usage: "corpus-extract [-o <目录>] [--since <时间>] [--channel <ID,...>] [--limit N]",
Brief: "从渠道调试日志提取协议转换测试语料(默认脱敏)",
Arguments: []gcmd.Argument{
{Name: "output", Short: "o", Brief: "语料输出根目录(默认 " + defaultCorpusOutput + ")"},
{Name: "since", Brief: "起始时间,如 2026-09-01 或 2026-09-01T10:00:00Z"},
{Name: "until", Brief: "结束时间,同上"},
{Name: "channel", Brief: "渠道 ID 过滤,逗号分隔"},
{Name: "format", Brief: "客户端格式过滤:openai / claude / gemini / responses"},
{Name: "limit", Brief: fmt.Sprintf("最多扫描日志行数(默认 %d)", defaultScanLimit)},
{Name: "per-shape", Brief: "每种结构形状保留的样本数(默认 1)"},
{Name: "max-text", Brief: fmt.Sprintf("单个字符串值的脱敏填充长度上限(默认 %d,0=等长不截断)", defaultMaxText)},
{Name: "max-inline", Brief: fmt.Sprintf("内联 base64 数据超过该字节数则替换为占位,控制语料体积(默认 %d,0=不处理)", defaultMaxInline)},
{Name: "no-scrub", Brief: "关闭脱敏,落盘报文原文(仅限确知无敏感数据的测试流量)", Orphan: true},
{Name: "keep-tool-names", Brief: "脱敏时保留自定义工具名原值(内置工具名始终保留)", Orphan: true},
{Name: "include-errors", Brief: "包含失败尝试(默认只取成功的最终尝试)", Orphan: true},
{Name: "dump-segments", Brief: "打印指定日志 ID(逗号分隔)的段1/段2报文片段,用于诊断"},
{Name: "dry-run", Brief: "只统计不落盘", Orphan: true},
},
Func: runCorpusExtract,
}
func runCorpusExtract(ctx context.Context, parser *gcmd.Parser) error {
output := corpusOutputDir(parser)
limit := parser.GetOpt("limit", defaultScanLimit).Int()
perShape := parser.GetOpt("per-shape", 1).Int()
maxInline := parser.GetOpt("max-inline", defaultMaxInline).Int()
noScrub := parser.GetOpt("no-scrub") != nil
keepToolNames := parser.GetOpt("keep-tool-names") != nil
includeErrors := parser.GetOpt("include-errors") != nil
dryRun := parser.GetOpt("dry-run") != nil
maxText := parser.GetOpt("max-text", fmt.Sprintf("%d", defaultMaxText)).Int()
channelFilter := parser.GetOpt("channel").String()
formatFilter := parser.GetOpt("format").String()
since := parser.GetOpt("since").String()
until := parser.GetOpt("until").String()
rows, err := queryDebugLogs(ctx, debugLogQuery{
since: since,
until: until,
channels: channelFilter,
format: formatFilter,
limit: limit,
includeErrors: includeErrors,
})
if err != nil {
return err
}
if len(rows) == 0 {
fmt.Println("未查询到符合条件的调试日志。请确认:")
fmt.Println(" 1) 目标渠道已开启 debug_log_enabled;")
fmt.Println(" 2) 采集窗口内确有流量;")
fmt.Println(" 3) --since / --until / --channel 过滤条件是否过严。")
return nil
}
scrubOpts := corpus.ScrubOptions{Enabled: !noScrub, KeepToolNames: keepToolNames, MaxTextLen: maxText}
collector := corpus.NewCollector(perShape, scrubOpts)
for _, row := range rows {
collector.Add(row.toRecord(maxInline))
}
if includeErrors {
printFailedAttempts(rows)
}
if ids := splitIDs(parser.GetOpt("dump-segments").String()); len(ids) > 0 {
printSegments(rows, ids)
}
samples := collector.Samples()
stats, err := corpus.Write(samples, collector.Stats(), corpus.WriteOptions{
OutputDir: output,
DryRun: dryRun,
Scrubbed: !noScrub,
Source: corpus.ManifestSource{
Since: since, Until: until,
Channel: channelFilter, Format: formatFilter,
Limit: limit,
},
})
if err != nil {
return err
}
printCorpusSummary(output, stats, samples, dryRun, !noScrub)
return nil
}
// corpusOutputDir 语料输出目录:命令行 -o > 环境变量 TEAM_API_CORPUS_DIR > 默认仓库外目录。
func corpusOutputDir(parser *gcmd.Parser) string {
if v := parser.GetOpt("output").String(); v != "" {
return v
}
if v := os.Getenv("TEAM_API_CORPUS_DIR"); v != "" {
return v
}
return defaultCorpusOutput
}
// printFailedAttempts 打印采集窗口内全部请求的时间线,失败尝试附上游状态、
// 错误信息与响应体片段(--include-errors 时)。段3 错误体按设计不进语料
// (不可作为响应语料),但它是排查「客户端没反应」类问题的关键证据。
func printFailedAttempts(rows []debugLogRow) {
for _, r := range rows {
created := ""
if r.CreatedAt != nil {
created = r.CreatedAt.Time.Format("15:04:05")
}
fmt.Printf("%s log=%-4d %-18s ch=%-2d retry=%d us=%-3d cs=%-3d stream=%-5v %s→%s\n",
created, r.Id, r.ModelName, r.ChannelType, r.RetryIndex,
r.UpstreamStatusCode, r.ClientStatusCode, r.IsStream,
convClient(r.Conversion), convUpstream(r.Conversion))
if r.Error != "" {
fmt.Printf(" error: %s\n", truncateStr(r.Error, 300))
}
if r.UpstreamStatusCode != 200 && r.UpstreamStatusCode != 0 {
body := decodeBody(r.UpstreamRespBody, r.UpstreamRespEncoding)
fmt.Printf(" 段3: %s\n", truncateStr(string(body), 400))
}
}
}
func truncateStr(s string, n int) string {
if len(s) > n {
return s[:n] + "..."
}
return s
}
// printSegments 打印指定日志的段2(发往上游的最终请求)结构化摘要,诊断转换结果用。
// 只打字段级信息不打原文:报文体动辄数十 KB(codex 系统提示),原文会淹没真正要看的参数区。
func printSegments(rows []debugLogRow, ids []string) {
wanted := make(map[string]bool, len(ids))
for _, id := range ids {
wanted[id] = true
}
for _, r := range rows {
if !wanted[fmt.Sprintf("%d", r.Id)] {
continue
}
fmt.Printf("===== log=%d %s %s→%s us=%d =====\n", r.Id, r.ModelName,
convClient(r.Conversion), convUpstream(r.Conversion), r.UpstreamStatusCode)
body := decodeBody(r.UpstreamReqBody, r.UpstreamReqEncoding)
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
fmt.Printf(" 段2 非 JSON(%v): %s\n", err, truncateStr(string(body), 200))
continue
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
fmt.Printf(" 顶层字段: %s\n", strings.Join(keys, ", "))
for _, k := range []string{"model", "max_tokens", "max_completion_tokens", "reasoning_effort", "temperature", "top_p", "stream"} {
if v, ok := m[k]; ok {
fmt.Printf(" %s: %v\n", k, v)
}
}
if v, ok := m["web_search_options"]; ok {
fmt.Printf(" web_search_options: %s\n", truncateStr(fmt.Sprint(v), 200))
} else {
fmt.Println(" web_search_options: <无>")
}
if tools, ok := m["tools"].([]any); ok {
types := map[string]int{}
for _, t := range tools {
if tm, ok := t.(map[string]any); ok {
if ty, _ := tm["type"].(string); ty != "" {
types[ty]++
}
}
}
fmt.Printf(" tools: %d 个 %v\n", len(tools), types)
} else {
fmt.Println(" tools: <无>")
}
if msgs, ok := m["messages"].([]any); ok {
roles := map[string]int{}
for _, msg := range msgs {
if mm, ok := msg.(map[string]any); ok {
if r, _ := mm["role"].(string); r != "" {
roles[r]++
}
}
}
fmt.Printf(" messages: %d 条 %v\n", len(msgs), roles)
}
}
}
func convClient(conversion string) string {
var c struct {
ClientFormat string `json:"client_format"`
}
_ = json.Unmarshal([]byte(conversion), &c)
return c.ClientFormat
}
func convUpstream(conversion string) string {
var c struct {
UpstreamFormat string `json:"upstream_format"`
}
_ = json.Unmarshal([]byte(conversion), &c)
return c.UpstreamFormat
}
func printCorpusSummary(output string, stats corpus.ManifestStats, samples []corpus.Sample, dryRun, scrubbed bool) {
fmt.Printf("\n扫描 %d 条日志 → %d 种结构形状 → %d 个语料文件\n",
stats.Scanned, stats.Shapes, stats.Written)
if stats.SkippedNoConv > 0 {
fmt.Printf(" 跳过 %d 条(缺 conversion 协议元数据,可能是非 relay 路径)\n", stats.SkippedNoConv)
}
if stats.SkippedEmpty > 0 {
fmt.Printf(" 跳过 %d 条(报文体为空或非 JSON)\n", stats.SkippedEmpty)
}
byDir := map[string]int{}
for _, s := range samples {
if s.Meta.ClientFormat != "" && s.Meta.UpstreamFormat != "" {
byDir[s.Meta.ClientFormat+"→"+s.Meta.UpstreamFormat]++
}
}
if len(byDir) > 0 {
fmt.Println("\n转换方向覆盖:")
for dir, n := range byDir {
fmt.Printf(" %-28s %d\n", dir, n)
}
fmt.Println("(方向覆盖不全时,补充对应渠道的调试采集再跑一次)")
}
if dryRun {
fmt.Println("\n[dry-run] 未写入任何文件")
return
}
fmt.Printf("\n语料已写入 %s/{real_inputs,real_stream_inputs,real_responses}\n", output)
fmt.Printf("清单:%s/corpus_manifest.json\n", output)
if scrubbed {
fmt.Println("\n语料已脱敏(自由文本等长替换、标识符一致映射、内联数据换占位图);" +
"脱敏是启发式的,提交前建议抽查确认。")
} else {
fmt.Println("\n⚠️ 语料为报文原文、未做脱敏,提交前请确认其中不含真实业务数据。")
}
}
// ---------- 数据库查询 ----------
type debugLogQuery struct {
since, until string
channels string
format string
limit int
includeErrors bool
}
// debugLogRow 与 chn_debug_logs 查询字段对应。
// (dump-segments 诊断需要段2 发往上游的最终请求体)
type debugLogRow struct {
Id int64 `orm:"id"`
RequestId string `orm:"request_id"`
ChannelType int `orm:"channel_type"`
ModelName string `orm:"model_name"`
UpstreamModel string `orm:"upstream_model"`
RelayMode string `orm:"relay_mode"`
InboundPath string `orm:"inbound_path"`
IsStream bool `orm:"is_stream"`
RetryIndex int `orm:"retry_index"`
UpstreamStatusCode int `orm:"upstream_status_code"`
ClientStatusCode int `orm:"client_status_code"`
Error string `orm:"error"`
ClientReqBody string `orm:"client_req_body"`
ClientReqEncoding string `orm:"client_req_encoding"`
UpstreamReqBody string `orm:"upstream_req_body"`
UpstreamReqEncoding string `orm:"upstream_req_encoding"`
UpstreamRespBody string `orm:"upstream_resp_body"`
UpstreamRespEncoding string `orm:"upstream_resp_encoding"`
Conversion string `orm:"conversion"`
CreatedAt *gtime.Time `orm:"created_at"`
}
func queryDebugLogs(ctx context.Context, q debugLogQuery) ([]debugLogRow, error) {
m := dao.ChnDebugLogs.Ctx(ctx).
Fields("id,request_id,channel_type,model_name,upstream_model,relay_mode,inbound_path,"+
"is_stream,retry_index,upstream_status_code,client_status_code,error,"+
"client_req_body,client_req_encoding,upstream_req_body,upstream_req_encoding,"+
"upstream_resp_body,upstream_resp_encoding,conversion,created_at").
// 只取最终尝试:中间失败尝试的段1 与最终尝试相同,段3 多为错误体
Where("is_final", true).
OrderDesc("id")
if !q.includeErrors {
// 只取上游成功的尝试:失败尝试的段3 是错误体,不是可用的响应语料
m = m.Where("upstream_status_code", 200)
}
if q.since != "" {
t, err := parseTimeFlag(q.since)
if err != nil {
return nil, fmt.Errorf("--since 时间格式无法解析: %w", err)
}
m = m.WhereGTE("created_at", t)
}
if q.until != "" {
t, err := parseTimeFlag(q.until)
if err != nil {
return nil, fmt.Errorf("--until 时间格式无法解析: %w", err)
}
m = m.WhereLTE("created_at", t)
}
if ids := splitIDs(q.channels); len(ids) > 0 {
m = m.WhereIn("channel_id", ids)
}
if q.format != "" {
// conversion 是 JSONB,按 client_format 过滤
m = m.Where("conversion->>'client_format' = ?", q.format)
}
if q.limit > 0 {
m = m.Limit(q.limit)
}
var rows []debugLogRow
if err := m.Scan(&rows); err != nil {
return nil, fmt.Errorf("查询调试日志失败: %w", err)
}
return rows, nil
}
// toRecord 解码报文体并解析协议元数据。
// maxInline > 0 时把超长的 base64 内联数据替换为占位,避免单条语料膨胀到数 MB。
func (r debugLogRow) toRecord(maxInline int) corpus.Record {
rec := corpus.Record{
ID: r.Id,
RequestID: r.RequestId,
ChannelType: r.ChannelType,
ModelName: r.ModelName,
UpstreamModel: r.UpstreamModel,
RelayMode: r.RelayMode,
InboundPath: r.InboundPath,
IsStream: r.IsStream,
UpstreamStatus: r.UpstreamStatusCode,
ClientReqBody: decodeBody(r.ClientReqBody, r.ClientReqEncoding),
UpstreamRespBody: decodeBody(r.UpstreamRespBody, r.UpstreamRespEncoding),
}
if r.CreatedAt != nil {
rec.CapturedAt = r.CreatedAt.Time
}
if r.Conversion != "" {
_ = json.Unmarshal([]byte(r.Conversion), &rec.Conversion)
}
if maxInline > 0 {
rec.ClientReqBody = corpus.ShrinkInlineData(rec.ClientReqBody, maxInline)
rec.UpstreamRespBody = corpus.ShrinkInlineData(rec.UpstreamRespBody, maxInline)
}
return rec
}
// decodeBody 按 encoding 列还原原始字节(plain 直取、base64 解码)。
func decodeBody(body, encoding string) []byte {
if body == "" {
return nil
}
if encoding == "base64" {
if raw, err := base64.StdEncoding.DecodeString(body); err == nil {
return raw
}
return nil
}
return []byte(body)
}
func parseTimeFlag(s string) (*gtime.Time, error) {
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
// 无时区的格式按本地时区解释(time.Parse 会按 UTC,造成「白天的时间被当成晚上」
// 而查不到日志);RFC3339 自带时区偏移,Parse 与 ParseInLocation 行为一致
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return gtime.NewFromTime(t), nil
}
}
return nil, fmt.Errorf("支持的格式:2006-01-02 / 2006-01-02 15:04:05 / RFC3339,实际收到 %q", s)
}
func splitIDs(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
var out []string
for _, part := range strings.Split(s, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
func init() {
if err := Main.AddCommand(&corpusExtractCmd); err != nil {
panic(err)
}
}
+13 -12
View File
@@ -150,18 +150,17 @@ const (
CodeInvalidConfirmToken = 10046
// Security errors (10047+)
CodeTotpRequired = 10047
CodeTotpInvalid = 10048
CodeTotpAlreadyEnabled = 10049
CodeTotpNotEnabled = 10050
CodeBackupCodeInvalid = 10051
CodeTurnstileFailed = 10052
CodeIpRestricted = 10053
CodeHighRisk2FARequired = 10054
CodeNoAvailableApiKey = 10055
CodeSandboxQuotaExceeded = 10056
CodeCaptchaRequired = 10057
CodeCaptchaFailed = 10058
CodeTotpRequired = 10047
CodeTotpInvalid = 10048
CodeTotpAlreadyEnabled = 10049
CodeTotpNotEnabled = 10050
CodeBackupCodeInvalid = 10051
CodeTurnstileFailed = 10052
CodeIpRestricted = 10053
CodeHighRisk2FARequired = 10054
CodeNoAvailableApiKey = 10055
CodeCaptchaRequired = 10057
CodeCaptchaFailed = 10058
// OAuth errors (10059+)
CodeOAuthDisabled = 10059
@@ -183,6 +182,7 @@ const (
CodeHelpCategorySlugExists = 10074
CodeHelpArticleNotFound = 10075
CodeHelpArticleSlugExists = 10076
CodeHelpRateLimitExceeded = 10099
// Registration errors
CodeRegistrationDisabled = 10077
@@ -338,6 +338,7 @@ const (
MsgHelpCategorySlugExists = "分类标识已存在"
MsgHelpArticleNotFound = "帮助文章不存在"
MsgHelpArticleSlugExists = "文章标识已存在"
MsgHelpRateLimitExceeded = "访问过于频繁,请稍后再试"
// Registration errors
MsgRegistrationDisabled = "注册功能已关闭"
MsgEmailVerificationDisabled = "注册邮箱验证未启用"
@@ -34,3 +34,6 @@ func (c *ControllerV1) ContentFilterLogList(ctx context.Context, req *v1.Content
func (c *ControllerV1) ForwardingTraceGet(ctx context.Context, req *v1.ForwardingTraceGetReq) (res *v1.ForwardingTraceGetRes, err error) {
return service.Admin().ForwardingTraceGet(ctx, req)
}
func (c *ControllerV1) ContentFilterLogClear(ctx context.Context, req *v1.ContentFilterLogClearReq) (res *v1.ContentFilterLogClearRes, err error) {
return service.Admin().ContentFilterLogClear(ctx, req)
}
@@ -28,3 +28,6 @@ func (c *ControllerV1) AdminForceLogout(ctx context.Context, req *v1.AdminForceL
func (c *ControllerV1) AdminChangePassword(ctx context.Context, req *v1.AdminChangePasswordReq) (res *v1.AdminChangePasswordRes, err error) {
return service.Admin().ChangePassword(ctx, req)
}
func (c *ControllerV1) AdminMe(ctx context.Context, req *v1.AdminMeReq) (res *v1.AdminMeRes, err error) {
return service.Admin().GetMe(ctx, req)
}
@@ -52,3 +52,6 @@ func (c *ControllerV1) ChannelClone(ctx context.Context, req *v1.ChannelCloneReq
func (c *ControllerV1) ChannelResetHealth(ctx context.Context, req *v1.ChannelResetHealthReq) (res *v1.ChannelResetHealthRes, err error) {
return service.Admin().ResetChannelHealth(ctx, req)
}
func (c *ControllerV1) ChannelOptions(ctx context.Context, req *v1.ChannelOptionsReq) (res *v1.ChannelOptionsRes, err error) {
return service.Admin().ListChannelOptions(ctx, req)
}
@@ -22,3 +22,6 @@ func (c *ControllerV1) ChannelErrorTopChannels(ctx context.Context, req *v1.Chan
func (c *ControllerV1) ChannelErrorCategories(ctx context.Context, req *v1.ChannelErrorCategoriesReq) (res *v1.ChannelErrorCategoriesRes, err error) {
return service.Admin().ChannelErrorCategories(ctx, req)
}
func (c *ControllerV1) ChannelErrorClear(ctx context.Context, req *v1.ChannelErrorClearReq) (res *v1.ChannelErrorClearRes, err error) {
return service.Admin().ChannelErrorClear(ctx, req)
}
@@ -73,3 +73,6 @@ func (c *ControllerV1) AdminWalletFrozenReleaseAll(ctx context.Context, req *v1.
func (c *ControllerV1) AdminUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (res *v1.AdminUsageLogSummaryRes, err error) {
return service.Admin().GetUsageLogSummary(ctx, req)
}
func (c *ControllerV1) AdminUsageLogDetail(ctx context.Context, req *v1.AdminUsageLogDetailReq) (res *v1.AdminUsageLogDetailRes, err error) {
return service.Admin().GetUsageLogDetail(ctx, req)
}
@@ -13,9 +13,6 @@ func (c *ControllerV1) AdminPermissionList(ctx context.Context, req *v1.AdminPer
func (c *ControllerV1) AdminPermissionUpdate(ctx context.Context, req *v1.AdminPermissionUpdateReq) (res *v1.AdminPermissionUpdateRes, err error) {
return service.Admin().UpdateUserPermissions(ctx, req)
}
func (c *ControllerV1) AdminDataScopeUpdate(ctx context.Context, req *v1.AdminDataScopeUpdateReq) (res *v1.AdminDataScopeUpdateRes, err error) {
return service.Admin().UpdateUserDataScopes(ctx, req)
}
func (c *ControllerV1) AdminAllPermissions(ctx context.Context, req *v1.AdminAllPermissionsReq) (res *v1.AdminAllPermissionsRes, err error) {
return service.Admin().GetAllPermissions(ctx, req)
}
@@ -0,0 +1,36 @@
package admin
import (
"context"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) AdminRoleList(ctx context.Context, req *v1.AdminRoleListReq) (res *v1.AdminRoleListRes, err error) {
return service.Admin().ListRoles(ctx, req)
}
func (c *ControllerV1) AdminRoleDetail(ctx context.Context, req *v1.AdminRoleDetailReq) (res *v1.AdminRoleDetailRes, err error) {
return service.Admin().GetRoleDetail(ctx, req)
}
func (c *ControllerV1) AdminRoleCreate(ctx context.Context, req *v1.AdminRoleCreateReq) (res *v1.AdminRoleCreateRes, err error) {
return service.Admin().CreateRole(ctx, req)
}
func (c *ControllerV1) AdminRoleUpdate(ctx context.Context, req *v1.AdminRoleUpdateReq) (res *v1.AdminRoleUpdateRes, err error) {
return service.Admin().UpdateRole(ctx, req)
}
func (c *ControllerV1) AdminRoleDelete(ctx context.Context, req *v1.AdminRoleDeleteReq) (res *v1.AdminRoleDeleteRes, err error) {
return service.Admin().DeleteRole(ctx, req)
}
func (c *ControllerV1) AdminRoleStatusUpdate(ctx context.Context, req *v1.AdminRoleStatusUpdateReq) (res *v1.AdminRoleStatusUpdateRes, err error) {
return service.Admin().UpdateRoleStatus(ctx, req)
}
func (c *ControllerV1) AdminRoleReset(ctx context.Context, req *v1.AdminRoleResetReq) (res *v1.AdminRoleResetRes, err error) {
return service.Admin().ResetRoleDefaults(ctx, req)
}
func (c *ControllerV1) AdminUserRoleList(ctx context.Context, req *v1.AdminUserRoleListReq) (res *v1.AdminUserRoleListRes, err error) {
return service.Admin().GetUserRoles(ctx, req)
}
func (c *ControllerV1) AdminUserRoleAssign(ctx context.Context, req *v1.AdminUserRoleAssignReq) (res *v1.AdminUserRoleAssignRes, err error) {
return service.Admin().AssignUserRoles(ctx, req)
}
@@ -0,0 +1,12 @@
package admin
import (
"context"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) AdminSupportPendingSummary(ctx context.Context, req *v1.AdminSupportPendingSummaryReq) (res *v1.AdminSupportPendingSummaryRes, err error) {
return service.Admin().GetSupportPendingSummary(ctx, req)
}
@@ -28,3 +28,6 @@ func (c *ControllerV1) TenantSessionList(ctx context.Context, req *v1.TenantSess
func (c *ControllerV1) TenantRevokeSession(ctx context.Context, req *v1.TenantRevokeSessionReq) (res *v1.TenantRevokeSessionRes, err error) {
return service.Tenant().RevokeSession(ctx, req)
}
func (c *ControllerV1) TenantRegisterRateLimit(ctx context.Context, req *v1.TenantRegisterRateLimitReq) (res *v1.TenantRegisterRateLimitRes, err error) {
return service.Tenant().GetRegisterRateLimitStatus(ctx, req)
}
@@ -13,3 +13,9 @@ 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)
}
func (c *ControllerV1) TenantUsageLogDetail(ctx context.Context, req *v1.TenantUsageLogDetailReq) (res *v1.TenantUsageLogDetailRes, err error) {
return service.Tenant().UsageLogDetail(ctx, req)
}
@@ -1,15 +0,0 @@
package tenant
import (
"context"
v1 "github.com/qianfree/team-api/api/tenant/v1"
"github.com/qianfree/team-api/internal/service"
)
func (c *ControllerV1) SandboxChat(ctx context.Context, req *v1.SandboxChatReq) (res *v1.SandboxChatRes, err error) {
return service.Tenant().SandboxChat(ctx, req)
}
func (c *ControllerV1) SandboxQuota(ctx context.Context, req *v1.SandboxQuotaReq) (res *v1.SandboxQuotaRes, err error) {
return service.Tenant().SandboxQuota(ctx, req)
}
@@ -51,6 +51,7 @@ type AudRequestLogsColumns struct {
TaskResult string // 异步任务完成时上游返回的原始响应体
TaskUpstreamHeaders string // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
TaskCompletedAt string // 异步任务达到终态的时间
ModelName string // 请求使用的模型名(用户请求体中的原始模型,未经渠道映射)
}
// audRequestLogsColumns holds the columns for the table aud_request_logs.
@@ -85,6 +86,7 @@ var audRequestLogsColumns = AudRequestLogsColumns{
TaskResult: "task_result",
TaskUpstreamHeaders: "task_upstream_headers",
TaskCompletedAt: "task_completed_at",
ModelName: "model_name",
}
// NewAudRequestLogsDao creates and returns a new DAO object for table data access.
+4
View File
@@ -78,6 +78,8 @@ type BilUsageLogsColumns struct {
BillingSnapshot string // 完整计费计算过程快照(JSONB)
ProjectId string // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
TaskId string // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
DurationSeconds string // 按秒计费任务的视频时长(秒),来自任务提交时的 spec.duration;非时长类任务为 NULL
UpstreamRequestId string // 上游请求ID:对话=上游响应头请求ID(X-Oneapi-Request-Id等),任务=上游任务ID;排障反查用
}
// bilUsageLogsColumns holds the columns for the table bil_usage_logs.
@@ -139,6 +141,8 @@ var bilUsageLogsColumns = BilUsageLogsColumns{
BillingSnapshot: "billing_snapshot",
ProjectId: "project_id",
TaskId: "task_id",
DurationSeconds: "duration_seconds",
UpstreamRequestId: "upstream_request_id",
}
// NewBilUsageLogsDao creates and returns a new DAO object for table data access.
+2
View File
@@ -31,6 +31,7 @@ type BilWalletsColumns struct {
UpdatedAt string // 更新时间
CumulativeRecharge string // 累计充值总额(USD)
LowBalanceNotified string // 低余额预警是否已推送(充值恢复后重置为 false)
TotalConsumed string // 累计消费总额(本位币;Redis 钱包 hash total_consumed 的物化副本,随结算事件递增/补偿递减)
}
// bilWalletsColumns holds the columns for the table bil_wallets.
@@ -45,6 +46,7 @@ var bilWalletsColumns = BilWalletsColumns{
UpdatedAt: "updated_at",
CumulativeRecharge: "cumulative_recharge",
LowBalanceNotified: "low_balance_notified",
TotalConsumed: "total_consumed",
}
// NewBilWalletsDao creates and returns a new DAO object for table data access.
+2
View File
@@ -36,6 +36,7 @@ type MdlModelsColumns struct {
DeprecatedAt string // 标记弃用的时间(NULL表示未弃用)
SunsetDate string // 计划下线日期(到达后返回410 Gone,NULL表示未设置)
ReplacementModel string // 推荐替代模型名(NULL表示无替代)
Vendor string // 研发厂商枚举:openai/anthropic/google/xai/mistral/cohere/meta/alibaba/bytedance/deepseek/zhipu/moonshot/minimax/baidu/tencent/xunfei/kuaishou/midjourney/suno,空串=未分类
}
// mdlModelsColumns holds the columns for the table mdl_models.
@@ -55,6 +56,7 @@ var mdlModelsColumns = MdlModelsColumns{
DeprecatedAt: "deprecated_at",
SunsetDate: "sunset_date",
ReplacementModel: "replacement_model",
Vendor: "vendor",
}
// NewMdlModelsDao creates and returns a new DAO object for table data access.
+20 -32
View File
@@ -21,42 +21,30 @@ type MdlPricingDao struct {
// MdlPricingColumns defines and stores column names for the table mdl_pricing.
type MdlPricingColumns struct {
Id string //
ModelId string // 关联模型ID
BillingMode string // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
MinTokens string // 阶梯起始 token 数(仅 tiered 模式,其他模式为 0)
MaxTokens string // 阶梯结束 token 数(NULL=无上限,仅 tiered 模式)
InputPrice string // 每 1M input token 价格(token/tiered 模式)
OutputPrice string // 每 1M output token 价格(token/tiered 模式)
PerRequestPrice string // 按次计费单价(仅 per_request 模式)
CreatedAt string //
UpdatedAt string //
CacheReadPrice string // 缓存读取每 1M token 价格(直接定价)
CacheCreationPrice string // 缓存创建每 1M token 价格(直接定价)
TimeSegments string // 时段定价(JSONB 有序数组,仅 min_tokens=0 锚点行生效):[{"name":"闲时","days":[1,2,3,4,5],"start_time":"00:00","end_time":"08:00","valid_from":"","valid_to":"","multiplier":0.5}],按序先命中先生效,未命中=默认价(乘数 1.0),days 1=周一..7=周日 空=每天,end<start 表示跨零点
PriceNote string // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
DiscountLabel string // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
PriceChangeNote string // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
Id string //
ModelId string // 关联模型ID
BillingMode string // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
CreatedAt string //
UpdatedAt string //
PriceNote string // 价格说明(仅管理后台可见,调价背景等内部备注),仅 min_tokens=0 锚点行使用,NULL=无
DiscountLabel string // 折扣标签(对外展示,如"7折起"、"限时5折"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
PriceChangeNote string // 价格调整说明(对外展示,提示用户价格有变动,如"9月1日起输入价下调"),仅 min_tokens=0 锚点行使用,NULL/空=不展示
Pricing string // 计费详情(JSONB,按 billing_mode 单模式存储):token={input_price,output_price,cache_read_price,cache_creation_price};tiered={tiers:[{min_tokens,max_tokens,input_price,output_price}],cache_read_price,cache_creation_price};per_request={price};per_second={unit:"second",prices:{分辨率规格:每秒单价,"*":兜底价}};顶层可含 time_segments(时段定价)
OfficialPricing string // 官方参考定价 JSONB(结构与 pricing 一致 + billing_mode 键,本位币;仅作定价折扣计算基准与几折展示,非计费依据)
}
// mdlPricingColumns holds the columns for the table mdl_pricing.
var mdlPricingColumns = MdlPricingColumns{
Id: "id",
ModelId: "model_id",
BillingMode: "billing_mode",
MinTokens: "min_tokens",
MaxTokens: "max_tokens",
InputPrice: "input_price",
OutputPrice: "output_price",
PerRequestPrice: "per_request_price",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
CacheReadPrice: "cache_read_price",
CacheCreationPrice: "cache_creation_price",
TimeSegments: "time_segments",
PriceNote: "price_note",
DiscountLabel: "discount_label",
PriceChangeNote: "price_change_note",
Id: "id",
ModelId: "model_id",
BillingMode: "billing_mode",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
PriceNote: "price_note",
DiscountLabel: "discount_label",
PriceChangeNote: "price_change_note",
Pricing: "pricing",
OfficialPricing: "official_pricing",
}
// NewMdlPricingDao creates and returns a new DAO object for table data access.
@@ -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)
}
@@ -23,7 +23,7 @@ type SysAdminRolePermsDao struct {
type SysAdminRolePermsColumns struct {
Id string // 主键ID
AdminUserId string // 关联的管理员用户ID
PermissionPoint string // 权限点标识(如 tenant:create、channel:edit)
PermissionPoint string // 特批的权限点标识(独立于角色,角色禁用不影响特批)
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
}
+95
View File
@@ -0,0 +1,95 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// SysAdminRolesDao is the data access object for the table sys_admin_roles.
type SysAdminRolesDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns SysAdminRolesColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// SysAdminRolesColumns defines and stores column names for the table sys_admin_roles.
type SysAdminRolesColumns struct {
Id string // 主键ID
Code string // 角色标识(小写字母/数字/下划线,创建后不可修改,是权限缓存与审计日志的稳定标识;保留字 super_admin 不可占用)
Name string // 角色显示名称(可修改)
Description string // 角色说明,在分配界面展示
IsBuiltin string // 是否系统预置:仅用于标识来源与支撑「恢复默认权限」,不构成删除保护(预置角色同样可改可删)
IsEnabled string // 是否启用:禁用后该角色的权限对全部关联用户立即失效,无需解除关联
Sort string // 排序权重(升序)
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
}
// sysAdminRolesColumns holds the columns for the table sys_admin_roles.
var sysAdminRolesColumns = SysAdminRolesColumns{
Id: "id",
Code: "code",
Name: "name",
Description: "description",
IsBuiltin: "is_builtin",
IsEnabled: "is_enabled",
Sort: "sort",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewSysAdminRolesDao creates and returns a new DAO object for table data access.
func NewSysAdminRolesDao(handlers ...gdb.ModelHandler) *SysAdminRolesDao {
return &SysAdminRolesDao{
group: "default",
table: "sys_admin_roles",
columns: sysAdminRolesColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *SysAdminRolesDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *SysAdminRolesDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *SysAdminRolesDao) Columns() SysAdminRolesColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *SysAdminRolesDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysAdminRolesDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysAdminRolesDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,87 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// SysAdminUserRolesDao is the data access object for the table sys_admin_user_roles.
type SysAdminUserRolesDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns SysAdminUserRolesColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// SysAdminUserRolesColumns defines and stores column names for the table sys_admin_user_roles.
type SysAdminUserRolesColumns struct {
Id string // 主键ID
AdminUserId string // 管理员用户ID(逻辑关联 sys_admin_users.id,无外键)
RoleId string // 角色ID(逻辑关联 sys_admin_roles.id,无外键)
CreatedAt string // 创建时间
UpdatedAt string // 更新时间
}
// sysAdminUserRolesColumns holds the columns for the table sys_admin_user_roles.
var sysAdminUserRolesColumns = SysAdminUserRolesColumns{
Id: "id",
AdminUserId: "admin_user_id",
RoleId: "role_id",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewSysAdminUserRolesDao creates and returns a new DAO object for table data access.
func NewSysAdminUserRolesDao(handlers ...gdb.ModelHandler) *SysAdminUserRolesDao {
return &SysAdminUserRolesDao{
group: "default",
table: "sys_admin_user_roles",
columns: sysAdminUserRolesColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *SysAdminUserRolesDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *SysAdminUserRolesDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *SysAdminUserRolesDao) Columns() SysAdminUserRolesColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *SysAdminUserRolesDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *SysAdminUserRolesDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *SysAdminUserRolesDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
+4
View File
@@ -40,6 +40,8 @@ type TntTenantsColumns struct {
DefaultChannelScope string // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)
Level string // 当前等级(对应 tnt_tenant_level_configs.level)
TeamEnabled string // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)
Tags string // 租户标签(JSONB 字符串数组,管理后台运营标注,不暴露给租户控制台)
Remark string // 管理员备注(运营备注,不暴露给租户控制台)
}
// tntTenantsColumns holds the columns for the table tnt_tenants.
@@ -63,6 +65,8 @@ var tntTenantsColumns = TntTenantsColumns{
DefaultChannelScope: "default_channel_scope",
Level: "level",
TeamEnabled: "team_enabled",
Tags: "tags",
Remark: "remark",
}
// NewTntTenantsDao creates and returns a new DAO object for table data access.
+4
View File
@@ -46,6 +46,8 @@ type TskModelTasksColumns struct {
CreatedAt string // 记录创建时间
UpdatedAt string // 记录更新时间
RequestId string // 任务提交时的原始请求 ID(req_xxxxx),关联 aud_request_logs.request_id
Deleted string // 软删除标记:true 表示已被客户端删除,查询/下载按不存在处理(计费与审计记录保留)
DeletedAt string // 软删除时间
}
// tskModelTasksColumns holds the columns for the table tsk_model_tasks.
@@ -75,6 +77,8 @@ var tskModelTasksColumns = TskModelTasksColumns{
CreatedAt: "created_at",
UpdatedAt: "updated_at",
RequestId: "request_id",
Deleted: "deleted",
DeletedAt: "deleted_at",
}
// NewTskModelTasksDao creates and returns a new DAO object for table data access.
@@ -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.
+22
View File
@@ -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.
+22
View File
@@ -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.
+12 -6
View File
@@ -328,12 +328,18 @@ func (s *RedisState) processOutcome(ctx context.Context, o dispatch.Outcome) {
if o.Success {
success = "1"
}
// 健康观察成功即静默:本函数消费每个请求的结果事件,任何逐请求日志都会在生产流量下
// 淹没日志;健康分轨迹排查走维护轮落盘日志 + ReadRuntime 直接读值。
if _, err := g.Redis().Do(ctx, "EVAL", luaHealthObserve, 1, healthKey,
success, o.LatencyMs, healthDecayFor(o.Class), now, stateKeyTTLMs); err != nil {
s.local.observe(o) // 降级:实例本地健康镜像
return
// 探测失败不衰减健康 EWMA:cron 自动探测每 5 分钟一次,若持续失败按 0.93^N 衰减会把
// 无真实流量的渠道健康分指数级拖垮,跟真实故障没有区别地互相污染。探测成功仍照常
// 记录(回升健康 + 采样延迟),只跳过失败这一侧;熔断窗口不受影响,下方正常计数。
skipHealthObserve := o.Probe && !o.Success
if !skipHealthObserve {
// 健康观察成功即静默:本函数消费每个请求的结果事件,任何逐请求日志都会在生产流量下
// 淹没日志;健康分轨迹排查走维护轮落盘日志 + ReadRuntime 直接读值。
if _, err := g.Redis().Do(ctx, "EVAL", luaHealthObserve, 1, healthKey,
success, o.LatencyMs, healthDecayFor(o.Class), now, stateKeyTTLMs); err != nil {
s.local.observe(o) // 降级:实例本地健康镜像
return
}
}
// 429 → softLimit 自动估计器(基线方案 §8.2)
+112
View File
@@ -0,0 +1,112 @@
package relay
import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/qianfree/team-api/internal/logic/monitor"
"github.com/qianfree/team-api/internal/middleware"
relay_constant "github.com/qianfree/team-api/relay/constant"
relay_handler "github.com/qianfree/team-api/relay/handler"
)
// 阿里 DashScope 官方视频协议入站胶水(/api/v1 端点):
// 创建请求在此完成官方请求体解析与通用任务体转换,复用任务提交管线
// (selectTaskChannel 渠道调度 + relay_handler.HandleTaskSubmit 计费/提交/落库);
// 查询直连 relay_handler 的阿里视频处理器(回放 DashScope 官方响应形态)。
// 协议文档:docs/协议文档/阿里系列/视频-wan3.0.md
// HandleAliVideoSubmit 处理 POST /api/v1/services/aigc/video-generation/video-synthesis
// (创建视频生成任务,wan2.x / wan3.0 通用)
func HandleAliVideoSubmit(r *ghttp.Request) {
body := r.GetBody()
if len(body) == 0 {
writeVideosError(r, 400, "request body is empty", "")
return
}
// 官方请求体 → 归一结构(input.media[] 原样保留)
req, taskErr := relay_handler.ParseAliVideoCreateRequest(body)
if taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
if taskErr := relay_handler.ValidateAliVideoCreateRequest(req); taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
// 转成任务框架通用请求体(适配器消费 media/metadata + 双写通用词汇)
taskBody, err := relay_handler.BuildAliVideoTaskBody(req)
if err != nil {
writeVideosError(r, 500, "build task body failed", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
Scope: r.GetCtxVar("ApiKeyScope").String(),
ClientIP: r.GetClientIp(),
KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(),
KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(),
KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(),
KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(),
KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(),
// 阿里 DashScope 官方协议:{"output":{"task_id":...}} 响应 + task_ 前缀 ID
// (协议标识与 relay/handler.aliVideoProtocol 对齐,对齐 videos.go 胶水层字面量惯例)
Protocol: "ali_native",
RelayMode: int(relay_constant.RelayModeAliVideo),
}
// 选择渠道(模型权限校验 + 调度引擎,与 /v1/video/generations 同一链路)
channelMeta, err := selectTaskChannel(r, taskBody)
if err != nil {
writeTaskChannelSelectionError(r, err)
return
}
// 响应捕获用于审计(对齐 HandleTaskSubmit 模式)
capture := NewResponseCaptureWriter(r.Response.Writer)
rc.Writer = capture
registerAsyncTask(rc.RequestID, rc.TenantID, rc.UserID, rc.ProjectID, req.Model, channelMeta, r.URL.Path)
relay_handler.HandleTaskSubmit(
r.Context(), taskBody, r.URL.Path, r.Header,
rc, taskDataProvider, taskBillingProvider, channelMeta,
)
// 提交成功后切换为任务 ID 跟踪(任务生命周期远超 HTTP 请求)
if rc.TaskID != "" {
monitor.SwitchToTaskID(rc.RequestID, rc.TaskID)
} else {
monitor.UnregisterRequest(rc.RequestID)
}
rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, taskBody, channelMeta, capture.StatusCode())
go recordTaskSubmitAudit(r, rc, capture, taskBody)
}
// HandleAliVideoRetrieve 处理 GET /api/v1/tasks/{task_id}(查询视频任务,回放官方响应)
func HandleAliVideoRetrieve(r *ghttp.Request) {
taskID := r.Get("task_id").String()
if taskID == "" {
writeVideosError(r, 400, "task id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleAliVideoRetrieve(r.Context(), taskID, rc, taskDataProvider)
}
+224
View File
@@ -0,0 +1,224 @@
package relay
import (
"github.com/gogf/gf/v2/net/ghttp"
"github.com/qianfree/team-api/internal/logic/monitor"
"github.com/qianfree/team-api/internal/middleware"
relay_constant "github.com/qianfree/team-api/relay/constant"
relay_handler "github.com/qianfree/team-api/relay/handler"
)
// MiniMax 官方视频协议入站胶水(/v2 端点):
// 创建请求在此完成官方请求体解析与通用任务体转换,复用任务提交管线
// (selectTaskChannel 渠道调度 + relay_handler.HandleTaskSubmit 计费/提交/落库);
// 查询/删除直连 relay_handler 的 MiniMax 视频处理器。
// 协议文档:docs/modeldocs/minimax/video/
// HandleMiniMaxVideoSubmit 处理 POST /v2/video_generation(创建视频生成任务)
func HandleMiniMaxVideoSubmit(r *ghttp.Request) {
body := r.GetBody()
if len(body) == 0 {
writeVideosError(r, 400, "request body is empty", "")
return
}
// 官方请求体 → 归一结构(content[] 原样保留,派生 prompt)
req, taskErr := relay_handler.ParseMiniMaxVideoCreateRequest(body)
if taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
if taskErr := relay_handler.ValidateMiniMaxVideoCreateRequest(req); taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
// 转成任务框架通用请求体(适配器消费 content/resolution/duration + 双写通用词汇)
taskBody, err := relay_handler.BuildMiniMaxVideoTaskBody(req)
if err != nil {
writeVideosError(r, 500, "build task body failed", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
Scope: r.GetCtxVar("ApiKeyScope").String(),
ClientIP: r.GetClientIp(),
KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(),
KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(),
KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(),
KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(),
KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(),
// MiniMax 官方协议:{"task_id"} 响应 + task_ 前缀 ID + 请求回显落库
// (协议标识与 relay/handler.minimaxVideoProtocol 对齐,对齐 videos.go 胶水层字面量惯例)
Protocol: "minimax_native",
RelayMode: int(relay_constant.RelayModeMiniMaxVideo),
RequestEcho: relay_handler.NewMiniMaxRequestEcho(req),
}
// 选择渠道(模型权限校验 + 调度引擎,与 /v1/video/generations 同一链路)
channelMeta, err := selectTaskChannel(r, taskBody)
if err != nil {
writeTaskChannelSelectionError(r, err)
return
}
// 响应捕获用于审计(对齐 HandleTaskSubmit 模式)
capture := NewResponseCaptureWriter(r.Response.Writer)
rc.Writer = capture
registerAsyncTask(rc.RequestID, rc.TenantID, rc.UserID, rc.ProjectID, req.Model, channelMeta, r.URL.Path)
relay_handler.HandleTaskSubmit(
r.Context(), taskBody, r.URL.Path, r.Header,
rc, taskDataProvider, taskBillingProvider, channelMeta,
)
// 提交成功后切换为任务 ID 跟踪(任务生命周期远超 HTTP 请求)
if rc.TaskID != "" {
monitor.SwitchToTaskID(rc.RequestID, rc.TaskID)
} else {
monitor.UnregisterRequest(rc.RequestID)
}
rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, taskBody, channelMeta, capture.StatusCode())
go recordTaskSubmitAudit(r, rc, capture, taskBody)
}
// HandleMiniMaxVideoRetrieve 处理 GET /v2/query/video_generation/{task_id}(查询视频任务)
func HandleMiniMaxVideoRetrieve(r *ghttp.Request) {
taskID := r.Get("task_id").String()
if taskID == "" {
writeVideosError(r, 400, "task id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleMiniMaxVideoRetrieve(r.Context(), taskID, rc, taskDataProvider)
}
// HandleMiniMaxVideoCancel 处理 DELETE /v2/video_generation/{task_id}(取消排队中的视频任务)
func HandleMiniMaxVideoCancel(r *ghttp.Request) {
taskID := r.Get("task_id").String()
if taskID == "" {
writeVideosError(r, 400, "task id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleMiniMaxVideoCancel(r.Context(), taskID, rc, taskDataProvider)
}
// HandleMiniMaxVideoV1Submit 处理 POST /v1/video_generation(MiniMax v1 官方协议创建,
// 文生/图生/首尾帧/主体参考四形态共用端点,扁平字段)
func HandleMiniMaxVideoV1Submit(r *ghttp.Request) {
body := r.GetBody()
if len(body) == 0 {
writeVideosError(r, 400, "request body is empty", "")
return
}
req, taskErr := relay_handler.ParseMiniMaxVideoV1CreateRequest(body)
if taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
if taskErr := relay_handler.ValidateMiniMaxVideoV1CreateRequest(req); taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
taskBody, err := relay_handler.BuildMiniMaxVideoV1TaskBody(req)
if err != nil {
writeVideosError(r, 500, "build task body failed", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
Scope: r.GetCtxVar("ApiKeyScope").String(),
ClientIP: r.GetClientIp(),
KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(),
KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(),
KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(),
KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(),
KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(),
// MiniMax v1 官方协议:{task_id, base_resp} 响应 + task_ 前缀 ID + 请求回显落库
Protocol: "minimax_native_v1",
RelayMode: int(relay_constant.RelayModeMiniMaxVideo),
RequestEcho: relay_handler.NewMiniMaxV1RequestEcho(req),
}
// 选择渠道(模型权限校验 + 调度引擎,与 /v1/video/generations 同一链路)
channelMeta, err := selectTaskChannel(r, taskBody)
if err != nil {
writeTaskChannelSelectionError(r, err)
return
}
capture := NewResponseCaptureWriter(r.Response.Writer)
rc.Writer = capture
registerAsyncTask(rc.RequestID, rc.TenantID, rc.UserID, rc.ProjectID, req.Model, channelMeta, r.URL.Path)
relay_handler.HandleTaskSubmit(
r.Context(), taskBody, r.URL.Path, r.Header,
rc, taskDataProvider, taskBillingProvider, channelMeta,
)
if rc.TaskID != "" {
monitor.SwitchToTaskID(rc.RequestID, rc.TaskID)
} else {
monitor.UnregisterRequest(rc.RequestID)
}
rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, taskBody, channelMeta, capture.StatusCode())
go recordTaskSubmitAudit(r, rc, capture, taskBody)
}
// HandleMiniMaxVideoV1Retrieve 处理 GET /v1/query/video_generation?task_id=(v1 官方协议任务查询)
func HandleMiniMaxVideoV1Retrieve(r *ghttp.Request) {
taskID := r.Get("task_id").String()
if taskID == "" {
writeVideosError(r, 400, "task id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleMiniMaxVideoV1Retrieve(r.Context(), taskID, rc, taskDataProvider)
}
+36 -3
View File
@@ -236,6 +236,38 @@ func HandleMessages(r *ghttp.Request) {
recordAudit(r, rc, capture, body, "/messages", latencyMs, firstTokenMs(billingResult))
}
// HandleCountTokens 处理 /v1/messages/count_tokens(Claude 原生格式)
// 本地估算输入 token 数:不请求上游、不计费,仅返回 {"input_tokens": N} 供客户端做上下文窗口管理参考。
func HandleCountTokens(r *ghttp.Request) {
body := r.GetBody()
if len(body) == 0 {
writeClaudeCountTokensError(r, http.StatusBadRequest, "request body is empty")
return
}
monitor.RecordBytesIn(len(body))
tokens, err := handler.CountClaudeTokens(body)
if err != nil {
writeClaudeCountTokensError(r, http.StatusBadRequest, err.Error())
return
}
r.Response.WriteJsonExit(g.Map{"input_tokens": tokens})
}
// writeClaudeCountTokensError 以 Claude 原生错误格式回写 count_tokens 的校验错误。
func writeClaudeCountTokensError(r *ghttp.Request, status int, message string) {
r.Response.WriteHeader(status)
r.Response.WriteJsonExit(g.Map{
"type": "error",
"error": g.Map{
"type": "invalid_request_error",
"message": message,
},
})
}
// HandleGeminiGenerateContent 处理 /v1beta/models/{model}:generateContent(Gemini 原生格式)
func HandleGeminiGenerateContent(r *ghttp.Request) {
g.Log().Debugf(r.Context(), "[HandleGeminiGenerateContent] Entry: method=%s, path=%s, uri=%s",
@@ -337,9 +369,10 @@ func HandleModels(r *ghttp.Request) {
resp, err := handler.HandleModels(r.Context(), rc.TenantID, rc.ApiKeyID, rc.UserID, dataProvider)
if err != nil {
r.Response.WriteJsonExit(g.Map{
"error": g.Map{"type": "internal_error", "message": err.Error()},
})
// 统一走 WriteRelayError:OpenAI 原生错误格式 + 500 状态码 + 服务端日志。
// 此前 200 + 裸 error body,客户端无法感知失败(GetAvailableModels 内部错误
// 一度被吞掉后更是直接返回空列表)
handler.WriteRelayError(r.Response.Writer, err)
return
}
+2 -2
View File
@@ -74,8 +74,8 @@ func HandleSyncImageSubmit(r *ghttp.Request, body []byte, rc *relay_handler.Task
// 2. 强制非流式:剥离 stream 字段,防 DoResponse 走 SSE 分支
cleanBody := stripStreamField(body)
// 3. 估价(图片走 per_request 价)
estimatedCost, err := taskBillingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, nil)
// 3. 估价(图片走 per_request 价;nil body = 参数倍率不参与,同步图片链路 ratios 不随任务持久化)
estimatedCost, err := taskBillingProvider.EstimateTaskCost(ctx, rc.TenantID, modelName, nil, nil)
if err != nil {
writeSyncImageError(w, http.StatusInternalServerError, "estimate cost failed: "+err.Error())
return
+1
View File
@@ -497,6 +497,7 @@ func recordTaskSubmitAudit(r *ghttp.Request, rc *relay_handler.TaskRelayContext,
StatusCode: capture.StatusCode(),
ClientIP: rc.ClientIP,
UserAgent: r.Header.Get("User-Agent"),
Model: extractModelName(body),
RequestBody: string(body),
ResponseBody: capture.Body(),
IsStream: false,
+165
View File
@@ -0,0 +1,165 @@
package relay
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/qianfree/team-api/internal/logic/monitor"
"github.com/qianfree/team-api/internal/middleware"
relay_constant "github.com/qianfree/team-api/relay/constant"
relay_handler "github.com/qianfree/team-api/relay/handler"
)
// OpenAI Videos 协议入站胶水(/v1/videos 端点):
// 创建请求在此完成 multipart/JSON 解析与通用任务体转换,复用任务提交管线
// (selectTaskChannel 渠道调度 + relay_handler.HandleTaskSubmit 计费/提交/落库);
// 查询/删除/内容下载直连 relay_handler 的 Videos 处理器。
// HandleVideoCreate 处理 POST /v1/videos(创建视频生成任务)
func HandleVideoCreate(r *ghttp.Request) {
body := r.GetBody()
if len(body) == 0 {
r.Response.WriteStatus(400, g.Map{
"error": g.Map{"type": "invalid_request_error", "message": "request body is empty"},
})
return
}
// OpenAI Videos 请求(multipart/JSON)→ 归一结构(官方 SDK 一律 multipart,必须支持)
req, taskErr := relay_handler.ParseVideosCreateRequest(body, r.Header.Get("Content-Type"))
if taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
if taskErr := relay_handler.ValidateVideosCreateRequest(req); taskErr != nil {
writeVideosError(r, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
return
}
// 转成任务框架通用请求体(适配器消费 model/prompt/seconds/metadata)
taskBody, err := relay_handler.BuildVideosTaskBody(req)
if err != nil {
writeVideosError(r, 500, "build task body failed", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
Scope: r.GetCtxVar("ApiKeyScope").String(),
ClientIP: r.GetClientIp(),
KeyRateLimitQps: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitQps).Int(),
KeyConcurrency: r.GetCtxVar(middleware.CtxKeyApiKeyRateLimitConcurrency).Int(),
KeyIpWhitelist: r.GetCtxVar(middleware.CtxKeyApiKeyIpWhitelist).String(),
KeyTotalQuota: r.GetCtxVar(middleware.CtxKeyApiKeyTotalQuota).Float64(),
KeyUsedQuota: r.GetCtxVar(middleware.CtxKeyApiKeyUsedQuota).Float64(),
// OpenAI Videos 协议:官方 Video 对象响应 + video_ 前缀 ID + 请求回显落库
Protocol: "openai_videos",
RelayMode: int(relay_constant.RelayModeVideos),
RequestEcho: relay_handler.NewVideosRequestEcho(req),
}
// 选择渠道(模型权限校验 + 调度引擎,与 /v1/video/generations 同一链路)
channelMeta, err := selectTaskChannel(r, taskBody)
if err != nil {
writeTaskChannelSelectionError(r, err)
return
}
// 响应捕获用于审计(对齐 HandleTaskSubmit 模式)
capture := NewResponseCaptureWriter(r.Response.Writer)
rc.Writer = capture
registerAsyncTask(rc.RequestID, rc.TenantID, rc.UserID, rc.ProjectID, req.Model, channelMeta, r.URL.Path)
relay_handler.HandleTaskSubmit(
r.Context(), taskBody, r.URL.Path, r.Header,
rc, taskDataProvider, taskBillingProvider, channelMeta,
)
// 提交成功后切换为任务 ID 跟踪(任务生命周期远超 HTTP 请求)
if rc.TaskID != "" {
monitor.SwitchToTaskID(rc.RequestID, rc.TaskID)
} else {
monitor.UnregisterRequest(rc.RequestID)
}
rc.ForwardingTrace = buildTaskForwardingTrace(r.URL.Path, taskBody, channelMeta, capture.StatusCode())
go recordTaskSubmitAudit(r, rc, capture, taskBody)
}
// HandleVideoRetrieve 处理 GET /v1/videos/{video_id}(查询视频任务)
func HandleVideoRetrieve(r *ghttp.Request) {
videoID := r.Get("video_id").String()
if videoID == "" {
writeVideosError(r, 400, "video id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleVideosRetrieve(r.Context(), videoID, rc, taskDataProvider)
}
// HandleVideoDelete 处理 DELETE /v1/videos/{video_id}(删除视频任务)
func HandleVideoDelete(r *ghttp.Request) {
videoID := r.Get("video_id").String()
if videoID == "" {
writeVideosError(r, 400, "video id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleVideosDelete(r.Context(), videoID, rc, taskDataProvider)
}
// HandleVideoContent 处理 GET /v1/videos/{video_id}/content(下载成品视频)
func HandleVideoContent(r *ghttp.Request) {
videoID := r.Get("video_id").String()
if videoID == "" {
writeVideosError(r, 400, "video id is required", "")
return
}
rc := &relay_handler.TaskRelayContext{
TenantID: middleware.GetTenantID(r.Context()),
UserID: middleware.GetUserID(r.Context()),
ApiKeyID: middleware.GetApiKeyID(r.Context()),
ProjectID: middleware.GetProjectID(r.Context()),
RequestID: r.GetCtxVar("RequestId").String(),
Writer: r.Response.Writer,
}
relay_handler.HandleVideosContent(r.Context(), videoID, r.Get("variant", "video").String(), rc, taskDataProvider)
}
// writeVideosError OpenAI 官方错误格式输出(与 writeTaskError 同构,供胶水层解析阶段使用)
func writeVideosError(r *ghttp.Request, statusCode int, message, code string) {
errObj := g.Map{
"type": "invalid_request_error",
"message": message,
}
if code != "" {
errObj["code"] = code
}
r.Response.WriteStatus(statusCode, g.Map{"error": errObj})
}
+189 -39
View File
@@ -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"
@@ -28,16 +29,16 @@ func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.A
m := buildUserFilters(dao.SysAdminUsers.Ctx(ctx), req.Keyword, req.Role, req.Status)
var users []struct {
Id int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Status string `json:"status"`
LastLoginAt *gtime.Time `json:"last_login_at"`
LastLoginIp string `json:"last_login_ip"`
LockedUntil *gtime.Time `json:"locked_until"`
CreatedAt *gtime.Time `json:"created_at"`
Id int64 `json:"id" orm:"id"`
Username string `json:"username" orm:"username"`
Email string `json:"email" orm:"email"`
DisplayName string `json:"display_name" orm:"display_name"`
Role string `json:"role" orm:"role"`
Status string `json:"status" orm:"status"`
LastLoginAt *gtime.Time `json:"last_login_at" orm:"last_login_at"`
LastLoginIp string `json:"last_login_ip" orm:"last_login_ip"`
LockedUntil *gtime.Time `json:"locked_until" orm:"locked_until"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
}
err = m.OrderDesc("id").
Page(page, pageSize).
@@ -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
@@ -367,15 +517,15 @@ func (s *sAdmin) ExportUsers(ctx context.Context, req *v1.AdminUserExportReq) (*
for {
m := buildUserFilters(dao.SysAdminUsers.Ctx(ctx), req.Keyword, req.Role, req.Status)
var batch []struct {
Id int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Status string `json:"status"`
LastLoginAt *gtime.Time `json:"last_login_at"`
LastLoginIp string `json:"last_login_ip"`
CreatedAt *gtime.Time `json:"created_at"`
Id int64 `json:"id" orm:"id"`
Username string `json:"username" orm:"username"`
Email string `json:"email" orm:"email"`
DisplayName string `json:"display_name" orm:"display_name"`
Role string `json:"role" orm:"role"`
Status string `json:"status" orm:"status"`
LastLoginAt *gtime.Time `json:"last_login_at" orm:"last_login_at"`
LastLoginIp string `json:"last_login_ip" orm:"last_login_ip"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
}
if err := m.OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil {
return
+30 -20
View File
@@ -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)
@@ -356,6 +337,10 @@ func (s *sAdmin) ListRequestAuditLogs(ctx context.Context, req *v1.RequestAuditL
conditions = append(conditions, "path LIKE ?")
args = append(args, "%"+req.Path+"%")
}
if req.Model != "" {
conditions = append(conditions, "model_name = ?")
args = append(args, req.Model)
}
if req.StatusCode > 0 {
conditions = append(conditions, "status_code = ?")
args = append(args, req.StatusCode)
@@ -370,12 +355,19 @@ func (s *sAdmin) ListRequestAuditLogs(ctx context.Context, req *v1.RequestAuditL
}
where := strings.Join(conditions, " AND ")
auditFields := "id, tenant_id, user_id, api_key_id, project_id, request_id, method, path, query_params, status_code, client_ip, user_agent, latency_ms, first_token_ms, audit_level, created_at"
// 列表白名单:表格列 + EnrichAuditRecords 回填名称所需的关联键。
// user_id / project_id 仅作关联查询键(username / project_name 据此回填),表格不展示,出参剔除;
// request_id / query_params / user_agent 等列表不展示的字段经详情接口获取。
auditFields := "id, tenant_id, user_id, api_key_id, project_id, method, path, model_name, status_code, client_ip, latency_ms, first_token_ms, audit_level, created_at"
items, total, err := queryAuditPage(ctx, common.GetAuditDB(), "aud_request_logs", auditFields, where, args, page, pageSize)
if err != nil {
return nil, err
}
for _, item := range items {
delete(item, "user_id")
delete(item, "project_id")
}
return &v1.RequestAuditLogListRes{
List: items,
@@ -397,6 +389,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 +595,18 @@ func (s *sAdmin) ContentFilterLogList(ctx context.Context, req *v1.ContentFilter
PageSize: pageSize,
}, nil
}
// ContentFilterLogClear 硬删除全部内容过滤拦截日志。
// 拦截日志为追加型审计流水,堆积后用 TRUNCATE 秒级清空并立即归还磁盘空间
// (DELETE 需等 VACUUM 回收)。TRUNCATE 不返回行数,故先取删除前条数作为反馈。
// 表无数据库级外键,TRUNCATE 安全。
func (s *sAdmin) ContentFilterLogClear(ctx context.Context, _ *v1.ContentFilterLogClearReq) (*v1.ContentFilterLogClearRes, error) {
count, err := dao.AudContentFilterLogs.Ctx(ctx).Count()
if err != nil {
return nil, err
}
if _, err := g.DB().Ctx(ctx).Exec(ctx, "TRUNCATE TABLE aud_content_filter_logs RESTART IDENTITY"); err != nil {
return nil, err
}
return &v1.ContentFilterLogClearRes{Deleted: int64(count)}, nil
}
+112 -19
View File
@@ -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
}
+99 -21
View File
@@ -55,6 +55,58 @@ func channelRuntimeStates() map[int64]dispatchadapter.ChannelRuntimeState {
return cat.ChannelRuntimeStates()
}
// channelOptionsCache 渠道下拉选项缓存(全量渠道,单个条目)。
// 日志 / 统计页的渠道筛选每次打开都要拉一次全量渠道,而渠道量级为几十且极少变动,
// 反复对 chn_channels 全表扫描纯属浪费;整表结果缓存为一个条目,写侧主动失效。
var channelOptionsCache = common.NewCache("channel_options", 300*time.Second)
// channelOptionsCacheKey 渠道下拉是全量结果,只有一个缓存条目。
const channelOptionsCacheKey = "all"
// InvalidateChannelOptionsCache 失效渠道下拉选项缓存(渠道增 / 删 / 改 / 克隆后调用)。
func InvalidateChannelOptionsCache(ctx context.Context) {
channelOptionsCache.Delete(ctx, channelOptionsCacheKey)
}
// ListChannelOptions 渠道下拉选项(不分页,返回全部渠道)。
// 刻意不做状态过滤:日志 / 统计页的渠道筛选项要覆盖历史数据里出现过的全部渠道,
// 已停用渠道同样需要能作为筛选条件;status 原样返回,由前端决定是否标记。
// 注意与 /admin/channels 的区别:后者是渠道管理列表(分页 + 健康度联表 + 运行态),
// 下拉场景不要用它 —— 那是本接口存在的意义。
func (s *sAdmin) ListChannelOptions(ctx context.Context, _ *v1.ChannelOptionsReq) (*v1.ChannelOptionsRes, error) {
list := make([]v1.ChannelOptionItem, 0)
if channelOptionsCache.GetJSON(ctx, channelOptionsCacheKey, &list) {
return &v1.ChannelOptionsRes{List: list}, nil
}
var rows []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type int `json:"type"`
Status string `json:"status"`
}
err := dao.ChnChannels.Ctx(ctx).
Fields("id, name, type, status").
OrderAsc("id").
Scan(&rows)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
list = make([]v1.ChannelOptionItem, 0, len(rows))
for _, r := range rows {
list = append(list, v1.ChannelOptionItem{
ID: r.ID,
Name: r.Name,
Type: r.Type,
Status: r.Status,
})
}
channelOptionsCache.Set(ctx, channelOptionsCacheKey, list)
return &v1.ChannelOptionsRes{List: list}, nil
}
// ListChannels 获取渠道列表
func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.ChannelListRes, error) {
query := dao.ChnChannels.Ctx(ctx).
@@ -277,6 +329,8 @@ func (s *sAdmin) CloneChannel(ctx context.Context, req *v1.ChannelCloneReq) (*v1
g.Log().Warningf(ctx, "init health score for channel %d failed: %v", newID, err)
}
InvalidateChannelOptionsCache(ctx)
return &v1.ChannelCloneRes{ID: newID}, nil
}
@@ -288,10 +342,19 @@ func (s *sAdmin) CreateChannel(ctx context.Context, req *v1.ChannelCreateReq) (*
baseURL = defaultProviderURL(req.Type)
}
// Build settings JSON
// Build settings JSON(只写显式开启的布尔项,其余字段用 Parse 时的默认值)
settingsJSON := "{}"
if req.UseProxy {
settingsJSON = `{"use_proxy":true}`
if req.UseProxy || req.WebSearchToGoogleSearch {
boolFlags := map[string]bool{}
if req.UseProxy {
boolFlags["use_proxy"] = true
}
if req.WebSearchToGoogleSearch {
boolFlags["web_search_to_google_search"] = true
}
if b, err := json.Marshal(boolFlags); err == nil {
settingsJSON = string(b)
}
}
tier := req.Tier
@@ -341,6 +404,8 @@ func (s *sAdmin) CreateChannel(ctx context.Context, req *v1.ChannelCreateReq) (*
g.Log().Warningf(ctx, "init health score for channel %d failed: %v", id, err)
}
InvalidateChannelOptionsCache(ctx)
return &v1.ChannelCreateRes{ID: id}, nil
}
@@ -411,8 +476,8 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (*
// Update use_proxy / debug_log_* in settings JSONB。
// 必须单块合并处理(读一次旧值 → 同时应用全部字段 → 回写一次):
// 若写成多个同构块,各自从 DB 读旧值再各自 marshal,同请求多字段时会互相覆盖。
if req.UseProxy != nil || req.DebugLogEnabled != nil || req.DebugLogTenantID != nil ||
req.DebugLogUserID != nil || req.DebugLogApiKeyID != nil {
if req.UseProxy != nil || req.WebSearchToGoogleSearch != nil || req.DebugLogEnabled != nil ||
req.DebugLogTenantID != nil || req.DebugLogUserID != nil || req.DebugLogApiKeyID != nil {
// 注意:读单列字符串必须用 Value()——gdb 的 Model.Scan 只接受 struct 系列,
// 传入 *string 会直接报错;此前用 _ = Scan(&string) 吞错导致永远读到空串,
// 每次局部更新都会把未提交的 settings 字段静默重置为默认值
@@ -424,6 +489,9 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (*
if req.UseProxy != nil {
settings.UseProxy = *req.UseProxy
}
if req.WebSearchToGoogleSearch != nil {
settings.WebSearchToGoogleSearch = *req.WebSearchToGoogleSearch
}
if req.DebugLogEnabled != nil {
settings.DebugLogEnabled = *req.DebugLogEnabled
}
@@ -478,6 +546,7 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (*
}
}
dispatchadapter.InvalidateChannel(ctx, req.ID)
InvalidateChannelOptionsCache(ctx)
if req.Status == "active" {
// 手动启用/恢复:复位熔断并开启爬坡窗口,恢复初期小流量验证(rampFactor)
dispatchadapter.MarkChannelRecovered(ctx, req.ID)
@@ -507,6 +576,7 @@ func (s *sAdmin) DeleteChannel(ctx context.Context, req *v1.ChannelDeleteReq) (*
return nil, err
}
dispatchadapter.InvalidateChannel(ctx, req.ID)
InvalidateChannelOptionsCache(ctx)
return nil, nil
}
@@ -590,6 +660,7 @@ func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq)
Remark: ch.Remark,
IsVIP: ch.IsVIP,
UseProxy: settings.UseProxy,
WebSearchToGoogleSearch: settings.WebSearchToGoogleSearch,
DebugLogEnabled: settings.DebugLogEnabled,
DebugLogTenantID: settings.DebugLogTenantID,
DebugLogUserID: settings.DebugLogUserID,
@@ -719,13 +790,13 @@ func (s *sAdmin) GetChannelKeys(ctx context.Context, req *v1.ChannelKeyListReq)
// GetChannelAbilities 获取渠道模型能力列表
func (s *sAdmin) GetChannelAbilities(ctx context.Context, req *v1.ChannelAbilitiesGetReq) (*v1.ChannelAbilitiesGetRes, error) {
var abilities []struct {
ID int64 `json:"id"`
ModelName string `json:"model_name"`
UpstreamModel string `json:"upstream_model"`
Enabled bool `json:"enabled"`
CostRatio float64 `json:"cost_ratio"`
SupportsResponses bool `json:"supports_responses"`
ChatViaResponses bool `json:"chat_via_responses"`
ID int64 `json:"id" orm:"id"`
ModelName string `json:"model_name" orm:"model_name"`
UpstreamModel string `json:"upstream_model" orm:"upstream_model"`
Enabled bool `json:"enabled" orm:"enabled"`
CostRatio float64 `json:"cost_ratio" orm:"cost_ratio"`
SupportsResponses bool `json:"supports_responses" orm:"supports_responses"`
ChatViaResponses bool `json:"chat_via_responses" orm:"chat_via_responses"`
}
err := dao.ChnAbilities.Ctx(ctx).
@@ -914,11 +985,7 @@ var defaultProviderURLs = map[int]string{
28: "http://localhost:9997",
// MiniMax 国内站(海外站为 api.minimaxi.chat),adaptor 自拼 /v1/text/chatcompletion_v2
29: "https://api.minimax.chat",
// Coze 国内站(海外站为 api.coze.com),adaptor 自拼 /v3/chat
32: "https://api.coze.cn",
// Dify 云端默认(api.dify.ai/v1/chat-messages);自部署实例需自行填写。
// 填裸域名即可,adaptor 自拼 /v1/chat-messages,带 /v1 会双拼
33: "https://api.dify.ai",
// 32 = Coze、33 = Dify:渠道已移除,编号永久保留不复用
// 即梦:适配器拼 /v2/images/generations,无官方对齐端点可验证,需自行填写
34: "",
// Codex 直连 chatgpt.com/backend-api/codex/responses(对齐 Codex CLI)
@@ -1055,7 +1122,14 @@ func (s *sAdmin) ExportChannels(ctx context.Context, req *v1.ChannelExportReq) (
}
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
offset := 0
// keyset 翻页:导出按优先级倒序展示,游标取 (priority, id) 复合键 ——
// priority 大量重复,必须带 id 作 tiebreaker,否则同优先级的行会被跳过或重复。
// PostgreSQL 行值比较 (a, b) < (?, ?) 按字典序逐列比较,与排序方向一致。
var (
hasCursor bool
cursorPriority int
cursorID int64
)
for {
query := dao.ChnChannels.Ctx(ctx).
LeftJoin("chn_health_scores h ON chn_channels.id = h.channel_id")
@@ -1078,6 +1152,9 @@ func (s *sAdmin) ExportChannels(ctx context.Context, req *v1.ChannelExportReq) (
// 与 ListChannels 保持一致:EXISTS 相关子查询避免 JOIN 重复行
query = query.Where("EXISTS (SELECT 1 FROM chn_abilities WHERE chn_abilities.channel_id = chn_channels.id AND (chn_abilities.model_name LIKE ? OR chn_abilities.upstream_model LIKE ?))", "%"+req.Model+"%", "%"+req.Model+"%")
}
if hasCursor {
query = query.Where("(chn_channels.priority, chn_channels.id) < (?, ?)", cursorPriority, cursorID)
}
var batch []struct {
ID int64 `json:"id"`
Name string `json:"name"`
@@ -1088,8 +1165,8 @@ func (s *sAdmin) ExportChannels(ctx context.Context, req *v1.ChannelExportReq) (
CreatedAt *gtime.Time `json:"created_at"`
HealthScore *float64 `json:"health_score"`
}
if err := query.Fields(channelFields).OrderDesc("chn_channels.priority").Limit(1000).Offset(offset).Scan(&batch); err != nil {
g.Log().Errorf(ctx, "ExportChannels: query batch at offset %d failed: %v", offset, err)
if err := query.Fields(channelFields).OrderDesc("chn_channels.priority").OrderDesc("chn_channels.id").Limit(1000).Scan(&batch); err != nil {
g.Log().Errorf(ctx, "ExportChannels: query batch failed (cursor priority=%d id=%d): %v", cursorPriority, cursorID, err)
return
}
for _, ch := range batch {
@@ -1113,11 +1190,12 @@ func (s *sAdmin) ExportChannels(ctx context.Context, req *v1.ChannelExportReq) (
}) {
return
}
cursorPriority, cursorID = ch.Priority, ch.ID
}
if len(batch) < 1000 {
break
}
offset += 1000
hasCursor = true
}
})
}
+16
View File
@@ -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
}
+3
View File
@@ -314,6 +314,9 @@ func createOAuthChannelAndKey(ctx context.Context, oauthData *oauth.OAuthKeyData
return 0, 0, err
}
// OAuth 建渠道同样要让渠道下拉缓存失效(新建渠道不出现/不失效会滞后一个 TTL)
InvalidateChannelOptionsCache(ctx)
g.Log().Infof(ctx, "[OAuth] 创建 OAuth 渠道: platform=%s, channel_id=%d, key_id=%d, email=%s",
platform, channelID, keyID, oauthData.EmailAddress)
+233 -118
View File
@@ -20,6 +20,27 @@ import (
"github.com/qianfree/team-api/internal/utility/export"
)
// dashboardCache 仪表盘聚合接口的短缓存(L1 内存 + L2 Redis)。
// 趋势 / 租户排行 / 模型分布 / 小时费用这几个接口都是对 bil_usage_logs
// 的多天窗口聚合扫描,且数据本身不要求秒级新鲜 —— 5 分钟缓存能消除
// 仪表盘反复打开 / 多管理员同时在线造成的绝大部分重复扫表。
var dashboardCache = common.NewCache("admin:dashboard", 5*time.Minute)
// cachedDashboard 按 key 读仪表盘缓存,miss 时回源并写缓存。
// key 必须编入全部查询参数(如天数 / TopN),避免不同参数共享一份结果。
func cachedDashboard[T any](ctx context.Context, key string, load func() (*T, error)) (*T, error) {
var cached T
if dashboardCache.GetJSON(ctx, key, &cached) {
return &cached, nil
}
res, err := load()
if err != nil {
return nil, err
}
dashboardCache.Set(ctx, key, res)
return res, nil
}
// GetDashboardStats 获取管理后台仪表盘统计
func (s *sAdmin) GetDashboardStats(ctx context.Context, req *v1.AdminDashboardReq) (*v1.AdminDashboardRes, error) {
today := time.Now().Format("2006-01-02")
@@ -133,25 +154,27 @@ func (s *sAdmin) GetDashboardTrends(ctx context.Context, req *v1.AdminDashboardT
days = 30
}
startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
return cachedDashboard(ctx, fmt.Sprintf("trends:%d", days), func() (*v1.AdminDashboardTrendsRes, error) {
startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
result, err := g.DB().Ctx(ctx).Raw(`
SELECT
DATE(created_at) as date,
COUNT(*) as requests,
COUNT(DISTINCT tenant_id) as active_tenants,
COALESCE(SUM(total_cost), 0) as revenue
FROM bil_usage_logs
WHERE created_at >= ?
GROUP BY DATE(created_at)
ORDER BY date ASC
`, startDate+" 00:00:00").All()
if err != nil {
return nil, err
}
result, err := g.DB().Ctx(ctx).Raw(`
SELECT
DATE(created_at) as date,
COUNT(*) as requests,
COUNT(DISTINCT tenant_id) as active_tenants,
COALESCE(SUM(total_cost), 0) as revenue
FROM bil_usage_logs
WHERE created_at >= ?
GROUP BY DATE(created_at)
ORDER BY date ASC
`, startDate+" 00:00:00").All()
if err != nil {
return nil, err
}
records := result.List()
return &v1.AdminDashboardTrendsRes{List: records}, nil
records := result.List()
return &v1.AdminDashboardTrendsRes{List: records}, nil
})
}
// GetTopTenants returns the top 10 tenants by revenue.
@@ -161,28 +184,30 @@ func (s *sAdmin) GetTopTenants(ctx context.Context, req *v1.AdminDashboardTopTen
days = 30
}
startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
return cachedDashboard(ctx, fmt.Sprintf("top-tenants:%d", days), func() (*v1.AdminDashboardTopTenantsRes, error) {
startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
result, err := g.DB().Ctx(ctx).Raw(`
SELECT
t.id as tenant_id,
t.name as tenant_name,
COALESCE(SUM(ul.total_cost), 0) as total_cost,
COUNT(*) as requests,
COUNT(DISTINCT ul.user_id) as active_members
FROM bil_usage_logs ul
JOIN tnt_tenants t ON t.id = ul.tenant_id
WHERE ul.created_at >= ?
GROUP BY t.id, t.name
ORDER BY total_cost DESC
LIMIT 10
`, startDate+" 00:00:00").All()
if err != nil {
return nil, err
}
result, err := g.DB().Ctx(ctx).Raw(`
SELECT
t.id as tenant_id,
t.name as tenant_name,
COALESCE(SUM(ul.total_cost), 0) as total_cost,
COUNT(*) as requests,
COUNT(DISTINCT ul.user_id) as active_members
FROM bil_usage_logs ul
JOIN tnt_tenants t ON t.id = ul.tenant_id
WHERE ul.created_at >= ?
GROUP BY t.id, t.name
ORDER BY total_cost DESC
LIMIT 10
`, startDate+" 00:00:00").All()
if err != nil {
return nil, err
}
records := result.List()
return &v1.AdminDashboardTopTenantsRes{List: records}, nil
records := result.List()
return &v1.AdminDashboardTopTenantsRes{List: records}, nil
})
}
// GetModelDistribution returns the model usage distribution.
@@ -192,27 +217,29 @@ func (s *sAdmin) GetModelDistribution(ctx context.Context, req *v1.AdminDashboar
days = 30
}
startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
return cachedDashboard(ctx, fmt.Sprintf("model-dist:%d", days), func() (*v1.AdminDashboardModelDistributionRes, error) {
startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
result, err := g.DB().Ctx(ctx).Raw(`
SELECT
model_name,
COUNT(*) as requests,
COALESCE(SUM(input_tokens), 0) as input_tokens,
COALESCE(SUM(output_tokens), 0) as output_tokens,
COALESCE(SUM(total_cost), 0) as total_cost
FROM bil_usage_logs
WHERE created_at >= ?
GROUP BY model_name
ORDER BY total_cost DESC
LIMIT 20
`, startDate+" 00:00:00").All()
if err != nil {
return nil, err
}
result, err := g.DB().Ctx(ctx).Raw(`
SELECT
model_name,
COUNT(*) as requests,
COALESCE(SUM(input_tokens), 0) as input_tokens,
COALESCE(SUM(output_tokens), 0) as output_tokens,
COALESCE(SUM(total_cost), 0) as total_cost
FROM bil_usage_logs
WHERE created_at >= ?
GROUP BY model_name
ORDER BY total_cost DESC
LIMIT 20
`, startDate+" 00:00:00").All()
if err != nil {
return nil, err
}
records := result.List()
return &v1.AdminDashboardModelDistributionRes{List: records}, nil
records := result.List()
return &v1.AdminDashboardModelDistributionRes{List: records}, nil
})
}
// GetModelHourlyCost 模型费用按小时堆叠统计,用于仪表盘堆叠柱状图:
@@ -227,6 +254,13 @@ func (s *sAdmin) GetModelHourlyCost(ctx context.Context, req *v1.AdminDashboardM
topN = 8
}
return cachedDashboard(ctx, fmt.Sprintf("model-hourly:%d:%d", hours, topN), func() (*v1.AdminDashboardModelHourlyRes, error) {
return s.queryModelHourlyCost(ctx, hours, topN)
})
}
// queryModelHourlyCost 实际执行小时费用聚合(GetModelHourlyCost 的缓存回源路径)。
func (s *sAdmin) queryModelHourlyCost(ctx context.Context, hours, topN int) (*v1.AdminDashboardModelHourlyRes, error) {
// 步骤1:取费用最高的 TopN 个模型名
topResult, err := g.DB().Ctx(ctx).Raw(`
SELECT model_name
@@ -358,7 +392,10 @@ func validateUsageLogDateRange(start, end string) error {
}
// buildUsageLogFilter 构建用量日志查询的 WHERE 条件与绑定参数(列表 / 统计 / 导出共用,保证筛选口径一致)。
// 注意:Username 筛选条件引用 t.username,调用方需按需 JOIN tnt_users(别名 t)。
// 条件只引用主表别名 u,不依赖任何 JOIN —— 计数与统计路径因此可以完全不联表,
// 只有需要展示名称的列表 / 导出才挂 LEFT JOIN。
// 用户维度只支持 user_id 精确筛选(前端从下拉选择带出 ID),不提供用户名模糊匹配:
// 前导通配 LIKE 在分区大表的筛选路径上无法走索引,且前端本就没有该查询条件。
func buildUsageLogFilter(f v1.AdminUsageLogFilter) (where string, args []any) {
var conditions []string
@@ -374,10 +411,6 @@ func buildUsageLogFilter(f v1.AdminUsageLogFilter) (where string, args []any) {
conditions = append(conditions, "u.user_id = ?")
args = append(args, f.UserID)
}
if f.Username != "" {
conditions = append(conditions, "t.username LIKE ?")
args = append(args, "%"+f.Username+"%")
}
if f.ApiKeyID > 0 {
conditions = append(conditions, "u.api_key_id = ?")
args = append(args, f.ApiKeyID)
@@ -398,6 +431,10 @@ func buildUsageLogFilter(f v1.AdminUsageLogFilter) (where string, args []any) {
conditions = append(conditions, "u.request_type = ?")
args = append(args, f.RequestType)
}
if f.UpstreamRequestId != "" {
conditions = append(conditions, "u.upstream_request_id = ?")
args = append(args, f.UpstreamRequestId)
}
if f.StartDate != "" {
conditions = append(conditions, "u.created_at >= ?")
args = append(args, common.StartOfRange(f.StartDate))
@@ -413,6 +450,32 @@ func buildUsageLogFilter(f v1.AdminUsageLogFilter) (where string, args []any) {
return where, args
}
// usageLogJoinedFrom 用量日志展示用联表:租户 / 成员 / 项目 / Key 名称。
// 四个联表目标都是主键列(1:1,不放大行数),但仍会被分区大表实打实执行 4 次 —— 只给需要名称的查询用。
const usageLogJoinedFrom = "bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_projects p ON u.project_id = p.id LEFT JOIN tnt_tenants tn ON u.tenant_id = tn.id LEFT JOIN api_keys ak ON u.api_key_id = ak.id"
// usageLogListFields 列表展示字段白名单:表格列 + 悬浮明细(Token / 费用分解)所需。
// 纯关联 ID(user_id / project_id / channel_id)列表不展示(名称经 JOIN 回填,
// tenant_id / api_key_id 保留作名称为空时的兜底展示),只进详情接口;
// 大字段(billing_snapshot、billing_summary、user_agent、error_message 等)同样只进详情,
// 禁止改回全字段 —— 列表每页最多 100 行,快照 JSONB 会成倍放大响应体积。
const usageLogListFields = `u.id, u.tenant_id, COALESCE(tn.name, '') AS tenant_name, COALESCE(t.username, '') AS username,
COALESCE(p.name, '') AS project_name, u.api_key_id, COALESCE(ak.name, '') AS api_key_name,
u.channel_name, u.model_name, u.upstream_model, u.request_type, u.billing_mode,
u.input_tokens, u.output_tokens, u.cache_creation_tokens, u.cache_read_tokens,
u.cache_creation_5m_tokens, u.cache_creation_1h_tokens, u.reasoning_tokens,
u.audio_input_tokens, u.audio_output_tokens, u.image_output_tokens,
u.input_cost, u.output_cost, u.cache_creation_cost, u.cache_read_cost, u.total_cost, u.actual_cost,
u.rate_multiplier, u.latency_ms, u.first_token_ms, u.status, u.retry_index, u.created_at`
// usageLogDetailFields 详情字段:列表白名单 + 纯关联 ID + 仅详情展示的大字段(错误信息、UA、计费快照等)
const usageLogDetailFields = usageLogListFields + `,
u.user_id, u.project_id, u.channel_id,
u.channel_type, u.requested_model, u.relay_mode, u.currency, u.billing_source,
u.error_message, u.client_ip, u.user_agent, u.service_tier, u.reasoning_effort, u.stream_end_reason,
u.image_count, u.image_size, u.pre_deduct_amount, u.refund_amount, u.supplement_amount,
u.billing_summary, u.billing_snapshot, u.inbound_endpoint, u.request_id, u.task_id, u.upstream_request_id`
// GetAllUsageLogs 获取所有租户的用量日志(管理后台)
func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListReq) (*v1.AdminUsageLogListRes, error) {
if err := validateUsageLogDateRange(req.StartDate, req.EndDate); err != nil {
@@ -422,29 +485,25 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
where, args := buildUsageLogFilter(req.AdminUsageLogFilter)
fromClause := "bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_projects p ON u.project_id = p.id LEFT JOIN tnt_tenants tn ON u.tenant_id = tn.id LEFT JOIN api_keys ak ON u.api_key_id = ak.id"
countSQL := "SELECT COUNT(*) AS total FROM " + fromClause + where
countResult, err := g.DB().Ctx(ctx).Query(ctx, countSQL, args...)
// 计数不展示任何名称,且 buildUsageLogFilter 的条件只引用 u.*,
// 4 个 LEFT JOIN 对计数整体剥离。大范围查询走 bil_usage_daily 日汇总拆段
// (详见 usage_log_count.go),短窗口仍为明细精确 COUNT。
total, err := countUsageLogs(ctx, req.AdminUsageLogFilter)
if err != nil {
return nil, err
}
total := 0
if len(countResult) > 0 {
total = countResult[0]["total"].Int()
}
dataSQL := `SELECT u.id, u.tenant_id, COALESCE(tn.name, '') AS tenant_name, u.user_id, COALESCE(t.username, '') AS username, u.project_id, COALESCE(p.name, '') AS project_name, u.api_key_id, COALESCE(ak.name, '') AS api_key_name, u.channel_id, u.channel_name, u.channel_type, u.model_name, u.requested_model, u.upstream_model, u.relay_mode, u.request_type, u.input_tokens, u.output_tokens, u.cache_creation_tokens, u.cache_read_tokens, u.cache_creation_5m_tokens, u.cache_creation_1h_tokens, u.reasoning_tokens, u.audio_input_tokens, u.audio_output_tokens, u.image_output_tokens, u.input_cost, u.output_cost, u.cache_creation_cost, u.cache_read_cost, u.total_cost, u.actual_cost, u.currency, u.billing_mode, u.billing_source, u.rate_multiplier, u.latency_ms, u.first_token_ms, u.status, u.error_message, u.retry_index, u.client_ip, u.user_agent, u.service_tier, u.reasoning_effort, u.stream_end_reason, u.image_count, u.image_size, u.pre_deduct_amount, u.refund_amount, u.supplement_amount, u.billing_summary, u.billing_snapshot, u.inbound_endpoint, u.request_id, u.task_id, u.created_at
FROM ` + fromClause + where + ` ORDER BY u.created_at DESC LIMIT ? OFFSET ?`
dataSQL := `SELECT ` + usageLogListFields + `
FROM ` + usageLogJoinedFrom + where + ` ORDER BY u.created_at DESC LIMIT ? OFFSET ?`
dataArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize)
result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, dataArgs...)
if err != nil {
return nil, err
}
logs := make([]*v1.AdminUsageLogItem, 0, len(result))
logs := make([]*v1.AdminUsageLogListItem, 0, len(result))
for _, row := range result {
item := &v1.AdminUsageLogItem{}
item := &v1.AdminUsageLogListItem{}
if err := row.Struct(item); err != nil {
continue
}
@@ -459,6 +518,30 @@ func (s *sAdmin) GetAllUsageLogs(ctx context.Context, req *v1.AdminUsageLogListR
}, nil
}
// GetUsageLogDetail 获取单条用量日志详情(含 billing_snapshot / billing_summary /
// user_agent / error_message 等列表不返回的大字段)。
// bil_usage_logs 按 created_at 月分区且 PK 为 (id, created_at):仅按 id 查询无法
// 分区裁剪,计划为对各月分区的 Append 索引探测(每分区走本地 PK 前缀命中 id),
// 分区数随月份线性增长但单次探测为微秒级,与 upstream_request_id 反查(000023)
// 同一模式;若日后分区数显著增大,可让调用方携带 created_at 提示做裁剪。
func (s *sAdmin) GetUsageLogDetail(ctx context.Context, req *v1.AdminUsageLogDetailReq) (*v1.AdminUsageLogDetailRes, error) {
dataSQL := `SELECT ` + usageLogDetailFields + `
FROM ` + usageLogJoinedFrom + ` WHERE u.id = ? LIMIT 1`
result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, req.Id)
if err != nil {
return nil, err
}
if len(result) == 0 {
return nil, common.NewNotFoundError("用量日志")
}
item := &v1.AdminUsageLogItem{}
if err := result[0].Struct(item); err != nil {
return nil, err
}
return &v1.AdminUsageLogDetailRes{Data: item}, nil
}
// GetUsageLogSummary 获取用量日志统计汇总(独立接口,与列表共用筛选口径)
func (s *sAdmin) GetUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSummaryReq) (*v1.AdminUsageLogSummaryRes, error) {
if err := validateUsageLogDateRange(req.StartDate, req.EndDate); err != nil {
@@ -466,18 +549,14 @@ func (s *sAdmin) GetUsageLogSummary(ctx context.Context, req *v1.AdminUsageLogSu
}
where, args := buildUsageLogFilter(req.AdminUsageLogFilter)
// 统计聚合不展示名称,仅在按用户名筛选时才 JOIN 用户表,避免大表 SUM 背负无关 JOIN
fromClause := "bil_usage_logs u"
if req.Username != "" {
fromClause += " LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id"
}
// 统计聚合不展示名称,筛选条件也只引用 u.*,全程无需联表:
// 大表 SUM 不再背负任何 join。
summarySQL := `SELECT
COALESCE(SUM(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
FROM bil_usage_logs u` + where
summaryResult, err := g.DB().Ctx(ctx).Query(ctx, summarySQL, args...)
if err != nil {
@@ -1178,6 +1257,15 @@ func (s *sAdmin) GetAllTransactions(ctx context.Context, req *v1.AdminTransactio
EndDate: req.EndDate,
})
// COUNT 与列表拆开执行:计数不需要租户/用户名称列,筛选条件也不依赖 JOIN
//(Username 过滤在 BuildTransactionQuery 内是 EXISTS 子查询),大表
// bil_transactions 的计数因此免背 2 个联表。不用 ScanAndCount —— 它的
// COUNT 会携带模型上的全部 JOIN(gf v2.10.2 源码确认:Clone 保留 join)。
total, err := query.Count()
if err != nil {
return nil, err
}
type transactionRow struct {
Id int64 `json:"id"`
TenantId int64 `json:"tenant_id"`
@@ -1193,19 +1281,16 @@ func (s *sAdmin) GetAllTransactions(ctx context.Context, req *v1.AdminTransactio
CreatedAt *gtime.Time `json:"created_at"`
}
var records []*transactionRow
var total int
err := query.Fields("bil_transactions.id, bil_transactions.tenant_id, COALESCE(tn.name, '') AS tenant_name, bil_transactions.type, bil_transactions.amount, bil_transactions.balance_after, bil_transactions.description, bil_transactions.user_id, COALESCE(tu.username, '') AS username, bil_transactions.request_id, bil_transactions.model_name, bil_transactions.created_at").
LeftJoin("tnt_users tu", "bil_transactions.user_id = tu.id AND bil_transactions.tenant_id = tu.tenant_id").
LeftJoin("tnt_tenants tn", "bil_transactions.tenant_id = tn.id").
OrderDesc("bil_transactions.created_at").
Page(page, pageSize).
ScanAndCount(&records, &total, false)
if err != nil {
return nil, err
}
if records == nil {
records = make([]*transactionRow, 0)
records := make([]*transactionRow, 0)
if total > 0 {
if err := query.Fields("bil_transactions.id, bil_transactions.tenant_id, COALESCE(tn.name, '') AS tenant_name, bil_transactions.type, bil_transactions.amount, bil_transactions.balance_after, bil_transactions.description, bil_transactions.user_id, COALESCE(tu.username, '') AS username, bil_transactions.request_id, bil_transactions.model_name, bil_transactions.created_at").
LeftJoin("tnt_users tu", "bil_transactions.user_id = tu.id AND bil_transactions.tenant_id = tu.tenant_id").
LeftJoin("tnt_tenants tn", "bil_transactions.tenant_id = tn.id").
OrderDesc("bil_transactions.created_at").
Page(page, pageSize).
Scan(&records); err != nil {
return nil, err
}
}
list := make([]*v1.AdminTransactionItem, 0, len(records))
@@ -1278,6 +1363,9 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor
if err := validateUsageLogDateRange(req.StartDate, req.EndDate); err != nil {
return nil, err
}
if err := common.ValidateExportTimeWindow(req.StartDate, req.EndDate, common.UsageLogExportMaxWindowDays); err != nil {
return nil, err
}
columns := []export.Column{
{Field: "id", Header: "ID"},
@@ -1289,6 +1377,8 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor
{Field: "output_tokens", Header: "输出Token"},
{Field: "total_cost", Header: "费用"},
{Field: "status", Header: "状态"},
{Field: "request_id", Header: "请求ID"},
{Field: "upstream_request_id", Header: "上游请求ID"},
{Field: "created_at", Header: "创建时间"},
}
@@ -1301,17 +1391,32 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor
where, filterArgs := buildUsageLogFilter(req.AdminUsageLogFilter)
fromClause := "bil_usage_logs u LEFT JOIN tnt_users t ON u.user_id = t.id AND u.tenant_id = t.tenant_id LEFT JOIN tnt_tenants tn ON u.tenant_id = tn.id"
selectFields := "u.id, COALESCE(tn.name, '') AS tenant_name, COALESCE(t.username, '') AS username, u.model_name, u.request_type, u.input_tokens, u.output_tokens, u.total_cost, u.status, u.created_at"
selectFields := "u.id, COALESCE(tn.name, '') AS tenant_name, COALESCE(t.username, '') AS username, u.model_name, u.request_type, u.input_tokens, u.output_tokens, u.total_cost, u.status, u.request_id, u.upstream_request_id, u.created_at"
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
offset := 0
// keyset(游标)翻页替代 OFFSET:OFFSET 每翻一批都要重新扫过并丢弃前面的
// 所有行,深翻页代价线性上涨;游标条件让每批从上一批断点直接续读。
// 游标取 id(bigserial 单调递增,PK 索引可直接定位):排序从 created_at DESC
// 改为 id DESC,两者在追加写表上近似同序(同为「新的在前」),却避免了
// 时间戳做游标时亚秒精度在驱动往返中丢失导致的跳行/重行问题。
var cursorID int64
for {
sql := "SELECT " + selectFields + " FROM " + fromClause + where + " ORDER BY u.created_at DESC LIMIT ? OFFSET ?"
// 复制一份再追加分页参数,避免污染循环外构建的共用筛选参数
batchArgs := append(append([]any{}, filterArgs...), 1000, offset)
sql := "SELECT " + selectFields + " FROM " + fromClause + where
batchArgs := append([]any{}, filterArgs...)
if cursorID > 0 {
// 时间窗必填保证 where 非空,这里安全起见仍处理空 where 的情况
if where == "" {
sql += " WHERE u.id < ?"
} else {
sql += " AND u.id < ?"
}
batchArgs = append(batchArgs, cursorID)
}
sql += " ORDER BY u.id DESC LIMIT ?"
batchArgs = append(batchArgs, 1000)
result, err := g.DB().Ctx(ctx).Query(ctx, sql, batchArgs...)
if err != nil {
g.Log().Errorf(ctx, "ExportUsageLogs: query batch at offset %d failed: %v", offset, err)
g.Log().Errorf(ctx, "ExportUsageLogs: query batch failed (cursor id=%d): %v", cursorID, err)
return
}
for _, row := range result {
@@ -1320,24 +1425,26 @@ func (s *sAdmin) ExportUsageLogs(ctx context.Context, req *v1.AdminUsageLogExpor
createdAt = fmt.Sprintf("%v", t.Val())
}
if !yield(map[string]any{
"id": row["id"].Val(),
"tenant_name": row["tenant_name"].Val(),
"username": row["username"].Val(),
"model_name": row["model_name"].Val(),
"request_type": row["request_type"].Val(),
"input_tokens": row["input_tokens"].Val(),
"output_tokens": row["output_tokens"].Val(),
"total_cost": row["total_cost"].Val(),
"status": row["status"].Val(),
"created_at": createdAt,
"id": row["id"].Val(),
"tenant_name": row["tenant_name"].Val(),
"username": row["username"].Val(),
"model_name": row["model_name"].Val(),
"request_type": row["request_type"].Val(),
"input_tokens": row["input_tokens"].Val(),
"output_tokens": row["output_tokens"].Val(),
"total_cost": row["total_cost"].Val(),
"status": row["status"].Val(),
"request_id": row["request_id"].Val(),
"upstream_request_id": row["upstream_request_id"].Val(),
"created_at": createdAt,
}) {
return
}
cursorID = row["id"].Int64()
}
if len(result) < 1000 {
break
}
offset += 1000
}
})
}
@@ -1363,11 +1470,17 @@ func (s *sAdmin) ExportBillingRecords(ctx context.Context, req *v1.AdminBillingR
Columns: columns,
}
fetchRecords := func(offset, limit int) ([]map[string]any, error) {
// fetchRecords 取一批记录并返回本批末行 id 作为下一批游标(0 表示无数据)
fetchRecords := func(cursorID int64, limit int) ([]map[string]any, int64, error) {
query := dao.BilRecords.Ctx(ctx)
if req.TenantID > 0 {
query = query.Where("tenant_id", req.TenantID)
}
// keyset 翻页:id 游标替代 OFFSET,避免深翻页时重复扫弃前面的行;
// 排序取 id DESC(追加写表上与 created_at DESC 近似同序)
if cursorID > 0 {
query = query.Where("id < ?", cursorID)
}
var records []struct {
Id int64 `json:"id"`
TenantId int64 `json:"tenant_id"`
@@ -1381,8 +1494,8 @@ func (s *sAdmin) ExportBillingRecords(ctx context.Context, req *v1.AdminBillingR
CreatedAt *gtime.Time `json:"created_at"`
}
if err := query.Fields("id, tenant_id, user_id, channel_id, model_name, input_tokens, output_tokens, total_cost, status, created_at").
OrderDesc("created_at").Limit(limit).Offset(offset).Scan(&records); err != nil {
return nil, err
OrderDesc("id").Limit(limit).Scan(&records); err != nil {
return nil, 0, err
}
// Batch resolve names
@@ -1433,6 +1546,7 @@ func (s *sAdmin) ExportBillingRecords(ctx context.Context, req *v1.AdminBillingR
}
data := make([]map[string]any, len(records))
var lastID int64
for i, r := range records {
data[i] = map[string]any{
"id": r.Id,
@@ -1446,16 +1560,17 @@ func (s *sAdmin) ExportBillingRecords(ctx context.Context, req *v1.AdminBillingR
"status": r.Status,
"created_at": r.CreatedAt.String(),
}
lastID = r.Id
}
return data, nil
return data, lastID, nil
}
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
offset := 0
var cursorID int64
for {
batch, err := fetchRecords(offset, 1000)
batch, lastID, err := fetchRecords(cursorID, 1000)
if err != nil {
g.Log().Errorf(ctx, "ExportBillingRecords: query batch at offset %d failed: %v", offset, err)
g.Log().Errorf(ctx, "ExportBillingRecords: query batch failed (cursor id=%d): %v", cursorID, err)
return
}
for _, row := range batch {
@@ -1466,7 +1581,7 @@ func (s *sAdmin) ExportBillingRecords(ctx context.Context, req *v1.AdminBillingR
if len(batch) < 1000 {
break
}
offset += 1000
cursorID = lastID
}
})
}
+59 -5
View File
@@ -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()
}
+12 -10
View File
@@ -130,17 +130,15 @@ func (s *sAdmin) CreateMember(ctx context.Context, req *v1.AdminMemberCreateReq)
func (s *sAdmin) ListAllMembers(ctx context.Context, req *v1.AdminMemberListReq) (*v1.AdminMemberListRes, error) {
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
m := dao.TntUsers.Ctx(ctx).
LeftJoin("tnt_tenants t ON tnt_users.tenant_id = t.id")
m = buildMemberFilters(m, req.Keyword, req.Status, req.Role, req.TenantID)
total, err := m.Count()
// COUNT 不需要租户名称列,且 buildMemberFilters 的条件只引用 tnt_users.* ——
// 计数免联表(联表目标是 tnt_tenants 主键,1:1 不影响行数)
total, err := buildMemberFilters(dao.TntUsers.Ctx(ctx), req.Keyword, req.Status, req.Role, req.TenantID).Count()
if err != nil {
return nil, err
}
// Rebuild model for data query (Count() modifies internal state)
m = dao.TntUsers.Ctx(ctx).
// 列表查询才需要 JOIN 补租户名称/代码
m := dao.TntUsers.Ctx(ctx).
LeftJoin("tnt_tenants t ON tnt_users.tenant_id = t.id")
m = buildMemberFilters(m, req.Keyword, req.Status, req.Role, req.TenantID)
@@ -393,12 +391,16 @@ func (s *sAdmin) ExportMembers(ctx context.Context, req *v1.AdminMemberExportReq
}
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
offset := 0
// keyset 翻页:id 游标替代 OFFSET,避免深翻页时重复扫弃前面的行(排序本就是 id DESC)
var cursorID int64
for {
m := buildMemberFilters(
dao.TntUsers.Ctx(ctx).LeftJoin("tnt_tenants t ON tnt_users.tenant_id = t.id"),
req.Keyword, req.Status, req.Role, req.TenantID,
)
if cursorID > 0 {
m = m.Where("tnt_users.id < ?", cursorID)
}
var batch []struct {
Id int64 `json:"id"`
TenantName string `json:"tenant_name"`
@@ -410,7 +412,7 @@ func (s *sAdmin) ExportMembers(ctx context.Context, req *v1.AdminMemberExportReq
LastLoginAt *gtime.Time `json:"last_login_at"`
CreatedAt *gtime.Time `json:"created_at"`
}
if err := m.Fields(memberFields).OrderDesc("tnt_users.id").Limit(1000).Offset(offset).Scan(&batch); err != nil {
if err := m.Fields(memberFields).OrderDesc("tnt_users.id").Limit(1000).Scan(&batch); err != nil {
return
}
for _, m := range batch {
@@ -427,11 +429,11 @@ func (s *sAdmin) ExportMembers(ctx context.Context, req *v1.AdminMemberExportReq
}) {
return
}
cursorID = m.Id
}
if len(batch) < 1000 {
break
}
offset += 1000
}
})
}
+499 -141
View File
@@ -3,8 +3,10 @@ package admin
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gcode"
@@ -23,40 +25,67 @@ import (
"github.com/qianfree/team-api/internal/utility/export"
)
// modelOptionsCache 模型下拉选项缓存(全量模型,单个条目)。
// 下拉接口是若干管理页每次打开都要调的,而模型表极少变动;
// 整表结果缓存为一个条目、按 status/category 在内存里过滤,写侧主动失效。
var modelOptionsCache = common.NewCache("model_options", 300*time.Second)
// modelOptionsCacheKey 模型下拉是全量结果,只有一个缓存条目。
const modelOptionsCacheKey = "all"
// InvalidateModelOptionsCache 失效模型下拉选项缓存(模型增 / 删 / 改后调用)。
// 注:task 包的模型下线定时任务改 status 时不经此处,下拉最多滞后一个 TTL(300s);
// 对「按状态筛模型」的下拉而言可接受,故不为此在 task 包反向依赖 admin。
func InvalidateModelOptionsCache(ctx context.Context) {
modelOptionsCache.Delete(ctx, modelOptionsCacheKey)
}
// modelOptionRow 下拉缓存行:比出参多一个 status,供内存过滤。
// 缓存存全量(不过滤),避免 status / category 的每个组合各占一个缓存条目。
type modelOptionRow struct {
ID int64 `json:"id"`
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Status string `json:"status"`
}
// ListModelOptions 获取模型选项列表(不分页,用于下拉选择)
func (s *sAdmin) ListModelOptions(ctx context.Context, req *v1.ModelOptionsReq) (*v1.ModelOptionsRes, error) {
query := dao.MdlModels.Ctx(ctx)
if req.Status != "" {
query = query.Where("status", req.Status)
} else {
query = query.Where("status", "active")
}
if req.Category != "" {
query = query.Where("category", req.Category)
rows := make([]modelOptionRow, 0)
if !modelOptionsCache.GetJSON(ctx, modelOptionsCacheKey, &rows) {
err := dao.MdlModels.Ctx(ctx).
Fields("id, model_id, model_name, category, status").
OrderAsc("category").OrderAsc("model_id").
Scan(&rows)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
if rows == nil {
rows = make([]modelOptionRow, 0)
}
modelOptionsCache.Set(ctx, modelOptionsCacheKey, rows)
}
var models []struct {
ID int64 `json:"id"`
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
// 沿用原接口语义:不传 status 即只要可用模型
status := req.Status
if status == "" {
status = "active"
}
err := query.Fields("id, model_id, model_name, category").
OrderAsc("category").OrderAsc("model_id").
Scan(&models)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
list := make([]v1.ModelOptionItem, 0, len(models))
for _, m := range models {
list := make([]v1.ModelOptionItem, 0, len(rows))
for _, r := range rows {
if r.Status != status {
continue
}
if req.Category != "" && r.Category != req.Category {
continue
}
list = append(list, v1.ModelOptionItem{
ID: m.ID,
ModelId: m.ModelId,
ModelName: m.ModelName,
Category: m.Category,
ID: r.ID,
ModelId: r.ModelId,
ModelName: r.ModelName,
Category: r.Category,
})
}
@@ -70,19 +99,28 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
if req.Category != "" {
query = query.Where("category", req.Category)
}
if req.Vendor != "" {
query = query.Where("vendor", req.Vendor)
}
if req.Status != "" {
query = query.Where("status", req.Status)
}
if req.Search != "" {
query = query.Where("model_id LIKE ? OR model_name LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%")
}
// 定价状态筛选
// 定价状态筛选(pricing JSONB 唯一真相:任一模式有正价即已定价)
// 已配价谓词:token 价>0 / per_request 价>0 / per_second 矩阵非空 / tiered 任一档价>0
const pricedPredicate = `EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND (
(pricing->>'input_price')::numeric > 0
OR (pricing->>'output_price')::numeric > 0
OR (pricing->>'price')::numeric > 0
OR (pricing->'prices' IS NOT NULL AND pricing->'prices' <> '{}'::jsonb)
OR EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(pricing->'tiers', '[]'::jsonb)) tx WHERE (tx->>'input_price')::numeric > 0 OR (tx->>'output_price')::numeric > 0)
))`
if req.PricingStatus == "priced" {
// 已定价:存在 min_tokens=0 的记录,且输入价格或输出价格不为 0
query = query.Where("EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND mdl_pricing.min_tokens = 0 AND (mdl_pricing.input_price > 0 OR mdl_pricing.output_price > 0 OR mdl_pricing.per_request_price > 0))")
query = query.Where(pricedPredicate)
} else if req.PricingStatus == "unpriced" {
// 未定价:存在 min_tokens=0 的记录,但所有价格字段都为 0
query = query.Where("EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id AND mdl_pricing.min_tokens = 0 AND mdl_pricing.input_price = 0 AND mdl_pricing.output_price = 0 AND (mdl_pricing.per_request_price IS NULL OR mdl_pricing.per_request_price = 0))")
query = query.Where("EXISTS (SELECT 1 FROM mdl_pricing WHERE mdl_pricing.model_id = mdl_models.id) AND NOT " + pricedPredicate)
}
var total int
@@ -91,6 +129,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Vendor string `json:"vendor"`
Status string `json:"status"`
MaxContextTokens int `json:"max_context_tokens"`
MaxOutputTokens int `json:"max_output_tokens"`
@@ -104,7 +143,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
ReplacementModel string `json:"replacement_model"`
}
err := query.Fields("id, model_id, model_name, category, status, max_context_tokens, max_output_tokens, capabilities, description, tags, created_at, updated_at, deprecated_at, sunset_date, replacement_model").
err := query.Fields("id, model_id, model_name, category, vendor, status, max_context_tokens, max_output_tokens, capabilities, description, tags, created_at, updated_at, deprecated_at, sunset_date, replacement_model").
OrderDesc("id").
Page(req.Page, req.PageSize).
ScanAndCount(&models, &total, false)
@@ -118,17 +157,15 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
modelIDs = append(modelIDs, m.ID)
}
type pricingRow struct {
ModelId int64 `json:"model_id"`
BillingMode string `json:"billing_mode"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
ModelId int64 `json:"model_id"`
BillingMode string `json:"billing_mode"`
Pricing string `json:"pricing"`
}
var pricingRows []pricingRow
if len(modelIDs) > 0 {
err = dao.MdlPricing.Ctx(ctx).
Fields("model_id, billing_mode, input_price, output_price, per_request_price").
Where("model_id IN (?) AND min_tokens = 0", modelIDs).
Fields("model_id, billing_mode, pricing").
Where("model_id IN (?)", modelIDs).
Scan(&pricingRows)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
@@ -179,6 +216,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
ModelId: m.ModelId,
ModelName: m.ModelName,
Category: m.Category,
Vendor: m.Vendor,
Status: m.Status,
MaxContext: m.MaxContextTokens,
MaxOutput: m.MaxOutputTokens,
@@ -197,13 +235,28 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
item.SunsetDate = &s
}
item.ReplacementModel = m.ReplacementModel
// 填充定价摘要
// 填充定价摘要(从 pricing JSONB 解析;tiered 取首档价,与旧列表口径一致)
if p, ok := pricingMap[m.ID]; ok {
item.PricingMode = p.BillingMode
item.InputPrice = p.InputPrice
item.OutputPrice = p.OutputPrice
if p.PerRequestPrice != nil {
item.PerRequestPrice = *p.PerRequestPrice
if blob := billing.ParsePricingBlob(p.Pricing); blob != nil {
switch {
case len(blob.Tiers) > 0:
item.InputPrice = blob.Tiers[0].InputPrice
item.OutputPrice = blob.Tiers[0].OutputPrice
default:
if blob.InputPrice != nil {
item.InputPrice = *blob.InputPrice
}
if blob.OutputPrice != nil {
item.OutputPrice = *blob.OutputPrice
}
if blob.Price != nil {
item.PerRequestPrice = *blob.Price
}
if len(blob.Prices) > 0 {
item.PerSecondPrices = blob.Prices
}
}
}
}
// 填充可用渠道
@@ -235,6 +288,7 @@ func (s *sAdmin) CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.M
ModelId: req.ModelId,
ModelName: req.ModelName,
Category: req.Category,
Vendor: req.Vendor, // 空串=未分类
Status: "active",
MaxContextTokens: req.MaxContext,
MaxOutputTokens: req.MaxOutput,
@@ -255,21 +309,37 @@ func (s *sAdmin) CreateModel(ctx context.Context, req *v1.ModelCreateReq) (*v1.M
return nil, err
}
// 默认 token 模式空定价行(未配价,SetModelPricing 时覆盖 pricing JSONB)
_, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
ModelId: id,
BillingMode: "token",
MinTokens: 0,
MaxTokens: nil,
InputPrice: 0,
OutputPrice: 0,
Pricing: "{}",
})
if err != nil {
return nil, err
}
InvalidateModelOptionsCache(ctx)
return &v1.ModelCreateRes{ID: id}, nil
}
// validModelVendors 模型研发厂商合法值集合(与 mdl_models.vendor 枚举、API v:"in:" 标签、前端字典对齐)。
// 注意与 chn_channels.type 的"渠道供应商"是两个维度:研发厂商指模型的开发公司,
// 例如 Bedrock 上的 Claude 厂商仍是 anthropic。
var validModelVendors = map[string]struct{}{
"openai": {}, "anthropic": {}, "google": {}, "xai": {}, "mistral": {}, "cohere": {},
"meta": {}, "alibaba": {}, "bytedance": {}, "deepseek": {}, "zhipu": {}, "moonshot": {},
"minimax": {}, "baidu": {}, "tencent": {}, "xunfei": {}, "kuaishou": {}, "midjourney": {},
"suno": {},
}
// isValidModelVendor 校验研发厂商枚举值
func isValidModelVendor(v string) bool {
_, ok := validModelVendors[v]
return ok
}
// UpdateModel 更新模型(含弃用状态管理)
func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.ModelUpdateRes, error) {
var oldModel *struct {
@@ -293,6 +363,14 @@ func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.M
data.Category = req.Category
hasUpdate = true
}
// 厂商指针语义:nil=不更新;空串=清空回未分类(do 字段为 any,空串不会被 ORM 忽略);非空=校验枚举后设置
if req.Vendor != nil {
if *req.Vendor != "" && !isValidModelVendor(*req.Vendor) {
return nil, common.NewBusinessError(422, "厂商值不合法")
}
data.Vendor = *req.Vendor
hasUpdate = true
}
data.MaxContextTokens = req.MaxContext
data.MaxOutputTokens = req.MaxOutput
hasUpdate = true
@@ -349,6 +427,10 @@ func (s *sAdmin) UpdateModel(ctx context.Context, req *v1.ModelUpdateReq) (*v1.M
return nil, err
}
// 下拉缓存按全量存、内存过滤 status/category,故改名 / 改分类同样要失效,
// 不能只在 statusChanged 时清。
InvalidateModelOptionsCache(ctx)
if statusChanged {
// 状态变更时清除模型缓存和租户分组缓存
relay.NewDataProvider().InvalidateModelCache(oldModel.ModelId)
@@ -425,6 +507,7 @@ func (s *sAdmin) DeleteModel(ctx context.Context, req *v1.ModelDeleteReq) (*v1.M
if model.ModelId != "" {
billing.ClearModelPriceCache(ctx, model.ModelId)
}
InvalidateModelOptionsCache(ctx)
return nil, nil
}
@@ -440,68 +523,126 @@ func parseCapabilities(raw string) map[string]bool {
return caps
}
// GetModelPricing 获取模型定价
// GetModelPricing 获取模型定价(pricing JSONB 单行还原为 PricingItem 列表,响应结构与旧多行结构保持兼容)
func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v1.PricingGetRes, error) {
var rows []struct {
BillingMode string `json:"billing_mode"`
MinTokens int64 `json:"min_tokens"`
MaxTokens *int64 `json:"max_tokens"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
CacheReadPrice float64 `json:"cache_read_price"`
CacheCreationPrice float64 `json:"cache_creation_price"`
TimeSegments string `json:"time_segments"`
PriceNote *string `json:"price_note"`
DiscountLabel *string `json:"discount_label"`
PriceChangeNote *string `json:"price_change_note"`
var row *struct {
BillingMode string `json:"billing_mode" orm:"billing_mode"`
Pricing string `json:"pricing" orm:"pricing"`
OfficialPricing string `json:"official_pricing" orm:"official_pricing"`
PriceNote *string `json:"price_note" orm:"price_note"`
DiscountLabel *string `json:"discount_label" orm:"discount_label"`
PriceChangeNote *string `json:"price_change_note" orm:"price_change_note"`
}
err := dao.MdlPricing.Ctx(ctx).
Where("model_id", req.ModelID).
OrderAsc("min_tokens").
Scan(&rows)
Scan(&row)
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
result := make([]v1.PricingItem, 0, len(rows))
var timeSegments []billing.TimeSegment
var result []v1.PricingItem
var timeSegments []v1.TimeSegmentItem
var paramRules []v1.ParamMultiplierItem
var officialItems []v1.PricingItem
var officialTimeSegments []v1.TimeSegmentItem
var officialParamRules []v1.ParamMultiplierItem
var priceNote, discountLabel, priceChangeNote string
for i := range rows {
r := rows[i]
result = append(result, v1.PricingItem{
BillingMode: r.BillingMode,
MinTokens: r.MinTokens,
MaxTokens: r.MaxTokens,
InputPrice: r.InputPrice,
OutputPrice: r.OutputPrice,
PerRequestPrice: r.PerRequestPrice,
CacheReadPrice: r.CacheReadPrice,
CacheCreationPrice: r.CacheCreationPrice,
})
// 时段定价与展示字段只认锚点行(min_tokens=0),阶梯行上的残留忽略
if r.MinTokens == 0 {
if r.TimeSegments != "" && r.TimeSegments != "null" {
if err := json.Unmarshal([]byte(r.TimeSegments), &timeSegments); err != nil {
g.Log().Warningf(ctx, "admin: 模型 %d 时段定价解析失败: %v", req.ModelID, err)
timeSegments = nil
}
}
priceNote = derefStr(r.PriceNote)
discountLabel = derefStr(r.DiscountLabel)
priceChangeNote = derefStr(r.PriceChangeNote)
var scheme string
var schemeConfig json.RawMessage
if row != nil {
// 主定价:pricing JSONB → items + 时段 + 倍率(与官方参考定价共用展开逻辑)
blob := billing.ParsePricingBlob(row.Pricing)
result, timeSegments, paramRules = expandPricingBlob(row.BillingMode, blob)
if blob != nil {
scheme = blob.Scheme
schemeConfig = blob.SchemeConfig
}
// 官方参考定价:official_pricing JSONB 同构展开;未配置(NULL/解析失败)时三个字段保持 nil
offMode, offBlob := officialBlobFromJSON(row.OfficialPricing)
if offBlob != nil {
officialItems, officialTimeSegments, officialParamRules = expandPricingBlob(offMode, offBlob)
}
priceNote = derefStr(row.PriceNote)
discountLabel = derefStr(row.DiscountLabel)
priceChangeNote = derefStr(row.PriceChangeNote)
}
return &v1.PricingGetRes{
List: result,
TimeSegments: timeSegmentsToAPI(timeSegments),
PriceNote: priceNote,
DiscountLabel: discountLabel,
PriceChangeNote: priceChangeNote,
List: result,
TimeSegments: timeSegments,
ParamMultipliers: paramRules,
OfficialItems: officialItems,
OfficialTimeSegments: officialTimeSegments,
OfficialParamMultipliers: officialParamRules,
PriceNote: priceNote,
DiscountLabel: discountLabel,
PriceChangeNote: priceChangeNote,
Scheme: scheme,
SchemeConfig: schemeConfig,
}, nil
}
// expandPricingBlob pricing JSONB 内存形态 → API 响应结构(items 行 + 时段 + 参数倍率)。
// tiered 的 tiers 数组还原为多行 PricingItem(与旧档位行结构一致)。blob 为 nil 时
// 仍返回单行空 item(mode 随入参),保持「无定价」的既有响应形态。主定价与官方参考定价共用。
func expandPricingBlob(mode string, blob *billing.PricingBlob) ([]v1.PricingItem, []v1.TimeSegmentItem, []v1.ParamMultiplierItem) {
if blob == nil {
blob = &billing.PricingBlob{}
}
items := make([]v1.PricingItem, 0, 1)
switch {
case len(blob.Tiers) > 0:
// tiered:tiers 数组还原为多行 PricingItem(与旧档位行结构一致)。
// 逐档缓存价随行还原;首档无档内值时回退 blob 顶层锚点缓存价(存量数据兼容)
for i, tier := range blob.Tiers {
item := v1.PricingItem{
BillingMode: mode,
MinTokens: tier.MinTokens,
MaxTokens: tier.MaxTokens,
InputPrice: tier.InputPrice,
OutputPrice: tier.OutputPrice,
}
if tier.CacheReadPrice != nil {
item.CacheReadPrice = *tier.CacheReadPrice
} else if i == 0 && blob.CacheReadPrice != nil {
item.CacheReadPrice = *blob.CacheReadPrice
}
if tier.CacheCreationPrice != nil {
item.CacheCreationPrice = *tier.CacheCreationPrice
} else if i == 0 && blob.CacheCreationPrice != nil {
item.CacheCreationPrice = *blob.CacheCreationPrice
}
items = append(items, item)
}
default:
item := v1.PricingItem{BillingMode: mode}
if blob.InputPrice != nil {
item.InputPrice = *blob.InputPrice
}
if blob.OutputPrice != nil {
item.OutputPrice = *blob.OutputPrice
}
if blob.CacheReadPrice != nil {
item.CacheReadPrice = *blob.CacheReadPrice
}
if blob.CacheCreationPrice != nil {
item.CacheCreationPrice = *blob.CacheCreationPrice
}
if blob.Price != nil {
p := *blob.Price
item.PerRequestPrice = &p
}
if len(blob.Prices) > 0 {
item.PerSecondPrices = blob.Prices
}
items = append(items, item)
}
// 时段与倍率横切所有计费模式(含 tiered),随 blob 顶层还原
return items, timeSegmentsToAPI(blob.TimeSegments), paramMultipliersToAPI(blob.ParamMultipliers)
}
// derefStr 字符串指针安全解引用(NULL → 空串)
func derefStr(s *string) string {
if s == nil {
@@ -553,17 +694,222 @@ func timeSegmentsFromAPI(items []v1.TimeSegmentItem) ([]billing.TimeSegment, err
return result, nil
}
// writeTimeSegmentsForModel 把时段定价写到模型定价锚点行(min_tokens=0),空列表=清除(全量替换语义)。
// 定价保存与模型导入共用。清除时写 "[]" 而非 SQL NULL:避免依赖 ORM 对 nil 值 UPDATE 的默认行为
// (被省略会让「关闭时段」静默失效),所有读取方(计费/租户/管理端)均已把空数组视为未配置。
// 用列名直写而非 do 字段:do.MdlPricing 的 TimeSegments 字段需迁移落库后 gf gen dao 重新生成才存在,
// 直写列名与生成物解耦(wire 格式与 custom_pricing_tiers JSONB 先例一致)。调用方需保证事务 ctx 传播。
// paramMultipliersFromAPI API 参数倍率规则 → billing 规则(含校验)
func paramMultipliersFromAPI(items []v1.ParamMultiplierItem) ([]billing.ParamRule, error) {
if len(items) == 0 {
return nil, nil
}
rules := make([]billing.ParamRule, 0, len(items))
for _, item := range items {
rule := billing.ParamRule{
Multiplier: item.Multiplier,
Note: strings.TrimSpace(item.Note),
}
for _, c := range item.Conditions {
rule.Conditions = append(rule.Conditions, billing.ParamCondition{
Path: strings.TrimSpace(c.Path),
Match: c.Match,
Value: c.Value,
})
}
rules = append(rules, rule)
}
if err := billing.ValidateParamMultipliers(rules); err != nil {
return nil, err
}
return rules, nil
}
// paramMultipliersToAPI billing 规则 → API 参数倍率项
func paramMultipliersToAPI(rules []billing.ParamRule) []v1.ParamMultiplierItem {
if len(rules) == 0 {
return nil
}
items := make([]v1.ParamMultiplierItem, 0, len(rules))
for _, rule := range rules {
item := v1.ParamMultiplierItem{
Multiplier: rule.Multiplier,
Note: rule.Note,
}
for _, c := range rule.Conditions {
item.Conditions = append(item.Conditions, v1.ParamConditionItem{
Path: c.Path,
Match: c.Match,
Value: c.Value,
})
}
items = append(items, item)
}
return items
}
// pricingItemsToInput API 定价项 → billing 构造输入
func pricingItemsToInput(items []v1.PricingItem) []billing.PricingItemInput {
result := make([]billing.PricingItemInput, 0, len(items))
for _, item := range items {
result = append(result, billing.PricingItemInput{
BillingMode: item.BillingMode,
MinTokens: item.MinTokens,
MaxTokens: item.MaxTokens,
InputPrice: item.InputPrice,
OutputPrice: item.OutputPrice,
PerRequestPrice: item.PerRequestPrice,
CacheReadPrice: item.CacheReadPrice,
CacheCreationPrice: item.CacheCreationPrice,
PerSecondPrices: item.PerSecondPrices,
})
}
return result
}
// writePricingForModel 全量替换模型定价:单行写 billing_mode + pricing JSONB(每模型一行,
// uk_mdl_pricing_model 唯一)。SetModelPricing 与模型导入共用;时段定价随后经
// writeTimeSegmentsForModel 的 jsonb_set 并入 JSON 顶层;参数倍率规则随 blob 顶层写入。
// scheme/schemeConfig 为计费方案声明与私有配置:导入路径透传导出文件中的方案字段
// (旧版导出文件无该字段=空串/nil,导入后为通用引擎定价);两条路径均已在调用前
// 完成 special ⇔ scheme 配对与 SchemeRegistered 校验。
// 调用方需保证事务 ctx 传播。
func writePricingForModel(ctx context.Context, modelDBID int64, items []v1.PricingItem, paramRules []billing.ParamRule, scheme string, schemeConfig json.RawMessage) error {
blob, err := billing.BuildPricingBlob(pricingItemsToInput(items))
if err != nil {
return err
}
blob.ParamMultipliers = paramRules
blob.Scheme = scheme
blob.SchemeConfig = schemeConfig
blobJSON, err := json.Marshal(blob)
if err != nil {
return err
}
// 全量替换语义改为 upsert:ON CONFLICT (model_id) 命中时只覆盖 billing_mode / pricing,
// official_pricing 与展示字段列未涉及时保留原值——模型导入共用本函数,不得误清人工
// 核对落库的官方定价;展示字段随后仍由调用方按「空=清除」全量回写,行为与删行重插等价。
// 列名直写而非 do 字段,与 gf gen dao 生成物解耦(同 writePricingDisplayFieldsForModel)。
if _, err := dao.MdlPricing.Ctx(ctx).
Data(g.Map{
"model_id": modelDBID,
"billing_mode": items[0].BillingMode,
"pricing": string(blobJSON),
}).
OnConflict("model_id").
OnDuplicate(g.Map{
"billing_mode": gdb.Raw("excluded.billing_mode"),
"pricing": gdb.Raw("excluded.pricing"),
}).
Save(); err != nil {
return err
}
return nil
}
// officialPricingBlob official_pricing JSONB 的内存形态:billing_mode 键 + PricingBlob 字段平铺
// (encoding/json 对嵌入结构体平铺序列化),与计费定价 pricing JSONB 完全同构,
// 仅多 billing_mode 键——官方定价无独立模式列,模式随 JSONB 存储。
type officialPricingBlob struct {
BillingMode string `json:"billing_mode"`
billing.PricingBlob
}
// buildOfficialBlob 官方定价项 + 时段/倍率 → PricingBlob(与计费定价同构,复用同一构建管线)。
// 全空(价格维度 + tiers + 时段 + 倍率均无)返回 nil,调用方按清除处理(写 NULL)。
// 官方价是折扣换算的参照基准而非计费依据,是可选配置:per_second 空/全零矩阵属「未配置」
// 形态,不走计费定价的矩阵强校验(否则未配置形态会报错,连带阻断主定价保存),
// 直接落成空 blob 交给下方全空判断;其余模式沿用 BuildPricingBlob 结构校验与时段/倍率 FromAPI 校验。
func buildOfficialBlob(items []v1.PricingItem, segments []v1.TimeSegmentItem, rules []v1.ParamMultiplierItem) (*billing.PricingBlob, error) {
segs, err := timeSegmentsFromAPI(segments)
if err != nil {
return nil, err
}
paramRules, err := paramMultipliersFromAPI(rules)
if err != nil {
return nil, err
}
input := pricingItemsToInput(items)
var blob *billing.PricingBlob
if len(input) > 0 && (input[0].BillingMode == "per_second" || input[0].BillingMode == billing.BillingModeSpecial) && !perSecondMatrixHasPositivePrice(input[0].PerSecondPrices) {
blob = &billing.PricingBlob{}
} else {
blob, err = billing.BuildPricingBlob(input)
if err != nil {
return nil, err
}
}
blob.TimeSegments = segs
blob.ParamMultipliers = paramRules
empty := blob.InputPrice == nil && blob.OutputPrice == nil && blob.CacheReadPrice == nil &&
blob.CacheCreationPrice == nil && len(blob.Tiers) == 0 && blob.Price == nil &&
len(blob.Prices) == 0 && len(blob.TimeSegments) == 0 && len(blob.ParamMultipliers) == 0
if empty {
return nil, nil
}
return blob, nil
}
// perSecondMatrixHasPositivePrice 按秒矩阵是否存在任一正价(空矩阵/全零价=未配置形态)
func perSecondMatrixHasPositivePrice(prices map[string]float64) bool {
for _, p := range prices {
if p > 0 {
return true
}
}
return false
}
// officialBlobFromJSON 解析 official_pricing JSONB → (billing_mode, PricingBlob)。
// 空串/null/解析失败返回 ("", nil),调用方按未配置处理(参照字段,解析失败不阻断定价编辑)。
func officialBlobFromJSON(raw string) (string, *billing.PricingBlob) {
if raw == "" || raw == "null" {
return "", nil
}
wrapped := officialPricingBlob{}
if err := json.Unmarshal([]byte(raw), &wrapped); err != nil {
return "", nil
}
mode := wrapped.BillingMode
if mode == "" {
mode = "token"
}
return mode, &wrapped.PricingBlob
}
// writeOfficialPricingForModel 官方参考定价写入模型定价行(official_pricing 列整体替换,
// 全空=清除写 NULL)。与 pricing 列同构:BuildPricingBlob 构建 + 时段/倍率并入 blob 顶层,
// 支持全部四种计费模式。列名直写而非 do 字段,与 gf gen dao 生成物解耦
// (同 writePricingDisplayFieldsForModel)。需在定价行存在之后执行。调用方需保证事务 ctx 传播。
func writeOfficialPricingForModel(ctx context.Context, modelDBID int64, items []v1.PricingItem, segments []v1.TimeSegmentItem, rules []v1.ParamMultiplierItem) error {
blob, err := buildOfficialBlob(items, segments, rules)
if err != nil {
return err
}
data := any(gdb.Raw("NULL"))
if blob != nil {
mode := "token"
if len(items) > 0 {
mode = items[0].BillingMode
}
raw, mErr := json.Marshal(officialPricingBlob{BillingMode: mode, PricingBlob: *blob})
if mErr != nil {
return mErr
}
data = string(raw)
}
_, err = dao.MdlPricing.Ctx(ctx).
Where("model_id", modelDBID).
Data(g.Map{"official_pricing": data}).
Update()
return err
}
// writeTimeSegmentsForModel 把时段定价并入模型定价行的 pricing JSONB 顶层(time_segments 键),
// 空列表=清除(全量替换语义,写 "[]" 而非删除键:nil 切片会序列化成 "null",语义不明确)。
// 定价保存与模型导入共用。jsonb_set 只更新目标键,不覆盖 JSON 内其他计费字段。
// 单引号需 SQL 转义(json.Marshal 不转义内容中的单引号)。调用方需保证事务 ctx 传播。
func writeTimeSegmentsForModel(ctx context.Context, modelDBID int64, items []v1.TimeSegmentItem) error {
segs, err := timeSegmentsFromAPI(items)
if err != nil {
return err
}
// 空配置显式写 "[]"(nil 切片会序列化成 "null",语义不明确)
segmentsJSON := "[]"
if len(segs) > 0 {
data, mErr := json.Marshal(segs)
@@ -572,15 +918,18 @@ func writeTimeSegmentsForModel(ctx context.Context, modelDBID int64, items []v1.
}
segmentsJSON = string(data)
}
escapedJSON := strings.ReplaceAll(segmentsJSON, "'", "''")
_, err = dao.MdlPricing.Ctx(ctx).
Where("model_id", modelDBID).
Where("min_tokens", 0).
Data(g.Map{"time_segments": segmentsJSON}).
Data(g.Map{
"pricing": gdb.Raw(fmt.Sprintf(
`jsonb_set(COALESCE(pricing, '{}'::jsonb), '{time_segments}', '%s'::jsonb)`, escapedJSON)),
}).
Update()
return err
}
// writePricingDisplayFieldsForModel 把展示字段(价格说明/折扣标签/价格调整说明)写到定价锚点行(min_tokens=0)。
// writePricingDisplayFieldsForModel 把展示字段(价格说明/折扣标签/价格调整说明)写到模型定价行。
// 全量替换语义:空串=清除(写 NULL)。与 writeTimeSegmentsForModel 同理用列名直写而非 do 字段,
// 与 gf gen dao 生成物解耦。调用方需保证事务 ctx 传播。
func writePricingDisplayFieldsForModel(ctx context.Context, modelDBID int64, priceNote, discountLabel, priceChangeNote string) error {
@@ -593,7 +942,6 @@ func writePricingDisplayFieldsForModel(ctx context.Context, modelDBID int64, pri
}
_, err := dao.MdlPricing.Ctx(ctx).
Where("model_id", modelDBID).
Where("min_tokens", 0).
Data(g.Map{
"price_note": trimOrNull(priceNote),
"discount_label": trimOrNull(discountLabel),
@@ -606,22 +954,46 @@ func writePricingDisplayFieldsForModel(ctx context.Context, modelDBID int64, pri
// SetModelPricing 设置模型定价(全量替换)
func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v1.PricingSetRes, error) {
// 时段定价校验(乘数范围/时间格式/星期/日期边界),fail-fast 返回用户可读错误
segments, err := timeSegmentsFromAPI(req.TimeSegments)
if _, err := timeSegmentsFromAPI(req.TimeSegments); err != nil {
return nil, err
}
// 参数倍率规则校验(路径/匹配方式/值形态/倍率区间),fail-fast
paramRules, err := paramMultipliersFromAPI(req.ParamMultipliers)
if err != nil {
return nil, err
}
// 时段配置挂在锚点行(min_tokens=0)上,列表中必须存在锚点行
if len(segments) > 0 {
hasAnchor := false
for _, item := range req.Items {
if item.MinTokens == 0 {
hasAnchor = true
break
}
// 官方参考定价的时段/倍率预校验(与主定价同一套校验;OfficialItems nil=本次不动官方定价)
if req.OfficialItems != nil {
if _, err := timeSegmentsFromAPI(req.OfficialTimeSegments); err != nil {
return nil, gerror.Wrap(err, "官方参考定价")
}
if !hasAnchor {
return nil, gerror.New("时段定价要求定价列表包含 min_tokens=0 的基础行")
if _, err := paramMultipliersFromAPI(req.OfficialParamMultipliers); err != nil {
return nil, gerror.Wrap(err, "官方参考定价")
}
}
// 计费方案校验:未注册的方案名不得写入(运行期引擎无从得知正确价格);
// scheme_config 由方案实现自校验(generic 不接受非空配置,拦截「空方案带配置」的错位数据)
req.Scheme = strings.TrimSpace(req.Scheme)
if req.Scheme != "" && !billing.SchemeRegistered(req.Scheme) {
return nil, gerror.Newf("计费方案 %q 未注册", req.Scheme)
}
if err := billing.LookupScheme(req.Scheme).ValidateSchemeConfig(req.SchemeConfig); err != nil {
return nil, err
}
// billing_mode 与 scheme 双向配对(special ⇔ 方案声明):裸 special 会落 generic
// 分发走 default 分支(错价口径不可预期),方案挂非 special 模式会造成展示标签
// 与计费口径错位(如显示「按秒」实为素材组合计费),两者都在保存时拦截
if len(req.Items) > 0 {
isSpecial := req.Items[0].BillingMode == billing.BillingModeSpecial
if isSpecial && req.Scheme == "" {
return nil, gerror.New("特殊计费模式(special)必须声明计费方案(scheme)")
}
if !isSpecial && req.Scheme != "" {
return nil, gerror.New("声明计费方案的模型必须使用特殊计费模式(billing_mode=special)")
}
}
@@ -635,42 +1007,28 @@ func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v
}
}
// 全量替换:先删旧定价再插新定价,放入同一事务。否则若删除后插入中途失败,
// 该模型会残留「无定价」状态,导致计费失败或回退到默认价。
// 全量替换:单行写 pricing JSONB(计费详情 + 参数倍率随 blob 顶层),同一事务。
// 否则中途失败会残留「无定价」状态,导致计费失败或回退到默认价。
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
if _, err := dao.MdlPricing.Ctx(ctx).Where("model_id", req.ModelID).Delete(); err != nil {
if err := writePricingForModel(ctx, req.ModelID, req.Items, paramRules, req.Scheme, req.SchemeConfig); err != nil {
return err
}
for _, item := range req.Items {
// API 边界 float64 → DO decimal 转换
var perRequestPriceDecimal *decimal.Decimal
if item.PerRequestPrice != nil {
d := billing.NewFromFloat(*item.PerRequestPrice)
perRequestPriceDecimal = &d
}
if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
ModelId: req.ModelID,
BillingMode: item.BillingMode,
MinTokens: item.MinTokens,
MaxTokens: item.MaxTokens,
InputPrice: billing.NewFromFloat(item.InputPrice),
OutputPrice: billing.NewFromFloat(item.OutputPrice),
PerRequestPrice: perRequestPriceDecimal,
CacheReadPrice: billing.NewFromFloat(item.CacheReadPrice),
CacheCreationPrice: billing.NewFromFloat(item.CacheCreationPrice),
}); err != nil {
return err
// 官方参考定价(OfficialItems nil=本次不动库内值;三个官方数组全空=清除)。
// 与 pricing 列同构,支持全部四种计费模式 + 时段/倍率。官方价非计费依据,
// 不触发价格缓存失效
if req.OfficialItems != nil {
if err := writeOfficialPricingForModel(ctx, req.ModelID, req.OfficialItems, req.OfficialTimeSegments, req.OfficialParamMultipliers); err != nil {
return gerror.Wrap(err, "官方参考定价")
}
}
// 时段定价写锚点行(空=清除,全量替换语义;需在锚点行插入之后执行)
// 时段定价并入 JSON 顶层(空=清除,全量替换语义;需在定价行插入之后执行)
if err := writeTimeSegmentsForModel(ctx, req.ModelID, req.TimeSegments); err != nil {
return err
}
// 展示字段写锚点行(空=清除,全量替换语义;需在锚点行插入之后执行)
// 展示字段写定价行(空=清除,全量替换语义;需在定价行插入之后执行)
if err := writePricingDisplayFieldsForModel(ctx, req.ModelID, req.PriceNote, req.DiscountLabel, req.PriceChangeNote); err != nil {
return err
}
+126 -95
View File
@@ -6,13 +6,13 @@ import (
"encoding/json"
"io"
"net/url"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/shopspring/decimal"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/consts"
@@ -29,6 +29,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
ModelId string `orm:"model_id" json:"model_id"`
ModelName string `orm:"model_name" json:"model_name"`
Category string `orm:"category" json:"category"`
Vendor string `orm:"vendor" json:"vendor"`
Status string `orm:"status" json:"status"`
MaxContextTokens int `orm:"max_context_tokens" json:"max_context_tokens"`
MaxOutputTokens int `orm:"max_output_tokens" json:"max_output_tokens"`
@@ -50,30 +51,37 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
}
type exportPricing struct {
BillingMode string `json:"billing_mode"`
MinTokens int64 `json:"min_tokens"`
MaxTokens *int64 `json:"max_tokens"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
CacheReadPrice float64 `json:"cache_read_price"`
CacheCreationPrice float64 `json:"cache_creation_price"`
BillingMode string `json:"billing_mode"`
MinTokens int64 `json:"min_tokens"`
MaxTokens *int64 `json:"max_tokens"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
CacheReadPrice float64 `json:"cache_read_price"`
CacheCreationPrice float64 `json:"cache_creation_price"`
PerSecondPrices map[string]float64 `json:"per_second_prices,omitempty"` // 仅 per_second 模式(锚点行 pricing JSONB)
}
type exportModel struct {
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Status string `json:"status"`
MaxContextTokens int `json:"max_context_tokens"`
MaxOutputTokens int `json:"max_output_tokens"`
Description string `json:"description"`
Tags []string `json:"tags"`
Capabilities map[string]bool `json:"capabilities"`
SunsetDate string `json:"sunset_date,omitempty"`
ReplacementModel string `json:"replacement_model,omitempty"`
Pricing []exportPricing `json:"pricing"`
TimeSegments []v1.TimeSegmentItem `json:"time_segments,omitempty"`
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Vendor string `json:"vendor,omitempty"` // 研发厂商(空=未分类,旧文件导入兼容)
Status string `json:"status"`
MaxContextTokens int `json:"max_context_tokens"`
MaxOutputTokens int `json:"max_output_tokens"`
Description string `json:"description"`
Tags []string `json:"tags"`
Capabilities map[string]bool `json:"capabilities"`
SunsetDate string `json:"sunset_date,omitempty"`
ReplacementModel string `json:"replacement_model,omitempty"`
Pricing []exportPricing `json:"pricing"`
TimeSegments []v1.TimeSegmentItem `json:"time_segments,omitempty"`
ParamMultipliers []v1.ParamMultiplierItem `json:"param_multipliers,omitempty"`
// 特殊计费方案声明(pricing JSONB 顶层 scheme/scheme_config,模型级语义):
// 随导出携带,导入侧透传还原,避免跨环境迁移后方案定价退化为通用模式
Scheme string `json:"scheme,omitempty"`
SchemeConfig json.RawMessage `json:"scheme_config,omitempty"`
}
// 批量查询所有模型的定价行(避免循环内逐模型查询导致 N+1),与 ListModels 的批量模式对齐。
@@ -82,16 +90,9 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
modelIDs = append(modelIDs, m.ID)
}
type pricingRow struct {
ModelId int64 `orm:"model_id" json:"model_id"`
BillingMode string `orm:"billing_mode" json:"billing_mode"`
MinTokens int64 `orm:"min_tokens" json:"min_tokens"`
MaxTokens *int64 `orm:"max_tokens" json:"max_tokens"`
InputPrice float64 `orm:"input_price" json:"input_price"`
OutputPrice float64 `orm:"output_price" json:"output_price"`
PerRequestPrice *float64 `orm:"per_request_price" json:"per_request_price"`
CacheReadPrice float64 `orm:"cache_read_price" json:"cache_read_price"`
CacheCreationPrice float64 `orm:"cache_creation_price" json:"cache_creation_price"`
TimeSegments string `orm:"time_segments" json:"time_segments"`
ModelId int64 `orm:"model_id" json:"model_id"`
BillingMode string `orm:"billing_mode" json:"billing_mode"`
Pricing string `orm:"pricing" json:"pricing"` // pricing JSONB(唯一真相)
}
var allPricingRows []pricingRow
if len(modelIDs) > 0 {
@@ -99,25 +100,58 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
return nil, err
}
}
// 按 model_id 分组,供后续组装;时段定价只认锚点行(min_tokens=0)
// 按 model_id 组装导出数据:pricing JSONB 为唯一真相
// (tiered 的 tiers 数组还原为多行 exportPricing,与导入提交的 items 结构互逆)
pricingByModel := make(map[int64][]exportPricing, len(allPricingRows))
segmentsByModel := make(map[int64][]v1.TimeSegmentItem, len(allPricingRows))
paramRulesByModel := make(map[int64][]v1.ParamMultiplierItem, len(allPricingRows))
schemeByModel := make(map[int64]string, len(allPricingRows))
schemeConfigByModel := make(map[int64]json.RawMessage, len(allPricingRows))
for _, p := range allPricingRows {
pricingByModel[p.ModelId] = append(pricingByModel[p.ModelId], exportPricing{
BillingMode: p.BillingMode,
MinTokens: p.MinTokens,
MaxTokens: p.MaxTokens,
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
PerRequestPrice: p.PerRequestPrice,
CacheReadPrice: p.CacheReadPrice,
CacheCreationPrice: p.CacheCreationPrice,
})
if p.MinTokens == 0 && p.TimeSegments != "" && p.TimeSegments != "null" {
var segs []billing.TimeSegment
if err := json.Unmarshal([]byte(p.TimeSegments), &segs); err == nil {
segmentsByModel[p.ModelId] = timeSegmentsToAPI(segs)
blob := billing.ParsePricingBlob(p.Pricing)
if blob == nil {
blob = &billing.PricingBlob{}
}
// tiered:阶梯数组展开为多行;其余模式单行
if len(blob.Tiers) > 0 {
for _, tier := range blob.Tiers {
pricingByModel[p.ModelId] = append(pricingByModel[p.ModelId], exportPricing{
BillingMode: p.BillingMode,
MinTokens: tier.MinTokens,
MaxTokens: tier.MaxTokens,
InputPrice: tier.InputPrice,
OutputPrice: tier.OutputPrice,
})
}
} else {
ep := exportPricing{
BillingMode: p.BillingMode,
PerSecondPrices: blob.Prices,
}
if blob.InputPrice != nil {
ep.InputPrice = *blob.InputPrice
}
if blob.OutputPrice != nil {
ep.OutputPrice = *blob.OutputPrice
}
ep.PerRequestPrice = blob.Price
if blob.CacheReadPrice != nil {
ep.CacheReadPrice = *blob.CacheReadPrice
}
if blob.CacheCreationPrice != nil {
ep.CacheCreationPrice = *blob.CacheCreationPrice
}
pricingByModel[p.ModelId] = append(pricingByModel[p.ModelId], ep)
}
if len(blob.TimeSegments) > 0 {
segmentsByModel[p.ModelId] = timeSegmentsToAPI(blob.TimeSegments)
}
if len(blob.ParamMultipliers) > 0 {
paramRulesByModel[p.ModelId] = paramMultipliersToAPI(blob.ParamMultipliers)
}
if blob.Scheme != "" {
schemeByModel[p.ModelId] = blob.Scheme
schemeConfigByModel[p.ModelId] = blob.SchemeConfig
}
}
@@ -127,6 +161,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
ModelId: m.ModelId,
ModelName: m.ModelName,
Category: m.Category,
Vendor: m.Vendor,
Status: m.Status,
MaxContextTokens: m.MaxContextTokens,
MaxOutputTokens: m.MaxOutputTokens,
@@ -135,6 +170,9 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
ReplacementModel: m.ReplacementModel,
Pricing: pricingByModel[m.ID],
TimeSegments: segmentsByModel[m.ID],
ParamMultipliers: paramRulesByModel[m.ID],
Scheme: schemeByModel[m.ID],
SchemeConfig: schemeConfigByModel[m.ID],
}
if em.Pricing == nil {
em.Pricing = []exportPricing{}
@@ -237,6 +275,23 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
res := &v1.ModelImportRes{}
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, item := range req.Models {
// 计费方案配对校验(与 SetModelPricing 同口径):special ⇔ scheme 声明,
// 且方案必须已注册——导入文件引用本环境不存在的方案时运行期会 fail-closed
// 拒绝该模型请求,应在导入时报错而非留隐患
scheme := strings.TrimSpace(item.Scheme)
if len(item.Pricing) > 0 {
isSpecial := item.Pricing[0].BillingMode == billing.BillingModeSpecial
if isSpecial && scheme == "" {
return gerror.Newf("模型 %s:特殊计费模式(special)缺少计费方案声明", item.ModelId)
}
if !isSpecial && scheme != "" {
return gerror.Newf("模型 %s:计费方案模型必须使用特殊计费模式(billing_mode=special)", item.ModelId)
}
}
if scheme != "" && !billing.SchemeRegistered(scheme) {
return gerror.Newf("模型 %s:计费方案 %q 未注册", item.ModelId, scheme)
}
var existing *struct {
ID int64 `orm:"id"`
}
@@ -258,6 +313,10 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
MaxOutputTokens: item.MaxOutputTokens,
Description: item.Description,
}
// 旧导出文件无 vendor 字段(空串),不覆盖已有厂商配置
if item.Vendor != "" {
updateData.Vendor = item.Vendor
}
if item.Tags != nil {
updateData.Tags = item.Tags
}
@@ -280,32 +339,12 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
return err
}
_, err = dao.MdlPricing.Ctx(ctx).Where("model_id", existing.ID).Delete()
if err != nil {
return err
paramRules, pErr := paramMultipliersFromAPI(item.ParamMultipliers)
if pErr != nil {
return gerror.Wrapf(pErr, "模型 %s 参数倍率", item.ModelId)
}
for _, p := range item.Pricing {
// API 边界 float64 → DO decimal 转换
var perRequestPriceDecimal *decimal.Decimal
if p.PerRequestPrice != nil {
d := billing.NewFromFloat(*p.PerRequestPrice)
perRequestPriceDecimal = &d
}
_, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
ModelId: existing.ID,
BillingMode: p.BillingMode,
MinTokens: p.MinTokens,
MaxTokens: p.MaxTokens,
InputPrice: billing.NewFromFloat(p.InputPrice),
OutputPrice: billing.NewFromFloat(p.OutputPrice),
PerRequestPrice: perRequestPriceDecimal,
CacheReadPrice: billing.NewFromFloat(p.CacheReadPrice),
CacheCreationPrice: billing.NewFromFloat(p.CacheCreationPrice),
})
if err != nil {
return err
}
if err := writePricingForModel(ctx, existing.ID, item.Pricing, paramRules, scheme, item.SchemeConfig); err != nil {
return gerror.Wrapf(err, "模型 %s 定价", item.ModelId)
}
// 时段定价透传(挂锚点行,全量替换语义:导入文件无该字段=清除)
@@ -322,6 +361,7 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
ModelId: item.ModelId,
ModelName: item.ModelName,
Category: item.Category,
Vendor: item.Vendor,
Status: "active",
MaxContextTokens: item.MaxContextTokens,
MaxOutputTokens: item.MaxOutputTokens,
@@ -342,28 +382,13 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
return err
}
for _, p := range item.Pricing {
// API 边界 float64 → DO decimal 转换
var perRequestPriceDecimal *decimal.Decimal
if p.PerRequestPrice != nil {
d := billing.NewFromFloat(*p.PerRequestPrice)
perRequestPriceDecimal = &d
}
_, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
ModelId: id,
BillingMode: p.BillingMode,
MinTokens: p.MinTokens,
MaxTokens: p.MaxTokens,
InputPrice: billing.NewFromFloat(p.InputPrice),
OutputPrice: billing.NewFromFloat(p.OutputPrice),
PerRequestPrice: perRequestPriceDecimal,
CacheReadPrice: billing.NewFromFloat(p.CacheReadPrice),
CacheCreationPrice: billing.NewFromFloat(p.CacheCreationPrice),
})
if err != nil {
return err
}
// 定价写入(pricing JSONB,含参数倍率,与 SetModelPricing 口径一致)
paramRules, pErr := paramMultipliersFromAPI(item.ParamMultipliers)
if pErr != nil {
return gerror.Wrapf(pErr, "模型 %s 参数倍率", item.ModelId)
}
if err := writePricingForModel(ctx, id, item.Pricing, paramRules, scheme, item.SchemeConfig); err != nil {
return gerror.Wrapf(err, "模型 %s 定价", item.ModelId)
}
// 时段定价透传(挂锚点行)
@@ -376,6 +401,12 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
}
return nil
})
if err != nil {
return res, err
}
return res, err
// 导入含新建 / 更新模型,逐条失效太碎,事务提交后整体失效一次下拉缓存
InvalidateModelOptionsCache(ctx)
return res, nil
}
+1 -1
View File
@@ -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("充值入账流水金额异常,无法退款")
+96 -83
View File
@@ -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"
@@ -66,11 +65,20 @@ var predefinedPermissionGroups = []v1.PermissionGroup{
"order:view", "order:refund",
},
},
{
Name: "payment",
Label: "支付渠道",
Permissions: []string{
// 渠道配置含商户密钥明文,独立于 system:* 权限点:必须显式授予才能
// 查看/修改支付渠道,避免"能进系统设置就能拿到收款密钥"
"payment:view", "payment:edit",
},
},
{
Name: "audit",
Label: "审计日志",
Permissions: []string{
"audit:view", "audit:export", "audit:read_sensitive",
"audit:view", "audit:export", "audit:read_sensitive", "audit:clear",
},
},
{
@@ -98,7 +106,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 +142,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,11 +161,18 @@ 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
var perms []struct {
PermissionPoint string `json:"permission_point"`
PermissionPoint string `json:"permission_point" orm:"permission_point"`
}
err := dao.SysAdminRolePerms.Ctx(ctx).
Where("admin_user_id", req.Id).
@@ -172,9 +188,9 @@ func (s *sAdmin) GetUserPermissions(ctx context.Context, req *v1.AdminPermission
// Get data scopes
var scopes []struct {
ID int64 `json:"id"`
ScopeType string `json:"scope_type"`
ScopeValue string `json:"scope_value"`
ID int64 `json:"id" orm:"id"`
ScopeType string `json:"scope_type" orm:"scope_type"`
ScopeValue string `json:"scope_value" orm:"scope_value"`
}
err = dao.SysAdminDataScopes.Ctx(ctx).
Where("admin_user_id", req.Id).
@@ -215,6 +231,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 +254,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,98 +275,91 @@ 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
}
var scopes []struct {
ID int64 `json:"id"`
ScopeType string `json:"scope_type"`
ScopeValue string `json:"scope_value"`
ID int64 `json:"id" orm:"id"`
ScopeType string `json:"scope_type" orm:"scope_type"`
ScopeValue string `json:"scope_value" orm:"scope_value"`
}
err := dao.SysAdminDataScopes.Ctx(ctx).
Where("admin_user_id", userID).
@@ -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)
}
}
+499
View File
@@ -0,0 +1,499 @@
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"},
},
// 支付渠道只有查看/编辑两个动作,无删除档:密钥类配置宁可少放开
"payment": {
TierRead: {"payment:view"},
TierOperate: {"payment:view", "payment:edit"},
},
"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": "可对订单发起退款到支付渠道",
"payment:edit": "可修改支付渠道配置与商户密钥,直接影响线上收款",
"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
}
+540
View File
@@ -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" orm:"id"`
Code string `json:"code" orm:"code"`
Name string `json:"name" orm:"name"`
Description string `json:"description" orm:"description"`
IsBuiltin bool `json:"is_builtin" orm:"is_builtin"`
IsEnabled bool `json:"is_enabled" orm:"is_enabled"`
Sort int `json:"sort" orm:"sort"`
CreatedAt string `json:"created_at" orm:"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
}
+361
View File
@@ -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_v0_2_17_version.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
}
+4
View File
@@ -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) {
+16
View File
@@ -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"]
+103
View File
@@ -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
}
+21
View File
@@ -2,6 +2,7 @@ package admin
import (
"context"
"encoding/json"
"time"
"github.com/gogf/gf/v2/errors/gcode"
@@ -132,6 +133,8 @@ func (s *sAdmin) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (*v1.Tas
ResultUrl string `json:"result_url"`
TenantId int64 `json:"tenant_id"`
UserId int64 `json:"user_id"`
PrivateData string `json:"private_data"`
RequestId string `json:"request_id"`
SubmitTime *gtime.Time `json:"submit_time"`
StartTime *gtime.Time `json:"start_time"`
FinishTime *gtime.Time `json:"finish_time"`
@@ -153,6 +156,7 @@ func (s *sAdmin) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (*v1.Tas
Status: task.Status,
Progress: task.Progress,
ModelName: task.ModelName,
UpstreamModel: task.UpstreamModel,
FailReason: task.FailReason,
PreDeductAmount: task.PreDeductAmount,
ActualCost: task.ActualCost,
@@ -160,15 +164,32 @@ func (s *sAdmin) TaskDetail(ctx context.Context, req *v1.TaskDetailReq) (*v1.Tas
ResultURL: task.ResultUrl,
TenantID: task.TenantId,
UserID: task.UserId,
RequestID: task.RequestId,
CreatedAt: task.CreatedAt.Format("Y-m-d H:i:s"),
}
if task.SubmitTime != nil {
item.SubmitTime = task.SubmitTime.Format("Y-m-d H:i:s")
}
if task.StartTime != nil {
item.StartTime = task.StartTime.Format("Y-m-d H:i:s")
}
if task.FinishTime != nil {
item.FinishTime = task.FinishTime.Format("Y-m-d H:i:s")
}
// 从 private_data 提取上游排障标识:仅取 upstream_task_id / upstream_request_id 两个键,
// 计费上下文等其余内部数据不外露(同步图片任务无上游任务 ID,两字段为空)
if task.PrivateData != "" {
var pd struct {
UpstreamTaskID string `json:"upstream_task_id"`
UpstreamRequestID string `json:"upstream_request_id"`
}
if json.Unmarshal([]byte(task.PrivateData), &pd) == nil {
item.UpstreamTaskID = pd.UpstreamTaskID
item.UpstreamRequestID = pd.UpstreamRequestID
}
}
// 对 re-host 到对象存储的结果图,生成新鲜的缩略图 URL 供详情弹窗内联预览,并刷新原图 URL
// (规避 result_url 24h 预签名过期)。非图片/上游直链任务查不到文件记录,保持原 result_url。
if thumb, orig := common.TaskResultImageURLs(ctx, task.TenantId, task.PublicTaskId, 600); thumb != "" {
+175 -26
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"strconv"
"strings"
"unicode/utf8"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
@@ -22,6 +23,64 @@ import (
"github.com/qianfree/team-api/internal/utility/export"
)
// 租户标签约束:数量与单项长度上限(字符数,非字节数)
const (
tenantTagsMaxCount = 10
tenantTagsMaxRunes = 30
tenantRemarkMaxRunes = 1000
)
// marshalTagFilter 把单个标签序列化为 JSONB 包含查询参数(tags @> '["vip"]')。
// 序列化不会失败(输入为字符串),错误时返回空数组条件(匹配不到任何行,安全降级)。
func marshalTagFilter(tag string) string {
b, err := json.Marshal([]string{strings.TrimSpace(tag)})
if err != nil {
return "[]"
}
return string(b)
}
// parseTenantTags 解析租户标签 JSONB(空/非法输入返回空切片,不报错——展示路径容错)
func parseTenantTags(raw string) []string {
if raw == "" {
return []string{}
}
var tags []string
if err := json.Unmarshal([]byte(raw), &tags); err != nil {
return []string{}
}
return tags
}
// marshalTenantTags 校验并序列化租户标签为 JSONB 字符串:
// 去首尾空白、跳过空项、去重,≤ tenantTagsMaxCount 个、每项 ≤ tenantTagsMaxRunes 字符。
func marshalTenantTags(tags []string) (string, error) {
cleaned := make([]string, 0, len(tags))
seen := make(map[string]struct{}, len(tags))
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
if utf8.RuneCountInString(tag) > tenantTagsMaxRunes {
return "", common.NewBadRequestError("单个标签最长 30 字符")
}
if _, ok := seen[tag]; ok {
continue
}
seen[tag] = struct{}{}
cleaned = append(cleaned, tag)
}
if len(cleaned) > tenantTagsMaxCount {
return "", common.NewBadRequestError("标签数量最多 10 个")
}
b, err := json.Marshal(cleaned)
if err != nil {
return "", err
}
return string(b), nil
}
// TenantSelect returns a lightweight paginated tenant list for dropdown selectors.
func (s *sAdmin) TenantSelect(ctx context.Context, req *v1.TenantSelectReq) (*v1.TenantSelectRes, error) {
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
@@ -125,6 +184,19 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
maxConcurrencyVal = req.MaxConcurrency
}
// 标签与备注:可选字段,校验规则与更新接口一致;空值落库为列默认([] / '')
tagsJSON, err := marshalTenantTags(req.Tags)
if err != nil {
return nil, err
}
remarkVal := ""
if req.Remark != nil {
remarkVal = strings.TrimSpace(*req.Remark)
if utf8.RuneCountInString(remarkVal) > tenantRemarkMaxRunes {
return nil, common.NewBadRequestError("备注最长 1000 字符")
}
}
var tenantID int64
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
@@ -135,6 +207,8 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1
MaxConcurrency: maxConcurrencyVal,
Level: 1,
Settings: "{}",
Tags: tagsJSON,
Remark: remarkVal,
}).Insert()
if err != nil {
return gerror.Wrapf(err, "create tenant")
@@ -217,10 +291,12 @@ func batchTenantAggregates(ctx context.Context, tenantIDs, ownerIDs []int64) (
ownerNames map[int64]string,
memberCounts map[int64]int,
walletBalances map[int64]string,
walletTotalConsumed map[int64]string,
) {
ownerNames = make(map[int64]string, len(ownerIDs))
memberCounts = make(map[int64]int, len(tenantIDs))
walletBalances = make(map[int64]string, len(tenantIDs))
walletTotalConsumed = make(map[int64]string, len(tenantIDs))
// owner 名称:按 owner_user_id 批量取
if len(ownerIDs) > 0 {
@@ -246,15 +322,17 @@ func batchTenantAggregates(ctx context.Context, tenantIDs, ownerIDs []int64) (
memberCounts[c.TenantID] = c.Count
}
// 钱包余额:按 tenant_id 批量取
// 钱包余额 / 累计消费:按 tenant_id 批量取(DB 物化副本,累计消费随物化器 ≤5s 滞后)
var wallets []struct {
TenantID int64 `json:"tenant_id"`
Balance string `json:"balance"`
TenantID int64 `json:"tenant_id"`
Balance string `json:"balance"`
TotalConsumed string `json:"total_consumed"`
}
_ = dao.BilWallets.Ctx(ctx).Fields("tenant_id, balance").
_ = dao.BilWallets.Ctx(ctx).Fields("tenant_id, balance, total_consumed").
WhereIn("tenant_id", tenantIDs).Scan(&wallets)
for _, w := range wallets {
walletBalances[w.TenantID] = w.Balance
walletTotalConsumed[w.TenantID] = w.TotalConsumed
}
}
@@ -274,6 +352,12 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
if req.Status != "" {
m = m.Where("status", req.Status)
}
if req.Tag != "" {
m = m.Where("tags @> ?::jsonb", marshalTagFilter(req.Tag))
}
if req.Level != nil {
m = m.Where("level", *req.Level)
}
total, err := m.Count()
if err != nil {
@@ -289,21 +373,32 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
if req.Status != "" {
m = m.Where("status", req.Status)
}
if req.Tag != "" {
m = m.Where("tags @> ?::jsonb", marshalTagFilter(req.Tag))
}
if req.Level != nil {
m = m.Where("level", *req.Level)
}
// 扫描结构体必须带 orm 标签:gf 的 Scan 会用「结构体字段名」自动生成 SELECT 字段列表,
// 无 orm 标签时靠运行时查表结构(TableFields)做 Id→id 映射;该元数据查询失败会被框架静默吞掉,
// 帕斯卡字段名将原样进 SQL,PostgreSQL 引号列名大小写敏感,报 column "Id" does not exist。
var tenants []struct {
Id int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
LogoURL string `json:"logo_url"`
OwnerUserID int64 `json:"owner_user_id"`
Status string `json:"status"`
MaxMembers *int `json:"max_members"`
MaxConcurrency *int `json:"max_concurrency"`
DefaultChannelScope string `json:"default_channel_scope"`
Settings string `json:"settings"`
Level int `json:"level"`
CreatedAt *gtime.Time `json:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at"`
Id int64 `json:"id" orm:"id"`
Name string `json:"name" orm:"name"`
Code string `json:"code" orm:"code"`
LogoURL string `json:"logo_url" orm:"logo_url"`
OwnerUserID int64 `json:"owner_user_id" orm:"owner_user_id"`
Status string `json:"status" orm:"status"`
MaxMembers *int `json:"max_members" orm:"max_members"`
MaxConcurrency *int `json:"max_concurrency" orm:"max_concurrency"`
DefaultChannelScope string `json:"default_channel_scope" orm:"default_channel_scope"`
Settings string `json:"settings" orm:"settings"`
Level int `json:"level" orm:"level"`
Tags string `json:"tags" orm:"tags"`
Remark string `json:"remark" orm:"remark"`
CreatedAt *gtime.Time `json:"created_at" orm:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at"`
}
err = m.OrderDesc("id").
Page(page, pageSize).
@@ -333,7 +428,7 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
tenantIDs[i] = t.Id
ownerIDs[i] = t.OwnerUserID
}
ownerNameMap, memberCountMap, walletBalanceMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs)
ownerNameMap, memberCountMap, walletBalanceMap, walletConsumedMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs)
for i, t := range tenants {
item := v1.TenantItem{
@@ -348,6 +443,8 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
DefaultChannelScope: t.DefaultChannelScope,
Level: t.Level,
LevelName: levelNameMap[t.Level],
Tags: parseTenantTags(t.Tags),
Remark: t.Remark,
CreatedAt: t.CreatedAt.String(),
UpdatedAt: t.UpdatedAt.String(),
}
@@ -368,7 +465,7 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
item.EffectiveMaxConcurrency = 0
}
// 从批量结果中取 owner 名称 / 成员数 / 钱包余额
// 从批量结果中取 owner 名称 / 成员数 / 钱包余额 / 累计消费
item.OwnerName = ownerNameMap[t.OwnerUserID]
item.MemberCount = memberCountMap[t.Id]
if bal, ok := walletBalanceMap[t.Id]; ok && bal != "" {
@@ -376,6 +473,11 @@ func (s *sAdmin) ListTenants(ctx context.Context, req *v1.TenantListReq) (*v1.Te
} else {
item.WalletBalance = "0"
}
if tc, ok := walletConsumedMap[t.Id]; ok && tc != "" {
item.TotalConsumed = tc
} else {
item.TotalConsumed = "0"
}
items[i] = item
}
@@ -402,6 +504,8 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan
DefaultChannelScope string `json:"default_channel_scope"`
Settings string `json:"settings"`
Level int `json:"level"`
Tags string `json:"tags"`
Remark string `json:"remark"`
CreatedAt *gtime.Time `json:"created_at"`
UpdatedAt *gtime.Time `json:"updated_at"`
}
@@ -425,19 +529,27 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan
memberCount, _ := dao.TntUsers.Ctx(ctx).
Where("tenant_id", req.Id).Count()
// Get wallet balance:优先读 Redis 权威值(调整余额/入账后详情页立即可见),DB 物化值兜底
// Get wallet balance / 累计消费:优先读 Redis 权威值(调整余额/入账/结算后详情页立即可见),DB 物化值兜底
walletBalance := "0"
totalConsumed := "0"
if w, err := billing.GetWallet(ctx, req.Id); err == nil {
walletBalance = strconv.FormatFloat(w.Balance, 'f', -1, 64)
totalConsumed = strconv.FormatFloat(w.TotalConsumed, 'f', -1, 64)
} else {
var wallet *struct {
Balance string `json:"balance"`
Balance string `json:"balance"`
TotalConsumed string `json:"total_consumed"`
}
_ = dao.BilWallets.Ctx(ctx).
Where("tenant_id", req.Id).Scan(&wallet)
if wallet != nil && wallet.Balance != "" {
walletBalance = wallet.Balance
if wallet != nil {
if wallet.Balance != "" {
walletBalance = wallet.Balance
}
if wallet.TotalConsumed != "" {
totalConsumed = wallet.TotalConsumed
}
}
}
ownerName := ""
@@ -483,6 +595,9 @@ func (s *sAdmin) GetTenant(ctx context.Context, req *v1.TenantGetReq) (*v1.Tenan
DefaultChannelScope: tenant.DefaultChannelScope,
MemberCount: memberCount,
WalletBalance: walletBalance,
Tags: parseTenantTags(tenant.Tags),
Remark: tenant.Remark,
TotalConsumed: totalConsumed,
Level: tenant.Level,
LevelName: levelName,
CreatedAt: tenant.CreatedAt.String(),
@@ -553,6 +668,22 @@ func (s *sAdmin) UpdateTenant(ctx context.Context, req *v1.TenantUpdateReq) (*v1
data.Level = *req.Level
}
// 标签:nil 表示未传(不更新);空数组表示清空(序列化为 [])
if req.Tags != nil {
tagsJSON, err := marshalTenantTags(req.Tags)
if err != nil {
return nil, err
}
data.Tags = tagsJSON
}
if req.Remark != nil {
remark := strings.TrimSpace(*req.Remark)
if utf8.RuneCountInString(remark) > tenantRemarkMaxRunes {
return nil, common.NewBadRequestError("备注最长 1000 字符")
}
data.Remark = remark
}
_, err := dao.TntTenants.Ctx(ctx).Where("id", req.Id).Update(data)
if err != nil {
return nil, err
@@ -590,8 +721,11 @@ func (s *sAdmin) ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v
{Field: "code", Header: "代码"},
{Field: "owner_name", Header: "所有者"},
{Field: "status", Header: "状态"},
{Field: "tags", Header: "标签"},
{Field: "remark", Header: "备注"},
{Field: "member_count", Header: "成员数"},
{Field: "wallet_balance", Header: "钱包余额"},
{Field: "total_consumed", Header: "累计消费"},
{Field: "created_at", Header: "创建时间"},
}
@@ -612,40 +746,55 @@ func (s *sAdmin) ExportTenants(ctx context.Context, req *v1.TenantExportReq) (*v
if req.Status != "" {
m = m.Where("status", req.Status)
}
if req.Tag != "" {
m = m.Where("tags @> ?::jsonb", marshalTagFilter(req.Tag))
}
if req.Level != nil {
m = m.Where("level", *req.Level)
}
var batch []struct {
Id int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
OwnerUserID int64 `json:"owner_user_id"`
Status string `json:"status"`
Tags string `json:"tags"`
Remark string `json:"remark"`
CreatedAt *gtime.Time `json:"created_at"`
}
if err := m.Fields("id, name, code, owner_user_id, status, created_at").OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil {
if err := m.Fields("id, name, code, owner_user_id, status, tags, remark, created_at").OrderDesc("id").Limit(1000).Offset(offset).Scan(&batch); err != nil {
return
}
// 批量查询本页的 owner 名称 / 成员数 / 钱包余额(避免逐行 N+1)
// 批量查询本页的 owner 名称 / 成员数 / 钱包余额 / 累计消费(避免逐行 N+1)
tenantIDs := make([]int64, len(batch))
ownerIDs := make([]int64, len(batch))
for i, t := range batch {
tenantIDs[i] = t.Id
ownerIDs[i] = t.OwnerUserID
}
ownerNameMap, memberCountMap, walletBalanceMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs)
ownerNameMap, memberCountMap, walletBalanceMap, walletConsumedMap := batchTenantAggregates(ctx, tenantIDs, ownerIDs)
for _, t := range batch {
walletBalance := "0"
if bal, ok := walletBalanceMap[t.Id]; ok && bal != "" {
walletBalance = bal
}
totalConsumed := "0"
if tc, ok := walletConsumedMap[t.Id]; ok && tc != "" {
totalConsumed = tc
}
row := map[string]any{
"id": t.Id,
"name": t.Name,
"code": t.Code,
"owner_name": ownerNameMap[t.OwnerUserID],
"status": t.Status,
"tags": strings.Join(parseTenantTags(t.Tags), ","),
"remark": t.Remark,
"member_count": memberCountMap[t.Id],
"wallet_balance": walletBalance,
"total_consumed": totalConsumed,
"created_at": t.CreatedAt.String(),
}
if !yield(row) {
+17 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"encoding/json"
"github.com/gogf/gf/v2/errors/gerror"
"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 +13,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 列出租户已分配的模型
@@ -190,6 +191,20 @@ func (s *sAdmin) UpdateTenantModel(ctx context.Context, req *v1.TenantModelUpdat
data.Enabled = *req.Enabled
}
if req.BillingMode != nil {
// 特殊计费方案模型不允许租户级 billing_mode 覆盖:计费模式由平台方案决定(special),
// 租户改模式只会造成日志标签与计费口径错位(读取侧 GetModelPriceAt 同口径忽略覆盖)。
// 置空(恢复平台默认)不受限
if *req.BillingMode != "" {
var pricingRow struct {
Pricing string `json:"pricing"`
}
if err := dao.MdlPricing.Ctx(ctx).Where("model_id", req.ModelID).Fields("pricing").Scan(&pricingRow); err != nil {
return nil, gerror.Wrap(err, "查询模型定价")
}
if blob := billing.ParsePricingBlob(pricingRow.Pricing); blob != nil && blob.Scheme != "" {
return nil, gerror.New("该模型使用特殊计费方案,计费模式由平台方案决定,不允许租户级覆盖")
}
}
data.BillingMode = *req.BillingMode
}
if req.PerRequestPrice != nil {
@@ -269,7 +284,7 @@ func (s *sAdmin) DeleteTenantModel(ctx context.Context, req *v1.TenantModelDelet
// ListTenantAvailableModels 预览租户实际可用的所有模型(显式分配 + 分组来源,去重)
func (s *sAdmin) ListTenantAvailableModels(ctx context.Context, req *v1.TenantAvailableModelsPreviewReq) (*v1.TenantAvailableModelsPreviewRes, error) {
models, err := tenantLogic.GetTenantAvailableModels(ctx, req.TenantID, "", "")
models, err := tenantLogic.GetTenantAvailableModels(ctx, req.TenantID, "", "", "")
if err != nil {
return nil, err
}
+66
View File
@@ -0,0 +1,66 @@
package admin
import (
"testing"
)
// TestMarshalTenantTags 标签校验与序列化:去空白、去重、跳过空项、数量与长度上限
func TestMarshalTenantTags(t *testing.T) {
cases := []struct {
name string
input []string
want string
wantErr bool
}{
{"普通标签", []string{"vip", "重点客户"}, `["vip","重点客户"]`, false},
{"去首尾空白", []string{" vip ", "dev"}, `["vip","dev"]`, false},
{"跳过空项", []string{"", " ", "vip"}, `["vip"]`, false},
{"去重", []string{"vip", "vip", "dev"}, `["vip","dev"]`, false},
{"空数组清空", []string{}, `[]`, false},
{"nil 清空", nil, `[]`, false},
{"超长单标签", []string{"一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一"}, "", true},
{"数量超限", []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"}, "", true},
{"英文30字符边界", []string{"abcdefghijklmnopqrstuvwxyz0123"}, `["abcdefghijklmnopqrstuvwxyz0123"]`, false},
{"英文31字符超限", []string{"abcdefghijklmnopqrstuvwxyz0123" + "4"}, "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := marshalTenantTags(c.input)
if c.wantErr {
if err == nil {
t.Fatalf("want error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != c.want {
t.Fatalf("got=%s want=%s", got, c.want)
}
})
}
}
// TestParseTenantTags 标签解析容错:空/非法输入返回空切片而非报错(展示路径)
func TestParseTenantTags(t *testing.T) {
if got := parseTenantTags(""); got == nil || len(got) != 0 {
t.Fatalf("empty input: want empty slice, got %v", got)
}
if got := parseTenantTags("not-json"); got == nil || len(got) != 0 {
t.Fatalf("invalid json: want empty slice, got %v", got)
}
if got := parseTenantTags(`["vip","dev"]`); len(got) != 2 || got[0] != "vip" || got[1] != "dev" {
t.Fatalf("valid json: got %v", got)
}
}
// TestMarshalTagFilter 标签筛选参数序列化为 JSONB 包含查询条件
func TestMarshalTagFilter(t *testing.T) {
if got := marshalTagFilter("vip"); got != `["vip"]` {
t.Fatalf("got=%s want=[\"vip\"]", got)
}
if got := marshalTagFilter(" vip "); got != `["vip"]` {
t.Fatalf("trimmed: got=%s", got)
}
}
Loaded 100 of 769 files, more files were not shown because too many files have changed in this diff. Show more