Compare commits

...

77 Commits

Author SHA1 Message Date
  fengsilin e6b4a06557 feat(logs): 隐藏使用日志中的分组信息展示 4 days ago
  fengsilin f9eeb7e47e fix(billing): 修复 Claude 计费明细未应用用户渠道折扣的问题 4 days ago
  fengsilin f7ff684d0d feat(pricing): 渠道定价展示用户折扣价格 4 days ago
  fengsilin e1b6aff7e1 fix(log): 对普通用户隐藏 Chat ID 和 Upstream ID 5 days ago
  fengsilin 02e0940555 fix(playground): 清空互斥模型默认列表 5 days ago
  fengsilin 2d1a8d2f28 fix: 设置 chat_id + 优化日志查询参数构建 5 days ago
  fengsilin 3e86f523db feat(playground): temperature/top_p 参数互斥设置 5 days ago
  fengsilin d26ad9e635 feat: 渠道-模型级联选择 + 隐藏首页统计卡片 6 days ago
  fengsilin bb708510fe docs: 用户倍率渠道-模型级联选择器设计文档 6 days ago
  fengsilin b026f6bc04 feat: 用户倍率前端展示 + 渠道下拉选择 + 上游调试日志 6 days ago
  fengsilin 9dbf3365fe feat(pricing): 添加用户-模型-渠道倍率功能 6 days ago
  fengsilin 17a0c2c26a refactor(playground): 隐藏对话页面的分组选择框 1 week ago
  fengsilin b033edec56 fix: pending sync records 队列被 quota=0 旧记录阻塞 1 week ago
  fengsilin 8945f8c39e fix: synced 用户剩余额度取 synced_quota + 记录 chat ID 1 week ago
  fengsilin e41f2951f7 fix: preserve relay body and capture invalid responses body 1 week ago
  fengsilin b2cb7bcf60 feat: record chat and upstream ids in usage logs 1 week ago
  fengsilin caf457ce9e feat: 错误时记录上游响应体,支持流式 1 week ago
  fengsilin 575f84064e feat: codex API key 模式 + 前端凭证回显修复 1 week ago
  fengsilin ca91922904 docs: add log chat/upstream id design 1 week ago
  fengsilin 332d5a62f2 feat: support codex api key credentials 1 week ago
  fengsilin 1c0898257c feat: add redemption remarks 2 weeks ago
  fengsilin f18e51e3a9 feat: add redemption remark support 2 weeks ago
  fengsilin fae24fdf37 test: stabilize channel affinity usage cache tests 2 weeks ago
  fengsilin fc0257f6ca chore: ignore local worktrees 2 weeks ago
  fengsilin d8272e7707 feat(channel): 添加渠道"对外名称"(public_name)字段 2 weeks ago
  fengsilin 73d10b5799 feat: 错误日志记录上游 request-id 和响应体,Playground 渠道路由改为 header 传递 2 weeks ago
  fengsilin 5b8303ae81 fix: 登录后跳转来源页,充值金额校验优化 2 weeks ago
  fengsilin 8bb2883d7e feat(home): 首页定价卡片添加"去体验"按钮,跳转 Playground 2 weeks ago
  fengsilin a0ac37b2a9 fix: 替换 println 为 SysLog,修复 logger nil context 崩溃 2 weeks ago
  fengsilin 74cc8c0d56 feat(playground): 添加渠道选择功能,支持指定渠道体验模型 2 weeks ago
  fengsilin a34d44a824 fix(user): 修复从节点同步用户余额显示为 0 的问题 2 weeks ago
  fengsilin 967309fb56 feat: 添加邮箱后缀注册额度规则功能 2 weeks ago
  fengsilin 83f767b40b Merge branch 'worktree-feat-captcha' 2 weeks ago
  fengsilin d24d68a916 fix(i18n): 修复令牌和充值页面货币显示不一致问题 2 weeks ago
  fengsilin fe88bc7758 Merge branch 'worktree-feat-captcha' 2 weeks ago
  fengsilin 1912dd3b72 feat: 添加图片验证码功能,防止脚本批量注册 2 weeks ago
  fengsilin 20163dbad5 fix(legal): 法律文档页面切换语言时强制重新加载内容 2 weeks ago
  fengsilin 56e8d14485 fix: 语言切换后 TextArea 内容丢失 2 weeks ago
  fengsilin 65bd1dabb8 fix: 法律文档编辑器切换语言时 TextArea 内容未刷新 2 weeks ago
  fengsilin 934b4b10a7 feat: 法律文档双语配置(中/英)+ 精简语言支持至中英双语 2 weeks ago
  fengsilin 676902ed58 Merge branch 'worktree-feat-terms-usage-policy' 2 weeks ago
  fengsilin 2757f77011 feat(i18n): 补全服务条款和使用政策的多语言翻译 2 weeks ago
  fengsilin 65c6446a30 Merge branch 'worktree-feat-terms-usage-policy' 2 weeks ago
  fengsilin 1cc75aa9a2 feat: 服务条款和使用政策页面,支持后台 Markdown 配置 2 weeks ago
  fengsilin ed195cada5 fix(frontend): sort_order 默认值显示为"未设置",编辑弹窗增加提示 3 weeks ago
  fengsilin d5157a779c fix(pricing): 统一 sort_order 排序逻辑,有 meta 但未设置的不优先 3 weeks ago
  fengsilin 54e53108ec fix(sort): sort_order 默认值改为 999999,简化排序逻辑 3 weeks ago
  fengsilin 3b99c83e32 fix(sort): sort_order=0 的记录排到最后,非0值按升序排列 3 weeks ago
  fengsilin 518f1ab87f refactor(frontend): 移除拖拽排序,改为 sort_order 数值输入 3 weeks ago
  fengsilin 65880fc69b feat(frontend): Vendor Tab 和 Model 表格支持拖拽排序,移除定价页字母排序 3 weeks ago
  fengsilin 5e0f38d26c chore(frontend): 安装 @dnd-kit 拖拽排序库 3 weeks ago
  fengsilin e507897f21 feat(pricing): updatePricing 按 sort_order 排序 vendors 和 models 3 weeks ago
  fengsilin 749064932c feat: 新增 PUT /api/vendors/reorder 和 /api/models/reorder 批量排序接口 3 weeks ago
  fengsilin f8859b7c35 feat: Vendor 和 Model 添加 sort_order 字段,查询按 sort_order ASC 排序 3 weeks ago
  fengsilin 3a29f3772f feat(channel): 添加模型默认通道功能,支持优先路由和卡片标识 3 weeks ago
  fengsilin 93e331b624 feat: 支持 LOGO_FILE_PATH 环境变量指定本地 Logo,优化定价与语言设置 3 weeks ago
  fengsilin 94f24b29f7 style: 首页文案"全球"改为"顶级" 3 weeks ago
  fengsilin f0f0215b20 fix(pricing): 修复缓存倍率写入默认值1和缓存创建token丢失 3 weeks ago
  fengsilin 387f6c1ae0 feat(pricing): 定价数据源切换到渠道表,新增缓存价格展示 3 weeks ago
  fengsilin 00058cd671 refactor(channel-pricing): 提取辅助方法消除重复代码,修复缓存一致性 3 weeks ago
  fengsilin 42082311d3 fix: 修复默认语言不生效的问题 3 weeks ago
  fengsilin 9eb58c684e feat: 后台设置默认语言 3 weeks ago
  fengsilin a2b27ea2a6 docs: 后台设置默认语言实施计划 3 weeks ago
  fengsilin 436fcb405b docs: 后台设置默认语言功能设计文档 3 weeks ago
  fengsilin 8d22c4e80e style(home): 临时隐藏首页工具链/核心价值/工作流/生态伙伴 section 3 weeks ago
  fengsilin 355757404b merge: feat/channel-pricing-extended → master 3 weeks ago
  fengsilin 235f7c6e5f refactor(channel-pricing): 提取 ParseTagIds 辅助函数 + 移除调试 console.log 3 weeks ago
  fengsilin f3577590bc test(channel-pricing): 添加 API 端到端集成测试脚本 3 weeks ago
  fengsilin 129e17ac42 feat(channel-pricing): 前端支持缓存/图片/音频倍率编辑和展示 3 weeks ago
  fengsilin c84c26b5a2 feat(channel-pricing): GetChannelPricingByModelWithChannelInfo 支持 CASE WHEN 回退 + 扩展字段 3 weeks ago
  fengsilin d5d4714908 test(channel-pricing): 缓存写穿 + 字段默认值单元测试 3 weeks ago
  fengsilin 646f37dd4b feat(channel-pricing): Controller 扩展 API 支持新字段 + 输入校验 + 操作日志 3 weeks ago
  fengsilin 3a24fd598f feat(channel-pricing): ModelPriceHelper + UpdatePriceDataForChannelPricing 适配新签名,支持扩展比率覆盖 3 weeks ago
  fengsilin e5f029f91e feat(channel-pricing): InitDB 启动时全量加载渠道定价缓存 3 weeks ago
  fengsilin 2d4c73d3aa refactor(channel-pricing): 结构体新增扩展字段 + 缓存重写为全量加载写穿 3 weeks ago
  fengsilin 156618fdad merge: feat/alipay-payment → master 3 weeks ago
  fengsilin b514a0798b refactor(payment): 合并微信/支付宝支付重复代码,删除冗余文件 3 weeks ago
100 changed files with 8185 additions and 473 deletions
Split View
  1. +21
    -1
      .dockerignore
  2. +1
    -0
      .gitattributes
  3. +1
    -0
      .gitignore
  4. +7
    -0
      .plans/alipay-payment/backend-dev/findings.md
  5. +7
    -0
      .plans/alipay-payment/backend-dev/progress.md
  6. +7
    -0
      .plans/alipay-payment/backend-dev/task-alipay-backend/findings.md
  7. +7
    -0
      .plans/alipay-payment/backend-dev/task-alipay-backend/progress.md
  8. +46
    -0
      .plans/alipay-payment/backend-dev/task-alipay-backend/task_plan.md
  9. +23
    -0
      .plans/alipay-payment/backend-dev/task_plan.md
  10. +26
    -0
      .plans/alipay-payment/decisions.md
  11. +7
    -0
      .plans/alipay-payment/findings.md
  12. +7
    -0
      .plans/alipay-payment/frontend-dev/findings.md
  13. +7
    -0
      .plans/alipay-payment/frontend-dev/progress.md
  14. +7
    -0
      .plans/alipay-payment/frontend-dev/task-alipay-frontend/findings.md
  15. +7
    -0
      .plans/alipay-payment/frontend-dev/task-alipay-frontend/progress.md
  16. +36
    -0
      .plans/alipay-payment/frontend-dev/task-alipay-frontend/task_plan.md
  17. +21
    -0
      .plans/alipay-payment/frontend-dev/task_plan.md
  18. +16
    -0
      .plans/alipay-payment/progress.md
  19. +7
    -0
      .plans/alipay-payment/reviewer/findings.md
  20. +5
    -0
      .plans/alipay-payment/reviewer/progress.md
  21. +16
    -0
      .plans/alipay-payment/reviewer/task_plan.md
  22. +47
    -0
      .plans/alipay-payment/task_plan.md
  23. +569
    -0
      .plans/channel-public-name.md
  24. +72
    -0
      .superpowers/brainstorm/6600-1777284109/content/button-position.html
  25. +3
    -0
      .superpowers/brainstorm/6600-1777284109/content/waiting.html
  26. +1
    -0
      .superpowers/brainstorm/6600-1777284109/state/server-stopped
  27. +1
    -0
      .superpowers/brainstorm/6600-1777284109/state/server.pid
  28. +30
    -0
      common/captcha.go
  29. +58
    -0
      common/codex_credential.go
  30. +10
    -0
      common/constants.go
  31. +18
    -0
      common/str.go
  32. +28
    -16
      controller/channel.go
  33. +137
    -56
      controller/channel_pricing.go
  34. +111
    -0
      controller/codex_channel_test.go
  35. +8
    -10
      controller/codex_usage.go
  36. +128
    -0
      controller/email_quota_rule.go
  37. +264
    -0
      controller/email_quota_rule_test.go
  38. +10
    -2
      controller/log.go
  39. +94
    -0
      controller/log_identity_test.go
  40. +74
    -5
      controller/misc.go
  41. +10
    -0
      controller/playground.go
  42. +62
    -0
      controller/playground_channels.go
  43. +112
    -0
      controller/pricing.go
  44. +410
    -0
      controller/pricing_user_test.go
  45. +2
    -0
      controller/redemption.go
  46. +159
    -0
      controller/redemption_test.go
  47. +6
    -0
      controller/relay.go
  48. +55
    -0
      controller/reorder.go
  49. +42
    -20
      controller/topup.go
  50. +10
    -63
      controller/topup_alipay.go
  51. +2
    -24
      controller/topup_wechat.go
  52. +5
    -1
      controller/user.go
  53. +116
    -0
      controller/user_channel_ratio.go
  54. BIN
      current-page
  55. +1
    -0
      docs/DATABASE_SCHEMA.md
  56. +307
    -0
      docs/superpowers/plans/2026-04-17-default-language-setting.md
  57. +114
    -0
      docs/superpowers/specs/2026-04-17-default-language-setting-design.md
  58. +236
    -0
      docs/superpowers/specs/2026-04-30-log-chatid-upstreamid-design.md
  59. +41
    -0
      docs/superpowers/specs/2026-05-08-cascading-channel-model-selector-design.md
  60. +1
    -0
      dto/openai_response.go
  61. +2
    -0
      go.mod
  62. +57
    -0
      go.sum
  63. +5
    -3
      logger/logger.go
  64. BIN
      login-page
  65. +10
    -0
      main.go
  66. +62
    -14
      middleware/distributor.go
  67. +306
    -0
      mockup-pricing-A.html
  68. +289
    -0
      mockup-pricing-B.html
  69. +362
    -0
      mockup-pricing-C.html
  70. +53
    -0
      model/ability.go
  71. +9
    -1
      model/channel.go
  72. +1020
    -0
      model/channel.go.bak
  73. +227
    -79
      model/channel_pricing.go
  74. +242
    -0
      model/channel_pricing_test.go
  75. +133
    -0
      model/email_quota_rule.go
  76. +246
    -0
      model/email_quota_rule_test.go
  77. +29
    -6
      model/log.go
  78. +197
    -0
      model/log_identity_test.go
  79. +29
    -3
      model/main.go
  80. +29
    -3
      model/model_meta.go
  81. +5
    -0
      model/option.go
  82. +2
    -1
      model/pending_sync_record.go
  83. +162
    -10
      model/pricing.go
  84. +451
    -0
      model/pricing.go.bak
  85. +122
    -0
      model/pricing_test.go
  86. +4
    -3
      model/redemption.go
  87. +101
    -14
      model/redemption_test.go
  88. +0
    -80
      model/topup_alipay.go
  89. +12
    -6
      model/topup_wechat.go
  90. +40
    -6
      model/user.go
  91. +129
    -0
      model/user_channel_ratio.go
  92. +105
    -0
      model/user_channel_ratio_test.go
  93. +18
    -2
      model/vendor_meta.go
  94. BIN
      models-page
  95. +21
    -13
      relay/channel/api_request.go
  96. +6
    -4
      relay/channel/claude/relay-claude.go
  97. +27
    -11
      relay/channel/codex/adaptor.go
  98. +17
    -11
      relay/channel/openai/chat_via_responses.go
  99. +3
    -0
      relay/channel/openai/relay-openai.go
  100. +17
    -5
      relay/channel/openai/relay_responses.go

+ 21
- 1
.dockerignore View File

@@ -7,4 +7,24 @@ Makefile
docs
.eslintcache
.gocache
/web/node_modules
/web/node_modules
.claude
.plans
current-page*
models-page*
pricing-page.png
login-page
scripts
relay/helper/price_test.go
.superpowers
*.png
*.bak
.worktrees
**/node_modules
**/.gocache
**/.gocache-temp
logs
*.db
*.db-journal
*.zip
web/dist

+ 1
- 0
.gitattributes View File

@@ -36,3 +36,4 @@
# ============================================
# Mark web frontend as vendored so GitHub recognizes this as a Go project
electron/** linguist-vendored
.dockerignore text eol=lf

+ 1
- 0
.gitignore View File

@@ -23,6 +23,7 @@ plans
docs/plans/
CLAUDE.md
.claude
.worktrees/
logs/
docs/superpowers



+ 7
- 0
.plans/alipay-payment/backend-dev/findings.md View File

@@ -0,0 +1,7 @@
# backend-dev - 发现索引

> 纯索引——每个条目应简短(Status + Report 链接 + Summary)。

---

<初始为空,工作中填写>

+ 7
- 0
.plans/alipay-payment/backend-dev/progress.md View File

@@ -0,0 +1,7 @@
# backend-dev - 工作日志

> 用于上下文恢复。压缩/重启后先读此文件。

---

<初始为空,工作中填写>

+ 7
- 0
.plans/alipay-payment/backend-dev/task-alipay-backend/findings.md View File

@@ -0,0 +1,7 @@
# 支付宝后端 - 发现记录

> 此任务开发中的技术发现。

---

<初始为空>

+ 7
- 0
.plans/alipay-payment/backend-dev/task-alipay-backend/progress.md View File

@@ -0,0 +1,7 @@
# 支付宝后端 - 工作日志

> 上下文恢复时只需读此文件。

---

<初始为空>

+ 46
- 0
.plans/alipay-payment/backend-dev/task-alipay-backend/task_plan.md View File

@@ -0,0 +1,46 @@
# 支付宝后端 - 任务计划

> 所属智能体: backend-dev
> 状态: pending
> 创建: 2026-04-14

## 目标

实现支付宝当面付(扫码支付)的完整后端功能,包括配置层、控制器、模型和路由。

## 详细步骤

- [ ] 1. 新建 `setting/payment_alipay.go`:配置变量 + IsAlipayConfigured() + OnAlipayConfigChanged
- [ ] 2. 修改 `model/option.go`:InitOptionMap + updateOptionMap + triggerAlipayReset
- [ ] 3. 新建 `model/topup_alipay.go`:RechargeAlipay 函数(事务+行锁+幂等)
- [ ] 4. 新建 `controller/topup_alipay.go`:客户端缓存 + 5 个 HTTP 处理函数
- [ ] 5. 修改 `controller/topup.go`:GetTopUpInfo 添加支付宝支付方式
- [ ] 6. 修改 `model/topup.go`:ManualCompleteTopUp 添加 "alipay"
- [ ] 7. 修改 `router/api-router.go`:注册 4 个路由
- [ ] 8. `go build` 编译验证

## 涉及文件

- `setting/payment_alipay.go` — 新建:支付宝配置
- `setting/payment_wechat.go` — 参考:微信支付配置模式
- `controller/topup_alipay.go` — 新建:支付宝控制器
- `controller/topup_wechat.go` — 参考:微信支付控制器
- `model/topup_alipay.go` — 新建:支付宝充值模型
- `model/topup_wechat.go` — 参考:微信充值模型
- `model/option.go` — 修改:注册配置 key
- `model/topup.go` — 修改:添加 alipay 支付方式
- `controller/topup.go` — 修改:GetTopUpInfo
- `router/api-router.go` — 修改:注册路由

## 关键技术点

- gopay alipay 子包:`github.com/go-pay/gopay/alipay`
- TradePrecreate API:当面付预下单
- 金额单位:元(字符串 "7.00"),不是微信的分
- 回调验签:alipay.VerifySign(alipayPublicKey, notifyReq)
- 回调响应:纯文本 "success"
- 订单号前缀:ali(区分微信的 wx)

## 依赖

- 无外部依赖,go-pay 已在项目中

+ 23
- 0
.plans/alipay-payment/backend-dev/task_plan.md View File

@@ -0,0 +1,23 @@
# backend-dev - 任务计划

> 角色: 后端开发
> 状态: pending
> 分配的任务: 支付宝当面付后端完整实现

## 任务

- [ ] 步骤 1: 新建 setting/payment_alipay.go — 支付宝配置变量
- [ ] 步骤 2: 修改 model/option.go — 注册配置 key + 热更新
- [ ] 步骤 3: 新建 model/topup_alipay.go — 充值完成处理逻辑
- [ ] 步骤 4: 新建 controller/topup_alipay.go — 核心控制器
- [ ] 步骤 5: 修改 controller/topup.go — GetTopUpInfo 添加支付宝
- [ ] 步骤 6: 修改 model/topup.go — ManualCompleteTopUp 添加 alipay
- [ ] 步骤 7: 修改 router/api-router.go — 注册路由
- [ ] 步骤 8: 编译验证

## 备注

- 参考文件:controller/topup_wechat.go(微信支付控制器)、setting/payment_wechat.go(配置)、model/topup_wechat.go(模型)
- 使用 gopay v1.5.117 的 alipay 子包
- 关键差异:金额单位是元(不是分)、回调响纯文本 "success"(不是 JSON)
- 完成后找 reviewer 审查

+ 26
- 0
.plans/alipay-payment/decisions.md View File

@@ -0,0 +1,26 @@
# alipay-payment - 架构决策记录

> 记录每个决策及其理由。

---

## D1: 支付产品选择

- 日期: 2026-04-14
- 决策: 使用支付宝当面付(扫码支付)
- 理由: 与现有微信 Native 支付模式一致,可最大程度复用架构
- 考虑过的替代方案: PC 网站支付、H5 手机支付

## D2: 签名方式

- 日期: 2026-04-14
- 决策: 公钥模式(RSA2)
- 理由: 参数少,配置简单,只需 AppID + 应用私钥 + 支付宝公钥
- 考虑过的替代方案: 证书模式(更安全但配置复杂)

## D3: 团队配置

- 日期: 2026-04-14
- 决策: 3 角色(backend-dev + frontend-dev + reviewer)
- 理由: 前后端可并行开发,支付涉及资金安全需要代码审查
- 考虑过的替代方案: 2 角色(无审查)、1 角色(全栈)

+ 7
- 0
.plans/alipay-payment/findings.md View File

@@ -0,0 +1,7 @@
# alipay-payment - 发现与技术记录

> 由团队智能体自动更新。每条标注来源。

---

<工作中添加条目>

+ 7
- 0
.plans/alipay-payment/frontend-dev/findings.md View File

@@ -0,0 +1,7 @@
# frontend-dev - 发现索引

> 纯索引——每个条目应简短。

---

<初始为空,工作中填写>

+ 7
- 0
.plans/alipay-payment/frontend-dev/progress.md View File

@@ -0,0 +1,7 @@
# frontend-dev - 工作日志

> 用于上下文恢复。

---

<初始为空,工作中填写>

+ 7
- 0
.plans/alipay-payment/frontend-dev/task-alipay-frontend/findings.md View File

@@ -0,0 +1,7 @@
# 支付宝前端 - 发现记录

> 此任务开发中的技术发现。

---

<初始为空>

+ 7
- 0
.plans/alipay-payment/frontend-dev/task-alipay-frontend/progress.md View File

@@ -0,0 +1,7 @@
# 支付宝前端 - 工作日志

> 上下文恢复时只需读此文件。

---

<初始为空>

+ 36
- 0
.plans/alipay-payment/frontend-dev/task-alipay-frontend/task_plan.md View File

@@ -0,0 +1,36 @@
# 支付宝前端 - 任务计划

> 所属智能体: frontend-dev
> 状态: pending
> 创建: 2026-04-14

## 目标

实现支付宝当面付的前端功能,包括管理后台设置页、用户充值页改造、二维码模态框重构。

## 详细步骤

- [ ] 1. 重构 `WechatPayQRCodeModal.jsx` → 通用 `QRCodePayModal`(新增 title/subtitle/statusApiPath props)
- [ ] 2. 新建 `SettingsPaymentGatewayAlipay.jsx`:管理后台支付宝设置页
- [ ] 3. 修改 `PaymentSetting.jsx`:导入支付宝设置组件 + getOptions 解析 + 渲染
- [ ] 4. 修改 `topup/index.jsx`:支付宝状态变量 + API 调用 + 模态框渲染
- [ ] 5. 修改 `RechargeCard.jsx`:传递 enableAlipayTopUp prop
- [ ] 6. 更新 i18n 翻译文件
- [ ] 7. `bun run build` 验证编译通过

## 涉及文件

- `web/src/components/topup/WechatPayQRCodeModal.jsx` — 重构为通用模态框
- `web/src/pages/Setting/Payment/SettingsPaymentGatewayAlipay.jsx` — 新建
- `web/src/components/settings/PaymentSetting.jsx` — 修改
- `web/src/components/topup/index.jsx` — 修改
- `web/src/components/topup/RechargeCard.jsx` — 修改(最小)
- `web/src/i18n/locales/en.json` — 修改(翻译)

## 关键技术点

- QRCodePayModal 需要兼容微信和支付宝两种场景
- 支付宝轮询 API: /api/user/alipay/pay/status
- 支付宝创建订单 API: POST /api/user/alipay/pay
- 支付宝图标 SiAlipay 已存在于 RechargeCard.jsx
- i18n key 遵循现有命名规范

+ 21
- 0
.plans/alipay-payment/frontend-dev/task_plan.md View File

@@ -0,0 +1,21 @@
# frontend-dev - 任务计划

> 角色: 前端开发
> 状态: pending
> 分配的任务: 支付宝当面付前端完整实现

## 任务

- [ ] 步骤 1: 重构 WechatPayQRCodeModal.jsx 为通用 QRCodePayModal
- [ ] 步骤 2: 新建 SettingsPaymentGatewayAlipay.jsx 管理后台设置页
- [ ] 步骤 3: 修改 PaymentSetting.jsx 注册支付宝设置
- [ ] 步骤 4: 修改 topup/index.jsx 添加支付宝支付逻辑
- [ ] 步骤 5: 修改 RechargeCard.jsx 传递 enableAlipayTopUp
- [ ] 步骤 6: 更新 i18n 翻译文件
- [ ] 步骤 7: bun run build 验证

## 备注

- 参考文件:WechatPayQRCodeModal.jsx(二维码模态框)、SettingsPaymentGatewayWechat.jsx(设置页)
- 支付宝图标 SiAlipay 已存在于 RechargeCard.jsx
- 完成后找 reviewer 审查

+ 16
- 0
.plans/alipay-payment/progress.md View File

@@ -0,0 +1,16 @@
# alipay-payment - 进度日志

> 按时间线记录。每条记录谁做了什么。

---

## 2026-04-14 Session 1 — 团队搭建

### 已完成
- [x] 读取设计文档
- [x] 确认团队配置:3 角色(backend-dev, frontend-dev, reviewer)
- [x] 创建规划文件和目录结构

### 待办
- [ ] 启动团队成员
- [ ] 开始并行开发

+ 7
- 0
.plans/alipay-payment/reviewer/findings.md View File

@@ -0,0 +1,7 @@
# reviewer - 发现索引

> 纯索引。

---

<初始为空,工作中填写>

+ 5
- 0
.plans/alipay-payment/reviewer/progress.md View File

@@ -0,0 +1,5 @@
# reviewer - 工作日志

---

<初始为空>

+ 16
- 0
.plans/alipay-payment/reviewer/task_plan.md View File

@@ -0,0 +1,16 @@
# reviewer - 任务计划

> 角色: 代码审查
> 状态: pending
> 分配的任务: 等待 backend-dev 和 frontend-dev 完成后进行代码审查

## 任务

- [ ] 审查 backend-dev 支付宝后端代码(安全 + 质量)
- [ ] 审查 frontend-dev 支付宝前端代码(质量 + 体验)

## 备注

- 支付涉及资金安全,重点关注:签名验签、金额处理、幂等性、并发安全
- 后端审查重点:回调验签、金额单位(元 vs 分)、订单幂等
- 前端审查重点:二维码模态框重构兼容性、支付状态轮询

+ 47
- 0
.plans/alipay-payment/task_plan.md View File

@@ -0,0 +1,47 @@
# alipay-payment - 主计划

> 状态: PLANNING
> 创建: 2026-04-14
> 更新: 2026-04-14
> 团队: alipay-payment (backend-dev, frontend-dev, reviewer)
> 决策记录: .plans/alipay-payment/decisions.md

---

## 1. 项目概述

在 New-API 项目中集成支付宝当面付(扫码支付),复用微信支付架构模式。使用 go-pay 框架,公钥模式签名,仅支持充值业务。

设计文档:`D:\markdown\workspace-lanqi\new-api\2026-04-14\支付宝当面付集成设计.md`

---

## 2. 文档索引

| 文档 | 位置 | 内容 |
|------|------|------|
| 设计文档(Obsidian) | D:\markdown\workspace-lanqi\new-api\2026-04-14\支付宝当面付集成设计.md | 完整设计方案 |

---

## 3. 阶段概览

- 阶段 1: 后端开发 — backend-dev 实现配置层、控制器、模型、路由
- 阶段 2: 前端开发 — frontend-dev 实现管理设置页、充值页改造、二维码模态框重构(可与阶段 1 并行)
- 阶段 3: 代码审查 — reviewer 审查后端和前端代码

---

## 4. 任务汇总

| # | 任务 | 负责人 | 状态 | 计划文件 |
|---|------|--------|------|----------|
| T1 | 后端支付宝支付完整实现 | backend-dev | pending | .plans/alipay-payment/backend-dev/task-alipay-backend/ |
| T2 | 前端支付宝支付完整实现 | frontend-dev | pending | .plans/alipay-payment/frontend-dev/task-alipay-frontend/ |
| T3 | 代码审查 | reviewer | pending | .plans/alipay-payment/reviewer/ |

---

## 5. 当前阶段

准备启动阶段 1 和阶段 2(并行开发)。

+ 569
- 0
.plans/channel-public-name.md View File

@@ -0,0 +1,569 @@
# Channel PublicName 实施计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 为 Channel 添加 `public_name` 字段,让管理员可以为每个渠道设置用户可见的友好名称,替代前端硬编码的"通道一/二/三"。

**Architecture:** 后端 Channel 模型新增字段 → AutoMigrate 自动建列 → 迁移函数回填数据 → 定价查询和 API 返回公共名称 → 前端表单支持编辑 → 展示层使用公共名称。

**Tech Stack:** Go (GORM) / React (Semi Design) / SQLite+MySQL+PostgreSQL

---

### Task 1: Channel 模型添加 PublicName 字段

**Files:**
- Modify: `model/channel.go:28`

- [ ] **Step 1: 在 Channel 结构体 Name 字段后添加 PublicName**

当前代码(`model/channel.go:28`):
```go
Name string `json:"name" gorm:"index"`
Weight *uint `json:"weight" gorm:"default:0"`
```

改为:
```go
Name string `json:"name" gorm:"index"`
PublicName string `json:"public_name" gorm:"size:255;default:''"`
Weight *uint `json:"weight" gorm:"default:0"`
```

- [ ] **Step 2: 验证编译通过**

Run: `cd D:/code/new-api && go build ./model/...`
Expected: 编译成功,无错误

- [ ] **Step 3: Commit**

```bash
git add model/channel.go
git commit -m "feat: add PublicName field to Channel model"
```

---

### Task 2: 修改 GetAllChannelsForBinding 查询包含 public_name

**Files:**
- Modify: `model/channel.go:282`

- [ ] **Step 1: 修改 Select 列**

当前代码(`model/channel.go:282`):
```go
err := DB.Select("id, name, type, remark").
```

改为:
```go
err := DB.Select("id, name, public_name, type, remark").
```

- [ ] **Step 2: 验证编译通过**

Run: `cd D:/code/new-api && go build ./model/...`
Expected: 编译成功

- [ ] **Step 3: Commit**

```bash
git add model/channel.go
git commit -m "feat: include public_name in GetAllChannelsForBinding query"
```

---

### Task 3: ChannelPricingWithChannel 扩展 + SQL 查询修改

**Files:**
- Modify: `model/channel_pricing.go:254` (结构体)
- Modify: `model/channel_pricing.go:299` (SELECT)
- Modify: `model/channel_pricing.go:319` (GROUP BY)

- [ ] **Step 1: 结构体添加 ChannelPublicName 字段**

当前代码(`model/channel_pricing.go:254`):
```go
ChannelName string `json:"channel_name"`
ChannelType int `json:"channel_type"`
```

改为:
```go
ChannelName string `json:"channel_name"`
ChannelPublicName string `json:"channel_public_name"`
ChannelType int `json:"channel_type"`
```

- [ ] **Step 2: SELECT 子句添加 channels.public_name**

当前代码(`model/channel_pricing.go:299`):
```go
Select(`abilities.channel_id, channels.name as channel_name, channels.type as channel_type,
```

改为:
```go
Select(`abilities.channel_id, channels.name as channel_name, channels.public_name as channel_public_name, channels.type as channel_type,
```

- [ ] **Step 3: GROUP BY 子句添加 channels.public_name**

当前代码(`model/channel_pricing.go:319`):
```go
Group("abilities.channel_id, channels.name, channels.type, channel_pricings.quota_type,
```

改为:
```go
Group("abilities.channel_id, channels.name, channels.public_name, channels.type, channel_pricings.quota_type,
```

注意:`channels.public_name` 插入在 `channels.name` 后面。

- [ ] **Step 4: 验证编译通过**

Run: `cd D:/code/new-api && go build ./model/...`
Expected: 编译成功

- [ ] **Step 5: Commit**

```bash
git add model/channel_pricing.go
git commit -m "feat: add public_name to channel pricing query"
```

---

### Task 4: pricing.go 默认通道名称使用 public_name

**Files:**
- Modify: `model/pricing.go:290-303`

- [ ] **Step 1: 扩展匿名结构体**

当前代码(`model/pricing.go:290-293`):
```go
var allCPs []struct {
ChannelPricing
ChannelName string
}
```

改为:
```go
var allCPs []struct {
ChannelPricing
ChannelName string
ChannelPublicName string
}
```

- [ ] **Step 2: 扩展 Select 子句**

当前代码(`model/pricing.go:295`):
```go
Select("channel_pricings.*, channels.name as channel_name").
```

改为:
```go
Select("channel_pricings.*, channels.name as channel_name, channels.public_name as channel_public_name").
```

- [ ] **Step 3: 修改 channelNameMap 构建逻辑**

当前代码(`model/pricing.go:303`):
```go
channelNameMap[allCPs[i].ChannelId] = allCPs[i].ChannelName
```

改为:
```go
name := allCPs[i].ChannelPublicName
if name == "" {
name = allCPs[i].ChannelName
}
channelNameMap[allCPs[i].ChannelId] = name
```

- [ ] **Step 4: 验证编译通过**

Run: `cd D:/code/new-api && go build ./model/...`
Expected: 编译成功

- [ ] **Step 5: Commit**

```bash
git add model/pricing.go
git commit -m "feat: use public_name as default channel display name"
```

---

### Task 5: 数据回填迁移函数

**Files:**
- Modify: `model/main.go:306-307` (调用位置)
- Modify: `model/main.go:695` (函数定义位置)

- [ ] **Step 1: 在 migrateDB 的 return nil 之前插入调用**

当前代码(`model/main.go:304-308`):
```go
// 将现有 sort_order=0 的模型和供应商更新为默认大数
DB.Model(&Model{}).Where("sort_order = 0").Update("sort_order", 999999)
DB.Model(&Vendor{}).Where("sort_order = 0").Update("sort_order", 999999)
return nil
}
```

改为:
```go
// 将现有 sort_order=0 的模型和供应商更新为默认大数
DB.Model(&Model{}).Where("sort_order = 0").Update("sort_order", 999999)
DB.Model(&Vendor{}).Where("sort_order = 0").Update("sort_order", 999999)

migrateChannelPublicName()

return nil
}
```

- [ ] **Step 2: 在文件末尾(第 696 行后)添加迁移函数**

```go
func migrateChannelPublicName() {
result := DB.Model(&Channel{}).
Where("public_name = '' OR public_name IS NULL").
Update("public_name", gorm.Expr("name"))
if result.Error != nil {
common.SysError("[Migration] migrateChannelPublicName failed: " + result.Error.Error())
} else if result.RowsAffected > 0 {
common.SysLog(fmt.Sprintf("[Migration] migrateChannelPublicName: backfilled %d channels", result.RowsAffected))
}
}
```

**说明:**
- AutoMigrate(`migrateDB()` 第 262 行 `&Channel{}`)会先创建列
- WHERE 条件保证幂等
- `gorm.Expr("name")` 引用列名,SQLite/MySQL/PostgreSQL 通用
- `model/main.go` 已有 `fmt`、`gorm`、`common` 的 import,无需额外导入

- [ ] **Step 3: 验证编译通过**

Run: `cd D:/code/new-api && go build ./model/...`
Expected: 编译成功

- [ ] **Step 4: Commit**

```bash
git add model/main.go
git commit -m "feat: add migration to backfill public_name from name"
```

---

### Task 6: controller 层验证 + API 返回

**Files:**
- Modify: `controller/channel.go:582-585` (验证)
- Modify: `controller/channel.go:2110-2115` (API 返回)

- [ ] **Step 1: 在 validateChannel 的 isAdd 块中添加 public_name 校验**

当前代码(`controller/channel.go:582-585`):
```go
if isAdd {
if channel == nil || channel.Key == "" {
return fmt.Errorf("channel cannot be empty")
}

// 检查模型名称长度是否超过 255
```

改为:
```go
if isAdd {
if channel == nil || channel.Key == "" {
return fmt.Errorf("channel cannot be empty")
}

if strings.TrimSpace(channel.PublicName) == "" {
return fmt.Errorf("public name cannot be empty")
}

// 检查模型名称长度是否超过 255
```

**说明:** 保持英文错误消息与现有代码一致。

- [ ] **Step 2: 在 GetUserChannelsForBinding 返回值中添加 public_name**

当前代码(`controller/channel.go:2110-2115`):
```go
result = append(result, gin.H{
"id": ch.Id,
"name": ch.Name,
"type": ch.Type,
"remark": ch.Remark,
})
```

改为:
```go
result = append(result, gin.H{
"id": ch.Id,
"name": ch.Name,
"public_name": ch.PublicName,
"type": ch.Type,
"remark": ch.Remark,
})
```

- [ ] **Step 3: 验证编译通过**

Run: `cd D:/code/new-api && go build ./controller/...`
Expected: 编译成功

- [ ] **Step 4: Commit**

```bash
git add controller/channel.go
git commit -m "feat: validate public_name on channel creation and return in binding API"
```

---

### Task 7: 前端 i18n 翻译

**Files:**
- Modify: `web/src/i18n/locales/zh-CN.json`
- Modify: `web/src/i18n/locales/en.json`

- [ ] **Step 1: zh-CN.json 添加翻译**

在 `"请为渠道命名"` 条目(第 2454 行)之后添加:

```json
"对外名称": "对外名称",
"用户看到的渠道名称,如"标准通道"、"高速通道"": "用户看到的渠道名称,如"标准通道"、"高速通道"",
"请填写对外名称": "请填写对外名称",
"请填写渠道名称、对外名称和渠道密钥!": "请填写渠道名称、对外名称和渠道密钥!",
```

注意:zh-CN.json 的 key 和 value 相同(中文 → 中文)。

- [ ] **Step 2: en.json 添加翻译**

在 `"Please name the channel"` 条目(第 2473 行)之后添加:

```json
"对外名称": "Public Name",
"用户看到的渠道名称,如"标准通道"、"高速通道"": "User-facing channel name, e.g. \"Standard\", \"Fast\"",
"请填写对外名称": "Please enter a public name",
"请填写渠道名称、对外名称和渠道密钥!": "Please enter channel name, public name and key!",
```

- [ ] **Step 3: Commit**

```bash
git add web/src/i18n/locales/zh-CN.json web/src/i18n/locales/en.json
git commit -m "feat: add i18n translations for channel public_name"
```

---

### Task 8: EditChannelModal 表单添加对外名称字段

**Files:**
- Modify: `web/src/components/table/channels/modals/EditChannelModal.jsx:142` (originInputs)
- Modify: `web/src/components/table/channels/modals/EditChannelModal.jsx:1980` (表单 UI)
- Modify: `web/src/components/table/channels/modals/EditChannelModal.jsx:1329` (提交校验)

- [ ] **Step 1: originInputs 添加 public_name 默认值**

当前代码(第 142-143 行):
```javascript
const originInputs = {
name: '',
type: 1,
```

改为:
```javascript
const originInputs = {
name: '',
public_name: '',
type: 1,
```

- [ ] **Step 2: 在 name 的 Form.Input 后添加 public_name 输入框**

当前代码(第 1972-1980 行):
```jsx
<Form.Input
field='name'
label={t('名称')}
placeholder={t('请为渠道命名')}
rules={[{ required: true, message: t('请为渠道命名') }]}
showClear
onChange={(value) => handleInputChange('name', value)}
autoComplete='new-password'
/>

{inputs.type === 33 && (
```

在第 1980 行 `/>` 和第 1982 行 `{inputs.type === 33` 之间插入:

```jsx
<Form.Input
field='name'
label={t('名称')}
placeholder={t('请为渠道命名')}
rules={[{ required: true, message: t('请为渠道命名') }]}
showClear
onChange={(value) => handleInputChange('name', value)}
autoComplete='new-password'
/>
<Form.Input
field='public_name'
label={t('对外名称')}
placeholder={t('用户看到的渠道名称,如"标准通道"、"高速通道"')}
rules={!isEdit ? [{ required: true, message: t('请填写对外名称') }] : []}
showClear
onChange={(value) => handleInputChange('public_name', value)}
autoComplete='new-password'
/>

{inputs.type === 33 && (
```

**说明:** `!isEdit` 条件确保新建时必填、编辑时允许清空。

- [ ] **Step 3: 修改提交前校验**

当前代码(第 1329-1332 行):
```javascript
if (!isEdit && (!localInputs.name || !localInputs.key)) {
showInfo(t('请填写渠道名称和渠道密钥!'));
return;
}
```

改为:
```javascript
if (!isEdit && (!localInputs.name || !localInputs.public_name || !localInputs.key)) {
showInfo(t('请填写渠道名称、对外名称和渠道密钥!'));
return;
}
```

- [ ] **Step 4: Commit**

```bash
git add web/src/components/table/channels/modals/EditChannelModal.jsx
git commit -m "feat: add public_name field to channel edit form"
```

---

### Task 9: ChannelPricingCard 展示公共名称

**Files:**
- Modify: `web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx:107`

- [ ] **Step 1: 修改 channelName 赋值逻辑**

当前代码(第 104-108 行):
```javascript
const tableData = channelPricingData.map((item, index) => ({
key: item.channel_id || index,
channelId: item.channel_id,
channelName: `通道${['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || ` ${index + 1}`}`,
channelTags: item.tags || [],
```

改为:
```javascript
const tableData = channelPricingData.map((item, index) => ({
key: item.channel_id || index,
channelId: item.channel_id,
channelName: item.channel_public_name || ('通道' + (['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || (index + 1))),
channelTags: item.tags || [],
```

**说明:** 优先使用后端 `channel_public_name`,为空时回退到中文数字。同时修复原代码反引号嵌套语法问题。

- [ ] **Step 2: Commit**

```bash
git add web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx
git commit -m "feat: display public_name in ChannelPricingCard"
```

---

### Task 10: 端到端验证

**Files:** 无代码修改,纯验证

- [ ] **Step 1: 后端编译**

Run: `cd D:/code/new-api && go build -o new-api main.go`
Expected: 编译成功

- [ ] **Step 2: 启动服务**

Run: `cd D:/code/new-api && go run main.go`
Expected: 日志中出现 `[Migration] migrateChannelPublicName: backfilled N channels`

- [ ] **Step 3: 再次重启确认幂等**

重启服务后 Expected: 日志中不再出现 backfilled(RowsAffected=0)

- [ ] **Step 4: API 测试 — 创建渠道不带 public_name**

Run:
```bash
curl -s -X POST http://localhost:3000/api/channel/ \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"mode":"single","channel":{"type":1,"name":"测试","key":"sk-test"}}'
```
Expected: 返回错误 `"public name cannot be empty"`

- [ ] **Step 5: API 测试 — 创建渠道带 public_name**

Run:
```bash
curl -s -X POST http://localhost:3000/api/channel/ \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"mode":"single","channel":{"type":1,"name":"测试","public_name":"标准通道","key":"sk-test"}}'
```
Expected: 返回成功

- [ ] **Step 6: API 测试 — 定价接口包含 public_name**

Run:
```bash
curl -s http://localhost:3000/api/channel-pricing/model/gpt-4
```
Expected: 响应中包含 `channel_public_name` 字段

- [ ] **Step 7: 前端验证**

在浏览器中依次验证:
1. 渠道管理 → 新建渠道 → 可见"对外名称"输入框
2. 不填"对外名称"提交 → 显示验证错误
3. 填写后提交成功
4. 编辑渠道 → 可见已保存的对外名称
5. 编辑时清空对外名称并保存 → 成功(向后兼容)
6. 模型详情侧边栏 → ChannelPricingCard 显示对外名称
7. 首页定价卡片 → "默认通道"标签显示对外名称
8. 未设 public_name 的渠道 → 显示"通道一/二/三..."

+ 72
- 0
.superpowers/brainstorm/6600-1777284109/content/button-position.html View File

@@ -0,0 +1,72 @@
<h2>"去体验"按钮位置选择</h2>
<p class="subtitle">当前模型卡片结构示意,请选择按钮的最佳放置位置</p>

<div class="cards">
<div class="card" data-choice="bottom-center" onclick="toggleSelect(this)">
<div class="card-image">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px; color: white;">
<div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(255,255,255,0.2);display:flex;align-items:center;justify-content:center;font-size:18px;">GPT</div>
<span style="font-weight:600;font-size:16px;">gpt-4o</span>
<span style="margin-left:auto;font-size:12px;opacity:0.7;">📋</span>
</div>
<p style="font-size:13px;opacity:0.85;margin:0 0 12px 0;line-height:1.4;">OpenAI 旗舰多模态模型,支持文本和图像输入输出</p>
<div style="display:flex;gap:6px;margin-bottom:16px;">
<span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">按量计费</span>
<span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">多模态</span>
</div>
<button style="width:100%;padding:8px 0;background:rgba(255,255,255,0.25);border:1px solid rgba(255,255,255,0.4);border-radius:6px;color:white;font-size:13px;cursor:pointer;">去体验 →</button>
</div>
</div>
<div class="card-body">
<h3>方案 A:卡片底部居中</h3>
<p>按钮占据卡片底部整行,醒目且易于点击。适合卡片高度固定的场景。</p>
</div>
</div>

<div class="card" data-choice="footer-right" onclick="toggleSelect(this)">
<div class="card-image">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px; color: white;">
<div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(255,255,255,0.2);display:flex;align-items:center;justify-content:center;font-size:18px;">GPT</div>
<span style="font-weight:600;font-size:16px;">gpt-4o</span>
<span style="margin-left:auto;font-size:12px;opacity:0.7;">📋</span>
</div>
<p style="font-size:13px;opacity:0.85;margin:0 0 12px 0;line-height:1.4;">OpenAI 旗舰多模态模型,支持文本和图像输入输出</p>
<div style="display:flex;align-items:center;gap:6px;">
<span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">按量计费</span>
<span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">多模态</span>
<span style="margin-left:auto;"></span>
<button style="padding:5px 12px;background:rgba(255,255,255,0.25);border:1px solid rgba(255,255,255,0.4);border-radius:6px;color:white;font-size:12px;cursor:pointer;">去体验 →</button>
</div>
</div>
</div>
<div class="card-body">
<h3>方案 B:标签行右侧</h3>
<p>按钮与标签同行,紧凑不占额外空间。但可能在小屏幕上显得拥挤。</p>
</div>
</div>

<div class="card" data-choice="header-right" onclick="toggleSelect(this)">
<div class="card-image">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px; color: white;">
<div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(255,255,255,0.2);display:flex;align-items:center;justify-content:center;font-size:18px;">GPT</div>
<span style="font-weight:600;font-size:16px;">gpt-4o</span>
<span style="margin-left:auto;"></span>
<button style="padding:5px 12px;background:rgba(255,255,255,0.25);border:1px solid rgba(255,255,255,0.4);border-radius:6px;color:white;font-size:12px;cursor:pointer;">去体验 →</button>
<span style="font-size:12px;opacity:0.7;">📋</span>
</div>
<p style="font-size:13px;opacity:0.85;margin:0 0 12px 0;line-height:1.4;">OpenAI 旗舰多模态模型,支持文本和图像输入输出</p>
<div style="display:flex;gap:6px;">
<span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">按量计费</span>
<span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">多模态</span>
</div>
</div>
</div>
<div class="card-body">
<h3>方案 C:标题行右侧</h3>
<p>按钮在模型名称旁边,最显眼的位置。但与复制按钮竞争空间。</p>
</div>
</div>
</div>

+ 3
- 0
.superpowers/brainstorm/6600-1777284109/content/waiting.html View File

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

+ 1
- 0
.superpowers/brainstorm/6600-1777284109/state/server-stopped View File

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1777286270028}

+ 1
- 0
.superpowers/brainstorm/6600-1777284109/state/server.pid View File

@@ -0,0 +1 @@
6600

+ 30
- 0
common/captcha.go View File

@@ -0,0 +1,30 @@
package common

import (
"github.com/mojocn/base64Captcha"
)

var captchaStore = base64Captcha.DefaultMemStore

var captchaDriver = base64Captcha.NewDriverString(
40, // height
120, // width
0, // noise count (auto)
base64Captcha.OptionShowSlimeLine, // show slime lines
5, // code length
base64Captcha.TxtSimpleCharaters, // digits+letters excluding confusing chars
nil, // bg color (auto)
nil, // font storage (auto)
[]string{"wqy-microhei.ttc"}, // font files
)

var captchaInstance = base64Captcha.NewCaptcha(captchaDriver, captchaStore)

func GenerateCaptcha() (string, string, error) {
id, b64s, _, err := captchaInstance.Generate()
return id, b64s, err
}

func VerifyCaptcha(id, code string) bool {
return captchaStore.Verify(id, code, true)
}

+ 58
- 0
common/codex_credential.go View File

@@ -0,0 +1,58 @@
package common

import (
"errors"
"strings"
)

type CodexCredentialMode string

const (
CodexCredentialModeAPIKey CodexCredentialMode = "api_key"
CodexCredentialModeOAuth CodexCredentialMode = "oauth"
)

var (
ErrCodexOAuthCredentialRequired = errors.New("codex channel: oauth credential required")
ErrCodexOAuthCredentialInvalidJSON = errors.New("codex channel: invalid oauth key json")
ErrCodexOAuthAccessTokenRequired = errors.New("codex channel: access_token is required")
ErrCodexOAuthAccountIDRequired = errors.New("codex channel: account_id is required")
)

type CodexOAuthCredential struct {
IDToken string `json:"id_token,omitempty"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
AccountID string `json:"account_id,omitempty"`
LastRefresh string `json:"last_refresh,omitempty"`
Email string `json:"email,omitempty"`
Type string `json:"type,omitempty"`
Expired string `json:"expired,omitempty"`
}

func ParseCodexOAuthCredential(raw string) (*CodexOAuthCredential, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || !strings.HasPrefix(trimmed, "{") {
return nil, ErrCodexOAuthCredentialRequired
}

var credential CodexOAuthCredential
if err := Unmarshal([]byte(trimmed), &credential); err != nil {
return nil, ErrCodexOAuthCredentialInvalidJSON
}
if strings.TrimSpace(credential.AccessToken) == "" {
return nil, ErrCodexOAuthAccessTokenRequired
}
if strings.TrimSpace(credential.AccountID) == "" {
return nil, ErrCodexOAuthAccountIDRequired
}

return &credential, nil
}

func DetectCodexCredentialMode(raw string) CodexCredentialMode {
if _, err := ParseCodexOAuthCredential(raw); err == nil {
return CodexCredentialModeOAuth
}
return CodexCredentialModeAPIKey
}

+ 10
- 0
common/constants.go View File

@@ -15,7 +15,16 @@ var Version = "v0.0.0" // this hard coding will be replaced automatic
var SystemName = "New API"
var Footer = ""
var Logo = ""
var LogoFilePath = "" // LOGO_FILE_PATH 环境变量指定的本地 Logo 文件路径

func GetEffectiveLogo() string {
if LogoFilePath != "" {
return "/logo.png"
}
return Logo
}
var TopUpLink = ""
var DefaultLanguage = "" // admin-configured default language; empty = follow browser detection

// var ChatLink = ""
// var ChatLink2 = ""
@@ -49,6 +58,7 @@ var LinuxDOOAuthEnabled = false
var WeChatAuthEnabled = false
var TelegramOAuthEnabled = false
var TurnstileCheckEnabled = false
var CaptchaEnabled = false
var RegisterEnabled = true

var EmailDomainRestrictionEnabled = false // 是否启用邮箱域名限制


+ 18
- 0
common/str.go View File

@@ -87,6 +87,24 @@ func StringsContains(strs []string, str string) bool {
return false
}

// StringsSubtract returns elements from source that are not in exclude.
func StringsSubtract(source, exclude []string) []string {
if len(exclude) == 0 {
return source
}
excludeSet := make(map[string]struct{}, len(exclude))
for _, s := range exclude {
excludeSet[s] = struct{}{}
}
result := make([]string, 0, len(source))
for _, s := range source {
if _, ok := excludeSet[s]; !ok {
result = append(result, s)
}
}
return result
}

// StringToByteSlice []byte only read, panic on append
func StringToByteSlice(s string) []byte {
tmp1 := (*[2]uintptr)(unsafe.Pointer(&s))


+ 28
- 16
controller/channel.go View File

@@ -3,6 +3,7 @@ package controller
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
@@ -584,6 +585,10 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
return fmt.Errorf("channel cannot be empty")
}

if strings.TrimSpace(channel.PublicName) == "" {
return fmt.Errorf("public name cannot be empty")
}

// 检查模型名称长度是否超过 255
for _, m := range channel.GetModels() {
if len(m) > 255 {
@@ -611,20 +616,22 @@ func validateChannel(channel *model.Channel, isAdd bool) error {
// Codex OAuth key validation (optional, only when JSON object is provided)
if channel.Type == constant.ChannelTypeCodex {
trimmedKey := strings.TrimSpace(channel.Key)
if isAdd || trimmedKey != "" {
if !strings.HasPrefix(trimmedKey, "{") {
return fmt.Errorf("Codex key must be a valid JSON object")
}
var keyMap map[string]any
if err := common.Unmarshal([]byte(trimmedKey), &keyMap); err != nil {
if isAdd && trimmedKey == "" {
return fmt.Errorf("Codex key cannot be empty")
}
if strings.HasPrefix(trimmedKey, "{") {
if _, err := common.ParseCodexOAuthCredential(trimmedKey); err != nil {
if errors.Is(err, common.ErrCodexOAuthCredentialInvalidJSON) {
return fmt.Errorf("Codex key must be a valid JSON object")
}
if errors.Is(err, common.ErrCodexOAuthAccessTokenRequired) {
return fmt.Errorf("Codex key JSON must include access_token")
}
if errors.Is(err, common.ErrCodexOAuthAccountIDRequired) {
return fmt.Errorf("Codex key JSON must include account_id")
}
return fmt.Errorf("Codex key must be a valid JSON object")
}
if v, ok := keyMap["access_token"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" {
return fmt.Errorf("Codex key JSON must include access_token")
}
if v, ok := keyMap["account_id"]; !ok || v == nil || strings.TrimSpace(fmt.Sprintf("%v", v)) == "" {
return fmt.Errorf("Codex key JSON must include account_id")
}
}
}

@@ -643,6 +650,10 @@ func RefreshCodexChannelCredential(c *gin.Context) {

oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true})
if err != nil {
if errors.Is(err, common.ErrCodexOAuthCredentialRequired) {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "当前凭证方式不支持刷新凭证"})
return
}
common.SysError("failed to refresh codex channel credential: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "刷新凭证失败,请稍后重试"})
return
@@ -2108,10 +2119,11 @@ func GetUserChannelsForBinding(c *gin.Context) {
result := make([]gin.H, 0, len(channels))
for _, ch := range channels {
result = append(result, gin.H{
"id": ch.Id,
"name": ch.Name,
"type": ch.Type,
"remark": ch.Remark,
"id": ch.Id,
"name": ch.Name,
"public_name": ch.PublicName,
"type": ch.Type,
"remark": ch.Remark,
})
}



+ 137
- 56
controller/channel_pricing.go View File

@@ -1,6 +1,7 @@
package controller

import (
"fmt"
"strconv"
"strings"

@@ -50,14 +51,25 @@ func GetChannelPricingByModel(c *gin.Context) {

// CreateChannelPricingRequest 创建渠道定价请求
type CreateChannelPricingRequest struct {
Id int `json:"id"`
ModelName string `json:"model_name" binding:"required"`
ChannelId int `json:"channel_id" binding:"required"`
QuotaType int `json:"quota_type"`
ModelRatio float64 `json:"model_ratio"`
CompletionRatio float64 `json:"completion_ratio"`
ModelPrice float64 `json:"model_price"`
TagIds string `json:"tag_ids"`
Id int `json:"id"`
ModelName string `json:"model_name" binding:"required"`
ChannelId int `json:"channel_id" binding:"required"`
QuotaType int `json:"quota_type"`
ModelRatio float64 `json:"model_ratio"`
CompletionRatio float64 `json:"completion_ratio"`
ModelPrice float64 `json:"model_price"`
TagIds string `json:"tag_ids"`
CacheRatio float64 `json:"cache_ratio"`
CacheCreationRatio float64 `json:"cache_creation_ratio"`
ImageRatio float64 `json:"image_ratio"`
AudioRatio float64 `json:"audio_ratio"`
AudioCompletionRatio float64 `json:"audio_completion_ratio"`
}

// applyRequest 将请求字段应用到 ChannelPricing
func applyRequestFields(cp *model.ChannelPricing, req *CreateChannelPricingRequest) {
cp.ApplyFields(req.QuotaType, req.ModelRatio, req.CompletionRatio, req.ModelPrice, req.TagIds,
req.CacheRatio, req.CacheCreationRatio, req.ImageRatio, req.AudioRatio, req.AudioCompletionRatio)
}

// CreateChannelPricing 创建或更新渠道定价
@@ -68,38 +80,38 @@ func CreateChannelPricing(c *gin.Context) {
return
}

if req.CacheRatio < 0 || req.CacheCreationRatio < 0 || req.ImageRatio < 0 || req.AudioRatio < 0 || req.AudioCompletionRatio < 0 {
common.ApiErrorMsg(c, "ratio values must be >= 0")
return
}

// 检查是否已存在
existing, _ := model.GetChannelPricing(req.ModelName, req.ChannelId)
if existing != nil {
// 更新
existing.QuotaType = req.QuotaType
existing.ModelRatio = req.ModelRatio
existing.CompletionRatio = req.CompletionRatio
existing.ModelPrice = req.ModelPrice
existing.TagIds = req.TagIds
applyRequestFields(existing, &req)
if err := existing.Update(); err != nil {
common.ApiError(c, err)
return
}
common.SysLog(fmt.Sprintf("[ChannelPricing] updated: id=%d model=%s channel=%d", existing.Id, existing.ModelName, existing.ChannelId))
common.ApiSuccess(c, existing)
return
}

// 创建
cp := &model.ChannelPricing{
ModelName: req.ModelName,
ChannelId: req.ChannelId,
QuotaType: req.QuotaType,
ModelRatio: req.ModelRatio,
CompletionRatio: req.CompletionRatio,
ModelPrice: req.ModelPrice,
TagIds: req.TagIds,
ModelName: req.ModelName,
ChannelId: req.ChannelId,
}
applyRequestFields(cp, &req)
if err := cp.Insert(); err != nil {
common.ApiError(c, err)
return
}

common.SysLog(fmt.Sprintf("[ChannelPricing] created: model=%s channel=%d quotaType=%d modelRatio=%.4f completionRatio=%.4f modelPrice=%.4f cacheRatio=%.4f cacheCreationRatio=%.4f imageRatio=%.4f audioRatio=%.4f audioCompletionRatio=%.4f",
req.ModelName, req.ChannelId, req.QuotaType, req.ModelRatio, req.CompletionRatio, req.ModelPrice,
req.CacheRatio, req.CacheCreationRatio, req.ImageRatio, req.AudioRatio, req.AudioCompletionRatio))
common.ApiSuccess(c, cp)
}

@@ -118,26 +130,20 @@ func BatchCreateChannelPricing(c *gin.Context) {

pricings := make([]*model.ChannelPricing, 0, len(req.Items))
for _, item := range req.Items {
pricings = append(pricings, &model.ChannelPricing{
ModelName: item.ModelName,
ChannelId: item.ChannelId,
QuotaType: item.QuotaType,
ModelRatio: item.ModelRatio,
CompletionRatio: item.CompletionRatio,
ModelPrice: item.ModelPrice,
TagIds: item.TagIds,
})
cp := &model.ChannelPricing{
ModelName: item.ModelName,
ChannelId: item.ChannelId,
}
applyRequestFields(cp, item)
pricings = append(pricings, cp)
}

// 使用事务逐个处理(GORM 的批量 upsert 在不同数据库表现不一致)
for _, cp := range pricings {
existing, _ := model.GetChannelPricing(cp.ModelName, cp.ChannelId)
if existing != nil {
existing.QuotaType = cp.QuotaType
existing.ModelRatio = cp.ModelRatio
existing.CompletionRatio = cp.CompletionRatio
existing.ModelPrice = cp.ModelPrice
existing.TagIds = cp.TagIds
existing.ApplyFields(cp.QuotaType, cp.ModelRatio, cp.CompletionRatio, cp.ModelPrice, cp.TagIds,
cp.CacheRatio, cp.CacheCreationRatio, cp.ImageRatio, cp.AudioRatio, cp.AudioCompletionRatio)
if err := existing.Update(); err != nil {
common.ApiError(c, err)
return
@@ -168,6 +174,7 @@ func DeleteChannelPricing(c *gin.Context) {
return
}

common.SysLog(fmt.Sprintf("[ChannelPricing] deleted: id=%d", id))
common.ApiSuccess(c, nil)
}

@@ -204,6 +211,28 @@ func CopyGlobalPricing(c *gin.Context) {
continue
}

// 获取全局扩展比率
globalCacheRatio, hasCacheRatio := ratio_setting.GetCacheRatio(ability.Model)
if !hasCacheRatio {
globalCacheRatio = 0
}
globalCacheCreationRatio, hasCacheCreationRatio := ratio_setting.GetCreateCacheRatio(ability.Model)
if !hasCacheCreationRatio {
globalCacheCreationRatio = 0
}
globalImageRatio, hasImageRatio := ratio_setting.GetImageRatio(ability.Model)
if !hasImageRatio {
globalImageRatio = 0
}
globalAudioRatio, hasAudioRatio := ratio_setting.GetAudioRatioV2(ability.Model)
if !hasAudioRatio {
globalAudioRatio = 0
}
globalAudioCompletionRatio, hasAudioCompRatio := ratio_setting.GetAudioCompletionRatioV2(ability.Model)
if !hasAudioCompRatio {
globalAudioCompletionRatio = 0
}

// 确定定价类型
var quotaType int
var ratio, completionRatio, price float64
@@ -224,28 +253,25 @@ func CopyGlobalPricing(c *gin.Context) {
}

if existing != nil {
existing.QuotaType = quotaType
existing.ModelRatio = ratio
existing.CompletionRatio = completionRatio
existing.ModelPrice = price
existing.ApplyFields(quotaType, ratio, completionRatio, price, "",
globalCacheRatio, globalCacheCreationRatio, globalImageRatio, globalAudioRatio, globalAudioCompletionRatio)
if err := existing.Update(); err == nil {
imported++
}
} else {
cp := &model.ChannelPricing{
ModelName: ability.Model,
ChannelId: channelId,
QuotaType: quotaType,
ModelRatio: ratio,
CompletionRatio: completionRatio,
ModelPrice: price,
ModelName: ability.Model,
ChannelId: channelId,
}
cp.ApplyFields(quotaType, ratio, completionRatio, price, "",
globalCacheRatio, globalCacheCreationRatio, globalImageRatio, globalAudioRatio, globalAudioCompletionRatio)
if err := cp.Insert(); err == nil {
imported++
}
}
}

common.SysLog(fmt.Sprintf("[ChannelPricing] copyGlobalPricing: channel=%d imported=%d/%d", channelId, imported, len(abilities)))
common.ApiSuccess(c, gin.H{
"total": len(abilities),
"imported": imported,
@@ -275,7 +301,23 @@ func GetChannelPricingByModelWithChannelInfo(c *gin.Context) {
return
}

common.ApiSuccess(c, list)
loggedIn, userId, userGroup, groupRatio := resolveCurrentUser(c)

if loggedIn {
for _, item := range list {
userChannelRatio := model.GetUserChannelRatio(userId, modelName, item.ChannelId)
item.UserRatio = groupRatio * userChannelRatio
}
}

c.JSON(200, gin.H{
"success": true,
"message": "",
"data": list,
"logged_in": loggedIn,
"group": userGroup,
"group_ratio": groupRatio,
})
}

// GetChannelPricingWithTags 获取渠道定价(带标签详情)
@@ -302,16 +344,7 @@ func GetChannelPricingWithTags(c *gin.Context) {
for _, cp := range list {
item := &ChannelPricingWithTags{
ChannelPricing: cp,
Tags: make([]*model.PricingTag, 0),
}
if cp.TagIds != "" {
for _, idStr := range strings.Split(cp.TagIds, ",") {
if id, err := strconv.Atoi(idStr); err == nil {
if tag, ok := tagMap[id]; ok {
item.Tags = append(item.Tags, tag)
}
}
}
Tags: model.ParseTagIds(cp.TagIds, tagMap),
}
result = append(result, item)
}
@@ -323,3 +356,51 @@ func GetChannelPricingWithTags(c *gin.Context) {
"items": result,
})
}

// SetDefaultChannelRequest 设置默认通道请求
type SetDefaultChannelRequest struct {
ModelName string `json:"model_name" binding:"required"`
ChannelId int `json:"channel_id" binding:"required"`
}

// SetDefaultChannel 设置指定模型的默认通道
func SetDefaultChannel(c *gin.Context) {
var req SetDefaultChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}

// 验证渠道定价记录存在
existing, err := model.GetChannelPricing(req.ModelName, req.ChannelId)
if err != nil || existing == nil {
common.ApiErrorMsg(c, "channel pricing not found for this model and channel")
return
}

if err := model.SetDefaultChannel(req.ModelName, req.ChannelId); err != nil {
common.ApiError(c, err)
return
}

common.SysLog(fmt.Sprintf("[ChannelPricing] set default: model=%s channel=%d", req.ModelName, req.ChannelId))
common.ApiSuccess(c, nil)
}

// ClearDefaultChannel 清除指定模型的默认通道
func ClearDefaultChannel(c *gin.Context) {
modelName := c.Param("name")
modelName = strings.TrimPrefix(modelName, "/")
if modelName == "" {
common.ApiErrorMsg(c, "model name is required")
return
}

if err := model.ClearDefaultChannel(modelName); err != nil {
common.ApiError(c, err)
return
}

common.SysLog(fmt.Sprintf("[ChannelPricing] cleared default: model=%s", modelName))
common.ApiSuccess(c, nil)
}

+ 111
- 0
controller/codex_channel_test.go View File

@@ -0,0 +1,111 @@
package controller

import (
"bytes"
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func setupCodexChannelDB(t *testing.T, key string) *gorm.DB {
t.Helper()

db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)

sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)

origDB := model.DB
origLogDB := model.LOG_DB
model.DB = db
model.LOG_DB = db
common.UsingSQLite = true
common.RedisEnabled = false

require.NoError(t, db.AutoMigrate(&model.Channel{}))
require.NoError(t, db.Create(&model.Channel{
Id: 1,
Name: "codex-channel",
PublicName: "codex-channel",
Type: constant.ChannelTypeCodex,
Key: key,
Status: common.ChannelStatusEnabled,
}).Error)

t.Cleanup(func() {
model.DB = origDB
model.LOG_DB = origLogDB
_ = sqlDB.Close()
})

return db
}

func setupCodexChannelRouter(t *testing.T, key string) *gin.Engine {
t.Helper()
setupCodexChannelDB(t, key)

gin.SetMode(gin.TestMode)
r := gin.New()
g := r.Group("/api/channel")
g.POST("/:id/codex/refresh", RefreshCodexChannelCredential)
g.GET("/:id/codex/usage", GetCodexChannelUsage)
return r
}

func TestValidateChannelAcceptsCodexAPIKey(t *testing.T) {
channel := &model.Channel{
Name: "codex-api-key",
PublicName: "codex-api-key",
Type: constant.ChannelTypeCodex,
Key: "sk-codex-api-key",
}

require.NoError(t, validateChannel(channel, true))
}

func TestValidateChannelRejectsCodexOAuthWithoutAccountID(t *testing.T) {
channel := &model.Channel{
Name: "codex-oauth",
PublicName: "codex-oauth",
Type: constant.ChannelTypeCodex,
Key: `{"access_token":"token-only"}`,
}

err := validateChannel(channel, true)
require.Error(t, err)
assert.Contains(t, err.Error(), "account_id")
}

func TestRefreshCodexChannelCredentialRejectsAPIKeyMode(t *testing.T) {
router := setupCodexChannelRouter(t, "sk-codex-api-key")

req := httptest.NewRequest(http.MethodPost, "/api/channel/1/codex/refresh", bytes.NewReader([]byte(`{}`)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "当前凭证方式不支持刷新凭证")
}

func TestGetCodexChannelUsageRejectsAPIKeyMode(t *testing.T) {
router := setupCodexChannelRouter(t, "sk-codex-api-key")

req := httptest.NewRequest(http.MethodGet, "/api/channel/1/codex/usage", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "当前凭证方式不支持查看用量")
}

+ 8
- 10
controller/codex_usage.go View File

@@ -2,6 +2,7 @@ package controller

import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
@@ -11,7 +12,6 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel/codex"
"github.com/QuantumNous/new-api/service"

"github.com/gin-gonic/gin"
@@ -42,22 +42,19 @@ func GetCodexChannelUsage(c *gin.Context) {
return
}

oauthKey, err := codex.ParseOAuthKey(strings.TrimSpace(ch.Key))
oauthKey, err := common.ParseCodexOAuthCredential(strings.TrimSpace(ch.Key))
if err != nil {
if errors.Is(err, common.ErrCodexOAuthCredentialRequired) {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "当前凭证方式不支持查看用量"})
return
}
common.SysError("failed to parse oauth key: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "解析凭证失败,请检查渠道配置"})
return
}

accessToken := strings.TrimSpace(oauthKey.AccessToken)
accountID := strings.TrimSpace(oauthKey.AccountID)
if accessToken == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "codex channel: access_token is required"})
return
}
if accountID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "codex channel: account_id is required"})
return
}

client, err := service.NewProxyHttpClient(ch.GetSetting().Proxy)
if err != nil {
@@ -98,6 +95,7 @@ func GetCodexChannelUsage(c *gin.Context) {

ctx2, cancel2 := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel2()

statusCode, body, err = service.FetchCodexWhamUsage(ctx2, client, ch.GetBaseURL(), oauthKey.AccessToken, accountID)
if err != nil {
common.SysError("failed to fetch codex usage after refresh: " + err.Error())


+ 128
- 0
controller/email_quota_rule.go View File

@@ -0,0 +1,128 @@
package controller

import (
"strconv"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"

"github.com/gin-gonic/gin"
)

func GetAllEmailQuotaRules(c *gin.Context) {
list, err := model.GetAllEmailQuotaRules()
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, list)
}

type CreateEmailQuotaRuleRequest struct {
EmailSuffix string `json:"email_suffix" binding:"required"`
Quota int64 `json:"quota" binding:"required"`
Enabled *bool `json:"enabled"`
Description string `json:"description"`
}

func CreateEmailQuotaRule(c *gin.Context) {
var req CreateEmailQuotaRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}

existing, _ := model.GetEmailQuotaRuleBySuffix(req.EmailSuffix)
if existing != nil {
common.ApiErrorMsg(c, "该邮箱后缀已存在")
return
}

enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}

rule := &model.EmailQuotaRule{
EmailSuffix: req.EmailSuffix,
Quota: req.Quota,
Enabled: enabled,
Description: req.Description,
}
if err := rule.Insert(); err != nil {
common.ApiError(c, err)
return
}

common.ApiSuccess(c, rule)
}

type UpdateEmailQuotaRuleRequest struct {
EmailSuffix string `json:"email_suffix"`
Quota *int64 `json:"quota"`
Enabled *bool `json:"enabled"`
Description *string `json:"description"`
}

func UpdateEmailQuotaRule(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}

var req UpdateEmailQuotaRuleRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}

rule, err := model.GetEmailQuotaRuleById(id)
if err != nil {
common.ApiError(c, err)
return
}

if req.EmailSuffix != "" && req.EmailSuffix != rule.EmailSuffix {
existing, _ := model.GetEmailQuotaRuleBySuffix(req.EmailSuffix)
if existing != nil && existing.Id != id {
common.ApiErrorMsg(c, "该邮箱后缀已存在")
return
}
rule.EmailSuffix = req.EmailSuffix
}
if req.Quota != nil {
rule.Quota = *req.Quota
}
if req.Enabled != nil {
rule.Enabled = *req.Enabled
}
if req.Description != nil {
rule.Description = *req.Description
}

if err := rule.Update(); err != nil {
common.ApiError(c, err)
return
}

common.ApiSuccess(c, rule)
}

func DeleteEmailQuotaRule(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}

rule := &model.EmailQuotaRule{Id: id}
if err := rule.Delete(); err != nil {
common.ApiError(c, err)
return
}

common.ApiSuccess(c, nil)
}

+ 264
- 0
controller/email_quota_rule_test.go View File

@@ -0,0 +1,264 @@
package controller

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/glebarez/sqlite"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func setupEmailQuotaRuleControllerDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)

origDB := model.DB
model.DB = db
common.UsingSQLite = true
common.RedisEnabled = false

require.NoError(t, db.AutoMigrate(&model.EmailQuotaRule{}))

t.Cleanup(func() {
model.DB = origDB
sqlDB.Close()
})
return db
}

func setupEmailQuotaRuleRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
g := r.Group("/api/email_quota_rule")
{
g.GET("/", GetAllEmailQuotaRules)
g.POST("/", CreateEmailQuotaRule)
g.PUT("/:id", UpdateEmailQuotaRule)
g.DELETE("/:id", DeleteEmailQuotaRule)
}
return r
}

func TestGetAllEmailQuotaRules_Empty(t *testing.T) {
setupEmailQuotaRuleControllerDB(t)
router := setupEmailQuotaRuleRouter()

w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/email_quota_rule/", nil)
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp["success"].(bool))
assert.Empty(t, resp["data"])
}

func TestCreateEmailQuotaRule_Success(t *testing.T) {
setupEmailQuotaRuleControllerDB(t)
router := setupEmailQuotaRuleRouter()

body, _ := json.Marshal(map[string]interface{}{
"email_suffix": "@test.com",
"quota": 500000,
"description": "Test company",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/email_quota_rule/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp["success"].(bool))

data := resp["data"].(map[string]interface{})
assert.Equal(t, "@test.com", data["email_suffix"])
assert.Equal(t, float64(500000), data["quota"])
assert.Equal(t, true, data["enabled"])
}

func TestCreateEmailQuotaRule_Duplicate(t *testing.T) {
setupEmailQuotaRuleControllerDB(t)
router := setupEmailQuotaRuleRouter()

body, _ := json.Marshal(map[string]interface{}{
"email_suffix": "@dup.com",
"quota": 100,
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/email_quota_rule/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
var firstResp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &firstResp))
assert.True(t, firstResp["success"].(bool))

// Second create should fail
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest("POST", "/api/email_quota_rule/", bytes.NewReader(body))
req2.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w2, req2)

var resp2 map[string]interface{}
require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &resp2))
assert.False(t, resp2["success"].(bool))
}

func TestCreateEmailQuotaRule_MissingFields(t *testing.T) {
setupEmailQuotaRuleControllerDB(t)
router := setupEmailQuotaRuleRouter()

body, _ := json.Marshal(map[string]interface{}{
"description": "no suffix",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/email_quota_rule/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.False(t, resp["success"].(bool))
}

func TestUpdateEmailQuotaRule_Success(t *testing.T) {
setupEmailQuotaRuleControllerDB(t)
router := setupEmailQuotaRuleRouter()

// Create first
rule := &model.EmailQuotaRule{EmailSuffix: "@up.com", Quota: 100, Enabled: true}
require.NoError(t, rule.Insert())

body, _ := json.Marshal(map[string]interface{}{
"quota": 999,
"description": "updated desc",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/email_quota_rule/"+itoa(rule.Id), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp["success"].(bool))

data := resp["data"].(map[string]interface{})
assert.Equal(t, float64(999), data["quota"])
assert.Equal(t, "updated desc", data["description"])
}

func TestUpdateEmailQuotaRule_ToggleEnabled(t *testing.T) {
setupEmailQuotaRuleControllerDB(t)
router := setupEmailQuotaRuleRouter()

rule := &model.EmailQuotaRule{EmailSuffix: "@toggle.com", Quota: 500, Enabled: true}
require.NoError(t, rule.Insert())

body, _ := json.Marshal(map[string]interface{}{
"enabled": false,
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/email_quota_rule/"+itoa(rule.Id), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp["success"].(bool))

data := resp["data"].(map[string]interface{})
assert.Equal(t, false, data["enabled"])

// Cache should reflect disabled
assert.Equal(t, int64(-1), model.MatchEmailQuotaRule("user@toggle.com"))
}

func TestDeleteEmailQuotaRule_Success(t *testing.T) {
setupEmailQuotaRuleControllerDB(t)
router := setupEmailQuotaRuleRouter()

rule := &model.EmailQuotaRule{EmailSuffix: "@del.com", Quota: 100, Enabled: true}
require.NoError(t, rule.Insert())

w := httptest.NewRecorder()
req, _ := http.NewRequest("DELETE", "/api/email_quota_rule/"+itoa(rule.Id), nil)
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp["success"].(bool))

// Verify deleted
assert.Equal(t, int64(-1), model.MatchEmailQuotaRule("user@del.com"))
}

func TestCRUD_FullFlow(t *testing.T) {
setupEmailQuotaRuleControllerDB(t)
router := setupEmailQuotaRuleRouter()

// 1. Create
body, _ := json.Marshal(map[string]interface{}{
"email_suffix": "@full.com",
"quota": 1000,
"description": "full flow test",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/email_quota_rule/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
var createResp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &createResp))
assert.True(t, createResp["success"].(bool))

// 2. List
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/api/email_quota_rule/", nil)
router.ServeHTTP(w, req)
var listResp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &listResp))
data := listResp["data"].([]interface{})
assert.Len(t, data, 1)

// 3. Update
ruleId := itoa(int(data[0].(map[string]interface{})["id"].(float64)))
body, _ = json.Marshal(map[string]interface{}{"quota": 2000})
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/api/email_quota_rule/"+ruleId, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
var updateResp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &updateResp))
assert.True(t, updateResp["success"].(bool))

// 4. Verify cache
assert.Equal(t, int64(2000), model.MatchEmailQuotaRule("user@full.com"))

// 5. Delete
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/api/email_quota_rule/"+ruleId, nil)
router.ServeHTTP(w, req)
var delResp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &delResp))
assert.True(t, delResp["success"].(bool))

// 6. Verify cache cleared
assert.Equal(t, int64(-1), model.MatchEmailQuotaRule("user@full.com"))
}

func itoa(i int) string {
return strconv.Itoa(i)
}

+ 10
- 2
controller/log.go View File

@@ -21,7 +21,9 @@ func GetAllLogs(c *gin.Context) {
channel, _ := strconv.Atoi(c.Query("channel"))
group := c.Query("group")
requestId := c.Query("request_id")
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId)
chatId := c.Query("chat_id")
upstreamId := c.Query("upstream_id")
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId, chatId, upstreamId)
if err != nil {
common.ApiError(c, err)
return
@@ -42,11 +44,17 @@ func GetUserLogs(c *gin.Context) {
modelName := c.Query("model_name")
group := c.Query("group")
requestId := c.Query("request_id")
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId)
chatId := c.Query("chat_id")
upstreamId := c.Query("upstream_id")
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId, chatId, upstreamId)
if err != nil {
common.ApiError(c, err)
return
}
for i := range logs {
logs[i].ChatId = ""
logs[i].UpstreamId = ""
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)


+ 94
- 0
controller/log_identity_test.go View File

@@ -0,0 +1,94 @@
package controller

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/glebarez/sqlite"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func setupControllerLogIdentityDB(t *testing.T) *gorm.DB {
t.Helper()

db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)

origLogDB := model.LOG_DB
origSQLite := common.UsingSQLite
origMySQL := common.UsingMySQL
origPostgreSQL := common.UsingPostgreSQL

model.LOG_DB = db
common.UsingSQLite = true
common.UsingMySQL = false
common.UsingPostgreSQL = false

require.NoError(t, db.AutoMigrate(&model.Log{}))

t.Cleanup(func() {
model.LOG_DB = origLogDB
common.UsingSQLite = origSQLite
common.UsingMySQL = origMySQL
common.UsingPostgreSQL = origPostgreSQL
})

return db
}

func TestGetAllLogsPassesChatIDAndUpstreamIDFilters(t *testing.T) {
db := setupControllerLogIdentityDB(t)

require.NoError(t, db.Create(&model.Log{
UserId: 1,
Username: "alice",
CreatedAt: 1714465001,
Type: model.LogTypeConsume,
Content: "usage-a",
ModelName: "gpt-4o-mini",
TokenName: "demo",
RequestId: "req_a",
ChatId: "chat_a",
UpstreamId: "up_a",
}).Error)
require.NoError(t, db.Create(&model.Log{
UserId: 1,
Username: "alice",
CreatedAt: 1714465002,
Type: model.LogTypeConsume,
Content: "usage-b",
ModelName: "gpt-4o-mini",
TokenName: "demo",
RequestId: "req_b",
ChatId: "chat_b",
UpstreamId: "up_b",
}).Error)

gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/log", GetAllLogs)

req := httptest.NewRequest(http.MethodGet, "/api/log?type=2&chat_id=chat_a&upstream_id=up_a", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

require.Equal(t, http.StatusOK, w.Code)

var payload struct {
Success bool `json:"success"`
Data struct {
Items []model.Log `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
require.True(t, payload.Success)
require.Len(t, payload.Data.Items, 1)
require.Equal(t, "chat_a", payload.Data.Items[0].ChatId)
require.Equal(t, "up_a", payload.Data.Items[0].UpstreamId)
}

+ 74
- 5
controller/misc.go View File

@@ -61,12 +61,13 @@ func GetStatus(c *gin.Context) {
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_bot_name": common.TelegramBotName,
"system_name": common.SystemName,
"logo": common.Logo,
"logo": common.GetEffectiveLogo(),
"footer_html": common.Footer,
"wechat_qrcode": common.WeChatAccountQRCodeImageURL,
"wechat_login": common.WeChatAuthEnabled,
"server_address": system_setting.ServerAddress,
"turnstile_check": common.TurnstileCheckEnabled,
"captcha_enabled": common.CaptchaEnabled,
"turnstile_site_key": common.TurnstileSiteKey,
"top_up_link": common.TopUpLink,
"docs_link": operation_setting.GetGeneralSetting().DocsLink,
@@ -87,6 +88,7 @@ func GetStatus(c *gin.Context) {
"demo_site_enabled": operation_setting.DemoSiteEnabled,
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
"default_language": common.DefaultLanguage,

"usd_exchange_rate": operation_setting.USDExchangeRate,
"price": operation_setting.Price,
@@ -113,8 +115,10 @@ func GetStatus(c *gin.Context) {
"passkey_user_verification": passkeySetting.UserVerification,
"passkey_attachment": passkeySetting.AttachmentPreference,
"setup": constant.Setup,
"user_agreement_enabled": legalSetting.UserAgreement != "",
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
"user_agreement_enabled": legalSetting.UserAgreementZh != "" || legalSetting.UserAgreementEn != "",
"privacy_policy_enabled": legalSetting.PrivacyPolicyZh != "" || legalSetting.PrivacyPolicyEn != "",
"terms_enabled": legalSetting.TermsOfServiceZh != "" || legalSetting.TermsOfServiceEn != "",
"usage_policy_enabled": legalSetting.UsagePolicyZh != "" || legalSetting.UsagePolicyEn != "",
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
"_qn": "new-api",
}
@@ -188,20 +192,53 @@ func GetAbout(c *gin.Context) {
return
}

func getLegalContent(zh, en string, lang string) string {
if lang == "en" {
return en
}
return zh
}

func GetUserAgreement(c *gin.Context) {
ls := system_setting.GetLegalSettings()
lang := c.Query("lang")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": system_setting.GetLegalSettings().UserAgreement,
"data": getLegalContent(ls.UserAgreementZh, ls.UserAgreementEn, lang),
})
return
}

func GetPrivacyPolicy(c *gin.Context) {
ls := system_setting.GetLegalSettings()
lang := c.Query("lang")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": getLegalContent(ls.PrivacyPolicyZh, ls.PrivacyPolicyEn, lang),
})
return
}

func GetTermsOfService(c *gin.Context) {
ls := system_setting.GetLegalSettings()
lang := c.Query("lang")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": getLegalContent(ls.TermsOfServiceZh, ls.TermsOfServiceEn, lang),
})
return
}

func GetUsagePolicy(c *gin.Context) {
ls := system_setting.GetLegalSettings()
lang := c.Query("lang")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": system_setting.GetLegalSettings().PrivacyPolicy,
"data": getLegalContent(ls.UsagePolicyZh, ls.UsagePolicyEn, lang),
})
return
}
@@ -228,7 +265,39 @@ func GetHomePageContent(c *gin.Context) {
return
}

func GetCaptcha(c *gin.Context) {
if !common.CaptchaEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "验证码功能未启用",
})
return
}
id, b64s, err := common.GenerateCaptcha()
if err != nil {
common.ApiErrorMsg(c, "生成验证码失败")
return
}
common.ApiSuccess(c, gin.H{
"id": id,
"captcha_image": b64s,
})
}

func SendEmailVerification(c *gin.Context) {
if common.CaptchaEnabled {
captchaId := c.Query("captcha_id")
captchaCode := c.Query("captcha_code")
if captchaId == "" || captchaCode == "" {
common.ApiErrorMsg(c, "请先完成图片验证码")
return
}
if !common.VerifyCaptcha(captchaId, captchaCode) {
common.ApiErrorMsg(c, "图片验证码错误或已过期")
return
}
}

email := c.Query("email")
if err := common.Validate.Var(email, "required,email"); err != nil {
c.JSON(http.StatusOK, gin.H{


+ 10
- 0
controller/playground.go View File

@@ -4,9 +4,11 @@ import (
"errors"
"fmt"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/types"

"github.com/gin-gonic/gin"
@@ -54,3 +56,11 @@ func Playground(c *gin.Context) {

Relay(c, types.RelayFormatOpenAI)
}

// GetPlaygroundConfig 获取 Playground 公开配置(无需认证)
func GetPlaygroundConfig(c *gin.Context) {
setting := operation_setting.GetPlaygroundSetting()
common.ApiSuccess(c, gin.H{
"mutual_exclusive_params": setting.MutualExclusiveParams,
})
}

+ 62
- 0
controller/playground_channels.go View File

@@ -0,0 +1,62 @@
package controller

import (
"net/http"

"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)

func GetModelChannels(c *gin.Context) {
modelName := c.Query("model")
if modelName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "model parameter is required",
})
return
}

userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "unauthorized",
})
return
}

userCache, err := model.GetUserCache(userId)
if err != nil || userCache == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "failed to get user info",
})
return
}
userGroup := userCache.Group
if userGroup == "" {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "failed to get user group",
})
return
}

channels, defaultChannelId, err := model.GetModelChannelsForGroup(modelName, userGroup)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": "failed to query channels",
})
return
}

c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"channels": channels,
"default_channel_id": defaultChannelId,
},
})
}

+ 112
- 0
controller/pricing.go View File

@@ -1,6 +1,10 @@
package controller

import (
"fmt"
"math"
"strings"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
@@ -9,6 +13,25 @@ import (
"github.com/gin-gonic/gin"
)

// resolveCurrentUser 从 gin.Context 解析当前登录用户信息。
// 未登录或查询失败时 loggedIn=false,groupRatio=1.0。
func resolveCurrentUser(c *gin.Context) (loggedIn bool, userId int, userGroup string, groupRatio float64) {
groupRatio = 1.0
raw, exists := c.Get("id")
if !exists {
return
}
userId = raw.(int)
user, err := model.GetUserCache(userId)
if err != nil {
return
}
loggedIn = true
userGroup = user.Group
groupRatio = service.GetUserGroupRatio(userGroup, userGroup)
return
}

func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing {
if len(pricing) == 0 {
return pricing
@@ -76,6 +99,95 @@ func GetPricing(c *gin.Context) {
})
}

// GetUserPricing 获取用户对指定模型的价格信息(原价 vs 用户价)
func GetUserPricing(c *gin.Context) {
modelName := c.Param("model")
// *model 通配符返回值带前导 /,需要去掉
modelName = strings.TrimPrefix(modelName, "/")
if modelName == "" {
common.ApiErrorMsg(c, "模型名不能为空")
return
}

pricingData := model.GetPricingByModel(modelName)
if pricingData == nil {
common.ApiErrorMsg(c, "未找到该模型的定价信息")
return
}

loggedIn, userId, userGroup, groupRatio := resolveCurrentUser(c)
if !loggedIn {
respondOriginalPrice(c, pricingData)
return
}

bestUserChannelRatio := model.GetBestUserChannelRatio(userId, modelName)
totalRatio := groupRatio * bestUserChannelRatio
savingsPercent := int(math.Round((1 - totalRatio) * 100))

result := gin.H{
"success": true,
"model_name": pricingData.ModelName,
"quota_type": pricingData.QuotaType,
"group": userGroup,
"group_ratio": groupRatio,
"user_channel_ratio": bestUserChannelRatio,
"savings_percent": savingsPercent,
"logged_in": true,
}

if pricingData.QuotaType == model.QuotaTypeByTokens {
originalInput := pricingData.ModelRatio * 2
originalOutput := pricingData.ModelRatio * pricingData.CompletionRatio * 2
result["original_input"] = originalInput
result["original_output"] = originalOutput
result["user_input"] = originalInput * totalRatio
result["user_output"] = originalOutput * totalRatio
} else {
result["original_price"] = pricingData.ModelPrice
result["user_price"] = pricingData.ModelPrice * totalRatio
}

if savingsPercent > 0 {
result["discount"] = formatDiscount(totalRatio)
}

c.JSON(200, result)
}

// respondOriginalPrice 未登录或用户查询失败时,只返回原价
func respondOriginalPrice(c *gin.Context, pricingData *model.Pricing) {
result := gin.H{
"success": true,
"model_name": pricingData.ModelName,
"quota_type": pricingData.QuotaType,
"logged_in": false,
}
if pricingData.QuotaType == model.QuotaTypeByTokens {
result["original_input"] = pricingData.ModelRatio * 2
result["original_output"] = pricingData.ModelRatio * pricingData.CompletionRatio * 2
} else {
result["original_price"] = pricingData.ModelPrice
}
c.JSON(200, result)
}

// formatDiscount 将倍率转换为中文折扣格式
func formatDiscount(ratio float64) string {
if ratio <= 0 {
return "免费"
}
discount := int(math.Round(ratio * 10))
if discount >= 10 {
return ""
}
remainder := int(math.Round(ratio*100)) % 10
if remainder == 0 {
return fmt.Sprintf("%d折", discount)
}
return fmt.Sprintf("%.1f折", ratio*10)
}

func ResetModelRatio(c *gin.Context) {
defaultStr := ratio_setting.DefaultModelRatio2JSONString()
err := model.UpdateOption("ModelRatio", defaultStr)


+ 410
- 0
controller/pricing_user_test.go View File

@@ -0,0 +1,410 @@
package controller

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/glebarez/sqlite"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

// setupPricingTestDB 初始化测试数据库
func setupPricingTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)

origDB := model.DB
model.DB = db
common.UsingSQLite = true
common.RedisEnabled = false

require.NoError(t, db.AutoMigrate(&model.UserChannelRatio{}, &model.User{}))

t.Cleanup(func() {
model.DB = origDB
sqlDB.Close()
})
return db
}

// setupPricingTestRouter 创建无认证路由(未登录场景)
func setupPricingTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/pricing/user/:model", GetUserPricing)
return r
}

// setupAuthRouter 创建带用户 ID 注入的路由(已登录场景)
func setupAuthRouter(userID int) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/api/pricing/user/:model", func(c *gin.Context) {
c.Set("id", userID)
c.Next()
}, GetUserPricing)
return r
}

// setPricingCache 直接设置定价缓存用于测试
func setPricingCache(pricing []model.Pricing) {
model.SetTestPricing(pricing)
}

// withGroupRatio 临时设置分组倍率,测试结束后恢复
func withGroupRatio(t *testing.T, jsonStr string) {
t.Helper()
original := ratio_setting.GetGroupRatioCopy()
ratio_setting.UpdateGroupRatioByJSONString(jsonStr)
t.Cleanup(func() {
origJSON, _ := json.Marshal(original)
ratio_setting.UpdateGroupRatioByJSONString(string(origJSON))
})
}

// ---- 测试用例 ----

// TestGetUserPricing_ModelNotFound 模型不存在时应返回错误
func TestGetUserPricing_ModelNotFound(t *testing.T) {
setupPricingTestDB(t)
router := setupPricingTestRouter()
setPricingCache([]model.Pricing{})

w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/pricing/user/nonexistent-model", nil)
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.False(t, resp["success"].(bool))
assert.Contains(t, resp["message"], "未找到")
}

// TestGetUserPricing_NotLoggedIn 未登录用户应只返回原价
func TestGetUserPricing_NotLoggedIn(t *testing.T) {
setupPricingTestDB(t)
router := setupPricingTestRouter()

setPricingCache([]model.Pricing{
{
ModelName: "gpt-4o",
QuotaType: 0,
ModelRatio: 15,
CompletionRatio: 4,
EnableGroup: []string{"default", "vip"},
},
})

w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil)
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp["success"].(bool))
assert.Equal(t, false, resp["logged_in"])
assert.Equal(t, "gpt-4o", resp["model_name"])
assert.Equal(t, float64(0), resp["quota_type"])

// 验证原价: model_ratio * 2 = 15 * 2 = 30
assert.Equal(t, float64(30), resp["original_input"])
// 输出原价: model_ratio * completion_ratio * 2 = 15 * 4 * 2 = 120
assert.Equal(t, float64(120), resp["original_output"])

// 不应有用户价字段
_, hasUserInput := resp["user_input"]
assert.False(t, hasUserInput)
}

// TestGetUserPricing_NotLoggedIn_PerCall 按次计费模型,未登录
func TestGetUserPricing_NotLoggedIn_PerCall(t *testing.T) {
setupPricingTestDB(t)
router := setupPricingTestRouter()

setPricingCache([]model.Pricing{
{
ModelName: "dall-e-3",
QuotaType: 1,
ModelPrice: 0.04,
EnableGroup: []string{"default"},
},
})

w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/pricing/user/dall-e-3", nil)
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp["success"].(bool))
assert.Equal(t, false, resp["logged_in"])
assert.Equal(t, float64(0.04), resp["original_price"])
}

// TestGetUserPricing_LoggedIn_NoDiscount 已登录但无折扣(分组倍率=1,无个人倍率)
func TestGetUserPricing_LoggedIn_NoDiscount(t *testing.T) {
db := setupPricingTestDB(t)

setPricingCache([]model.Pricing{
{
ModelName: "gpt-4o",
QuotaType: 0,
ModelRatio: 15,
CompletionRatio: 4,
EnableGroup: []string{"default", "vip"},
},
})

// 创建测试用户(default 分组,默认倍率为 1)
user := &model.User{Id: 100, Group: "default", Username: "testuser", Status: 1}
require.NoError(t, db.Create(user).Error)

router := setupAuthRouter(100)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil)
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp["success"].(bool))
assert.Equal(t, true, resp["logged_in"])
// default 分组默认倍率为 1,无折扣
assert.Equal(t, float64(0), resp["savings_percent"])
}

// TestGetUserPricing_LoggedIn_GroupDiscount 已登录,有分组折扣
func TestGetUserPricing_LoggedIn_GroupDiscount(t *testing.T) {
db := setupPricingTestDB(t)

setPricingCache([]model.Pricing{
{
ModelName: "gpt-4o",
QuotaType: 0,
ModelRatio: 15,
CompletionRatio: 4,
EnableGroup: []string{"default", "vip"},
},
})

// 创建 VIP 用户
user := &model.User{Id: 200, Group: "vip", Username: "vipuser", Status: 1}
require.NoError(t, db.Create(user).Error)

// 设置 VIP 分组倍率为 0.8
withGroupRatio(t, `{"default":1,"vip":0.8}`)

router := setupAuthRouter(200)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil)
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp["success"].(bool))
assert.Equal(t, true, resp["logged_in"])
assert.Equal(t, "vip", resp["group"])
assert.Equal(t, float64(0.8), resp["group_ratio"])

// 用户价 = 原价 * 0.8
// 输入: 30 * 0.8 = 24
assert.Equal(t, float64(24), resp["user_input"])
// 输出: 120 * 0.8 = 96
assert.Equal(t, float64(96), resp["user_output"])

assert.Equal(t, float64(20), resp["savings_percent"])
assert.Equal(t, "8折", resp["discount"])
}

// TestGetUserPricing_LoggedIn_UserChannelRatio 已登录,有用户渠道倍率
func TestGetUserPricing_LoggedIn_UserChannelRatio(t *testing.T) {
db := setupPricingTestDB(t)

setPricingCache([]model.Pricing{
{
ModelName: "gpt-4o",
QuotaType: 0,
ModelRatio: 15,
CompletionRatio: 4,
EnableGroup: []string{"default"},
},
})

// 创建用户(default 分组,倍率为1)
user := &model.User{Id: 300, Group: "default", Username: "specialuser", Status: 1}
require.NoError(t, db.Create(user).Error)

// 插入用户渠道倍率
ucr := &model.UserChannelRatio{
UserId: 300,
ModelName: "gpt-4o",
ChannelId: 1,
Ratio: 0.9,
}
require.NoError(t, ucr.Insert())

router := setupAuthRouter(300)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil)
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp["success"].(bool))
assert.Equal(t, true, resp["logged_in"])
// group_ratio=1 * user_channel_ratio=0.9 = 0.9
assert.Equal(t, float64(0.9), resp["user_channel_ratio"])
assert.Equal(t, float64(10), resp["savings_percent"])
// 输入用户价: 30 * 0.9 = 27
assert.Equal(t, float64(27), resp["user_input"])
}

// TestGetUserPricing_LoggedIn_BothDiscounts 分组倍率 + 用户渠道倍率叠加
func TestGetUserPricing_LoggedIn_BothDiscounts(t *testing.T) {
db := setupPricingTestDB(t)

setPricingCache([]model.Pricing{
{
ModelName: "gpt-4o",
QuotaType: 0,
ModelRatio: 15,
CompletionRatio: 4,
EnableGroup: []string{"default", "vip"},
},
})

user := &model.User{Id: 400, Group: "vip", Username: "bothdiscount", Status: 1}
require.NoError(t, db.Create(user).Error)

// VIP 分组倍率 0.8
withGroupRatio(t, `{"default":1,"vip":0.8}`)

// 用户渠道倍率 0.9
ucr := &model.UserChannelRatio{
UserId: 400,
ModelName: "gpt-4o",
ChannelId: 1,
Ratio: 0.9,
}
require.NoError(t, ucr.Insert())

router := setupAuthRouter(400)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil)
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp["success"].(bool))
assert.Equal(t, true, resp["logged_in"])
// total = 0.8 * 0.9 = 0.72, savings = 28%
assert.Equal(t, float64(28), resp["savings_percent"])
// 输入: 30 * 0.72 = 21.6
assert.Equal(t, float64(21.6), resp["user_input"])
// 输出: 120 * 0.72 = 86.4
assert.Equal(t, float64(86.4), resp["user_output"])
}

// TestGetUserPricing_PerCall_WithDiscount 按次计费 + 折扣
func TestGetUserPricing_PerCall_WithDiscount(t *testing.T) {
db := setupPricingTestDB(t)

setPricingCache([]model.Pricing{
{
ModelName: "dall-e-3",
QuotaType: 1,
ModelPrice: 0.04,
EnableGroup: []string{"default", "vip"},
},
})

user := &model.User{Id: 500, Group: "vip", Username: "percallvip", Status: 1}
require.NoError(t, db.Create(user).Error)

withGroupRatio(t, `{"default":1,"vip":0.5}`)

router := setupAuthRouter(500)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/pricing/user/dall-e-3", nil)
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp["success"].(bool))
assert.Equal(t, float64(0.04), resp["original_price"])
assert.Equal(t, float64(0.02), resp["user_price"])
assert.Equal(t, float64(50), resp["savings_percent"])
assert.Equal(t, "5折", resp["discount"])
}

// TestFormatDiscount 折扣格式化测试
func TestFormatDiscount(t *testing.T) {
tests := []struct {
ratio float64
expected string
}{
{0.5, "5折"},
{0.8, "8折"},
{0.9, "9折"},
{0.85, "8.5折"},
{0.75, "7.5折"},
{0.95, "9.5折"},
{1.0, ""},
{0.0, "免费"},
}
for _, tt := range tests {
result := formatDiscount(tt.ratio)
assert.Equal(t, tt.expected, result, "ratio=%.2f", tt.ratio)
}
}

// TestGetUserPricing_MultipleUserChannelRatios 多个渠道倍率取最低值
func TestGetUserPricing_MultipleUserChannelRatios(t *testing.T) {
db := setupPricingTestDB(t)

setPricingCache([]model.Pricing{
{
ModelName: "gpt-4o",
QuotaType: 0,
ModelRatio: 15,
CompletionRatio: 4,
EnableGroup: []string{"default"},
},
})

user := &model.User{Id: 600, Group: "default", Username: "multichannel", Status: 1}
require.NoError(t, db.Create(user).Error)

// 多个渠道倍率,取最低值 0.7
for i, ratio := range []float64{0.9, 0.7, 0.8} {
ucr := &model.UserChannelRatio{
UserId: 600,
ModelName: "gpt-4o",
ChannelId: i + 1,
Ratio: ratio,
}
require.NoError(t, ucr.Insert())
}

router := setupAuthRouter(600)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil)
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp["success"].(bool))
// 应取最低倍率 0.7
assert.Equal(t, float64(0.7), resp["user_channel_ratio"])
assert.Equal(t, float64(30), resp["savings_percent"])
}

+ 2
- 0
controller/redemption.go View File

@@ -87,6 +87,7 @@ func AddRedemption(c *gin.Context) {
cleanRedemption := model.Redemption{
UserId: c.GetInt("id"),
Name: redemption.Name,
Remark: redemption.Remark,
Key: key,
CreatedTime: common.GetTimestamp(),
Quota: redemption.Quota,
@@ -146,6 +147,7 @@ func UpdateRedemption(c *gin.Context) {
}
// If you add more fields, please also update redemption.Update()
cleanRedemption.Name = redemption.Name
cleanRedemption.Remark = redemption.Remark
cleanRedemption.Quota = redemption.Quota
cleanRedemption.ExpiredTime = redemption.ExpiredTime
}


+ 159
- 0
controller/redemption_test.go View File

@@ -0,0 +1,159 @@
package controller

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/glebarez/sqlite"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func setupRedemptionControllerDB(t *testing.T) *gorm.DB {
t.Helper()

db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)

sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)

origDB := model.DB
origLogDB := model.LOG_DB
model.DB = db
model.LOG_DB = db
common.UsingSQLite = true
common.RedisEnabled = false

require.NoError(t, db.AutoMigrate(&model.User{}, &model.Redemption{}, &model.Log{}))

t.Cleanup(func() {
model.DB = origDB
model.LOG_DB = origLogDB
_ = sqlDB.Close()
})

return db
}

func setupRedemptionRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("id", 1)
c.Next()
})

g := r.Group("/api/redemption")
g.POST("/", AddRedemption)
g.PUT("/", UpdateRedemption)

return r
}

func TestAddRedemptionStoresRemark(t *testing.T) {
db := setupRedemptionControllerDB(t)
router := setupRedemptionRouter()

body, err := json.Marshal(map[string]interface{}{
"name": "campaign-a",
"remark": "admin only note",
"quota": 500000,
"count": 1,
"expired_time": 0,
})
require.NoError(t, err)

req := httptest.NewRequest(http.MethodPost, "/api/redemption/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)

var rows []model.Redemption
require.NoError(t, db.Find(&rows).Error)
require.Len(t, rows, 1)
assert.Equal(t, "campaign-a", rows[0].Name)
assert.Equal(t, "admin only note", rows[0].Remark)
}

func TestUpdateRedemptionStoresRemark(t *testing.T) {
db := setupRedemptionControllerDB(t)
router := setupRedemptionRouter()

row := model.Redemption{
Id: 1,
UserId: 1,
Key: "update-remark-key",
Name: "campaign-b",
Remark: "before update",
Status: common.RedemptionCodeStatusEnabled,
Quota: 500000,
CreatedTime: common.GetTimestamp(),
}
require.NoError(t, db.Create(&row).Error)

body, err := json.Marshal(map[string]interface{}{
"id": 1,
"name": "campaign-b",
"remark": "after update",
"quota": 500000,
"expired_time": 0,
})
require.NoError(t, err)

req := httptest.NewRequest(http.MethodPut, "/api/redemption/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)

var stored model.Redemption
require.NoError(t, db.First(&stored, 1).Error)
assert.Equal(t, "after update", stored.Remark)
}

func TestUpdateRedemptionStatusOnlyKeepsRemark(t *testing.T) {
db := setupRedemptionControllerDB(t)
router := setupRedemptionRouter()

row := model.Redemption{
Id: 1,
UserId: 1,
Key: "status-only-remark-key",
Name: "campaign-c",
Remark: "keep this remark",
Status: common.RedemptionCodeStatusEnabled,
Quota: 500000,
CreatedTime: common.GetTimestamp(),
}
require.NoError(t, db.Create(&row).Error)

body, err := json.Marshal(map[string]interface{}{
"id": 1,
"status": common.RedemptionCodeStatusDisabled,
"remark": "should not overwrite",
})
require.NoError(t, err)

req := httptest.NewRequest(http.MethodPut, "/api/redemption/?status_only=true", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)

var stored model.Redemption
require.NoError(t, db.First(&stored, 1).Error)
assert.Equal(t, common.RedemptionCodeStatusDisabled, stored.Status)
assert.Equal(t, "keep this remark", stored.Remark)
}

+ 6
- 0
controller/relay.go View File

@@ -369,6 +369,12 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
other["channel_id"] = channelId
other["channel_name"] = c.GetString("channel_name")
other["channel_type"] = c.GetInt("channel_type")
if err.UpstreamRequestId != "" {
other["upstream_request_id"] = err.UpstreamRequestId
}
if err.UpstreamBody != "" {
other["upstream_body"] = err.UpstreamBody
}
adminInfo := make(map[string]interface{})
adminInfo["use_channel"] = c.GetStringSlice("use_channel")
isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey)


+ 55
- 0
controller/reorder.go View File

@@ -0,0 +1,55 @@
package controller

import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"

"github.com/gin-gonic/gin"
)

// ReorderVendors 批量更新供应商排序
func ReorderVendors(c *gin.Context) {
var req struct {
Items []struct {
Id int `json:"id"`
SortOrder int `json:"sort_order"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
if len(req.Items) == 0 {
common.ApiErrorMsg(c, "items 不能为空")
return
}
if err := model.ReorderVendors(req.Items); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}

// ReorderModels 批量更新模型排序
func ReorderModels(c *gin.Context) {
var req struct {
Items []struct {
Id int `json:"id"`
SortOrder int `json:"sort_order"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
if len(req.Items) == 0 {
common.ApiErrorMsg(c, "items 不能为空")
return
}
if err := model.ReorderModels(req.Items); err != nil {
common.ApiError(c, err)
return
}
model.RefreshPricing()
common.ApiSuccess(c, nil)
}

+ 42
- 20
controller/topup.go View File

@@ -72,25 +72,25 @@ func GetTopUpInfo(c *gin.Context) {
payMethods = append(payMethods, wechatMethod)
}
}
// 如果启用了支付宝支付,添加到支付方法列表
if setting.IsAlipayConfigured() {
hasAlipay := false
for _, method := range payMethods {
if method["type"] == PaymentMethodAlipay {
hasAlipay = true
break
}
// 如果启用了支付宝支付,添加到支付方法列表
if setting.IsAlipayConfigured() {
hasAlipay := false
for _, method := range payMethods {
if method["type"] == PaymentMethodAlipay {
hasAlipay = true
break
}
if !hasAlipay {
alipayMethod := map[string]string{
"name": "Alipay",
"type": PaymentMethodAlipay,
"color": "rgba(var(--semi-blue-5), 1)",
"min_topup": strconv.Itoa(setting.AlipayMinTopUp),
}
payMethods = append(payMethods, alipayMethod)
}
if !hasAlipay {
alipayMethod := map[string]string{
"name": "Alipay",
"type": PaymentMethodAlipay,
"color": "rgba(var(--semi-blue-5), 1)",
"min_topup": strconv.Itoa(setting.AlipayMinTopUp),
}
payMethods = append(payMethods, alipayMethod)
}
}

data := gin.H{
"enable_online_topup": enableOnlineTopup,
@@ -164,15 +164,37 @@ func getPayMoney(amount int64, group string) float64 {
}

func getMinTopup() int64 {
minTopup := operation_setting.MinTopUp
return calcMinTopup(operation_setting.MinTopUp)
}

// calcMinTopup 计算最低充值数量(考虑 QuotaDisplayType 换算)
func calcMinTopup(baseMinTopup int) int64 {
minTopup := baseMinTopup
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
dMinTopup := decimal.NewFromInt(int64(minTopup))
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart())
minTopup = minTopup * int(common.QuotaPerUnit)
}
return int64(minTopup)
}

// calcPayMoney 计算应付金额(元),使用指定的单价和最低充值
func calcPayMoney(amount float64, group string, unitPrice float64) float64 {
originalAmount := amount
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
amount = amount / common.QuotaPerUnit
}
topupGroupRatio := common.GetTopupGroupRatio(group)
if topupGroupRatio == 0 {
topupGroupRatio = 1
}
discount := 1.0
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok {
if ds > 0 {
discount = ds
}
}
return amount * unitPrice * topupGroupRatio * discount
}

func RequestEpay(c *gin.Context) {
var req EpayRequest
err := c.ShouldBindJSON(&req)


+ 10
- 63
controller/topup_alipay.go View File

@@ -3,19 +3,16 @@ package controller
import (
"context"
"encoding/base64"
"encoding/pem"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"

"github.com/gin-gonic/gin"
"github.com/go-pay/gopay"
@@ -64,8 +61,11 @@ func getAlipayClient() (*alipay.Client, error) {
SetNotifyUrl(setting.AlipayNotifyURL)

// 设置支付宝公钥(用于回调验签)
pubKeyPEM := wrapAlipayPublicKey(setting.AlipayPublicKey)
client.AutoVerifySign([]byte(pubKeyPEM))
pubKeyBytes, err := base64.StdEncoding.DecodeString(setting.AlipayPublicKey)
if err != nil {
pubKeyBytes = []byte(setting.AlipayPublicKey)
}
client.AutoVerifySign([]byte(wrapAsPEM(pubKeyBytes, "PUBLIC KEY")))

alipayClient = client
return alipayClient, nil
@@ -107,7 +107,7 @@ func RequestAlipayPayAmount(c *gin.Context) {
return
}

minTopup := getAlipayMinTopup()
minTopup := calcMinTopup(setting.AlipayMinTopUp)
if req.Amount < minTopup {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", minTopup)})
return
@@ -120,7 +120,7 @@ func RequestAlipayPayAmount(c *gin.Context) {
return
}

payMoney := getAlipayPayMoney(float64(req.Amount), group)
payMoney := calcPayMoney(float64(req.Amount), group, setting.AlipayUnitPrice)
if payMoney <= 0.01 {
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
return
@@ -137,7 +137,7 @@ func RequestAlipayPay(c *gin.Context) {
return
}

minTopup := getAlipayMinTopup()
minTopup := calcMinTopup(setting.AlipayMinTopUp)
if req.Amount < minTopup {
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", minTopup)})
return
@@ -175,8 +175,8 @@ func RequestAlipayPay(c *gin.Context) {

tradeNo := fmt.Sprintf("ali%d%s", time.Now().UnixMilli(), randstr.String(8))

payMoney := getAlipayPayMoney(float64(req.Amount), group)
totalAmount := strconv.FormatFloat(payMoney, 'f', 2, 64) // 支付宝金额单位是元
payMoney := calcPayMoney(float64(req.Amount), group, setting.AlipayUnitPrice)
totalAmount := strconv.FormatFloat(payMoney, 'f', 2, 64)

qrCode, err := createAlipayPrecreateOrder(client, fmt.Sprintf("充值%d", req.Amount), tradeNo, totalAmount)
if err != nil {
@@ -222,7 +222,6 @@ func AlipayPayStatus(c *gin.Context) {
return
}

// 验证订单属于当前用户
userId := c.GetInt("id")
if topUp.UserId != userId {
c.JSON(200, gin.H{"message": "error", "data": "订单不存在"})
@@ -261,7 +260,6 @@ func AlipayPayWebhook(c *gin.Context) {

tradeStatus := notifyReq.Get("trade_status")
if tradeStatus != "TRADE_SUCCESS" {
log.Printf("支付宝回调非成功状态: %s", tradeStatus)
c.String(http.StatusOK, "success")
return
}
@@ -280,54 +278,3 @@ func AlipayPayWebhook(c *gin.Context) {
log.Printf("支付宝充值成功: %s", tradeNo)
c.String(http.StatusOK, "success")
}

// getAlipayPayMoney 计算支付宝应付金额(元)
func getAlipayPayMoney(amount float64, group string) float64 {
originalAmount := amount
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
amount = amount / common.QuotaPerUnit
}
topupGroupRatio := common.GetTopupGroupRatio(group)
if topupGroupRatio == 0 {
topupGroupRatio = 1
}
discount := 1.0
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok {
if ds > 0 {
discount = ds
}
}
payMoney := amount * setting.AlipayUnitPrice * topupGroupRatio * discount
return payMoney
}

// getAlipayMinTopup 获取支付宝最低充值数量
func getAlipayMinTopup() int64 {
minTopup := setting.AlipayMinTopUp
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
minTopup = minTopup * int(common.QuotaPerUnit)
}
return int64(minTopup)
}

// wrapAlipayPublicKey 将支付宝公钥包装为 PEM 格式
// 输入可能是:原始 Base64 字符串 或 已有 PEM 格式
func wrapAlipayPublicKey(pubKey string) string {
if strings.Contains(pubKey, "-----BEGIN") {
return pubKey
}
// 去除空白字符
cleaned := strings.ReplaceAll(pubKey, "\n", "")
cleaned = strings.ReplaceAll(cleaned, "\r", "")
cleaned = strings.TrimSpace(cleaned)
// Base64 解码为 DER 字节
derBytes, err := base64.StdEncoding.DecodeString(cleaned)
if err != nil {
// 如果解码失败,原样返回让上层报错
return pubKey
}
return string(pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: derBytes,
}))
}

+ 2
- 24
controller/topup_wechat.go View File

@@ -18,7 +18,6 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/operation_setting"

"github.com/gin-gonic/gin"
"github.com/go-pay/gopay"
@@ -355,31 +354,10 @@ func WechatPayWebhook(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": "SUCCESS", "message": "成功"})
}

// getWechatPayMoney 计算微信支付应付金额(元)
func getWechatPayMoney(amount float64, group string) float64 {
originalAmount := amount
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
amount = amount / common.QuotaPerUnit
}
topupGroupRatio := common.GetTopupGroupRatio(group)
if topupGroupRatio == 0 {
topupGroupRatio = 1
}
discount := 1.0
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok {
if ds > 0 {
discount = ds
}
}
payMoney := amount * setting.WechatPayUnitPrice * topupGroupRatio * discount
return payMoney
return calcPayMoney(amount, group, setting.WechatPayUnitPrice)
}

// getWechatMinTopup 获取微信支付最低充值数量
func getWechatMinTopup() int64 {
minTopup := setting.WechatPayMinTopUp
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
minTopup = minTopup * int(common.QuotaPerUnit)
}
return int64(minTopup)
return calcMinTopup(setting.WechatPayMinTopUp)
}

+ 5
- 1
controller/user.go View File

@@ -270,6 +270,7 @@ func GetUser(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
return
}
user.ApplySyncedQuota()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
@@ -523,13 +524,16 @@ func GetUserModels(c *gin.Context) {
}
groups := service.GetUserUsableGroups(user.Group)
var models []string
seen := make(map[string]struct{})
for group := range groups {
for _, g := range model.GetGroupEnabledModels(group) {
if !common.StringsContains(models, g) {
if _, ok := seen[g]; !ok {
seen[g] = struct{}{}
models = append(models, g)
}
}
}
models = common.StringsSubtract(models, model.GetDisabledModelNames(models))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",


+ 116
- 0
controller/user_channel_ratio.go View File

@@ -0,0 +1,116 @@
package controller

import (
"net/http"
"strconv"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"

"github.com/gin-gonic/gin"
)

func GetUserChannelRatios(c *gin.Context) {
userIdStr := c.Param("user_id")
userId, err := strconv.Atoi(userIdStr)
if err != nil {
common.ApiError(c, err)
return
}

ratios, err := model.GetUserChannelRatiosByUserId(userId)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": ratios,
})
}

type createUserChannelRatioRequest struct {
UserId int `json:"user_id"`
ModelName string `json:"model_name"`
ChannelId int `json:"channel_id"`
Ratio float64 `json:"ratio"`
}

func CreateUserChannelRatio(c *gin.Context) {
var req createUserChannelRatioRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}

if req.Ratio <= 0 {
common.ApiErrorMsg(c, "ratio must be positive")
return
}

ucr := &model.UserChannelRatio{
UserId: req.UserId,
ModelName: req.ModelName,
ChannelId: req.ChannelId,
Ratio: req.Ratio,
}
if err := ucr.Insert(); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": ucr,
})
}

func UpdateUserChannelRatio(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}

var req struct {
Ratio float64 `json:"ratio"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
if req.Ratio <= 0 {
common.ApiErrorMsg(c, "ratio must be positive")
return
}

ucr := &model.UserChannelRatio{
Id: id,
Ratio: req.Ratio,
}
if err := ucr.Update(); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": ucr,
})
}

func DeleteUserChannelRatio(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}

if err := model.DeleteUserChannelRatioById(id); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
})
}

BIN
current-page View File

Before After
Width: 2074  |  Height: 1574  |  Size: 96 KiB

+ 1
- 0
docs/DATABASE_SCHEMA.md View File

@@ -189,6 +189,7 @@
| key | string | 兑换码(32字符,唯一) |
| status | int | 状态:1=启用,2=已使用,3=已禁用 |
| name | string | 兑换码名称 |
| remark | string | 备注 |
| quota | int | 额度值 |
| created_time | int64 | 创建时间 |
| redeemed_time | int64 | 兑换时间 |


+ 307
- 0
docs/superpowers/plans/2026-04-17-default-language-setting.md View File

@@ -0,0 +1,307 @@
# Default Language Setting Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Allow admin to configure a global default language in System Settings, which is forced for unauthenticated users and logged-in users without a personal language preference.

**Architecture:** Backend adds a `DefaultLanguage` option to the existing flat-key option system (same pattern as `SystemName`, `ServerAddress`). The value flows to frontend via `/api/status`. Frontend applies it in two places: `PageLayout.jsx` (for unauthenticated users) and `UserContext` (for logged-in users without preference).

**Tech Stack:** Go (backend), React + Semi UI (frontend), i18next (i18n)

**Spec:** `docs/superpowers/specs/2026-04-17-default-language-setting-design.md`

---

## File Map

| File | Action | Responsibility |
|------|--------|---------------|
| `common/constants.go` | Modify | Declare `DefaultLanguage` variable |
| `model/option.go` | Modify | Handle `DefaultLanguage` in option update switch |
| `controller/misc.go` | Modify | Expose `default_language` in `/api/status` response |
| `web/src/components/settings/SystemSetting.jsx` | Modify | Admin UI: language dropdown in system settings |
| `web/src/components/layout/PageLayout.jsx` | Modify | Apply default language for unauthenticated users |
| `web/src/context/User/index.jsx` | Modify | Fall back to default language when user has no preference |

---

### Task 1: Backend — Add DefaultLanguage variable and option handling

**Files:**
- Modify: `common/constants.go:17-18` (after `TopUpLink` declaration)
- Modify: `model/option.go:457-458` (after `Logo` case in `updateOptionMap` switch)

- [ ] **Step 1: Add `DefaultLanguage` variable to `common/constants.go`**

In `common/constants.go`, after line 18 (`var TopUpLink = ""`), add:

```go
var DefaultLanguage = "" // admin-configured default language; empty = follow browser detection
```

- [ ] **Step 2: Add case handler in `model/option.go`**

In `model/option.go`, after the `case "Logo":` block (line 457-458), add:

```go
case "DefaultLanguage":
common.DefaultLanguage = value
```

- [ ] **Step 3: Verify the Go code compiles**

Run: `go build ./...`
Expected: no errors

- [ ] **Step 4: Commit**

```bash
git add common/constants.go model/option.go
git commit -m "feat(default-language): add DefaultLanguage variable and option handler"
```

---

### Task 2: Backend — Expose default_language in /api/status

**Files:**
- Modify: `controller/misc.go:89` (after `"default_use_auto_group"` line)

- [ ] **Step 1: Add `default_language` field to GetStatus response**

In `controller/misc.go`, inside `GetStatus()`, after the line containing `"default_use_auto_group": setting.DefaultUseAutoGroup,` (line 89), add:

```go
"default_language": common.DefaultLanguage,
```

Place it right before the blank line at line 90, aligned with the surrounding entries.

- [ ] **Step 2: Verify the Go code compiles**

Run: `go build ./...`
Expected: no errors

- [ ] **Step 3: Commit**

```bash
git add controller/misc.go
git commit -m "feat(default-language): expose default_language in /api/status response"
```

---

### Task 3: Frontend — Add DefaultLanguage dropdown in System Settings

**Files:**
- Modify: `web/src/components/settings/SystemSetting.jsx`

- [ ] **Step 1: Add `DefaultLanguage` to `inputs` state**

In `web/src/components/settings/SystemSetting.jsx`, in the `inputs` useState (around line 49-112), add `DefaultLanguage: ''` after `ServerAddress: ''` (line 102):

```javascript
ServerAddress: '',
DefaultLanguage: '',
```

- [ ] **Step 2: Add `submitDefaultLanguage` function**

After `submitServerAddress` (line 315-318), add a new function:

```javascript
const submitDefaultLanguage = async () => {
await updateOptions([{ key: 'DefaultLanguage', value: inputs.DefaultLanguage || '' }]);
};
```

- [ ] **Step 3: Add language dropdown UI in the 通用设置 Card**

In the `通用设置` `Form.Section` (line 715-733), inside the `<Row>` block, after the `ServerAddress` `<Col>` closing tag (line 728) and before the `</Row>` closing tag (line 729), add a new `<Col>`:

```jsx
<Col xs={24} sm={24} md={24} lg={12} xl={12}>
<Form.Select
field='DefaultLanguage'
label={t('默认语言')}
placeholder={t('未设置时跟随浏览器语言')}
optionList={[
{ label: t('自动(跟随浏览器)'), value: '' },
{ label: '简体中文', value: 'zh-CN' },
{ label: '繁體中文', value: 'zh-TW' },
{ label: 'English', value: 'en' },
{ label: 'Français', value: 'fr' },
{ label: '日本語', value: 'ja' },
{ label: 'Русский', value: 'ru' },
{ label: 'Tiếng Việt', value: 'vi' },
]}
extraText={t(
'设置后,未登录用户和未设置语言偏好的已登录用户将强制使用此语言',
)}
/>
</Col>
```

Also change the `ServerAddress` Col from `md={24} lg={24} xl={24}` to `md={24} lg={12} xl={12}` so both fields sit side by side on large screens.

- [ ] **Step 4: Add save button for DefaultLanguage**

After the existing `submitServerAddress` button (line 730-732), add:

```jsx
<Button onClick={submitDefaultLanguage}>
{t('保存默认语言')}
</Button>
```

- [ ] **Step 5: Verify frontend compiles**

Run: `cd web && bun run build`
Expected: build succeeds

- [ ] **Step 6: Commit**

```bash
git add web/src/components/settings/SystemSetting.jsx
git commit -m "feat(default-language): add language dropdown in system settings"
```

---

### Task 4: Frontend — Apply default language for unauthenticated users

**Files:**
- Modify: `web/src/components/layout/PageLayout.jsx:87-100` (loadStatus function)

- [ ] **Step 1: Modify `loadStatus` to apply default language**

In `web/src/components/layout/PageLayout.jsx`, in the `loadStatus` function (line 87-100), after `setStatusData(data)` (line 93) and before the `} else {` (line 94), add:

```javascript
// Apply admin-configured default language for unauthenticated users
if (data.default_language && !localStorage.getItem('user')) {
i18n.changeLanguage(data.default_language);
}
```

- [ ] **Step 2: Update the existing localStorage language fallback logic**

In the same file, the existing `useEffect` (line 102-120) reads `localStorage.getItem('i18nextLng')` and calls `i18n.changeLanguage(savedLang)`. This logic should be kept as-is — it handles the case where `default_language` is not set (admin chose "auto"). No changes needed to this block.

- [ ] **Step 3: Verify frontend compiles**

Run: `cd web && bun run build`
Expected: build succeeds

- [ ] **Step 4: Commit**

```bash
git add web/src/components/layout/PageLayout.jsx
git commit -m "feat(default-language): apply default language for unauthenticated users"
```

---

### Task 5: Frontend — Fall back to default language for logged-in users without preference

**Files:**
- Modify: `web/src/context/User/index.jsx:20-45`

- [ ] **Step 1: Import StatusContext**

At the top of `web/src/context/User/index.jsx`, add `StatusContext` import after the existing imports:

```javascript
import { StatusContext } from '../Status';
```

- [ ] **Step 2: Access StatusContext inside UserProvider**

Inside `UserProvider` (line 29), before the `useEffect`, add:

```javascript
const [statusState] = React.useContext(StatusContext);
```

- [ ] **Step 3: Update language sync logic**

Replace the existing `useEffect` (lines 34-45) with:

```javascript
// Sync language preference when user data is loaded
useEffect(() => {
if (state.user?.setting) {
try {
const settings = JSON.parse(state.user.setting);
if (settings.language && settings.language !== i18n.language) {
i18n.changeLanguage(settings.language);
} else if (!settings.language && statusState.status?.default_language) {
// No personal preference — fall back to admin default
i18n.changeLanguage(statusState.status.default_language);
}
} catch (e) {
// Ignore parse errors
}
}
}, [state.user?.setting, statusState.status?.default_language, i18n]);
```

- [ ] **Step 4: Verify frontend compiles**

Run: `cd web && bun run build`
Expected: build succeeds

- [ ] **Step 5: Commit**

```bash
git add web/src/context/User/index.jsx
git commit -m "feat(default-language): fall back to default language for users without preference"
```

---

### Task 6: Integration verification

- [ ] **Step 1: Start full-stack dev server**

Terminal 1: `cd web && bun run dev`
Terminal 2: `go run main.go`

- [ ] **Step 2: Test admin setting**

1. Login as admin, navigate to Settings → System Settings (系统设置)
2. Find the "默认语言" dropdown in the 通用设置 section
3. Select "English" and click "保存默认语言"
4. Verify success toast appears
5. Refresh the page — the dropdown should still show "English"

- [ ] **Step 3: Test unauthenticated user behavior**

1. Open an incognito/private browser window
2. Visit the site
3. Expected: The page renders in English (not following browser language)
4. The header language selector still shows English as active

- [ ] **Step 4: Test logged-in user with personal preference**

1. Login as a regular user
2. Go to personal settings, set language to "Français"
3. Refresh — page should be in French (personal preference overrides admin default)

- [ ] **Step 5: Test logged-in user without personal preference**

1. Login as a user who has never set a language preference
2. Expected: Page renders in English (admin default), not browser language

- [ ] **Step 6: Test "auto" mode**

1. As admin, set "默认语言" back to "自动(跟随浏览器)"
2. Save, then test in incognito — should follow browser language again

- [ ] **Step 7: Verify /api/status returns the field**

```bash
curl -s http://localhost:3000/api/status | python -m json.tool | grep default_language
```

Expected: `"default_language": "en"` (or the set value, or `""` if auto)

+ 114
- 0
docs/superpowers/specs/2026-04-17-default-language-setting-design.md View File

@@ -0,0 +1,114 @@
---
created: 2026-04-17
status: approved
scope: backend + frontend
files: 6
---

# 后台设置默认语言

> 管理员可在系统设置中配置全局默认语言,影响未登录用户和未设置语言偏好的已登录用户。

## 需求

- 管理员在**系统设置**页面配置一个「默认语言」
- **未登录用户**:强制使用管理员设定的默认语言,忽略浏览器语言检测
- **已登录但未设置语言偏好的用户**:使用管理员设定的默认语言
- **已登录且已设置语言偏好的用户**:使用个人设置的语言(不受默认语言影响)
- 支持的语言:zh-CN、zh-TW、en、fr、ja、ru、vi
- 可选"自动(跟随浏览器)"选项,等于未设定,保持现有行为

## 语言优先级

```
1. 用户个人设置语言(已登录 + 已设置语言偏好)
2. 管理员设定的默认语言(强制使用,忽略浏览器语言)
3. zh-CN(fallbackLng)
```

## 后端改动

### 1. `common/constants.go`

新增变量:

```go
var DefaultLanguage = "" // 空字符串=未设定,保持现有浏览器检测行为
```

### 2. `model/option.go`

在 `updateOptionMap` 的 switch 中新增 case:

```go
case "DefaultLanguage":
common.DefaultLanguage = value
```

### 3. `controller/misc.go` — `GetStatus()`

在 status 响应 JSON 中新增字段:

```go
"default_language": common.DefaultLanguage,
```

## 前端改动

### 4. `web/src/components/settings/SystemSetting.jsx`

- `inputs` state 新增 `DefaultLanguage: ''`
- 在系统设置表单中添加一个 `Select` 下拉框
- 选项:7 种语言 + 空字符串选项("自动 / 跟随浏览器")
- 与其他系统设置一起通过 `/api/option/` 保存

### 5. `web/src/components/layout/PageLayout.jsx`

在 `loadStatus` 回调中,获取 `data.default_language` 后:

```javascript
if (data.default_language && data.default_language !== '') {
const savedLang = localStorage.getItem('i18nextLng');
// 仅在没有用户个人设置语言时应用默认语言
// 注意:UserContext 会在用户加载后覆盖此设置
if (!localStorage.getItem('user')) {
i18n.changeLanguage(data.default_language);
}
}
```

同时保留现有的 `localStorage.getItem('i18nextLng')` 逻辑作为 fallback。

### 6. `web/src/context/User/index.jsx`

调整语言同步逻辑:

```javascript
// 当前:仅在有 settings.language 时切换
// 新增:无 settings.language 时,回退到 status 中的 default_language
if (settings.language) {
i18n.changeLanguage(settings.language);
} else if (statusDefaultLanguage) {
i18n.changeLanguage(statusDefaultLanguage);
}
```

需要从 StatusContext 获取 `default_language` 值。

## 涉及文件

| 文件 | 改动类型 | 改动量 |
|------|---------|-------|
| `common/constants.go` | 新增变量 | +1 行 |
| `model/option.go` | 新增 switch case | +2 行 |
| `controller/misc.go` | 新增 status 字段 | +1 行 |
| `web/src/components/settings/SystemSetting.jsx` | inputs + 表单 UI | ~30 行 |
| `web/src/components/layout/PageLayout.jsx` | loadStatus 后应用默认语言 | ~5 行 |
| `web/src/context/User/index.jsx` | 无偏好时回退到默认语言 | ~5 行 |

## 不涉及

- 不修改 i18n.js 的初始化配置
- 不修改 LanguageSelector 组件
- 不添加新的 API 端点
- 不修改数据库 schema(使用现有 options 表)

+ 236
- 0
docs/superpowers/specs/2026-04-30-log-chatid-upstreamid-design.md View File

@@ -0,0 +1,236 @@
# Log Chat ID And Upstream ID Design

## Summary

This design adds two new usage-log fields:

- `chat_id`: extracted from the incoming request body
- `upstream_id`: extracted from the upstream response headers

The existing `logs.request_id` field keeps its current meaning and continues to represent the internal new-api request ID.

The extraction logic is centralized and writes normalized values into relay context first, then both consume logs and error logs read from the same context when persisting to `logs`.

## Problem

The current system has only one stable request identifier in usage logs: the internal `logs.request_id`.

For reconciliation work, that is not enough:

- the business request may already carry a `chat_id`
- the upstream provider may return its own request ID in response headers

Today these values are not recorded consistently in consume logs. Error logs have partial upstream request-id support, but consume logs do not have a unified path.

## Goals

- Persist request-body `chat_id` into usage logs
- Persist upstream response request ID into usage logs
- Keep success and error logs consistent
- Keep the extraction logic generic enough to support future upstream header changes
- Keep streaming support without buffering the full stream body
- Keep performance impact negligible

## Non-Goals

- No change to the meaning of existing `logs.request_id`
- No backfill of existing historical logs
- No first-version parsing of response body IDs
- No provider-specific per-channel logging branches unless the generic extractor cannot cover them

## Canonical Field Semantics

The `logs` table will have three distinct request identifiers:

- `request_id`: internal new-api request ID, already existing
- `chat_id`: business ID extracted from the incoming request body
- `upstream_id`: upstream request ID extracted from response headers

Field semantics must not overlap.

## Data Model

Add two nullable string columns to `logs`:

- `chat_id`
- `upstream_id`

Constraints:

- both fields default to empty string
- both fields use ordinary indexes
- neither field is unique

Reasoning:

- reconciliation queries need direct filtering and export
- uniqueness cannot be guaranteed across providers or retries

## Extraction Architecture

### 1. Request-side extraction

When the request body is parsed into the relay request object, the system performs a best-effort extraction of a top-level `chat_id`.

Behavior:

- extract only the business-level request `chat_id`
- do not deeply traverse nested objects
- do not fail the request if `chat_id` is missing
- store the result in relay context as `RelayInfo.ChatID`

### 2. Response-side extraction

When an upstream `http.Response` is received, before body consumption begins, the system performs a best-effort extraction of the upstream request ID and stores it in `RelayInfo.UpstreamID`.

First-version default rule chain:

1. `x-request-id`
2. `request-id`

This rule chain is intentionally generic. The extractor should be implemented as a reusable module with:

- a default candidate-header chain
- a future extension point for provider or channel-specific overrides

### 3. Persistence

Consume logs and error logs do not parse request bodies or response headers directly.

They only read:

- internal `request_id`
- `RelayInfo.ChatID`
- `RelayInfo.UpstreamID`

and persist them into `logs`.

This keeps extraction and logging decoupled.

## Data Flow

### Non-stream requests

1. request enters gateway
2. internal request ID is created as today
3. request parser extracts `chat_id`
4. relay runs normally
5. upstream response is received
6. response extractor reads `x-request-id` / `request-id`
7. final consume log or error log persists `request_id`, `chat_id`, `upstream_id`

### Stream requests

1. request parser extracts `chat_id` before relay starts
2. upstream response headers are received before stream body forwarding
3. response extractor reads `upstream_id` from headers
4. stream body is forwarded as usual
5. final consume log or error log persists `request_id`, `chat_id`, `upstream_id`

No per-chunk ID parsing is required in the first version.

## Retry Semantics

Retries must not collapse multiple upstream attempts into one combined ID set.

Rules:

- consume log records only the final successful attempt's `upstream_id`
- each failed attempt may still produce its own error log with its own `upstream_id`
- the final consume log should not store a list of retry upstream IDs

This preserves clean reconciliation semantics for billed requests.

## Performance Requirements

The design must not materially affect relay throughput or stream latency.

Allowed work:

- read response headers once
- best-effort request-side `chat_id` extraction while request parsing already happens

Forbidden first-version approaches:

- buffering the full response body only to find IDs
- parsing every SSE chunk to search for IDs
- adding extra database writes per request

Expected impact:

- request-side `chat_id` extraction: negligible, because request parsing already occurs
- response-side `upstream_id` extraction: negligible, because headers are already available
- database overhead: one existing log insert with two extra fields

## Error Handling

The feature is best-effort only.

Rules:

- missing `chat_id` does not fail the request
- missing upstream header does not fail the request
- malformed request payload does not add extra parsing failure beyond current validation behavior
- logs may store empty values for either field

## Query And UI Scope

First version should support:

- backend filtering by `chat_id`
- backend filtering by `upstream_id`
- frontend display in log detail or an equivalent low-risk display path

The first version does not need both fields as default main table columns.

## Testing Scope

### Backend tests

- non-stream success: request body includes `chat_id`, upstream returns `x-request-id`, consume log stores both
- stream success: request body includes `chat_id`, upstream stream response includes `x-request-id`, consume log stores both
- error response: error log stores both when available
- retry then success: consume log stores only the final successful `upstream_id`
- missing fields: request still succeeds or fails normally, log fields remain empty
- header fallback: if `x-request-id` is absent and `request-id` exists, `upstream_id` is still stored

### Regression focus

- no change to existing `logs.request_id` behavior
- no extra stream buffering
- no change to billing semantics

## Implementation Notes

Recommended implementation shape:

- extend `relay/common.RelayInfo` with `ChatID` and `UpstreamID`
- add a small extraction helper for request-side `chat_id`
- add a reusable response-header extractor for upstream IDs
- extend `model.Log` and log persistence functions to carry `chat_id` and `upstream_id`
- update log query/filter surfaces to support the two new fields

## Risks

- some providers may later rename or stop returning `x-request-id`
- some request formats may not include top-level `chat_id`
- stream and retry paths can drift if the extractor is not wired at a shared layer

Mitigation:

- use one shared response extractor with a candidate chain
- use one shared relay context as the single source of truth
- test both normal and retry flows explicitly

## Decision Summary

Approved design decisions:

- keep `logs.request_id` unchanged as the internal request ID
- add `logs.chat_id`
- add `logs.upstream_id`
- extract `chat_id` from the request body
- extract `upstream_id` from response headers, defaulting to `x-request-id` and falling back to `request-id`
- centralize extraction into shared relay context
- support both stream and non-stream requests without full-body buffering
- record only the final successful `upstream_id` in consume logs during retry scenarios

+ 41
- 0
docs/superpowers/specs/2026-05-08-cascading-channel-model-selector-design.md View File

@@ -0,0 +1,41 @@
---
created: 2026-05-08
status: approved
---

# Cascading Channel-Model Selector for User Ratio

## Goal

Improve the "Add Ratio" modal in UserRatioSection: select channel first, then pick a model from that channel's supported models via dropdown instead of manual text input.

## Scope

Single file: `web/src/components/table/users/modals/UserRatioSection.jsx`

No backend changes — the existing `/api/channel/` endpoint already returns the `models` field (comma-separated string) for each channel.

## Design

### Data flow

1. `loadChannels()` fetches channel list, stores two things:
- `channelOptions`: `[{ label, value }]` for the channel dropdown (existing)
- `channelMap`: `{ [channelId]: channelObject }` for looking up `models` by channel ID (new)
2. When user selects a channel in the form:
- Parse `channel.models` by comma into an array
- Set `modelOptions` to `[{ label: model, value: model }]` for each model
- Clear any previously selected model value
3. Model dropdown uses `modelOptions` with `filter` and `showClear`, disabled when no channel is selected

### Form field order

1. Channel (Select, with search) — existing
2. Model (Select, with search) — changed from Input to Select
3. Ratio (InputNumber) — existing

### What does NOT change

- Backend API and data model
- Table columns and display
- Submit payload format (`model_name`, `channel_id`, `ratio`)

+ 1
- 0
dto/openai_response.go View File

@@ -375,6 +375,7 @@ const (
type ResponsesStreamResponse struct {
Type string `json:"type"`
Response *OpenAIResponsesResponse `json:"response,omitempty"`
Error any `json:"error,omitempty"`
Delta string `json:"delta,omitempty"`
Item *ResponsesOutput `json:"item,omitempty"`
// - response.function_call_arguments.delta


+ 2
- 0
go.mod View File

@@ -94,6 +94,7 @@ require (
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-webauthn/x v0.1.25 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
@@ -117,6 +118,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mojocn/base64Captcha v1.3.8 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.1 // indirect


+ 57
- 0
go.sum View File

@@ -131,11 +131,14 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
@@ -223,6 +226,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mojocn/base64Captcha v1.3.8 h1:rrN9BhCwXKS8ht1e21kvR3iTaMgf4qPC9sRoV52bqEg=
github.com/mojocn/base64Captcha v1.3.8/go.mod h1:QFZy927L8HVP3+VV5z2b1EAEiv1KxVJKZbAucVgLUy4=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
@@ -324,6 +329,7 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c h1:xA2TJS9Hu/ivzaZIrDcwvpJ3Fnpsk5fDOJ4iSnL6J0w=
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c/go.mod h1:WSZ59bidJOO40JSJmLqlkBJrjZCtjbKKkygEMfzY/kc=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -332,21 +338,46 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw=
golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -355,21 +386,47 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=


+ 5
- 3
logger/logger.go View File

@@ -79,9 +79,11 @@ func logHelper(ctx context.Context, level string, msg string) {
if level == loggerINFO {
writer = gin.DefaultWriter
}
id := ctx.Value(common.RequestIdKey)
if id == nil {
id = "SYSTEM"
id := "SYSTEM"
if ctx != nil {
if v := ctx.Value(common.RequestIdKey); v != nil {
id = fmt.Sprintf("%v", v)
}
}
now := time.Now()
_, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg)


BIN
login-page View File

Before After
Width: 1034  |  Height: 785  |  Size: 80 KiB

+ 10
- 0
main.go View File

@@ -137,6 +137,16 @@ func main() {
model.InitBatchUpdater()
}

logoFilePath := os.Getenv("LOGO_FILE_PATH")
if logoFilePath != "" {
if _, err := os.Stat(logoFilePath); err != nil {
common.SysLog("LOGO_FILE_PATH file not found: " + logoFilePath + ", falling back to default")
} else {
common.LogoFilePath = logoFilePath
common.SysLog("custom logo file: " + common.LogoFilePath)
}
}

if os.Getenv("ENABLE_PPROF") == "true" {
gopool.Go(func() {
log.Println(http.ListenAndServe("0.0.0.0:8005", nil))


+ 62
- 14
middleware/distributor.go View File

@@ -23,19 +23,20 @@ import (
)

type ModelRequest struct {
Model string `json:"model"`
Group string `json:"group,omitempty"`
Model string `json:"model"`
Group string `json:"group,omitempty"`
ChannelId int `json:"channel_id,omitempty"`
}

func Distribute() func(c *gin.Context) {
return func(c *gin.Context) {
var channel *model.Channel
channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId)
modelRequest, shouldSelectChannel, err := getModelRequest(c)
if err != nil {
abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()}))
return
}
channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId)
if ok {
id, err := strconv.Atoi(channelId.(string))
if err != nil {
@@ -107,25 +108,61 @@ func Distribute() func(c *gin.Context) {
}
}

if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
preferred, err := model.CacheGetChannel(preferredChannelID)
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
if usingGroup == "auto" {
// 默认通道检查(管理员配置的模型默认通道)
if channel == nil {
if defaultChannelId, ok := model.GetDefaultChannelId(modelRequest.Model); ok {
defaultCh, err := model.CacheGetChannel(defaultChannelId)
if err != nil || defaultCh == nil {
common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not found in cache, fallback", modelRequest.Model, defaultChannelId))
} else if defaultCh.Status != common.ChannelStatusEnabled {
common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d disabled(status=%d), fallback", modelRequest.Model, defaultChannelId, defaultCh.Status))
} else if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup)
for _, g := range autoGroups {
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, defaultCh.Id) {
channel = defaultCh
selectGroup = g
common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
channel = preferred
service.MarkChannelAffinityUsed(c, g, preferred.Id)
common.SysLog(fmt.Sprintf("[Distribute] model=%s using default_channel=%d (auto group=%s)", modelRequest.Model, defaultChannelId, g))
break
}
}
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
channel = preferred
if channel == nil {
common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not enabled for any auto group, fallback", modelRequest.Model, defaultChannelId))
}
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, defaultCh.Id) {
channel = defaultCh
selectGroup = usingGroup
service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
common.SysLog(fmt.Sprintf("[Distribute] model=%s using default_channel=%d (group=%s)", modelRequest.Model, defaultChannelId, usingGroup))
} else {
common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not enabled for group=%s, fallback", modelRequest.Model, defaultChannelId, usingGroup))
}
}
}

// 通道亲和性检查(仅在未选中默认通道时生效)
if channel == nil {
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
preferred, err := model.CacheGetChannel(preferredChannelID)
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
if usingGroup == "auto" {
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
autoGroups := service.GetUserAutoGroup(userGroup)
for _, g := range autoGroups {
if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) {
selectGroup = g
common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
channel = preferred
service.MarkChannelAffinityUsed(c, g, preferred.Id)
break
}
}
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
channel = preferred
selectGroup = usingGroup
service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
}
}
}
}
@@ -330,8 +367,19 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
return nil, false, err
}
modelRequest.Model = req.Model
modelRequest.Group = req.Group
// group: body 优先,fallback 到 header
if req.Group != "" {
modelRequest.Group = req.Group
} else if g := c.GetHeader("X-Group"); g != "" {
modelRequest.Group = g
}
common.SetContextKey(c, constant.ContextKeyTokenGroup, modelRequest.Group)
// channel_id: body 优先,fallback 到 header
if req.ChannelId > 0 {
common.SetContextKey(c, constant.ContextKeyTokenSpecificChannelId, strconv.Itoa(req.ChannelId))
} else if ch := c.GetHeader("X-Channel-Id"); ch != "" {
common.SetContextKey(c, constant.ContextKeyTokenSpecificChannelId, ch)
}
}

if strings.HasPrefix(c.Request.URL.Path, "/v1/responses/compact") && modelRequest.Model != "" {


+ 306
- 0
mockup-pricing-A.html View File

@@ -0,0 +1,306 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>方案A:简洁划线对比</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }

.panel {
max-width: 580px;
margin: 40px auto;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
overflow: hidden;
}

.panel-header {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px 16px;
border-bottom: 1px solid #f0f0f0;
}

.model-icon {
width: 48px; height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 18px;
}

.model-name { font-size: 18px; font-weight: 600; }
.model-desc { font-size: 13px; color: #888; margin-top: 2px; }

.section { padding: 16px 24px; }
.section-title {
display: flex; align-items: center; gap: 8px;
margin-bottom: 12px;
}
.section-title .icon {
width: 28px; height: 28px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; color: #fff;
}
.section-title .icon.orange { background: #fa8c16; }
.section-title .icon.cyan { background: #13c2c2; }
.section-title h3 { font-size: 15px; font-weight: 600; }
.section-title small { font-size: 12px; color: #999; }

/* ===== 方案A核心样式:简洁划线对比 ===== */
.price-comparison {
background: linear-gradient(135deg, #fff7e6 0%, #fff 100%);
border: 1px solid #ffe7ba;
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 12px;
}

.group-tag {
display: inline-flex; align-items: center; gap: 4px;
background: #722ed1; color: #fff;
padding: 2px 10px; border-radius: 10px;
font-size: 12px; font-weight: 500;
margin-bottom: 12px;
}

.price-row {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 8px;
}

.price-label {
font-size: 13px;
color: #666;
min-width: 56px;
}

.original-price {
font-size: 14px;
color: #bbb;
text-decoration: line-through;
text-decoration-color: #d48806;
text-decoration-thickness: 2px;
}

.discount-price {
font-size: 22px;
font-weight: 700;
color: #cf1322;
}

.discount-badge {
display: inline-flex; align-items: center;
background: linear-gradient(135deg, #cf1322, #a8071a);
color: #fff;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-left: 8px;
}

.savings-line {
font-size: 12px;
color: #52c41a;
margin-top: 4px;
display: flex; align-items: center; gap: 4px;
}

.price-unit {
font-size: 12px;
color: #999;
margin-left: 4px;
}

.divider {
height: 1px;
background: #f0f0f0;
margin: 0 24px;
}

/* 表格样式 */
.channel-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.channel-table th {
text-align: left;
padding: 8px 0;
color: #999;
font-weight: 500;
font-size: 12px;
border-bottom: 1px solid #f0f0f0;
}
.channel-table td {
padding: 10px 0;
border-bottom: 1px solid #f5f5f5;
}
.channel-table tr:last-child td { border-bottom: none; }
.channel-tag {
display: inline-block;
padding: 1px 8px;
border-radius: 8px;
font-size: 11px;
margin-right: 4px;
}
.channel-tag.cyan { background: #e6fffb; color: #13c2c2; }
.channel-tag.violet { background: #f9f0ff; color: #722ed1; }
.channel-tag.amber { background: #fff7e6; color: #d48806; }
.channel-tag.green { background: #f6ffed; color: #52c41a; }
.price-orange { color: #d4380d; font-weight: 600; }

/* 方案标签 */
.scheme-label {
text-align: center;
padding: 12px;
background: #667eea;
color: #fff;
font-weight: 600;
font-size: 16px;
letter-spacing: 1px;
}
.scheme-sub {
font-size: 12px;
font-weight: 400;
opacity: 0.85;
}
</style>
</head>
<body>

<div class="scheme-label">
方案A:简洁划线对比
<div class="scheme-sub">原价划掉 + 折后价突出 + 折扣标签</div>
</div>

<div class="panel">
<!-- 模型头部 -->
<div class="panel-header">
<div class="model-icon">G4</div>
<div>
<div class="model-name">gpt-4o</div>
<div class="model-desc">OpenAI 最新多模态模型,支持文本和视觉</div>
</div>
</div>

<!-- 用户分组价格区域 -->
<div class="section">
<div class="section-title">
<div class="icon orange">$</div>
<div>
<h3>您的分组价格</h3>
<small>基于您所在分组的专属价格</small>
</div>
</div>

<div class="price-comparison">
<div class="group-tag">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
VIP 分组
</div>

<!-- 输入价格 -->
<div class="price-row">
<span class="price-label">输入价格</span>
<span class="original-price">¥0.0300</span>
<span class="discount-price">¥0.0240</span>
<span class="discount-badge">8折</span>
<span class="price-unit">/ 1K tokens</span>
</div>
<div class="savings-line">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#52c41a" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
每千 tokens 节省 ¥0.006
</div>

<!-- 输出价格 -->
<div class="price-row" style="margin-top: 12px;">
<span class="price-label">输出价格</span>
<span class="original-price">¥0.0600</span>
<span class="discount-price">¥0.0480</span>
<span class="discount-badge">8折</span>
<span class="price-unit">/ 1K tokens</span>
</div>
<div class="savings-line">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#52c41a" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
每千 tokens 节省 ¥0.012
</div>
</div>

<!-- 如果是按次计费的模型 -->
<div class="price-comparison" style="background: linear-gradient(135deg, #f0f5ff 0%, #fff 100%); border-color: #adc6ff;">
<div style="font-size: 12px; color: #999; margin-bottom: 8px;">按次计费模型示例</div>
<div class="group-tag" style="background: #1890ff;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
SVIP 分组
</div>
<div class="price-row">
<span class="price-label">每次调用</span>
<span class="original-price">¥0.0500</span>
<span class="discount-price">¥0.0350</span>
<span class="discount-badge">7折</span>
<span class="price-unit">/ 次</span>
</div>
<div class="savings-line">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#52c41a" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
每次调用节省 ¥0.015
</div>
</div>
</div>

<div class="divider"></div>

<!-- 通道价格 -->
<div class="section">
<div class="section-title">
<div class="icon cyan">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<div>
<h3>通道价格</h3>
<small>所有支持该模型的通道价格</small>
</div>
</div>

<table class="channel-table">
<thead>
<tr>
<th>通道</th>
<th>计费类型</th>
<th>输入价格</th>
<th>输出价格</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<span class="channel-tag cyan">OpenAI 官方</span>
<span class="channel-tag amber">默认</span>
</td>
<td><span class="channel-tag violet">按量计费</span></td>
<td class="price-orange">¥0.0240<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
<td class="price-orange">¥0.0480<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
</tr>
<tr>
<td>
<span class="channel-tag cyan">Azure 中转</span>
<span class="channel-tag green">高速</span>
</td>
<td><span class="channel-tag violet">按量计费</span></td>
<td class="price-orange">¥0.0280<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
<td class="price-orange">¥0.0560<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
</tr>
</tbody>
</table>
</div>
</div>

</body>
</html>

+ 289
- 0
mockup-pricing-B.html View File

@@ -0,0 +1,289 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>方案B:双卡片对比</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }

.panel {
max-width: 580px;
margin: 40px auto;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
overflow: hidden;
}

.panel-header {
display: flex; align-items: center; gap: 12px;
padding: 20px 24px 16px;
border-bottom: 1px solid #f0f0f0;
}

.model-icon {
width: 48px; height: 48px; border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 18px;
}
.model-name { font-size: 18px; font-weight: 600; }
.model-desc { font-size: 13px; color: #888; margin-top: 2px; }

.section { padding: 16px 24px; }
.section-title {
display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
}
.section-title .icon {
width: 28px; height: 28px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; color: #fff;
}
.section-title .icon.orange { background: #fa8c16; }
.section-title .icon.cyan { background: #13c2c2; }
.section-title h3 { font-size: 15px; font-weight: 600; }
.section-title small { font-size: 12px; color: #999; }

.divider { height: 1px; background: #f0f0f0; margin: 0 24px; }

/* ===== 方案B核心样式:双卡片对比 ===== */
.price-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}

.price-card {
border-radius: 12px;
padding: 16px;
position: relative;
overflow: hidden;
}

.price-card.standard {
background: #fafafa;
border: 1px solid #e8e8e8;
}

.price-card.discounted {
background: linear-gradient(135deg, #f6ffed 0%, #d9f7be 100%);
border: 2px solid #52c41a;
}

.price-card .card-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
margin-bottom: 8px;
}

.price-card.standard .card-label { color: #999; }
.price-card.discounted .card-label { color: #389e0d; }

.price-card .card-group {
font-size: 12px;
color: #888;
margin-bottom: 12px;
}

.price-card.discounted .card-group {
color: #389e0d;
}

.price-item {
margin-bottom: 8px;
}
.price-item:last-child { margin-bottom: 0; }
.price-item-label {
font-size: 11px;
color: #999;
margin-bottom: 2px;
}
.price-item-value {
font-weight: 700;
font-size: 18px;
}
.price-card.standard .price-item-value { color: #bbb; }
.price-card.discounted .price-item-value { color: #237804; }

.price-unit {
font-size: 11px;
font-weight: 400;
opacity: 0.6;
}

.savings-banner {
background: linear-gradient(90deg, #52c41a, #73d13d);
color: #fff;
border-radius: 8px;
padding: 8px 16px;
text-align: center;
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}

.savings-amount {
font-size: 18px;
font-weight: 700;
}

/* 通道表格 */
.channel-table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
.channel-table th {
text-align: left; padding: 8px 0; color: #999;
font-weight: 500; font-size: 12px;
border-bottom: 1px solid #f0f0f0;
}
.channel-table td {
padding: 10px 0; border-bottom: 1px solid #f5f5f5;
}
.channel-table tr:last-child td { border-bottom: none; }
.channel-tag {
display: inline-block; padding: 1px 8px; border-radius: 8px;
font-size: 11px; margin-right: 4px;
}
.channel-tag.cyan { background: #e6fffb; color: #13c2c2; }
.channel-tag.violet { background: #f9f0ff; color: #722ed1; }
.channel-tag.amber { background: #fff7e6; color: #d48806; }
.channel-tag.green { background: #f6ffed; color: #52c41a; }
.price-orange { color: #d4380d; font-weight: 600; }

/* 方案标签 */
.scheme-label {
text-align: center; padding: 12px;
background: #52c41a; color: #fff;
font-weight: 600; font-size: 16px; letter-spacing: 1px;
}
.scheme-sub { font-size: 12px; font-weight: 400; opacity: 0.85; }

/* 节省箭头 */
.arrow-down {
display: flex; justify-content: center; margin: 8px 0;
}
.arrow-down svg { color: #52c41a; }
</style>
</head>
<body>

<div class="scheme-label">
方案B:双卡片对比
<div class="scheme-sub">标准价格 vs 用户价格 左右对比,突出节省金额</div>
</div>

<div class="panel">
<div class="panel-header">
<div class="model-icon">G4</div>
<div>
<div class="model-name">gpt-4o</div>
<div class="model-desc">OpenAI 最新多模态模型</div>
</div>
</div>

<div class="section">
<div class="section-title">
<div class="icon orange">$</div>
<div>
<h3>分组价格对比</h3>
<small>VIP 分组专属折扣</small>
</div>
</div>

<!-- 输入价格对比 -->
<div style="font-size:12px;color:#888;margin-bottom:6px;">输入价格</div>
<div class="price-cards">
<div class="price-card standard">
<div class="card-label">标准价格</div>
<div class="card-group">default 分组</div>
<div class="price-item">
<div class="price-item-label">每千 tokens</div>
<div class="price-item-value">¥0.0300 <span class="price-unit">/ 1K</span></div>
</div>
</div>
<div class="price-card discounted">
<div class="card-label">您的价格</div>
<div class="card-group">VIP 分组 · 8折</div>
<div class="price-item">
<div class="price-item-label">每千 tokens</div>
<div class="price-item-value">¥0.0240 <span class="price-unit">/ 1K</span></div>
</div>
</div>
</div>

<div class="savings-banner">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
输入节省 <span class="savings-amount">20%</span> · 每千 tokens 省 ¥0.006
</div>

<!-- 输出价格对比 -->
<div style="font-size:12px;color:#888;margin-bottom:6px;margin-top:20px;">输出价格</div>
<div class="price-cards">
<div class="price-card standard">
<div class="card-label">标准价格</div>
<div class="card-group">default 分组</div>
<div class="price-item">
<div class="price-item-label">每千 tokens</div>
<div class="price-item-value">¥0.0600 <span class="price-unit">/ 1K</span></div>
</div>
</div>
<div class="price-card discounted">
<div class="card-label">您的价格</div>
<div class="card-group">VIP 分组 · 8折</div>
<div class="price-item">
<div class="price-item-label">每千 tokens</div>
<div class="price-item-value">¥0.0480 <span class="price-unit">/ 1K</span></div>
</div>
</div>
</div>

<div class="savings-banner">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
输出节省 <span class="savings-amount">20%</span> · 每千 tokens 省 ¥0.012
</div>
</div>

<div class="divider"></div>

<div class="section">
<div class="section-title">
<div class="icon cyan">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<div>
<h3>通道价格</h3>
<small>所有支持该模型的通道价格</small>
</div>
</div>
<table class="channel-table">
<thead>
<tr><th>通道</th><th>计费类型</th><th>输入价格</th><th>输出价格</th></tr>
</thead>
<tbody>
<tr>
<td><span class="channel-tag cyan">OpenAI 官方</span><span class="channel-tag amber">默认</span></td>
<td><span class="channel-tag violet">按量计费</span></td>
<td class="price-orange">¥0.0240<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
<td class="price-orange">¥0.0480<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
</tr>
<tr>
<td><span class="channel-tag cyan">Azure 中转</span><span class="channel-tag green">高速</span></td>
<td><span class="channel-tag violet">按量计费</span></td>
<td class="price-orange">¥0.0280<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
<td class="price-orange">¥0.0560<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
</tr>
</tbody>
</table>
</div>
</div>

</body>
</html>

+ 362
- 0
mockup-pricing-C.html View File

@@ -0,0 +1,362 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>方案C:进度条式折扣可视化</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }

.panel {
max-width: 580px; margin: 40px auto; background: #fff;
border-radius: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow: hidden;
}

.panel-header {
display: flex; align-items: center; gap: 12px;
padding: 20px 24px 16px; border-bottom: 1px solid #f0f0f0;
}
.model-icon {
width: 48px; height: 48px; border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 18px;
}
.model-name { font-size: 18px; font-weight: 600; }
.model-desc { font-size: 13px; color: #888; margin-top: 2px; }

.section { padding: 16px 24px; }
.section-title {
display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
}
.section-title .icon {
width: 28px; height: 28px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; color: #fff;
}
.section-title .icon.orange { background: #fa8c16; }
.section-title .icon.cyan { background: #13c2c2; }
.section-title h3 { font-size: 15px; font-weight: 600; }
.section-title small { font-size: 12px; color: #999; }

.divider { height: 1px; background: #f0f0f0; margin: 0 24px; }

/* ===== 方案C核心样式:进度条式折扣可视化 ===== */
.discount-card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 12px;
position: relative;
}

.discount-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}

.discount-big-number {
font-size: 36px;
font-weight: 800;
background: linear-gradient(135deg, #ff4d4f, #cf1322);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
}

.discount-percent {
font-size: 14px;
font-weight: 600;
color: #cf1322;
}

.group-badge {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500;
}
.group-badge.vip { background: #f9f0ff; color: #722ed1; border: 1px solid #d3adf7; }
.group-badge.svip { background: #fff7e6; color: #d48806; border: 1px solid #ffe58f; }

/* 进度条 */
.bar-container {
position: relative;
height: 32px;
background: #f5f5f5;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
}

.bar-full {
position: absolute;
top: 0; left: 0;
height: 100%;
background: #e8e8e8;
border-radius: 6px;
display: flex; align-items: center; justify-content: flex-end;
padding-right: 8px;
font-size: 11px; color: #999; font-weight: 500;
transition: width 0.5s ease;
}

.bar-discount {
position: absolute;
top: 0; left: 0;
height: 100%;
border-radius: 6px;
display: flex; align-items: center;
padding-left: 10px;
font-size: 12px; color: #fff; font-weight: 600;
transition: width 0.5s ease;
}

.bar-discount.green { background: linear-gradient(90deg, #52c41a, #73d13d); }
.bar-discount.blue { background: linear-gradient(90deg, #1890ff, #40a9ff); }
.bar-discount.orange { background: linear-gradient(90deg, #fa8c16, #ffa940); }

.bar-labels {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #bbb;
margin-bottom: 16px;
}

/* 价格明细 */
.price-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}

.price-detail-item {
background: #fafafa;
border-radius: 8px;
padding: 10px 12px;
}

.price-detail-label {
font-size: 11px;
color: #999;
margin-bottom: 4px;
}

.price-detail-row {
display: flex;
align-items: baseline;
gap: 6px;
}

.price-old {
font-size: 13px;
color: #ccc;
text-decoration: line-through;
}

.price-new {
font-size: 16px;
font-weight: 700;
color: #237804;
}

.price-new.red { color: #cf1322; }

.price-detail-unit {
font-size: 10px;
color: #bbb;
}

/* 通道表格 */
.channel-table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
.channel-table th {
text-align: left; padding: 8px 0; color: #999;
font-weight: 500; font-size: 12px;
border-bottom: 1px solid #f0f0f0;
}
.channel-table td {
padding: 10px 0; border-bottom: 1px solid #f5f5f5;
}
.channel-table tr:last-child td { border-bottom: none; }
.channel-tag {
display: inline-block; padding: 1px 8px; border-radius: 8px;
font-size: 11px; margin-right: 4px;
}
.channel-tag.cyan { background: #e6fffb; color: #13c2c2; }
.channel-tag.violet { background: #f9f0ff; color: #722ed1; }
.channel-tag.amber { background: #fff7e6; color: #d48806; }
.channel-tag.green { background: #f6ffed; color: #52c41a; }
.price-orange { color: #d4380d; font-weight: 600; }

.scheme-label {
text-align: center; padding: 12px;
background: #722ed1; color: #fff;
font-weight: 600; font-size: 16px; letter-spacing: 1px;
}
.scheme-sub { font-size: 12px; font-weight: 400; opacity: 0.85; }
</style>
</head>
<body>

<div class="scheme-label">
方案C:进度条式折扣可视化
<div class="scheme-sub">用进度条直观展示折扣力度 + 价格明细</div>
</div>

<div class="panel">
<div class="panel-header">
<div class="model-icon">G4</div>
<div>
<div class="model-name">gpt-4o</div>
<div class="model-desc">OpenAI 最新多模态模型</div>
</div>
</div>

<div class="section">
<div class="section-title">
<div class="icon orange">$</div>
<div>
<h3>您的分组价格</h3>
<small>VIP 分组专属折扣</small>
</div>
</div>

<!-- 折扣大卡片 -->
<div class="discount-card">
<div class="discount-card-header">
<div>
<span class="group-badge vip">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
VIP 分组
</span>
</div>
<div style="text-align:right;">
<div class="discount-big-number">8折</div>
<div class="discount-percent">节省 20%</div>
</div>
</div>

<!-- 输入价格进度条 -->
<div style="font-size:12px;color:#888;margin-bottom:4px;">输入价格</div>
<div class="bar-container">
<div class="bar-full" style="width: 100%;">
¥0.0300
</div>
<div class="bar-discount green" style="width: 80%;">
¥0.0240
</div>
</div>
<div class="bar-labels">
<span>default 分组基准价</span>
<span style="color:#52c41a;font-weight:500;">VIP 折扣价</span>
</div>

<!-- 输出价格进度条 -->
<div style="font-size:12px;color:#888;margin-bottom:4px;">输出价格</div>
<div class="bar-container">
<div class="bar-full" style="width: 100%;">
¥0.0600
</div>
<div class="bar-discount blue" style="width: 80%;">
¥0.0480
</div>
</div>
<div class="bar-labels">
<span>default 分组基准价</span>
<span style="color:#1890ff;font-weight:500;">VIP 折扣价</span>
</div>

<!-- 价格明细网格 -->
<div class="price-details">
<div class="price-detail-item">
<div class="price-detail-label">输入价格</div>
<div class="price-detail-row">
<span class="price-old">¥0.0300</span>
<span class="price-new">¥0.0240</span>
</div>
<div class="price-detail-unit">/ 1K tokens</div>
</div>
<div class="price-detail-item">
<div class="price-detail-label">输出价格</div>
<div class="price-detail-row">
<span class="price-old">¥0.0600</span>
<span class="price-new">¥0.0480</span>
</div>
<div class="price-detail-unit">/ 1K tokens</div>
</div>
</div>
</div>

<!-- 按次计费示例 -->
<div class="discount-card" style="border-color:#e6f7ff;">
<div class="discount-card-header">
<div>
<span class="group-badge svip">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z"/></svg>
SVIP 分组
</span>
<span style="font-size:11px;color:#999;margin-left:4px;">按次计费示例</span>
</div>
<div style="text-align:right;">
<div class="discount-big-number" style="background:linear-gradient(135deg, #d48806, #ad6800);-webkit-background-clip:text;background-clip:text;">7折</div>
<div class="discount-percent" style="color:#d48806;">节省 30%</div>
</div>
</div>
<div style="font-size:12px;color:#888;margin-bottom:4px;">每次调用价格</div>
<div class="bar-container">
<div class="bar-full" style="width: 100%;">
¥0.0500
</div>
<div class="bar-discount orange" style="width: 70%;">
¥0.0350
</div>
</div>
<div class="bar-labels">
<span>default 分组基准价</span>
<span style="color:#fa8c16;font-weight:500;">SVIP 折扣价</span>
</div>
</div>
</div>

<div class="divider"></div>

<div class="section">
<div class="section-title">
<div class="icon cyan">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<div>
<h3>通道价格</h3>
<small>所有支持该模型的通道价格</small>
</div>
</div>
<table class="channel-table">
<thead>
<tr><th>通道</th><th>计费类型</th><th>输入价格</th><th>输出价格</th></tr>
</thead>
<tbody>
<tr>
<td><span class="channel-tag cyan">OpenAI 官方</span><span class="channel-tag amber">默认</span></td>
<td><span class="channel-tag violet">按量计费</span></td>
<td class="price-orange">¥0.0240<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
<td class="price-orange">¥0.0480<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
</tr>
<tr>
<td><span class="channel-tag cyan">Azure 中转</span><span class="channel-tag green">高速</span></td>
<td><span class="channel-tag violet">按量计费</span></td>
<td class="price-orange">¥0.0280<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
<td class="price-orange">¥0.0560<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
</tr>
</tbody>
</table>
</div>
</div>

</body>
</html>

+ 53
- 0
model/ability.go View File

@@ -66,6 +66,59 @@ func GetAbilitiesByChannelId(channelId int) ([]*Ability, error) {
return abilities, err
}

// GetModelChannelsForGroup 返回指定模型在指定分组下的可用渠道列表及默认渠道ID
func GetModelChannelsForGroup(modelName string, group string) ([]map[string]any, int, error) {
var channelIds []int
err := DB.Model(&Ability{}).
Where("model = ?", modelName).
Where("enabled = ?", true).
Where(commonGroupCol+" = ?", group).
Distinct("channel_id").
Pluck("channel_id", &channelIds).Error
if err != nil {
return nil, 0, err
}

if len(channelIds) == 0 {
return []map[string]any{}, 0, nil
}

type channelInfo struct {
Id int `json:"id"`
Name string `json:"name"`
PublicName string `json:"public_name"`
}
var channels []channelInfo
err = DB.Table("channels").
Where("id IN ? AND status = ?", channelIds, common.ChannelStatusEnabled).
Select("id, name, public_name").
Find(&channels).Error
if err != nil {
return nil, 0, err
}

defaultChannelId := 0
if defaultChId, ok := GetDefaultChannelId(modelName); ok {
for _, id := range channelIds {
if id == defaultChId {
defaultChannelId = defaultChId
break
}
}
}

result := make([]map[string]any, 0, len(channels))
for _, ch := range channels {
result = append(result, map[string]any{
"id": ch.Id,
"name": ch.Name,
"public_name": ch.PublicName,
})
}

return result, defaultChannelId, nil
}

func getPriority(group string, model string, retry int) (int, error) {

var priorities []int


+ 9
- 1
model/channel.go View File

@@ -26,6 +26,7 @@ type Channel struct {
TestModel *string `json:"test_model"`
Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index"`
PublicName string `json:"public_name" gorm:"size:255;default:''"`
Weight *uint `json:"weight" gorm:"default:0"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
TestTime int64 `json:"test_time" gorm:"bigint"`
@@ -72,6 +73,13 @@ func (c ChannelInfo) Value() (driver.Value, error) {
return common.Marshal(&c)
}

func ChannelDisplayName(publicName, name string) string {
if publicName != "" {
return publicName
}
return name
}

// Scan implements sql.Scanner interface
func (c *ChannelInfo) Scan(value interface{}) error {
bytesValue, _ := value.([]byte)
@@ -279,7 +287,7 @@ func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Chan
// 只返回 id, name, type, remark,不包含敏感信息
func GetAllChannelsForBinding() ([]*Channel, error) {
var channels []*Channel
err := DB.Select("id, name, type, remark").
err := DB.Select("id, name, public_name, type, remark").
Where("status = ?", common.ChannelStatusEnabled).
Order("priority desc").
Find(&channels).Error


+ 1020
- 0
model/channel.go.bak
File diff suppressed because it is too large
View File


+ 227
- 79
model/channel_pricing.go View File

@@ -5,7 +5,6 @@ import (
"strconv"
"strings"
"sync"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -17,8 +16,10 @@ import (
var (
channelPricingCache = make(map[string]*ChannelPricing) // key: "modelName:channelId"
channelPricingCacheLock sync.RWMutex
channelPricingCacheTime time.Time
channelPricingCacheTTL = time.Minute * 5 // 缓存5分钟

// 默认通道缓存:modelName → channelId
defaultChannelCache = make(map[string]int)
defaultChannelCacheLock sync.RWMutex
)

// QuotaType 计费类型
@@ -41,6 +42,40 @@ type ChannelPricing struct {
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
// === 新增字段(0 = 未设置,回退全局值) ===
CacheRatio float64 `json:"cache_ratio" gorm:"default:0"`
CacheCreationRatio float64 `json:"cache_creation_ratio" gorm:"default:0"`
ImageRatio float64 `json:"image_ratio" gorm:"default:0"`
AudioRatio float64 `json:"audio_ratio" gorm:"default:0"`
AudioCompletionRatio float64 `json:"audio_completion_ratio" gorm:"default:0"`
IsDefault bool `json:"is_default" gorm:"default:false;index"`
}

// setCache 写穿透缓存
func setCache(key string, cp *ChannelPricing) {
channelPricingCacheLock.Lock()
channelPricingCache[key] = cp
channelPricingCacheLock.Unlock()
}

func removeCache(key string) {
channelPricingCacheLock.Lock()
delete(channelPricingCache, key)
channelPricingCacheLock.Unlock()
}

// ApplyFields 批量设置定价字段(消除 controller 层的重复赋值)
func (cp *ChannelPricing) ApplyFields(quotaType int, modelRatio, completionRatio, modelPrice float64, tagIds string, cacheRatio, cacheCreationRatio, imageRatio, audioRatio, audioCompletionRatio float64) {
cp.QuotaType = quotaType
cp.ModelRatio = modelRatio
cp.CompletionRatio = completionRatio
cp.ModelPrice = modelPrice
cp.TagIds = tagIds
cp.CacheRatio = cacheRatio
cp.CacheCreationRatio = cacheCreationRatio
cp.ImageRatio = imageRatio
cp.AudioRatio = audioRatio
cp.AudioCompletionRatio = audioCompletionRatio
}

func (cp *ChannelPricing) Insert() error {
@@ -49,31 +84,43 @@ func (cp *ChannelPricing) Insert() error {
cp.UpdatedTime = now
err := DB.Create(cp).Error
if err == nil {
InvalidateChannelPricingCache()
setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp)
if cp.IsDefault {
setDefaultChannelCache(cp.ModelName, cp.ChannelId)
}
}
return err
}

func (cp *ChannelPricing) Update() error {
cp.UpdatedTime = common.GetTimestamp()
err := DB.Model(&ChannelPricing{}).Where("id = ?", cp.Id).Updates(map[string]interface{}{
"quota_type": cp.QuotaType,
"model_ratio": cp.ModelRatio,
"completion_ratio": cp.CompletionRatio,
"model_price": cp.ModelPrice,
"tag_ids": cp.TagIds,
"updated_time": cp.UpdatedTime,
}).Error
err := DB.Model(&ChannelPricing{}).Where("id = ?", cp.Id).
Select("quota_type", "model_ratio", "completion_ratio", "model_price",
"tag_ids", "cache_ratio", "cache_creation_ratio", "image_ratio",
"audio_ratio", "audio_completion_ratio", "is_default", "updated_time").
Updates(cp).Error
if err == nil {
InvalidateChannelPricingCache()
setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp)
if cp.IsDefault {
setDefaultChannelCache(cp.ModelName, cp.ChannelId)
} else {
clearDefaultChannelCacheIfMatch(cp.ModelName, cp.Id)
}
}
return err
}

func (cp *ChannelPricing) Delete() error {
var existing ChannelPricing
if err := DB.First(&existing, cp.Id).Error; err != nil {
return err
}
err := DB.Delete(cp).Error
if err == nil {
InvalidateChannelPricingCache()
removeCache(getChannelPricingCacheKey(existing.ModelName, existing.ChannelId))
if existing.IsDefault {
clearDefaultChannelCache(existing.ModelName)
}
}
return err
}
@@ -121,7 +168,7 @@ func BatchUpsertChannelPricing(pricings []*ChannelPricing) error {
}
// 使用 GORM 的 OnConflict 实现 upsert
// 唯一索引为 idx_model_channel (model_name, channel_id)
return DB.Clauses(clause.OnConflict{
err := DB.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "model_name"},
{Name: "channel_id"},
@@ -132,9 +179,20 @@ func BatchUpsertChannelPricing(pricings []*ChannelPricing) error {
"completion_ratio",
"model_price",
"tag_ids",
"cache_ratio",
"cache_creation_ratio",
"image_ratio",
"audio_ratio",
"audio_completion_ratio",
"updated_time",
}),
}).Create(&pricings).Error
if err == nil {
for _, cp := range pricings {
setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp)
}
}
return err
}

// getChannelPricingCacheKey 生成缓存键
@@ -142,71 +200,51 @@ func getChannelPricingCacheKey(modelName string, channelId int) string {
return fmt.Sprintf("%s:%d", modelName, channelId)
}

// GetEffectivePricing 获取有效定价(优先渠道定价,回退全局定价)
// 返回: modelRatio, completionRatio, modelPrice, usePrice, found
func GetEffectivePricing(modelName string, channelId int) (modelRatio, completionRatio, modelPrice float64, usePrice, found bool) {
cacheKey := getChannelPricingCacheKey(modelName, channelId)

// 首先检查缓存
// GetEffectivePricing 获取有效定价(纯内存查找)
func GetEffectivePricing(modelName string, channelId int) (*ChannelPricing, bool) {
key := getChannelPricingCacheKey(modelName, channelId)
channelPricingCacheLock.RLock()
// 检查缓存是否过期
if time.Since(channelPricingCacheTime) < channelPricingCacheTTL {
if cp, ok := channelPricingCache[cacheKey]; ok {
channelPricingCacheLock.RUnlock()
return cp.ModelRatio, cp.CompletionRatio, cp.ModelPrice, cp.QuotaType == QuotaTypeByCall, true
}
}
cp, ok := channelPricingCache[key]
channelPricingCacheLock.RUnlock()

// 缓存未命中或已过期,查询数据库
var cp ChannelPricing
err := DB.Where("model_name = ? AND channel_id = ?", modelName, channelId).First(&cp).Error
if err != nil {
// 未找到渠道定价,返回 false 让调用者使用全局定价
return 0, 0, 0, false, false
if !ok {
return nil, false
}
return cp, true
}

// 更新缓存
channelPricingCacheLock.Lock()
if channelPricingCacheTime.IsZero() || time.Since(channelPricingCacheTime) >= channelPricingCacheTTL {
// 缓存过期,清空并更新时间
channelPricingCache = make(map[string]*ChannelPricing)
channelPricingCacheTime = time.Now()
// ParseTagIds 解析逗号分隔的标签ID字符串为 PricingTag 切片
func ParseTagIds(tagIds string, tagMap map[int]*PricingTag) []*PricingTag {
if tagIds == "" {
return nil
}
channelPricingCache[cacheKey] = &cp
channelPricingCacheLock.Unlock()

return cp.ModelRatio, cp.CompletionRatio, cp.ModelPrice, cp.QuotaType == QuotaTypeByCall, true
tags := make([]*PricingTag, 0)
for _, idStr := range strings.Split(tagIds, ",") {
if id, err := strconv.Atoi(strings.TrimSpace(idStr)); err == nil {
if tag, ok := tagMap[id]; ok {
tags = append(tags, tag)
}
}
}
return tags
}

// RefreshChannelPricingCache 刷新渠道定价缓存
func RefreshChannelPricingCache() {
channelPricingCacheLock.Lock()
defer channelPricingCacheLock.Unlock()

// 清空缓存
channelPricingCache = make(map[string]*ChannelPricing)
channelPricingCacheTime = time.Now()

// 预加载所有渠道定价
// LoadChannelPricingCache 全量加载渠道定价到内存(启动时调用)
func LoadChannelPricingCache() {
var pricings []*ChannelPricing
if err := DB.Find(&pricings).Error; err != nil {
common.SysError("[ChannelPricing] LoadChannelPricingCache failed: " + err.Error())
return
}

channelPricingCacheLock.Lock()
channelPricingCache = make(map[string]*ChannelPricing, len(pricings))
for _, cp := range pricings {
cacheKey := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId)
channelPricingCache[cacheKey] = cp
key := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId)
channelPricingCache[key] = cp
}
}

// InvalidateChannelPricingCache 使渠道定价缓存失效
func InvalidateChannelPricingCache() {
channelPricingCacheLock.Lock()
defer channelPricingCacheLock.Unlock()
channelPricingCacheLock.Unlock()

channelPricingCache = make(map[string]*ChannelPricing)
channelPricingCacheTime = time.Time{} // 重置为零值
rebuildDefaultChannelCache(pricings)
common.SysLog(fmt.Sprintf("[ChannelPricing] cache loaded %d records", len(pricings)))
}

// ChannelPricingWithChannel 带渠道信息的定价响应
@@ -214,6 +252,7 @@ type ChannelPricingWithChannel struct {
Id int `json:"id"`
ChannelId int `json:"channel_id"`
ChannelName string `json:"channel_name"`
ChannelPublicName string `json:"channel_public_name"`
ChannelType int `json:"channel_type"`
TagIds string `json:"tag_ids" gorm:"column:tag_ids"` // 渠道定价的标签ID列表(逗号分隔)
Tags []*PricingTag `json:"tags" gorm:"-"` // 渠道定价的标签详情(不参与数据库扫描)
@@ -221,7 +260,14 @@ type ChannelPricingWithChannel struct {
ModelRatio float64 `json:"model_ratio"`
CompletionRatio float64 `json:"completion_ratio"`
ModelPrice float64 `json:"model_price"`
HasCustomPricing bool `json:"has_custom_pricing"` // 是否有自定义定价
HasCustomPricing bool `json:"has_custom_pricing"` // 是否有自定义定价
CacheRatio float64 `json:"cache_ratio"`
CacheCreationRatio float64 `json:"cache_creation_ratio"`
ImageRatio float64 `json:"image_ratio"`
AudioRatio float64 `json:"audio_ratio"`
AudioCompletionRatio float64 `json:"audio_completion_ratio"`
IsDefault bool `json:"is_default"`
UserRatio float64 `json:"user_ratio"`
}

// GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息)
@@ -249,16 +295,22 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin
if !hasPrice {
globalModelPrice = 0
}

// 查询所有支持该模型的渠道,左连接渠道定价表
// 高级字段(cache/image/audio)不回退全局值,直接返回 0
err := DB.Table("abilities").
Select(`abilities.channel_id, channels.name as channel_name, channels.type as channel_type,
Select(`abilities.channel_id, channels.name as channel_name, channels.public_name as channel_public_name, channels.type as channel_type,
COALESCE(channel_pricings.quota_type, ?) as quota_type,
COALESCE(channel_pricings.model_ratio, ?) as model_ratio,
COALESCE(channel_pricings.completion_ratio, ?) as completion_ratio,
COALESCE(channel_pricings.model_price, ?) as model_price,
channel_pricings.id as id,
channel_pricings.tag_ids as tag_ids,
COALESCE(channel_pricings.cache_ratio, 0) as cache_ratio,
COALESCE(channel_pricings.cache_creation_ratio, 0) as cache_creation_ratio,
COALESCE(channel_pricings.image_ratio, 0) as image_ratio,
COALESCE(channel_pricings.audio_ratio, 0) as audio_ratio,
COALESCE(channel_pricings.audio_completion_ratio, 0) as audio_completion_ratio,
COALESCE(channel_pricings.is_default, false) as is_default,
(channel_pricings.id IS NOT NULL) as has_custom_pricing`,
defaultQuotaType, globalModelRatio, globalCompletionRatio, globalModelPrice).
Joins("LEFT JOIN channels ON abilities.channel_id = channels.id").
@@ -266,7 +318,7 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin
Where("abilities.model = ?", modelName).
Where("abilities.enabled = ?", true).
Where("channels.status = ?", 1). // 只显示启用的渠道
Group("abilities.channel_id, channels.name, channels.type, channel_pricings.quota_type, channel_pricings.model_ratio, channel_pricings.completion_ratio, channel_pricings.model_price, channel_pricings.id, channel_pricings.tag_ids").
Group("abilities.channel_id, channels.name, channels.public_name, channels.type, channel_pricings.quota_type, channel_pricings.model_ratio, channel_pricings.completion_ratio, channel_pricings.model_price, channel_pricings.id, channel_pricings.tag_ids, channel_pricings.cache_ratio, channel_pricings.cache_creation_ratio, channel_pricings.image_ratio, channel_pricings.audio_ratio, channel_pricings.audio_completion_ratio, channel_pricings.is_default").
Scan(&results).Error
if err != nil {
return nil, err
@@ -286,17 +338,113 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin

// 为每个渠道定价填充标签
for _, result := range results {
if result.TagIds != "" {
result.Tags = make([]*PricingTag, 0)
for _, idStr := range strings.Split(result.TagIds, ",") {
if id, err := strconv.Atoi(strings.TrimSpace(idStr)); err == nil {
if tag, ok := tagMap[id]; ok {
result.Tags = append(result.Tags, tag)
}
}
result.Tags = ParseTagIds(result.TagIds, tagMap)
}

return results, nil
}

// === 默认通道缓存 ===

// syncIsDefaultToCache 将默认标记的变更同步到 channelPricingCache
func syncIsDefaultToCache(modelName string, channelId int, isDefault bool) {
channelPricingCacheLock.Lock()
for key, cp := range channelPricingCache {
if cp.ModelName == modelName {
if isDefault {
cp.IsDefault = cp.ChannelId == channelId
} else {
cp.IsDefault = false
}
}
channelPricingCache[key] = cp
}
channelPricingCacheLock.Unlock()
}

return results, nil
// setDefaultChannelCache 设置默认通道缓存
func setDefaultChannelCache(modelName string, channelId int) {
defaultChannelCacheLock.Lock()
defaultChannelCache[modelName] = channelId
defaultChannelCacheLock.Unlock()
}

// clearDefaultChannelCache 清除指定模型的默认通道缓存
func clearDefaultChannelCache(modelName string) {
defaultChannelCacheLock.Lock()
delete(defaultChannelCache, modelName)
defaultChannelCacheLock.Unlock()
}

// clearDefaultChannelCacheIfMatch 如果默认通道的定价记录 ID 匹配则清除
func clearDefaultChannelCacheIfMatch(modelName string, pricingId int) {
defaultChannelCacheLock.RLock()
cachedId, ok := defaultChannelCache[modelName]
defaultChannelCacheLock.RUnlock()
if !ok {
return
}
// 需要通过缓存找到对应的 pricing 来比对
key := getChannelPricingCacheKey(modelName, cachedId)
channelPricingCacheLock.RLock()
cp, exists := channelPricingCache[key]
channelPricingCacheLock.RUnlock()
if exists && cp.Id == pricingId {
clearDefaultChannelCache(modelName)
}
}

// rebuildDefaultChannelCache 从全量数据构建默认通道缓存(启动时调用)
func rebuildDefaultChannelCache(pricings []*ChannelPricing) {
defaultChannelCacheLock.Lock()
defaultChannelCache = make(map[string]int)
for _, cp := range pricings {
if cp.IsDefault {
defaultChannelCache[cp.ModelName] = cp.ChannelId
}
}
defaultChannelCacheLock.Unlock()
common.SysLog(fmt.Sprintf("[ChannelPricing] default channel cache loaded %d records", len(defaultChannelCache)))
}

// GetDefaultChannelId 获取指定模型的默认通道 ID(纯内存读)
func GetDefaultChannelId(modelName string) (int, bool) {
defaultChannelCacheLock.RLock()
id, ok := defaultChannelCache[modelName]
defaultChannelCacheLock.RUnlock()
return id, ok
}

// SetDefaultChannel 设置指定模型的默认通道(事务保证互斥)
func SetDefaultChannel(modelName string, channelId int) error {
return DB.Transaction(func(tx *gorm.DB) error {
// 清除该模型所有现有的默认标记
if err := tx.Model(&ChannelPricing{}).
Where("model_name = ? AND is_default = ?", modelName, true).
Update("is_default", false).Error; err != nil {
return err
}
// 设置新的默认
if err := tx.Model(&ChannelPricing{}).
Where("model_name = ? AND channel_id = ?", modelName, channelId).
Update("is_default", true).Error; err != nil {
return err
}
// 更新缓存
setDefaultChannelCache(modelName, channelId)
syncIsDefaultToCache(modelName, channelId, true)
return nil
})
}

// ClearDefaultChannel 清除指定模型的默认通道标记
func ClearDefaultChannel(modelName string) error {
err := DB.Model(&ChannelPricing{}).
Where("model_name = ? AND is_default = ?", modelName, true).
Update("is_default", false).Error
if err == nil {
clearDefaultChannelCache(modelName)
syncIsDefaultToCache(modelName, 0, false)
}
return err
}

+ 242
- 0
model/channel_pricing_test.go View File

@@ -0,0 +1,242 @@
package model

import (
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func setupChannelPricingDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)

origDB := DB
DB = db
common.UsingSQLite = true
common.RedisEnabled = false

require.NoError(t, db.AutoMigrate(&ChannelPricing{}))

t.Cleanup(func() {
DB = origDB
sqlDB.Close()
})
return db
}

func TestCacheWriteThrough_Insert(t *testing.T) {
setupChannelPricingDB(t)

cp := &ChannelPricing{
ModelName: "test-insert-model",
ChannelId: 9001,
QuotaType: QuotaTypeByTokens,
ModelRatio: 1.0,
CacheRatio: 0.8,
ImageRatio: 1.2,
}
require.NoError(t, cp.Insert())
defer cp.Delete()

found, ok := GetEffectivePricing("test-insert-model", 9001)
assert.True(t, ok)
assert.Equal(t, 0.8, found.CacheRatio)
assert.Equal(t, 1.2, found.ImageRatio)
}

func TestCacheWriteThrough_Update(t *testing.T) {
setupChannelPricingDB(t)

cp := &ChannelPricing{
ModelName: "test-update-model",
ChannelId: 9002,
QuotaType: QuotaTypeByTokens,
ModelRatio: 1.0,
}
require.NoError(t, cp.Insert())
defer cp.Delete()

cp.CacheRatio = 0.9
cp.AudioRatio = 1.5
require.NoError(t, cp.Update())

found, ok := GetEffectivePricing("test-update-model", 9002)
assert.True(t, ok)
assert.Equal(t, 0.9, found.CacheRatio)
assert.Equal(t, 1.5, found.AudioRatio)
}

func TestCacheWriteThrough_Delete(t *testing.T) {
setupChannelPricingDB(t)

cp := &ChannelPricing{
ModelName: "test-delete-model",
ChannelId: 9003,
QuotaType: QuotaTypeByTokens,
ModelRatio: 1.0,
}
require.NoError(t, cp.Insert())

require.NoError(t, cp.Delete())

found, ok := GetEffectivePricing("test-delete-model", 9003)
assert.False(t, ok)
assert.Nil(t, found)
}

func TestExtendedFields_DefaultZero(t *testing.T) {
setupChannelPricingDB(t)

cp := &ChannelPricing{
ModelName: "test-default-model",
ChannelId: 9004,
QuotaType: QuotaTypeByTokens,
ModelRatio: 1.0,
}
require.NoError(t, cp.Insert())
defer cp.Delete()

found, ok := GetEffectivePricing("test-default-model", 9004)
assert.True(t, ok)
assert.Equal(t, 0.0, found.CacheRatio)
assert.Equal(t, 0.0, found.CacheCreationRatio)
assert.Equal(t, 0.0, found.ImageRatio)
assert.Equal(t, 0.0, found.AudioRatio)
assert.Equal(t, 0.0, found.AudioCompletionRatio)
}

func TestGetEffectivePricing_NotFound(t *testing.T) {
setupChannelPricingDB(t)

found, ok := GetEffectivePricing("nonexistent-model-xyz", 99999)
assert.False(t, ok)
assert.Nil(t, found)
}

// === 默认通道测试 ===

func TestDefaultChannel_SetAndGet(t *testing.T) {
setupChannelPricingDB(t)

// 创建两条定价记录
cp1 := &ChannelPricing{ModelName: "default-test-model", ChannelId: 100, QuotaType: QuotaTypeByTokens, ModelRatio: 1.0}
cp2 := &ChannelPricing{ModelName: "default-test-model", ChannelId: 200, QuotaType: QuotaTypeByTokens, ModelRatio: 2.0}
require.NoError(t, cp1.Insert())
require.NoError(t, cp2.Insert())
t.Cleanup(func() { cp1.Delete(); cp2.Delete() })

// 初始没有默认
_, ok := GetDefaultChannelId("default-test-model")
assert.False(t, ok)

// 设置通道 100 为默认
require.NoError(t, SetDefaultChannel("default-test-model", 100))
id, ok := GetDefaultChannelId("default-test-model")
assert.True(t, ok)
assert.Equal(t, 100, id)

// 切换默认到通道 200
require.NoError(t, SetDefaultChannel("default-test-model", 200))
id, ok = GetDefaultChannelId("default-test-model")
assert.True(t, ok)
assert.Equal(t, 200, id)

// 验证旧的默认标记被清除
found, _ := GetEffectivePricing("default-test-model", 100)
assert.False(t, found.IsDefault)
found, _ = GetEffectivePricing("default-test-model", 200)
assert.True(t, found.IsDefault)
}

func TestDefaultChannel_Clear(t *testing.T) {
setupChannelPricingDB(t)

cp := &ChannelPricing{ModelName: "clear-test-model", ChannelId: 300, QuotaType: QuotaTypeByTokens, ModelRatio: 1.0}
require.NoError(t, cp.Insert())
t.Cleanup(func() { cp.Delete() })

require.NoError(t, SetDefaultChannel("clear-test-model", 300))
_, ok := GetDefaultChannelId("clear-test-model")
assert.True(t, ok)

require.NoError(t, ClearDefaultChannel("clear-test-model"))
_, ok = GetDefaultChannelId("clear-test-model")
assert.False(t, ok)
}

func TestDefaultChannel_DeleteClearsCache(t *testing.T) {
setupChannelPricingDB(t)

cp := &ChannelPricing{ModelName: "delete-test-model", ChannelId: 400, QuotaType: QuotaTypeByTokens, ModelRatio: 1.0, IsDefault: true}
require.NoError(t, cp.Insert())

id, ok := GetDefaultChannelId("delete-test-model")
assert.True(t, ok)
assert.Equal(t, 400, id)

// 删除记录后缓存应清除
require.NoError(t, cp.Delete())
_, ok = GetDefaultChannelId("delete-test-model")
assert.False(t, ok)
}

func TestDefaultChannel_LoadCache(t *testing.T) {
db := setupChannelPricingDB(t)

// 直接插入数据(绕过缓存)
db.Create(&ChannelPricing{ModelName: "load-model", ChannelId: 500, ModelRatio: 1.0, IsDefault: true})
db.Create(&ChannelPricing{ModelName: "load-model", ChannelId: 501, ModelRatio: 2.0, IsDefault: false})
db.Create(&ChannelPricing{ModelName: "other-model", ChannelId: 502, ModelRatio: 1.0, IsDefault: true})

// 全量加载缓存
LoadChannelPricingCache()

id, ok := GetDefaultChannelId("load-model")
assert.True(t, ok)
assert.Equal(t, 500, id)

id, ok = GetDefaultChannelId("other-model")
assert.True(t, ok)
assert.Equal(t, 502, id)

// 无默认的模型
_, ok = GetDefaultChannelId("nonexistent")
assert.False(t, ok)
}

func TestDefaultChannel_InsertWithDefault(t *testing.T) {
setupChannelPricingDB(t)

cp := &ChannelPricing{ModelName: "insert-default-model", ChannelId: 600, ModelRatio: 1.0, IsDefault: true}
require.NoError(t, cp.Insert())
t.Cleanup(func() { cp.Delete() })

id, ok := GetDefaultChannelId("insert-default-model")
assert.True(t, ok)
assert.Equal(t, 600, id)
}

func TestDefaultChannel_UpdateWithDefault(t *testing.T) {
setupChannelPricingDB(t)

cp1 := &ChannelPricing{ModelName: "update-default-model", ChannelId: 700, ModelRatio: 1.0, IsDefault: true}
cp2 := &ChannelPricing{ModelName: "update-default-model", ChannelId: 701, ModelRatio: 2.0}
require.NoError(t, cp1.Insert())
require.NoError(t, cp2.Insert())
t.Cleanup(func() { cp1.Delete(); cp2.Delete() })

// cp1 是默认,通过 Update 把 cp2 设为默认
cp2.IsDefault = true
require.NoError(t, cp2.Update())

id, ok := GetDefaultChannelId("update-default-model")
assert.True(t, ok)
assert.Equal(t, 701, id)
}

+ 133
- 0
model/email_quota_rule.go View File

@@ -0,0 +1,133 @@
package model

import (
"strings"
"sync"

"github.com/QuantumNous/new-api/common"
)

type EmailQuotaRule struct {
Id int `json:"id" gorm:"primaryKey"`
EmailSuffix string `json:"email_suffix" gorm:"size:128;not null;uniqueIndex"`
Quota int64 `json:"quota" gorm:"not null"`
Enabled bool `json:"enabled" gorm:"default:1"`
Description string `json:"description" gorm:"size:256"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
}

var (
emailQuotaCache map[string]int64
emailQuotaCacheMu sync.RWMutex
)

// LoadEmailQuotaCache loads all enabled rules into memory cache.
func LoadEmailQuotaCache() {
var rules []EmailQuotaRule
DB.Where("enabled = ?", true).Find(&rules)
cache := make(map[string]int64, len(rules))
for _, r := range rules {
cache[strings.ToLower(r.EmailSuffix)] = r.Quota
}
emailQuotaCacheMu.Lock()
emailQuotaCache = cache
emailQuotaCacheMu.Unlock()
}

// MatchEmailQuotaRule checks if the email matches any enabled suffix rule.
// Returns the matched quota, or -1 if no match.
func MatchEmailQuotaRule(email string) int64 {
if email == "" {
return -1
}
at := strings.LastIndex(email, "@")
if at < 0 {
return -1
}
suffix := strings.ToLower(email[at:])
emailQuotaCacheMu.RLock()
defer emailQuotaCacheMu.RUnlock()
if quota, ok := emailQuotaCache[suffix]; ok {
return quota
}
return -1
}

func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

func (r *EmailQuotaRule) Insert() error {
r.CreatedTime = common.GetTimestamp()
r.UpdatedTime = r.CreatedTime
err := DB.Model(&EmailQuotaRule{}).Create(map[string]interface{}{
"email_suffix": r.EmailSuffix,
"quota": r.Quota,
"enabled": boolToInt(r.Enabled),
"description": r.Description,
"created_time": r.CreatedTime,
"updated_time": r.UpdatedTime,
}).Error
if err != nil {
return err
}
var last EmailQuotaRule
if err := DB.Where("email_suffix = ?", r.EmailSuffix).First(&last).Error; err == nil {
r.Id = last.Id
}
LoadEmailQuotaCache()
return nil
}

func (r *EmailQuotaRule) Update() error {
r.UpdatedTime = common.GetTimestamp()
err := DB.Model(&EmailQuotaRule{}).Where("id = ?", r.Id).Updates(map[string]interface{}{
"email_suffix": r.EmailSuffix,
"quota": r.Quota,
"enabled": boolToInt(r.Enabled),
"description": r.Description,
"updated_time": r.UpdatedTime,
}).Error
if err != nil {
return err
}
LoadEmailQuotaCache()
return nil
}

func (r *EmailQuotaRule) Delete() error {
err := DB.Delete(r).Error
if err != nil {
return err
}
LoadEmailQuotaCache()
return nil
}

func GetAllEmailQuotaRules() ([]EmailQuotaRule, error) {
var list []EmailQuotaRule
err := DB.Order("id ASC").Find(&list).Error
return list, err
}

func GetEmailQuotaRuleById(id int) (*EmailQuotaRule, error) {
var r EmailQuotaRule
err := DB.First(&r, id).Error
if err != nil {
return nil, err
}
return &r, nil
}

func GetEmailQuotaRuleBySuffix(suffix string) (*EmailQuotaRule, error) {
var r EmailQuotaRule
err := DB.Where("email_suffix = ?", suffix).First(&r).Error
if err != nil {
return nil, err
}
return &r, nil
}

+ 246
- 0
model/email_quota_rule_test.go View File

@@ -0,0 +1,246 @@
package model

import (
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func setupEmailQuotaRuleDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)

origDB := DB
DB = db
common.UsingSQLite = true
common.RedisEnabled = false

require.NoError(t, db.AutoMigrate(&EmailQuotaRule{}))

t.Cleanup(func() {
DB = origDB
sqlDB.Close()
})
return db
}

func TestMatchEmailQuotaRule_EmptyEmail(t *testing.T) {
setupEmailQuotaRuleDB(t)
LoadEmailQuotaCache()

result := MatchEmailQuotaRule("")
assert.Equal(t, int64(-1), result)
}

func TestMatchEmailQuotaRule_NoAtSign(t *testing.T) {
setupEmailQuotaRuleDB(t)
LoadEmailQuotaCache()

result := MatchEmailQuotaRule("invalidemail")
assert.Equal(t, int64(-1), result)
}

func TestMatchEmailQuotaRule_NoRules(t *testing.T) {
setupEmailQuotaRuleDB(t)
LoadEmailQuotaCache()

result := MatchEmailQuotaRule("user@example.com")
assert.Equal(t, int64(-1), result)
}

func TestMatchEmailQuotaRule_MatchEnabled(t *testing.T) {
setupEmailQuotaRuleDB(t)

rule := &EmailQuotaRule{
EmailSuffix: "@example.com",
Quota: 500000,
Enabled: true,
}
require.NoError(t, rule.Insert())

result := MatchEmailQuotaRule("user@example.com")
assert.Equal(t, int64(500000), result)
}

func TestMatchEmailQuotaRule_CaseInsensitive(t *testing.T) {
setupEmailQuotaRuleDB(t)

rule := &EmailQuotaRule{
EmailSuffix: "@Example.COM",
Quota: 300000,
Enabled: true,
}
require.NoError(t, rule.Insert())

assert.Equal(t, int64(300000), MatchEmailQuotaRule("user@example.com"))
assert.Equal(t, int64(300000), MatchEmailQuotaRule("user@EXAMPLE.COM"))
assert.Equal(t, int64(300000), MatchEmailQuotaRule("user@Example.Com"))
}

func TestMatchEmailQuotaRule_DisabledRule(t *testing.T) {
setupEmailQuotaRuleDB(t)

rule := &EmailQuotaRule{
EmailSuffix: "@disabled.com",
Quota: 100000,
Enabled: false,
}
require.NoError(t, rule.Insert())

result := MatchEmailQuotaRule("user@disabled.com")
assert.Equal(t, int64(-1), result)
}

func TestMatchEmailQuotaRule_NoMatch(t *testing.T) {
setupEmailQuotaRuleDB(t)

rule := &EmailQuotaRule{
EmailSuffix: "@company.com",
Quota: 500000,
Enabled: true,
}
require.NoError(t, rule.Insert())

result := MatchEmailQuotaRule("user@other.com")
assert.Equal(t, int64(-1), result)
}

func TestEmailQuotaRule_Insert(t *testing.T) {
setupEmailQuotaRuleDB(t)

rule := &EmailQuotaRule{
EmailSuffix: "@test.com",
Quota: 100000,
Enabled: true,
Description: "Test rule",
}
require.NoError(t, rule.Insert())
assert.Greater(t, rule.Id, 0)
assert.Greater(t, rule.CreatedTime, int64(0))
assert.Equal(t, rule.CreatedTime, rule.UpdatedTime)

// Verify cache is populated
assert.Equal(t, int64(100000), MatchEmailQuotaRule("user@test.com"))
}

func TestEmailQuotaRule_Insert_DuplicateSuffix(t *testing.T) {
setupEmailQuotaRuleDB(t)

rule1 := &EmailQuotaRule{EmailSuffix: "@dup.com", Quota: 100, Enabled: true}
require.NoError(t, rule1.Insert())

rule2 := &EmailQuotaRule{EmailSuffix: "@dup.com", Quota: 200, Enabled: true}
assert.Error(t, rule2.Insert())
}

func TestEmailQuotaRule_Update(t *testing.T) {
setupEmailQuotaRuleDB(t)

rule := &EmailQuotaRule{EmailSuffix: "@update.com", Quota: 100, Enabled: true}
require.NoError(t, rule.Insert())

rule.Quota = 999
rule.Description = "updated"
require.NoError(t, rule.Update())

// Verify DB
found, err := GetEmailQuotaRuleById(rule.Id)
require.NoError(t, err)
assert.Equal(t, int64(999), found.Quota)
assert.Equal(t, "updated", found.Description)

// Verify cache refreshed
assert.Equal(t, int64(999), MatchEmailQuotaRule("user@update.com"))
}

func TestEmailQuotaRule_Update_Disable(t *testing.T) {
setupEmailQuotaRuleDB(t)

rule := &EmailQuotaRule{EmailSuffix: "@toggled.com", Quota: 500, Enabled: true}
require.NoError(t, rule.Insert())
assert.Equal(t, int64(500), MatchEmailQuotaRule("user@toggled.com"))

rule.Enabled = false
require.NoError(t, rule.Update())

assert.Equal(t, int64(-1), MatchEmailQuotaRule("user@toggled.com"))
}

func TestEmailQuotaRule_Delete(t *testing.T) {
setupEmailQuotaRuleDB(t)

rule := &EmailQuotaRule{EmailSuffix: "@delete.com", Quota: 100, Enabled: true}
require.NoError(t, rule.Insert())
assert.Equal(t, int64(100), MatchEmailQuotaRule("user@delete.com"))

require.NoError(t, rule.Delete())
assert.Equal(t, int64(-1), MatchEmailQuotaRule("user@delete.com"))
}

func TestGetAllEmailQuotaRules(t *testing.T) {
setupEmailQuotaRuleDB(t)

r1 := &EmailQuotaRule{EmailSuffix: "@a.com", Quota: 100, Enabled: true}
r2 := &EmailQuotaRule{EmailSuffix: "@b.com", Quota: 200, Enabled: false}
require.NoError(t, r1.Insert())
require.NoError(t, r2.Insert())

list, err := GetAllEmailQuotaRules()
require.NoError(t, err)
assert.Len(t, list, 2)
// Ordered by id ASC
assert.Equal(t, "@a.com", list[0].EmailSuffix)
assert.Equal(t, "@b.com", list[1].EmailSuffix)
}

func TestGetEmailQuotaRuleBySuffix(t *testing.T) {
setupEmailQuotaRuleDB(t)

rule := &EmailQuotaRule{EmailSuffix: "@find.com", Quota: 300, Enabled: true}
require.NoError(t, rule.Insert())

found, err := GetEmailQuotaRuleBySuffix("@find.com")
require.NoError(t, err)
assert.Equal(t, int64(300), found.Quota)

_, err = GetEmailQuotaRuleBySuffix("@notexist.com")
assert.Error(t, err)
}

func TestLoadEmailQuotaCache_OnlyEnabled(t *testing.T) {
setupEmailQuotaRuleDB(t)

r1 := &EmailQuotaRule{EmailSuffix: "@enabled.com", Quota: 100, Enabled: true}
r2 := &EmailQuotaRule{EmailSuffix: "@disabled.com", Quota: 200, Enabled: false}
require.NoError(t, r1.Insert())
require.NoError(t, r2.Insert())
LoadEmailQuotaCache()

assert.Equal(t, int64(100), MatchEmailQuotaRule("user@enabled.com"))
assert.Equal(t, int64(-1), MatchEmailQuotaRule("user@disabled.com"))
}

func TestMatchEmailQuotaRule_MultipleRules(t *testing.T) {
setupEmailQuotaRuleDB(t)

rules := []*EmailQuotaRule{
{EmailSuffix: "@company.com", Quota: 500000, Enabled: true},
{EmailSuffix: "@tsinghua.edu.cn", Quota: 1000000, Enabled: true},
{EmailSuffix: "@vip.org", Quota: 2000000, Enabled: true},
}
for _, r := range rules {
require.NoError(t, r.Insert())
}

assert.Equal(t, int64(500000), MatchEmailQuotaRule("user@company.com"))
assert.Equal(t, int64(1000000), MatchEmailQuotaRule("student@tsinghua.edu.cn"))
assert.Equal(t, int64(2000000), MatchEmailQuotaRule("admin@vip.org"))
assert.Equal(t, int64(-1), MatchEmailQuotaRule("random@unknown.net"))
}

+ 29
- 6
model/log.go View File

@@ -8,6 +8,7 @@ import (

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types"

"github.com/gin-gonic/gin"
@@ -36,6 +37,8 @@ type Log struct {
Group string `json:"group" gorm:"index"`
Ip string `json:"ip" gorm:"index;default:''"`
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
ChatId string `json:"chat_id,omitempty" gorm:"type:varchar(128);index:idx_logs_chat_id;default:''"`
UpstreamId string `json:"upstream_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_id;default:''"`
Other string `json:"other"`
}

@@ -94,6 +97,8 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
username := c.GetString("username")
requestId := c.GetString(common.RequestIdKey)
chatId := relaycommon.GetRelayChatID(c)
upstreamId := relaycommon.GetRelayUpstreamID(c)
otherStr := common.MapToJsonStr(other)
// 判断是否需要记录 IP
needRecordIp := false
@@ -124,8 +129,10 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
}
return ""
}(),
RequestId: requestId,
Other: otherStr,
RequestId: requestId,
ChatId: chatId,
UpstreamId: upstreamId,
Other: otherStr,
}
err := LOG_DB.Create(log).Error
if err != nil {
@@ -155,6 +162,8 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params)))
username := c.GetString("username")
requestId := c.GetString(common.RequestIdKey)
chatId := relaycommon.GetRelayChatID(c)
upstreamId := relaycommon.GetRelayUpstreamID(c)
otherStr := common.MapToJsonStr(params.Other)
// 判断是否需要记录 IP
needRecordIp := false
@@ -185,8 +194,10 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
}
return ""
}(),
RequestId: requestId,
Other: otherStr,
RequestId: requestId,
ChatId: chatId,
UpstreamId: upstreamId,
Other: otherStr,
}
err := LOG_DB.Create(log).Error
if err != nil {
@@ -242,7 +253,7 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) {
}
}

func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string) (logs []*Log, total int64, err error) {
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string, chatId string, upstreamId string) (logs []*Log, total int64, err error) {
var tx *gorm.DB
if logType == LogTypeUnknown {
tx = LOG_DB
@@ -262,6 +273,12 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
if requestId != "" {
tx = tx.Where("logs.request_id = ?", requestId)
}
if chatId != "" {
tx = tx.Where("logs.chat_id = ?", chatId)
}
if upstreamId != "" {
tx = tx.Where("logs.upstream_id = ?", upstreamId)
}
if startTimestamp != 0 {
tx = tx.Where("logs.created_at >= ?", startTimestamp)
}
@@ -328,7 +345,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName

const logSearchCountLimit = 10000

func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string) (logs []*Log, total int64, err error) {
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string, chatId string, upstreamId string) (logs []*Log, total int64, err error) {
var tx *gorm.DB
if logType == LogTypeUnknown {
tx = LOG_DB.Where("logs.user_id = ?", userId)
@@ -349,6 +366,12 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
if requestId != "" {
tx = tx.Where("logs.request_id = ?", requestId)
}
if chatId != "" {
tx = tx.Where("logs.chat_id = ?", chatId)
}
if upstreamId != "" {
tx = tx.Where("logs.upstream_id = ?", upstreamId)
}
if startTimestamp != 0 {
tx = tx.Where("logs.created_at >= ?", startTimestamp)
}


+ 197
- 0
model/log_identity_test.go View File

@@ -0,0 +1,197 @@
package model

import (
"net/http/httptest"
"testing"

"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func setupLogIdentityDB(t *testing.T) *gorm.DB {
t.Helper()

db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)

origDB := DB
origLogDB := LOG_DB
origSQLite := common.UsingSQLite
origMySQL := common.UsingMySQL
origPostgres := common.UsingPostgreSQL

DB = db
LOG_DB = db
common.UsingSQLite = true
common.UsingMySQL = false
common.UsingPostgreSQL = false
initCol()

require.NoError(t, db.AutoMigrate(&Log{}))

t.Cleanup(func() {
DB = origDB
LOG_DB = origLogDB
common.UsingSQLite = origSQLite
common.UsingMySQL = origMySQL
common.UsingPostgreSQL = origPostgres
initCol()
})

return db
}

func TestLogPersistsChatIDAndUpstreamID(t *testing.T) {
db := setupLogIdentityDB(t)

entry := &Log{
UserId: 1,
Username: "alice",
CreatedAt: 1714464000,
Type: LogTypeConsume,
Content: "usage",
ModelName: "gpt-4o-mini",
TokenName: "demo",
ChatId: "chat_123",
UpstreamId: "req_upstream_123",
RequestId: "req_internal_123",
}

require.NoError(t, db.Create(entry).Error)

var saved Log
require.NoError(t, db.First(&saved).Error)
require.Equal(t, "chat_123", saved.ChatId)
require.Equal(t, "req_upstream_123", saved.UpstreamId)
require.Equal(t, "req_internal_123", saved.RequestId)
}

func TestGetAllLogsFiltersByChatIDAndUpstreamID(t *testing.T) {
db := setupLogIdentityDB(t)

require.NoError(t, db.Create(&Log{
UserId: 1,
Username: "alice",
CreatedAt: 1714464001,
Type: LogTypeConsume,
Content: "usage-a",
ModelName: "gpt-4o-mini",
TokenName: "demo",
ChatId: "chat_a",
UpstreamId: "up_a",
RequestId: "req_a",
}).Error)
require.NoError(t, db.Create(&Log{
UserId: 2,
Username: "bob",
CreatedAt: 1714464002,
Type: LogTypeConsume,
Content: "usage-b",
ModelName: "gpt-4o-mini",
TokenName: "demo",
ChatId: "chat_b",
UpstreamId: "up_b",
RequestId: "req_b",
}).Error)

logs, total, err := GetAllLogs(LogTypeConsume, 0, 0, "", "", "", 0, 20, 0, "", "", "chat_a", "")
require.NoError(t, err)
require.Equal(t, int64(1), total)
require.Len(t, logs, 1)
require.Equal(t, "chat_a", logs[0].ChatId)

logs, total, err = GetAllLogs(LogTypeConsume, 0, 0, "", "", "", 0, 20, 0, "", "", "", "up_b")
require.NoError(t, err)
require.Equal(t, int64(1), total)
require.Len(t, logs, 1)
require.Equal(t, "up_b", logs[0].UpstreamId)
}

func TestGetUserLogsFiltersByChatIDAndUpstreamID(t *testing.T) {
db := setupLogIdentityDB(t)

require.NoError(t, db.Create(&Log{
UserId: 9,
Username: "owner",
CreatedAt: 1714464010,
Type: LogTypeConsume,
Content: "usage-owner-a",
ModelName: "gpt-4o-mini",
TokenName: "demo",
ChatId: "chat-owner-a",
UpstreamId: "up-owner-a",
RequestId: "req-owner-a",
}).Error)
require.NoError(t, db.Create(&Log{
UserId: 9,
Username: "owner",
CreatedAt: 1714464011,
Type: LogTypeConsume,
Content: "usage-owner-b",
ModelName: "gpt-4o-mini",
TokenName: "demo",
ChatId: "chat-owner-b",
UpstreamId: "up-owner-b",
RequestId: "req-owner-b",
}).Error)

logs, total, err := GetUserLogs(9, LogTypeConsume, 0, 0, "", "", 0, 20, "", "", "chat-owner-b", "")
require.NoError(t, err)
require.Equal(t, int64(1), total)
require.Len(t, logs, 1)
require.Equal(t, "chat-owner-b", logs[0].ChatId)

logs, total, err = GetUserLogs(9, LogTypeConsume, 0, 0, "", "", 0, 20, "", "", "", "up-owner-a")
require.NoError(t, err)
require.Equal(t, int64(1), total)
require.Len(t, logs, 1)
require.Equal(t, "up-owner-a", logs[0].UpstreamId)
}

func TestRecordConsumeLogPersistsContextChatIDAndUpstreamID(t *testing.T) {
db := setupLogIdentityDB(t)

gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("username", "alice")
c.Set(common.RequestIdKey, "req_internal_ctx")
relaycommon.SetRelayChatID(c, "chat_ctx")
relaycommon.SetRelayUpstreamID(c, "up_ctx")

RecordConsumeLog(c, 1, RecordConsumeLogParams{
ChannelId: 1,
ModelName: "gpt-4o-mini",
TokenName: "demo",
Content: "usage",
Quota: 10,
})

var saved Log
require.NoError(t, db.First(&saved).Error)
require.Equal(t, "chat_ctx", saved.ChatId)
require.Equal(t, "up_ctx", saved.UpstreamId)
}

func TestRecordErrorLogPersistsContextChatIDAndUpstreamID(t *testing.T) {
db := setupLogIdentityDB(t)

gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("username", "alice")
c.Set(common.RequestIdKey, "req_internal_err")
relaycommon.SetRelayChatID(c, "chat_err")
relaycommon.SetRelayUpstreamID(c, "up_err")

RecordErrorLog(c, 1, 2, "gpt-4o-mini", "demo", "upstream failed", 3, 4, false, "default", map[string]interface{}{})

var saved Log
require.NoError(t, db.First(&saved).Error)
require.Equal(t, "chat_err", saved.ChatId)
require.Equal(t, "up_err", saved.UpstreamId)
}

+ 29
- 3
model/main.go View File

@@ -203,7 +203,13 @@ func InitDB() (err error) {
}
common.SysLog("database migration started")
err = migrateDB()
return err
if err != nil {
return err
}
LoadEmailQuotaCache()
LoadChannelPricingCache()
LoadUserChannelRatioCache()
return nil
} else {
common.FatalLog(err)
}
@@ -280,8 +286,10 @@ func migrateDB() error {
&UserOAuthBinding{},
&ChannelPricing{},
&PricingTag{},
&UserChannelRatio{},
&PendingSyncRecord{},
&QuotaSyncLog{},
&EmailQuotaRule{},
)
if err != nil {
return err
@@ -294,9 +302,14 @@ func migrateDB() error {
if err := DB.AutoMigrate(&SubscriptionPlan{}); err != nil {
return err
}
}
// 将现有 sort_order=0 的模型和供应商更新为默认大数
DB.Model(&Model{}).Where("sort_order = 0").Update("sort_order", 999999)
DB.Model(&Vendor{}).Where("sort_order = 0").Update("sort_order", 999999)
migrateChannelPublicName()

return nil
}
return nil
}

func migrateDBFast() error {
// Drop bound_channel_id column from tokens table (deprecated field)
@@ -334,8 +347,10 @@ func migrateDBFast() error {
{&UserOAuthBinding{}, "UserOAuthBinding"},
{&ChannelPricing{}, "ChannelPricing"},
{&PricingTag{}, "PricingTag"},
{&UserChannelRatio{}, "UserChannelRatio"},
{&PendingSyncRecord{}, "PendingSyncRecord"},
{&QuotaSyncLog{}, "QuotaSyncLog"},
{&EmailQuotaRule{}, "EmailQuotaRule"},
}
// 动态计算migration数量,确保errChan缓冲区足够大
errChan := make(chan error, len(migrations))
@@ -683,3 +698,14 @@ func PingDB() error {
common.SysLog("Database pinged successfully")
return nil
}

func migrateChannelPublicName() {
result := DB.Model(&Channel{}).
Where("public_name = '' OR public_name IS NULL").
Update("public_name", gorm.Expr("name"))
if result.Error != nil {
common.SysError("[Migration] migrateChannelPublicName failed: " + result.Error.Error())
} else if result.RowsAffected > 0 {
common.SysLog(fmt.Sprintf("[Migration] migrateChannelPublicName: backfilled %d channels", result.RowsAffected))
}
}

+ 29
- 3
model/model_meta.go View File

@@ -42,6 +42,7 @@ type Model struct {
Endpoints string `json:"endpoints,omitempty" gorm:"type:text"`
Status int `json:"status" gorm:"default:1"`
SyncOfficial int `json:"sync_official" gorm:"default:1"`
SortOrder int `json:"sort_order" gorm:"default:999999"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"`
@@ -89,7 +90,7 @@ func (mi *Model) Update() error {
mi.UpdatedTime = common.GetTimestamp()
// 使用 Select 强制更新所有字段,包括零值
return DB.Model(&Model{}).Where("id = ?", mi.Id).
Select("model_name", "description", "icon", "tags", "type", "vendor_id", "endpoints", "status", "sync_official", "name_rule", "updated_time").
Select("model_name", "description", "icon", "tags", "type", "vendor_id", "endpoints", "status", "sync_official", "name_rule", "sort_order", "updated_time").
Updates(mi).Error
}

@@ -97,6 +98,16 @@ func (mi *Model) Delete() error {
return DB.Delete(mi).Error
}

// GetDisabledModelNames returns model names that are disabled (status = 0) from the given list.
func GetDisabledModelNames(names []string) []string {
if len(names) == 0 {
return nil
}
var disabled []string
DB.Table("models").Where("model_name IN ? AND status = 0", names).Pluck("model_name", &disabled)
return disabled
}

func GetVendorModelCounts() (map[int64]int64, error) {
var stats []struct {
VendorID int64
@@ -117,7 +128,7 @@ func GetVendorModelCounts() (map[int64]int64, error) {

func GetAllModels(offset int, limit int) ([]*Model, error) {
var models []*Model
err := DB.Order("id DESC").Offset(offset).Limit(limit).Find(&models).Error
err := DB.Order("sort_order ASC, id ASC").Offset(offset).Limit(limit).Find(&models).Error
return models, err
}

@@ -165,8 +176,23 @@ func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Mode
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Order("models.id DESC").Offset(offset).Limit(limit).Find(&models).Error; err != nil {
if err := db.Order("sort_order ASC, models.id ASC").Offset(offset).Limit(limit).Find(&models).Error; err != nil {
return nil, 0, err
}
return models, total, nil
}

// ReorderModels 批量更新模型排序值
func ReorderModels(items []struct {
Id int `json:"id"`
SortOrder int `json:"sort_order"`
}) error {
return DB.Transaction(func(tx *gorm.DB) error {
for _, item := range items {
if err := tx.Model(&Model{}).Where("id = ?", item.Id).Update("sort_order", item.SortOrder).Error; err != nil {
return err
}
}
return nil
})
}

+ 5
- 0
model/option.go View File

@@ -43,6 +43,7 @@ func InitOptionMap() {
common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled)
common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled)
common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled)
common.OptionMap["CaptchaEnabled"] = strconv.FormatBool(common.CaptchaEnabled)
common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled)
common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled)
common.OptionMap["AutomaticEnableChannelEnabled"] = strconv.FormatBool(common.AutomaticEnableChannelEnabled)
@@ -268,6 +269,8 @@ func updateOptionMap(key string, value string) (err error) {
common.TelegramOAuthEnabled = boolValue
case "TurnstileCheckEnabled":
common.TurnstileCheckEnabled = boolValue
case "CaptchaEnabled":
common.CaptchaEnabled = boolValue
case "RegisterEnabled":
common.RegisterEnabled = boolValue
case "EmailDomainRestrictionEnabled":
@@ -456,6 +459,8 @@ func updateOptionMap(key string, value string) (err error) {
common.SystemName = value
case "Logo":
common.Logo = value
case "DefaultLanguage":
common.DefaultLanguage = value
case "WeChatServerAddress":
common.WeChatServerAddress = value
case "WeChatServerToken":


+ 2
- 1
model/pending_sync_record.go View File

@@ -87,9 +87,10 @@ func SettlePendingSyncRecord(requestId string, actualQuota int) (bool, error) {
}

// GetPendingRecordsForSync 获取待同步记录(只同步 settled 和 failed)
// 优先同步 quota > 0 的记录,quota=0 的旧记录不会阻塞正常扣费同步
func GetPendingRecordsForSync(limit, maxRetry int) []PendingSyncRecord {
var records []PendingSyncRecord
DB.Where("status IN ? AND retry_count < ?",
DB.Where("status IN ? AND retry_count < ? AND quota > 0",
[]string{PendingSyncStatusSettled, PendingSyncStatusFailed},
maxRetry).
Limit(limit).


+ 162
- 10
model/pricing.go View File

@@ -3,6 +3,7 @@ package model
import (
"encoding/json"
"fmt"
"sort"
"strings"

"sync"
@@ -25,10 +26,13 @@ type Pricing struct {
ModelPrice float64 `json:"model_price"`
OwnerBy string `json:"owner_by"`
CompletionRatio float64 `json:"completion_ratio"`
CacheRatio float64 `json:"cache_ratio"`
CacheCreationRatio float64 `json:"cache_creation_ratio"`
EnableGroup []string `json:"enable_groups"`
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
PricingVersion string `json:"pricing_version,omitempty"`
Type int `json:"type"`
DefaultChannelName string `json:"default_channel_name,omitempty"`
}

type PricingVendor struct {
@@ -40,6 +44,7 @@ type PricingVendor struct {

var (
pricingMap []Pricing
pricingByModel map[string]*Pricing
vendorsList []PricingVendor
supportedEndpointMap map[string]common.EndpointInfo
lastGetPricingTime time.Time
@@ -56,6 +61,19 @@ var (
modelSupportEndpointsLock = sync.RWMutex{}
)

// SetTestPricing 设置测试用的定价缓存(仅用于测试)
func SetTestPricing(pricing []Pricing) {
updatePricingLock.Lock()
defer updatePricingLock.Unlock()
pricingMap = pricing
idx := make(map[string]*Pricing, len(pricing))
for i := range pricing {
idx[pricing[i].ModelName] = &pricingMap[i]
}
pricingByModel = idx
lastGetPricingTime = time.Now()
}

func GetPricing() []Pricing {
if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
updatePricingLock.Lock()
@@ -70,6 +88,15 @@ func GetPricing() []Pricing {
return pricingMap
}

// GetPricingByModel 从缓存中查找指定模型的定价信息
func GetPricingByModel(modelName string) *Pricing {
GetPricing() // 确保缓存已刷新
modelEnableGroupsLock.RLock()
p := pricingByModel[modelName]
modelEnableGroupsLock.RUnlock()
return p
}

// GetVendors 返回当前定价接口使用到的供应商信息
func GetVendors() []PricingVendor {
if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
@@ -162,6 +189,11 @@ func updatePricing() {
initDefaultVendorMapping(metaMap, vendorMap, enableAbilities)

// 构建对前端友好的供应商列表
vendorOrderMap := make(map[int]int)
for _, v := range vendorMap {
vendorOrderMap[v.Id] = v.SortOrder
}

vendorsList = make([]PricingVendor, 0, len(vendorMap))
for _, v := range vendorMap {
vendorsList = append(vendorsList, PricingVendor{
@@ -171,6 +203,14 @@ func updatePricing() {
Icon: v.Icon,
})
}
sort.Slice(vendorsList, func(i, j int) bool {
oi := vendorOrderMap[vendorsList[i].ID]
oj := vendorOrderMap[vendorsList[j].ID]
if oi != oj {
return oi < oj
}
return vendorsList[i].ID < vendorsList[j].ID
})

modelGroupsMap := make(map[string]*types.Set[string])

@@ -269,6 +309,24 @@ func updatePricing() {
}
}

// 从渠道定价表加载实际定价数据(仅启用渠道),同时获取渠道名称
var allCPs []struct {
ChannelPricing
ChannelName string
ChannelPublicName string
}
DB.Table("channel_pricings").
Select("channel_pricings.*, channels.name as channel_name, channels.public_name as channel_public_name").
Joins("JOIN channels ON channel_pricings.channel_id = channels.id").
Where("channels.status = 1 AND channel_pricings.deleted_at IS NULL").
Find(&allCPs)
cpMap := make(map[string][]ChannelPricing)
channelNameMap := make(map[int]string)
for i := range allCPs {
cpMap[allCPs[i].ModelName] = append(cpMap[allCPs[i].ModelName], allCPs[i].ChannelPricing)
channelNameMap[allCPs[i].ChannelId] = ChannelDisplayName(allCPs[i].ChannelPublicName, allCPs[i].ChannelName)
}

pricingMap = make([]Pricing, 0)
for model, groups := range modelGroupsMap {
pricing := Pricing{
@@ -289,19 +347,37 @@ func updatePricing() {
pricing.VendorID = meta.VendorID
pricing.Type = meta.Type
}
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
if findPrice {
pricing.ModelPrice = modelPrice
pricing.QuotaType = 1
} else {
modelRatio, _, _ := ratio_setting.GetModelRatio(model)
pricing.ModelRatio = modelRatio
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
pricing.QuotaType = 0

// 使用渠道定价表中的实际数据,选取最便宜的渠道
applyBestChannelPricing(&pricing, cpMap[model], model)
// 填充默认通道名称
if chId, ok := GetDefaultChannelId(model); ok {
if name, found := channelNameMap[chId]; found {
pricing.DefaultChannelName = name
}
}

pricingMap = append(pricingMap, pricing)
}

// 按 sort_order 排序 pricingMap,999999 视为未设置
sort.Slice(pricingMap, func(i, j int) bool {
mi, okI := metaMap[pricingMap[i].ModelName]
mj, okJ := metaMap[pricingMap[j].ModelName]
si := 999999
sj := 999999
if okI {
si = mi.SortOrder
}
if okJ {
sj = mj.SortOrder
}
if si != sj {
return si < sj
}
return pricingMap[i].ModelName < pricingMap[j].ModelName
})

// 防止大更新后数据不通用
if len(pricingMap) > 0 {
pricingMap[0].PricingVersion = "82c4a357505fff6fee8462c3f7ec8a645bb95532669cb73b2cabee6a416ec24f"
@@ -311,10 +387,14 @@ func updatePricing() {
modelEnableGroupsLock.Lock()
modelEnableGroups = make(map[string][]string)
modelQuotaTypeMap = make(map[string]int)
for _, p := range pricingMap {
byModel := make(map[string]*Pricing, len(pricingMap))
for i := range pricingMap {
p := &pricingMap[i]
modelEnableGroups[p.ModelName] = p.EnableGroup
modelQuotaTypeMap[p.ModelName] = p.QuotaType
byModel[p.ModelName] = p
}
pricingByModel = byModel
modelEnableGroupsLock.Unlock()

lastGetPricingTime = time.Now()
@@ -324,3 +404,75 @@ func updatePricing() {
func GetSupportedEndpointMap() map[string]common.EndpointInfo {
return supportedEndpointMap
}

// applyGlobalDefault 用全局默认值填充 Pricing(无渠道定价时的回退)
func applyGlobalDefault(pricing *Pricing, model string) {
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
if findPrice {
pricing.ModelPrice = modelPrice
pricing.QuotaType = 1
} else {
modelRatio, _, _ := ratio_setting.GetModelRatio(model)
pricing.ModelRatio = modelRatio
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
pricing.QuotaType = 0
}
pricing.CacheRatio, _ = ratio_setting.GetCacheRatio(model)
pricing.CacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(model)
}

// applyBestChannelPricing 从渠道定价中选取最优(最便宜)的价格填充 Pricing
// 优先选按量计费(quota_type=0)的渠道,因为缓存价格仅对按量计费有意义
// 扩展比率字段(cache_ratio 等)为 0 表示未设置,需回退到全局默认值
func applyBestChannelPricing(pricing *Pricing, cps []ChannelPricing, model string) {
if len(cps) == 0 {
applyGlobalDefault(pricing, model)
return
}

// 优先选按量计费 (quota_type=0) 中 model_ratio 最低的渠道
var bestPerToken *ChannelPricing
for i := range cps {
cp := &cps[i]
if cp.QuotaType == 0 {
if bestPerToken == nil || cp.ModelRatio < bestPerToken.ModelRatio {
bestPerToken = cp
}
}
}

if bestPerToken != nil {
pricing.QuotaType = 0
pricing.ModelRatio = bestPerToken.ModelRatio
pricing.CompletionRatio = bestPerToken.CompletionRatio
if bestPerToken.CacheRatio > 0 {
pricing.CacheRatio = bestPerToken.CacheRatio
} else {
pricing.CacheRatio, _ = ratio_setting.GetCacheRatio(model)
}
if bestPerToken.CacheCreationRatio > 0 {
pricing.CacheCreationRatio = bestPerToken.CacheCreationRatio
} else {
pricing.CacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(model)
}
return
}

// 没有按量渠道,选按次计费 (quota_type=1) 中 model_price 最低的渠道
var bestPerCall *ChannelPricing
for i := range cps {
cp := &cps[i]
if cp.QuotaType == 1 {
if bestPerCall == nil || cp.ModelPrice < bestPerCall.ModelPrice {
bestPerCall = cp
}
}
}
if bestPerCall != nil {
pricing.QuotaType = 1
pricing.ModelPrice = bestPerCall.ModelPrice
return
}

applyGlobalDefault(pricing, model)
}

+ 451
- 0
model/pricing.go.bak View File

@@ -0,0 +1,451 @@
package model

import (
"encoding/json"
"fmt"
"sort"
"strings"

"sync"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
)

type Pricing struct {
ModelName string `json:"model_name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Tags string `json:"tags,omitempty"`
VendorID int `json:"vendor_id,omitempty"`
QuotaType int `json:"quota_type"`
ModelRatio float64 `json:"model_ratio"`
ModelPrice float64 `json:"model_price"`
OwnerBy string `json:"owner_by"`
CompletionRatio float64 `json:"completion_ratio"`
CacheRatio float64 `json:"cache_ratio"`
CacheCreationRatio float64 `json:"cache_creation_ratio"`
EnableGroup []string `json:"enable_groups"`
SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"`
PricingVersion string `json:"pricing_version,omitempty"`
Type int `json:"type"`
DefaultChannelName string `json:"default_channel_name,omitempty"`
}

type PricingVendor struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
}

var (
pricingMap []Pricing
vendorsList []PricingVendor
supportedEndpointMap map[string]common.EndpointInfo
lastGetPricingTime time.Time
updatePricingLock sync.Mutex

// 缓存映射:模型名 -> 启用分组 / 计费类型
modelEnableGroups = make(map[string][]string)
modelQuotaTypeMap = make(map[string]int)
modelEnableGroupsLock = sync.RWMutex{}
)

var (
modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
modelSupportEndpointsLock = sync.RWMutex{}
)

func GetPricing() []Pricing {
if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
updatePricingLock.Lock()
defer updatePricingLock.Unlock()
// Double check after acquiring the lock
if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
modelSupportEndpointsLock.Lock()
defer modelSupportEndpointsLock.Unlock()
updatePricing()
}
}
return pricingMap
}

// GetVendors 返回当前定价接口使用到的供应商信息
func GetVendors() []PricingVendor {
if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 {
// 保证先刷新一次
GetPricing()
}
return vendorsList
}

func GetModelSupportEndpointTypes(model string) []constant.EndpointType {
if model == "" {
return make([]constant.EndpointType, 0)
}
modelSupportEndpointsLock.RLock()
defer modelSupportEndpointsLock.RUnlock()
if endpoints, ok := modelSupportEndpointTypes[model]; ok {
return endpoints
}
return make([]constant.EndpointType, 0)
}

func updatePricing() {
//modelRatios := common.GetModelRatios()
enableAbilities, err := GetAllEnableAbilityWithChannels()
if err != nil {
common.SysLog(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err))
return
}
// 预加载模型元数据与供应商一次,避免循环查询
var allMeta []Model
_ = DB.Find(&allMeta).Error
metaMap := make(map[string]*Model)
prefixList := make([]*Model, 0)
suffixList := make([]*Model, 0)
containsList := make([]*Model, 0)
for i := range allMeta {
m := &allMeta[i]
if m.NameRule == NameRuleExact {
metaMap[m.ModelName] = m
} else {
switch m.NameRule {
case NameRulePrefix:
prefixList = append(prefixList, m)
case NameRuleSuffix:
suffixList = append(suffixList, m)
case NameRuleContains:
containsList = append(containsList, m)
}
}
}

// 将非精确规则模型匹配到 metaMap
for _, m := range prefixList {
for _, pricingModel := range enableAbilities {
if strings.HasPrefix(pricingModel.Model, m.ModelName) {
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
}
}
}
}
for _, m := range suffixList {
for _, pricingModel := range enableAbilities {
if strings.HasSuffix(pricingModel.Model, m.ModelName) {
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
}
}
}
}
for _, m := range containsList {
for _, pricingModel := range enableAbilities {
if strings.Contains(pricingModel.Model, m.ModelName) {
if _, exists := metaMap[pricingModel.Model]; !exists {
metaMap[pricingModel.Model] = m
}
}
}
}

// 预加载供应商
var vendors []Vendor
_ = DB.Find(&vendors).Error
vendorMap := make(map[int]*Vendor)
for i := range vendors {
vendorMap[vendors[i].Id] = &vendors[i]
}

// 初始化默认供应商映射
initDefaultVendorMapping(metaMap, vendorMap, enableAbilities)

// 构建对前端友好的供应商列表
vendorOrderMap := make(map[int]int)
for _, v := range vendorMap {
vendorOrderMap[v.Id] = v.SortOrder
}

vendorsList = make([]PricingVendor, 0, len(vendorMap))
for _, v := range vendorMap {
vendorsList = append(vendorsList, PricingVendor{
ID: v.Id,
Name: v.Name,
Description: v.Description,
Icon: v.Icon,
})
}
sort.Slice(vendorsList, func(i, j int) bool {
oi := vendorOrderMap[vendorsList[i].ID]
oj := vendorOrderMap[vendorsList[j].ID]
if oi != oj {
return oi < oj
}
return vendorsList[i].ID < vendorsList[j].ID
})

modelGroupsMap := make(map[string]*types.Set[string])

for _, ability := range enableAbilities {
groups, ok := modelGroupsMap[ability.Model]
if !ok {
groups = types.NewSet[string]()
modelGroupsMap[ability.Model] = groups
}
groups.Add(ability.Group)
}

//这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点
modelSupportEndpointsStr := make(map[string][]string)

// 先根据已有能力填充原生端点
for _, ability := range enableAbilities {
endpoints := modelSupportEndpointsStr[ability.Model]
channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model)
for _, channelType := range channelTypes {
if !common.StringsContains(endpoints, string(channelType)) {
endpoints = append(endpoints, string(channelType))
}
}
modelSupportEndpointsStr[ability.Model] = endpoints
}

// 再补充模型自定义端点:若配置有效则替换默认端点,不做合并
for modelName, meta := range metaMap {
if strings.TrimSpace(meta.Endpoints) == "" {
continue
}
var raw map[string]interface{}
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
endpoints := make([]string, 0, len(raw))
for k, v := range raw {
switch v.(type) {
case string, map[string]interface{}:
if !common.StringsContains(endpoints, k) {
endpoints = append(endpoints, k)
}
}
}
if len(endpoints) > 0 {
modelSupportEndpointsStr[modelName] = endpoints
}
}
}

modelSupportEndpointTypes = make(map[string][]constant.EndpointType)
for model, endpoints := range modelSupportEndpointsStr {
supportedEndpoints := make([]constant.EndpointType, 0)
for _, endpointStr := range endpoints {
endpointType := constant.EndpointType(endpointStr)
supportedEndpoints = append(supportedEndpoints, endpointType)
}
modelSupportEndpointTypes[model] = supportedEndpoints
}

// 构建全局 supportedEndpointMap(默认 + 自定义覆盖)
supportedEndpointMap = make(map[string]common.EndpointInfo)
// 1. 默认端点
for _, endpoints := range modelSupportEndpointTypes {
for _, et := range endpoints {
if info, ok := common.GetDefaultEndpointInfo(et); ok {
if _, exists := supportedEndpointMap[string(et)]; !exists {
supportedEndpointMap[string(et)] = info
}
}
}
}
// 2. 自定义端点(models 表)覆盖默认
for _, meta := range metaMap {
if strings.TrimSpace(meta.Endpoints) == "" {
continue
}
var raw map[string]interface{}
if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil {
for k, v := range raw {
switch val := v.(type) {
case string:
supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"}
case map[string]interface{}:
ep := common.EndpointInfo{Method: "POST"}
if p, ok := val["path"].(string); ok {
ep.Path = p
}
if m, ok := val["method"].(string); ok {
ep.Method = strings.ToUpper(m)
}
supportedEndpointMap[k] = ep
default:
// ignore unsupported types
}
}
}
}

// 从渠道定价表加载实际定价数据(仅启用渠道),同时获取渠道名称
var allCPs []struct {
ChannelPricing
ChannelName string
ChannelPublicName string
}
DB.Table("channel_pricings").
Select("channel_pricings.*, channels.name as channel_name, channels.public_name as channel_public_name").
Joins("JOIN channels ON channel_pricings.channel_id = channels.id").
Where("channels.status = 1 AND channel_pricings.deleted_at IS NULL").
Find(&allCPs)
cpMap := make(map[string][]ChannelPricing)
channelNameMap := make(map[int]string)
for i := range allCPs {
cpMap[allCPs[i].ModelName] = append(cpMap[allCPs[i].ModelName], allCPs[i].ChannelPricing)
channelNameMap[allCPs[i].ChannelId] = allCPs[i].ChannelName
}

pricingMap = make([]Pricing, 0)
for model, groups := range modelGroupsMap {
pricing := Pricing{
ModelName: model,
EnableGroup: groups.Items(),
SupportedEndpointTypes: modelSupportEndpointTypes[model],
}

// 补充模型元数据(描述、标签、供应商、状态)
if meta, ok := metaMap[model]; ok {
// 若模型被禁用(status==0),则直接跳过,不返回给前端
if meta.Status == 0 {
continue
}
pricing.Description = meta.Description
pricing.Icon = meta.Icon
pricing.Tags = meta.Tags
pricing.VendorID = meta.VendorID
pricing.Type = meta.Type
}

// 使用渠道定价表中的实际数据,选取最便宜的渠道
applyBestChannelPricing(&pricing, cpMap[model], model)
// 填充默认通道名称
if chId, ok := GetDefaultChannelId(model); ok {
if name, found := channelNameMap[chId]; found {
pricing.DefaultChannelName = name
}
}

pricingMap = append(pricingMap, pricing)
}

// 按 sort_order 排序 pricingMap,999999 视为未设置
sort.Slice(pricingMap, func(i, j int) bool {
mi, okI := metaMap[pricingMap[i].ModelName]
mj, okJ := metaMap[pricingMap[j].ModelName]
si := 999999
sj := 999999
if okI {
si = mi.SortOrder
}
if okJ {
sj = mj.SortOrder
}
if si != sj {
return si < sj
}
return pricingMap[i].ModelName < pricingMap[j].ModelName
})

// 防止大更新后数据不通用
if len(pricingMap) > 0 {
pricingMap[0].PricingVersion = "82c4a357505fff6fee8462c3f7ec8a645bb95532669cb73b2cabee6a416ec24f"
}

// 刷新缓存映射,供高并发快速查询
modelEnableGroupsLock.Lock()
modelEnableGroups = make(map[string][]string)
modelQuotaTypeMap = make(map[string]int)
for _, p := range pricingMap {
modelEnableGroups[p.ModelName] = p.EnableGroup
modelQuotaTypeMap[p.ModelName] = p.QuotaType
}
modelEnableGroupsLock.Unlock()

lastGetPricingTime = time.Now()
}

// GetSupportedEndpointMap 返回全局端点到路径的映射
func GetSupportedEndpointMap() map[string]common.EndpointInfo {
return supportedEndpointMap
}

// applyGlobalDefault 用全局默认值填充 Pricing(无渠道定价时的回退)
func applyGlobalDefault(pricing *Pricing, model string) {
modelPrice, findPrice := ratio_setting.GetModelPrice(model, false)
if findPrice {
pricing.ModelPrice = modelPrice
pricing.QuotaType = 1
} else {
modelRatio, _, _ := ratio_setting.GetModelRatio(model)
pricing.ModelRatio = modelRatio
pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model)
pricing.QuotaType = 0
}
pricing.CacheRatio, _ = ratio_setting.GetCacheRatio(model)
pricing.CacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(model)
}

// applyBestChannelPricing 从渠道定价中选取最优(最便宜)的价格填充 Pricing
// 优先选按量计费(quota_type=0)的渠道,因为缓存价格仅对按量计费有意义
// 扩展比率字段(cache_ratio 等)为 0 表示未设置,需回退到全局默认值
func applyBestChannelPricing(pricing *Pricing, cps []ChannelPricing, model string) {
if len(cps) == 0 {
applyGlobalDefault(pricing, model)
return
}

// 优先选按量计费 (quota_type=0) 中 model_ratio 最低的渠道
var bestPerToken *ChannelPricing
for i := range cps {
cp := &cps[i]
if cp.QuotaType == 0 {
if bestPerToken == nil || cp.ModelRatio < bestPerToken.ModelRatio {
bestPerToken = cp
}
}
}

if bestPerToken != nil {
pricing.QuotaType = 0
pricing.ModelRatio = bestPerToken.ModelRatio
pricing.CompletionRatio = bestPerToken.CompletionRatio
if bestPerToken.CacheRatio > 0 {
pricing.CacheRatio = bestPerToken.CacheRatio
} else {
pricing.CacheRatio, _ = ratio_setting.GetCacheRatio(model)
}
if bestPerToken.CacheCreationRatio > 0 {
pricing.CacheCreationRatio = bestPerToken.CacheCreationRatio
} else {
pricing.CacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(model)
}
return
}

// 没有按量渠道,选按次计费 (quota_type=1) 中 model_price 最低的渠道
var bestPerCall *ChannelPricing
for i := range cps {
cp := &cps[i]
if cp.QuotaType == 1 {
if bestPerCall == nil || cp.ModelPrice < bestPerCall.ModelPrice {
bestPerCall = cp
}
}
}
if bestPerCall != nil {
pricing.QuotaType = 1
pricing.ModelPrice = bestPerCall.ModelPrice
return
}

applyGlobalDefault(pricing, model)
}

+ 122
- 0
model/pricing_test.go View File

@@ -0,0 +1,122 @@
package model

import (
"testing"

"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

const testPricingModel = "test-pricing-model-apply"

func setupPricingTest(t *testing.T) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)

origDB := DB
DB = db
require.NoError(t, db.AutoMigrate(&ChannelPricing{}))

// 全局默认定价
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testPricingModel+`":10}`))
require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testPricingModel+`":3}`))
require.NoError(t, ratio_setting.UpdateCacheRatioByJSONString(`{"`+testPricingModel+`":0.5}`))
require.NoError(t, ratio_setting.UpdateCreateCacheRatioByJSONString(`{"`+testPricingModel+`":0.75}`))

t.Cleanup(func() {
DB = origDB
sqlDB.Close()
ratio_setting.UpdateModelRatioByJSONString(`{}`)
ratio_setting.UpdateCompletionRatioByJSONString(`{}`)
ratio_setting.UpdateCacheRatioByJSONString(`{}`)
ratio_setting.UpdateCreateCacheRatioByJSONString(`{}`)
ratio_setting.UpdateModelPriceByJSONString(`{}`)
})
}

func TestApplyBestChannelPricing_NoChannelPricing(t *testing.T) {
setupPricingTest(t)

p := &Pricing{}
applyBestChannelPricing(p, nil, testPricingModel)

require.Equal(t, 0, p.QuotaType, "应使用全局按量计费")
require.Equal(t, 10.0, p.ModelRatio, "应使用全局 model_ratio")
require.Equal(t, 3.0, p.CompletionRatio, "应使用全局 completion_ratio")
require.Equal(t, 0.5, p.CacheRatio, "应使用全局 cache_ratio")
require.Equal(t, 0.75, p.CacheCreationRatio, "应使用全局 cache_creation_ratio")
}

func TestApplyBestChannelPricing_PerTokenCheapest(t *testing.T) {
setupPricingTest(t)

// 渠道A: model_ratio=8(更便宜)
require.NoError(t, (&ChannelPricing{
ModelName: testPricingModel, ChannelId: 1,
QuotaType: QuotaTypeByTokens, ModelRatio: 8, CompletionRatio: 2,
CacheRatio: 0.3, CacheCreationRatio: 0.6,
}).Insert())
// 渠道B: model_ratio=12(更贵)
require.NoError(t, (&ChannelPricing{
ModelName: testPricingModel, ChannelId: 2,
QuotaType: QuotaTypeByTokens, ModelRatio: 12, CompletionRatio: 4,
CacheRatio: 0.8, CacheCreationRatio: 1.0,
}).Insert())

cps := []ChannelPricing{
{ModelRatio: 8, CompletionRatio: 2, CacheRatio: 0.3, CacheCreationRatio: 0.6},
{ModelRatio: 12, CompletionRatio: 4, CacheRatio: 0.8, CacheCreationRatio: 1.0},
}

p := &Pricing{}
applyBestChannelPricing(p, cps, testPricingModel)

require.Equal(t, 0, p.QuotaType)
require.Equal(t, 8.0, p.ModelRatio, "应选最便宜的渠道A")
require.Equal(t, 2.0, p.CompletionRatio)
require.Equal(t, 0.3, p.CacheRatio)
require.Equal(t, 0.6, p.CacheCreationRatio)
}

func TestApplyBestChannelPricing_ExtendedRatioZeroFallback(t *testing.T) {
setupPricingTest(t)

// 渠道定价中扩展比率为 0,应回退到全局值
require.NoError(t, (&ChannelPricing{
ModelName: testPricingModel, ChannelId: 1,
QuotaType: QuotaTypeByTokens, ModelRatio: 5, CompletionRatio: 1,
CacheRatio: 0, CacheCreationRatio: 0, // 0 = 未设置
}).Insert())

cps := []ChannelPricing{
{ModelRatio: 5, CompletionRatio: 1, CacheRatio: 0, CacheCreationRatio: 0},
}

p := &Pricing{}
applyBestChannelPricing(p, cps, testPricingModel)

require.Equal(t, 0.5, p.CacheRatio, "cache_ratio=0 应回退全局 0.5")
require.Equal(t, 0.75, p.CacheCreationRatio, "cache_creation_ratio=0 应回退全局 0.75")
}

func TestApplyBestChannelPricing_PerCallCheapest(t *testing.T) {
setupPricingTest(t)
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"`+testPricingModel+`":0.8}`))

// 只有按次计费的渠道
cps := []ChannelPricing{
{QuotaType: QuotaTypeByCall, ModelPrice: 0.3},
{QuotaType: QuotaTypeByCall, ModelPrice: 0.5},
}

p := &Pricing{}
applyBestChannelPricing(p, cps, testPricingModel)

require.Equal(t, 1, p.QuotaType)
require.Equal(t, 0.3, p.ModelPrice, "应选最便宜的按次渠道")
}

+ 4
- 3
model/redemption.go View File

@@ -20,6 +20,7 @@ type Redemption struct {
Key string `json:"key" gorm:"type:char(32);uniqueIndex"`
Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index"`
Remark string `json:"remark" gorm:"index"`
Quota int `json:"quota" gorm:"default:100"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"`
@@ -79,9 +80,9 @@ func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Re

// Only try to convert to ID if the string represents a valid integer
if id, err := strconv.Atoi(keyword); err == nil {
query = query.Where("id = ? OR name LIKE ?", id, keyword+"%")
query = query.Where("id = ? OR name LIKE ? OR remark LIKE ?", id, keyword+"%", keyword+"%")
} else {
query = query.Where("name LIKE ?", keyword+"%")
query = query.Where("name LIKE ? OR remark LIKE ?", keyword+"%", keyword+"%")
}

// Get total count
@@ -187,7 +188,7 @@ func (redemption *Redemption) SelectUpdate() error {
// Update Make sure your token's fields is completed, because this will update non-zero values
func (redemption *Redemption) Update() error {
var err error
err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error
err = DB.Model(redemption).Select("name", "remark", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error
return err
}



+ 101
- 14
model/redemption_test.go View File

@@ -48,13 +48,13 @@ func TestRedeem_Success(t *testing.T) {

// 创建兑换码
redemption := Redemption{
Id: 1,
UserId: 1,
Key: "test-key-123",
Status: common.RedemptionCodeStatusEnabled,
Quota: 50000,
CreatedTime: common.GetTimestamp(),
ExpiredTime: 0, // 永不过期
Id: 1,
UserId: 1,
Key: "test-key-123",
Status: common.RedemptionCodeStatusEnabled,
Quota: 50000,
CreatedTime: common.GetTimestamp(),
ExpiredTime: 0, // 永不过期
}
require.NoError(t, db.Create(&redemption).Error)

@@ -148,13 +148,13 @@ func TestRedeem_Expired(t *testing.T) {

// 创建已过期的兑换码
redemption := Redemption{
Id: 1,
UserId: 1,
Key: "expired-key",
Status: common.RedemptionCodeStatusEnabled,
Quota: 50000,
CreatedTime: common.GetTimestamp() - 86400,
ExpiredTime: common.GetTimestamp() - 3600, // 已过期
Id: 1,
UserId: 1,
Key: "expired-key",
Status: common.RedemptionCodeStatusEnabled,
Quota: 50000,
CreatedTime: common.GetTimestamp() - 86400,
ExpiredTime: common.GetTimestamp() - 3600, // 已过期
}
require.NoError(t, db.Create(&redemption).Error)

@@ -252,3 +252,90 @@ func TestRedeem_SyncedUser(t *testing.T) {
require.NoError(t, db.First(&updatedUser, 100).Error)
assert.Equal(t, 150000, updatedUser.Quota)
}

func TestRedemptionUpdateRemarkPersistsRemark(t *testing.T) {
db := setupRedemptionDB(t)

redemption := Redemption{
Id: 1,
UserId: 1,
Key: "remark-update-key",
Status: common.RedemptionCodeStatusEnabled,
Name: "starter",
Remark: "initial-note",
Quota: 100,
CreatedTime: common.GetTimestamp(),
}
require.NoError(t, db.Create(&redemption).Error)

redemption.Remark = "vip-updated"
require.NoError(t, redemption.Update())

var updated Redemption
require.NoError(t, db.First(&updated, redemption.Id).Error)
assert.Equal(t, "vip-updated", updated.Remark)
}

func TestSearchRedemptionsByRemark(t *testing.T) {
db := setupRedemptionDB(t)

require.NoError(t, db.Create(&Redemption{
Id: 1,
UserId: 1,
Key: "remark-search-key",
Status: common.RedemptionCodeStatusEnabled,
Name: "starter-pack",
Remark: "vip benefit",
Quota: 100,
CreatedTime: common.GetTimestamp(),
}).Error)
require.NoError(t, db.Create(&Redemption{
Id: 2,
UserId: 1,
Key: "remark-search-other",
Status: common.RedemptionCodeStatusEnabled,
Name: "basic-pack",
Remark: "standard benefit",
Quota: 100,
CreatedTime: common.GetTimestamp(),
}).Error)

results, total, err := SearchRedemptions("vip", 0, 10)
require.NoError(t, err)
require.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, 1, results[0].Id)
assert.Equal(t, "vip benefit", results[0].Remark)
}

func TestSearchRedemptionsByRemarkWithNumericKeyword(t *testing.T) {
db := setupRedemptionDB(t)

require.NoError(t, db.Create(&Redemption{
Id: 1,
UserId: 1,
Key: "remark-search-numeric",
Status: common.RedemptionCodeStatusEnabled,
Name: "starter-pack",
Remark: "123-vip benefit",
Quota: 100,
CreatedTime: common.GetTimestamp(),
}).Error)
require.NoError(t, db.Create(&Redemption{
Id: 2,
UserId: 1,
Key: "remark-search-other-numeric",
Status: common.RedemptionCodeStatusEnabled,
Name: "basic-pack",
Remark: "standard benefit",
Quota: 100,
CreatedTime: common.GetTimestamp(),
}).Error)

results, total, err := SearchRedemptions("123", 0, 10)
require.NoError(t, err)
require.Equal(t, int64(1), total)
require.Len(t, results, 1)
assert.Equal(t, 1, results[0].Id)
assert.Equal(t, "123-vip benefit", results[0].Remark)
}

+ 0
- 80
model/topup_alipay.go View File

@@ -1,80 +0,0 @@
package model

import (
"errors"
"fmt"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"

"github.com/shopspring/decimal"
"gorm.io/gorm"
)

// RechargeAlipay 支付宝支付充值完成(由回调触发)
// 与 RechargeWechat 类似,使用事务+行锁保证幂等
func RechargeAlipay(tradeNo string) error {
if tradeNo == "" {
return errors.New("未提供支付单号")
}

var quotaToAdd int64
var payMoney float64
var userId int

refCol := "`trade_no`"
if common.UsingPostgreSQL {
refCol = `"trade_no"`
}

err := DB.Transaction(func(tx *gorm.DB) error {
topUp := &TopUp{}
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
return errors.New("充值订单不存在")
}

if topUp.Status == common.TopUpStatusSuccess {
// 已处理,幂等返回
return nil
}

if topUp.Status != common.TopUpStatusPending {
return errors.New("充值订单状态错误")
}

topUp.CompleteTime = common.GetTimestamp()
topUp.Status = common.TopUpStatusSuccess
if err := tx.Save(topUp).Error; err != nil {
return err
}

// 支付宝充值额度计算:
// topUp.Money = req.Amount * topUpGroupRatio * unitPrice * discount(经分组倍率和折扣调整后的实际支付金额)
// 充值额度 = topUp.Money * QuotaPerUnit
dMoney := decimal.NewFromFloat(topUp.Money)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd = dMoney.Mul(dQuotaPerUnit).IntPart()

if quotaToAdd <= 0 {
return errors.New("无效的充值额度")
}

if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
return err
}

userId = topUp.UserId
payMoney = topUp.Money
return nil
})

if err != nil {
return err
}

if quotaToAdd > 0 {
RecordLog(userId, LogTypeTopup, fmt.Sprintf("使用支付宝充值成功,充值金额: %v,支付金额:%.2f", logger.FormatQuota(int(quotaToAdd)), payMoney))
}

return nil
}

+ 12
- 6
model/topup_wechat.go View File

@@ -12,8 +12,18 @@ import (
)

// RechargeWechat 微信支付充值完成(由回调触发)
// 与 Recharge/RechargeCreem 类似,使用事务+行锁保证幂等
func RechargeWechat(tradeNo string) error {
return rechargeByQRCodePayment(tradeNo, "微信支付")
}

// RechargeAlipay 支付宝充值完成(由回调触发)
func RechargeAlipay(tradeNo string) error {
return rechargeByQRCodePayment(tradeNo, "支付宝")
}

// rechargeByQRCodePayment 扫码支付充值完成(微信/支付宝通用)
// 使用事务+行锁保证幂等
func rechargeByQRCodePayment(tradeNo string, paymentMethod string) error {
if tradeNo == "" {
return errors.New("未提供支付单号")
}
@@ -34,7 +44,6 @@ func RechargeWechat(tradeNo string) error {
}

if topUp.Status == common.TopUpStatusSuccess {
// 已处理,幂等返回
return nil
}

@@ -48,9 +57,6 @@ func RechargeWechat(tradeNo string) error {
return err
}

// 微信支付充值额度计算:
// topUp.Money = req.Amount * topUpGroupRatio(经分组倍率调整后的数量)
// 充值额度 = topUp.Money * QuotaPerUnit(与 Stripe 的 Recharge 逻辑一致)
dMoney := decimal.NewFromFloat(topUp.Money)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd = dMoney.Mul(dQuotaPerUnit).IntPart()
@@ -73,7 +79,7 @@ func RechargeWechat(tradeNo string) error {
}

if quotaToAdd > 0 {
RecordLog(userId, LogTypeTopup, fmt.Sprintf("使用微信支付充值成功,充值金额: %v,支付金额:%.2f", logger.FormatQuota(int(quotaToAdd)), payMoney))
RecordLog(userId, LogTypeTopup, fmt.Sprintf("使用%s充值成功,充值金额: %v,支付金额:%.2f", paymentMethod, logger.FormatQuota(int(quotaToAdd)), payMoney))
}

return nil


+ 40
- 6
model/user.go View File

@@ -214,6 +214,20 @@ func GetMaxUserId() int {
return user.Id
}

// ApplySyncedQuota 将同步用户的 quota 替换为 synced_quota,
// 使 API 返回的 quota 对所有用户类型都表示实际可用余额。
func (u *User) ApplySyncedQuota() {
if u.IsSyncedUser() {
u.Quota = u.SyncedQuota
}
}

func applySyncedUserQuota(users []*User) {
for _, u := range users {
u.ApplySyncedQuota()
}
}

func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err error) {
// Start transaction
tx := DB.Begin()
@@ -245,6 +259,7 @@ func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err err
return nil, 0, err
}

applySyncedUserQuota(users)
return users, total, nil
}

@@ -312,6 +327,7 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User,
return nil, 0, err
}

applySyncedUserQuota(users)
return users, total, nil
}

@@ -410,7 +426,12 @@ func (user *User) Insert(inviterId int) error {
return err
}
}
user.Quota = common.QuotaForNewUser
matchedQuota := MatchEmailQuotaRule(user.Email)
if matchedQuota >= 0 {
user.Quota = int(matchedQuota)
} else {
user.Quota = common.QuotaForNewUser
}
//user.SetAccessToken(common.GetUUID())
user.AffCode = common.GetRandomString(4)

@@ -441,8 +462,12 @@ func (user *User) Insert(inviterId int) error {
}
}

if common.QuotaForNewUser > 0 {
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser)))
if user.Quota > 0 {
if MatchEmailQuotaRule(user.Email) >= 0 {
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s(邮箱后缀规则匹配)", logger.LogQuota(user.Quota)))
} else {
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(user.Quota)))
}
}
if inviterId != 0 {
if common.QuotaForInvitee > 0 {
@@ -469,7 +494,12 @@ func (user *User) InsertWithTx(tx *gorm.DB, inviterId int) error {
return err
}
}
user.Quota = common.QuotaForNewUser
matchedQuota := MatchEmailQuotaRule(user.Email)
if matchedQuota >= 0 {
user.Quota = int(matchedQuota)
} else {
user.Quota = common.QuotaForNewUser
}
user.AffCode = common.GetRandomString(4)

// 初始化用户设置
@@ -502,8 +532,12 @@ func (user *User) FinalizeOAuthUserCreation(inviterId int) {
}
}

if common.QuotaForNewUser > 0 {
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser)))
if user.Quota > 0 {
if matchedQuota := MatchEmailQuotaRule(user.Email); matchedQuota >= 0 {
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s(邮箱后缀规则匹配)", logger.LogQuota(user.Quota)))
} else {
RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(user.Quota)))
}
}
if inviterId != 0 {
if common.QuotaForInvitee > 0 {


+ 129
- 0
model/user_channel_ratio.go View File

@@ -0,0 +1,129 @@
package model

import (
"strconv"
"strings"
"sync"

"github.com/QuantumNous/new-api/common"
)

var (
userChannelRatioCache = make(map[string]float64) // key: "userId:modelName:channelId" -> ratio
userChannelRatioCacheLock sync.RWMutex
)

// UserChannelRatio 用户-模型-渠道倍率表
type UserChannelRatio struct {
Id int `json:"id" gorm:"primaryKey"`
UserId int `json:"user_id" gorm:"not null;uniqueIndex:idx_user_model_channel,priority:1"`
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:idx_user_model_channel,priority:2"`
ChannelId int `json:"channel_id" gorm:"not null;uniqueIndex:idx_user_model_channel,priority:3"`
Ratio float64 `json:"ratio" gorm:"default:1"`
CreatedAt int64 `json:"created_at" gorm:"bigint"`
UpdatedAt int64 `json:"updated_at" gorm:"bigint"`
}

func getUserChannelRatioCacheKey(userId int, modelName string, channelId int) string {
return strconv.Itoa(userId) + ":" + modelName + ":" + strconv.Itoa(channelId)
}

func setUserChannelRatioCache(key string, ratio float64) {
userChannelRatioCacheLock.Lock()
userChannelRatioCache[key] = ratio
userChannelRatioCacheLock.Unlock()
}

func removeUserChannelRatioCache(key string) {
userChannelRatioCacheLock.Lock()
delete(userChannelRatioCache, key)
userChannelRatioCacheLock.Unlock()
}

// GetUserChannelRatio 获取用户在指定模型+渠道的倍率(纯内存读)
// 未命中返回 1.0(不影响计费)
func GetUserChannelRatio(userId int, modelName string, channelId int) float64 {
key := getUserChannelRatioCacheKey(userId, modelName, channelId)
userChannelRatioCacheLock.RLock()
ratio, ok := userChannelRatioCache[key]
userChannelRatioCacheLock.RUnlock()
if !ok {
return 1.0
}
return ratio
}

// LoadUserChannelRatioCache 全量加载到内存(启动时调用)
func LoadUserChannelRatioCache() {
var records []*UserChannelRatio
if err := DB.Find(&records).Error; err != nil {
common.SysError("[UserChannelRatio] LoadCache failed: " + err.Error())
return
}
userChannelRatioCacheLock.Lock()
userChannelRatioCache = make(map[string]float64, len(records))
for _, r := range records {
key := getUserChannelRatioCacheKey(r.UserId, r.ModelName, r.ChannelId)
userChannelRatioCache[key] = r.Ratio
}
userChannelRatioCacheLock.Unlock()
common.SysLog("[UserChannelRatio] cache loaded " + strconv.Itoa(len(records)) + " records")
}

func (ucr *UserChannelRatio) Insert() error {
ucr.CreatedAt = common.GetTimestamp()
ucr.UpdatedAt = common.GetTimestamp()
err := DB.Create(ucr).Error
if err == nil {
setUserChannelRatioCache(getUserChannelRatioCacheKey(ucr.UserId, ucr.ModelName, ucr.ChannelId), ucr.Ratio)
}
return err
}

func (ucr *UserChannelRatio) Update() error {
var existing UserChannelRatio
if err := DB.First(&existing, ucr.Id).Error; err != nil {
return err
}
ucr.UpdatedAt = common.GetTimestamp()
err := DB.Model(&UserChannelRatio{}).Where("id = ?", ucr.Id).
Select("ratio", "updated_at").
Updates(ucr).Error
if err == nil {
setUserChannelRatioCache(getUserChannelRatioCacheKey(existing.UserId, existing.ModelName, existing.ChannelId), ucr.Ratio)
}
return err
}

func DeleteUserChannelRatioById(id int) error {
var existing UserChannelRatio
if err := DB.First(&existing, id).Error; err != nil {
return err
}
err := DB.Delete(&existing).Error
if err == nil {
removeUserChannelRatioCache(getUserChannelRatioCacheKey(existing.UserId, existing.ModelName, existing.ChannelId))
}
return err
}

// GetUserChannelRatiosByUserId 获取指定用户的所有倍率记录
func GetUserChannelRatiosByUserId(userId int) ([]*UserChannelRatio, error) {
var list []*UserChannelRatio
err := DB.Where("user_id = ?", userId).Find(&list).Error
return list, err
}

// GetBestUserChannelRatio 从内存缓存中查找指定用户+模型的最低渠道倍率(未命中返回 1.0)
func GetBestUserChannelRatio(userId int, modelName string) float64 {
best := 1.0
prefix := strconv.Itoa(userId) + ":" + modelName + ":"
userChannelRatioCacheLock.RLock()
for key, ratio := range userChannelRatioCache {
if len(key) > len(prefix) && strings.HasPrefix(key, prefix) && ratio < best {
best = ratio
}
}
userChannelRatioCacheLock.RUnlock()
return best
}

+ 105
- 0
model/user_channel_ratio_test.go View File

@@ -0,0 +1,105 @@
package model

import (
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func setupUserChannelRatioDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, _ := db.DB()
sqlDB.SetMaxOpenConns(1)

origDB := DB
DB = db
common.UsingSQLite = true
common.RedisEnabled = false

require.NoError(t, db.AutoMigrate(&UserChannelRatio{}))

t.Cleanup(func() {
DB = origDB
sqlDB.Close()
})
return db
}

func TestUserChannelRatioCRUD(t *testing.T) {
setupUserChannelRatioDB(t)
LoadUserChannelRatioCache()

ucr := &UserChannelRatio{
UserId: 1,
ModelName: "gpt-4o",
ChannelId: 5,
Ratio: 0.8,
}
err := ucr.Insert()
require.NoError(t, err)
assert.True(t, ucr.Id > 0)

ratio := GetUserChannelRatio(1, "gpt-4o", 5)
assert.Equal(t, 0.8, ratio)

ratio = GetUserChannelRatio(999, "nonexistent", 999)
assert.Equal(t, 1.0, ratio)

ucr.Ratio = 1.2
err = ucr.Update()
require.NoError(t, err)
ratio = GetUserChannelRatio(1, "gpt-4o", 5)
assert.Equal(t, 1.2, ratio)

list, err := GetUserChannelRatiosByUserId(1)
require.NoError(t, err)
assert.Len(t, list, 1)
assert.Equal(t, "gpt-4o", list[0].ModelName)

err = DeleteUserChannelRatioById(ucr.Id)
require.NoError(t, err)
ratio = GetUserChannelRatio(1, "gpt-4o", 5)
assert.Equal(t, 1.0, ratio)

list, err = GetUserChannelRatiosByUserId(1)
require.NoError(t, err)
assert.Len(t, list, 0)
}

func TestUserChannelRatioUniqueConstraint(t *testing.T) {
setupUserChannelRatioDB(t)
LoadUserChannelRatioCache()

ucr1 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 5, Ratio: 0.8}
err := ucr1.Insert()
require.NoError(t, err)

ucr2 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 5, Ratio: 1.0}
err = ucr2.Insert()
assert.Error(t, err)

ucr3 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 6, Ratio: 1.5}
err = ucr3.Insert()
assert.NoError(t, err)
}

func TestLoadUserChannelRatioCache(t *testing.T) {
setupUserChannelRatioDB(t)

ucr1 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 5, Ratio: 0.8}
ucr2 := &UserChannelRatio{UserId: 2, ModelName: "claude-3", ChannelId: 10, Ratio: 1.5}
require.NoError(t, ucr1.Insert())
require.NoError(t, ucr2.Insert())

LoadUserChannelRatioCache()

assert.Equal(t, 0.8, GetUserChannelRatio(1, "gpt-4o", 5))
assert.Equal(t, 1.5, GetUserChannelRatio(2, "claude-3", 10))
assert.Equal(t, 1.0, GetUserChannelRatio(3, "nonexistent", 99))
}

+ 18
- 2
model/vendor_meta.go View File

@@ -18,6 +18,7 @@ type Vendor struct {
Description string `json:"description,omitempty" gorm:"type:text"`
Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"`
Status int `json:"status" gorm:"default:1"`
SortOrder int `json:"sort_order" gorm:"default:999999"`
CreatedTime int64 `json:"created_time" gorm:"bigint"`
UpdatedTime int64 `json:"updated_time" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name_delete_at,priority:2"`
@@ -65,7 +66,7 @@ func GetVendorByID(id int) (*Vendor, error) {
// GetAllVendors 获取全部供应商(分页)
func GetAllVendors(offset int, limit int) ([]*Vendor, error) {
var vendors []*Vendor
err := DB.Offset(offset).Limit(limit).Find(&vendors).Error
err := DB.Order("sort_order ASC, id ASC").Offset(offset).Limit(limit).Find(&vendors).Error
return vendors, err
}

@@ -81,8 +82,23 @@ func SearchVendors(keyword string, offset int, limit int) ([]*Vendor, int64, err
return nil, 0, err
}
var vendors []*Vendor
if err := db.Offset(offset).Limit(limit).Order("id DESC").Find(&vendors).Error; err != nil {
if err := db.Order("sort_order ASC, id ASC").Offset(offset).Limit(limit).Find(&vendors).Error; err != nil {
return nil, 0, err
}
return vendors, total, nil
}

// ReorderVendors 批量更新供应商排序值
func ReorderVendors(items []struct {
Id int `json:"id"`
SortOrder int `json:"sort_order"`
}) error {
return DB.Transaction(func(tx *gorm.DB) error {
for _, item := range items {
if err := tx.Model(&Vendor{}).Where("id = ?", item.Id).Update("sort_order", item.SortOrder).Error; err != nil {
return err
}
}
return nil
})
}

BIN
models-page View File

Before After
Width: 3200  |  Height: 1626  |  Size: 481 KiB

+ 21
- 13
relay/channel/api_request.go View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"bytes"
"io"
"net/http"
"regexp"
@@ -282,7 +283,7 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
return nil, fmt.Errorf("get request url failed: %w", err)
}
if common2.DebugEnabled {
println("fullRequestURL:", fullRequestURL)
common2.SysLog("fullRequestURL: " + fullRequestURL)
}
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
if err != nil {
@@ -293,13 +294,12 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
if err != nil {
return nil, fmt.Errorf("setup request header failed: %w", err)
}
// 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高
// 这样可以覆盖默认的 Authorization header 设置
headerOverride, err := processHeaderOverride(info, c)
if err != nil {
return nil, err
}
applyHeaderOverrideToRequest(req, headerOverride)
dumpUpstreamRequest(req)
resp, err := doRequest(c, req, info)
if err != nil {
return nil, fmt.Errorf("do request failed: %w", err)
@@ -313,26 +313,24 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
return nil, fmt.Errorf("get request url failed: %w", err)
}
if common2.DebugEnabled {
println("fullRequestURL:", fullRequestURL)
common2.SysLog("fullRequestURL: " + fullRequestURL)
}
req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody)
if err != nil {
return nil, fmt.Errorf("new request failed: %w", err)
}
// set form data
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
headers := req.Header
err = a.SetupRequestHeader(c, &headers, info)
if err != nil {
return nil, fmt.Errorf("setup request header failed: %w", err)
}
// 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高
// 这样可以覆盖默认的 Authorization header 设置
headerOverride, err := processHeaderOverride(info, c)
if err != nil {
return nil, err
}
applyHeaderOverrideToRequest(req, headerOverride)
dumpUpstreamRequest(req)
resp, err := doRequest(c, req, info)
if err != nil {
return nil, fmt.Errorf("do request failed: %w", err)
@@ -340,6 +338,15 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
return resp, nil
}

func dumpUpstreamRequest(req *http.Request) {
if !common2.DebugEnabled || req == nil {
return
}
bodyBytes, _ := io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
common2.SysLog(fmt.Sprintf("[UpstreamRequest] URL: %s\nHeaders: %v\nBody: %s", req.URL.String(), req.Header, string(bodyBytes)))
}

func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*websocket.Conn, error) {
fullRequestURL, err := a.GetRequestURL(info)
if err != nil {
@@ -347,11 +354,6 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
}
targetHeader := http.Header{}
err = a.SetupRequestHeader(c, &targetHeader, info)
if err != nil {
return nil, fmt.Errorf("setup request header failed: %w", err)
}
// 在 SetupRequestHeader 之后应用 Header Override,确保用户设置优先级最高
// 这样可以覆盖默认的 Authorization header 设置
headerOverride, err := processHeaderOverride(info, c)
if err != nil {
return nil, err
@@ -360,10 +362,14 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
targetHeader.Set(key, value)
}
targetHeader.Set("Content-Type", c.Request.Header.Get("Content-Type"))
targetConn, _, err := websocket.DefaultDialer.Dial(fullRequestURL, targetHeader)
targetConn, resp, err := websocket.DefaultDialer.Dial(fullRequestURL, targetHeader)
if err != nil {
return nil, fmt.Errorf("dial failed to %s: %w", fullRequestURL, err)
}
if resp != nil {
common.CaptureUpstreamIDFromHTTPResponse(info, resp)
common.SetRelayUpstreamID(c, info.UpstreamID)
}
// send request body
//all, err := io.ReadAll(requestBody)
//err = service.WssString(c, targetConn, string(all))
@@ -512,6 +518,8 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
if resp == nil {
return nil, errors.New("resp is nil")
}
common.CaptureUpstreamIDFromHTTPResponse(info, resp)
common.SetRelayUpstreamID(c, info.UpstreamID)

_ = req.Body.Close()
_ = c.Request.Body.Close()


+ 6
- 4
relay/channel/claude/relay-claude.go View File

@@ -635,7 +635,7 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d
if claudeResponse.Message != nil && claudeResponse.Message.Usage != nil {
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.GetCacheCreationTotalTokens()
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens()
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
@@ -659,8 +659,8 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d
if claudeResponse.Usage.CacheReadInputTokens > 0 {
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
}
if claudeResponse.Usage.CacheCreationInputTokens > 0 {
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
if total := claudeResponse.Usage.GetCacheCreationTotalTokens(); total > 0 {
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = total
}
if cacheCreation5m := claudeResponse.Usage.GetCacheCreation5mTokens(); cacheCreation5m > 0 {
claudeInfo.Usage.ClaudeCacheCreation5mTokens = cacheCreation5m
@@ -790,6 +790,7 @@ func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.
}

HandleStreamFinalResponse(c, info, claudeInfo)
relaycommon.SetRelayChatID(c, claudeInfo.ResponseId)
return claudeInfo.Usage, nil
}

@@ -803,6 +804,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
return types.WithClaudeError(*claudeError, http.StatusInternalServerError)
}
maybeMarkClaudeRefusal(c, claudeResponse.StopReason)
relaycommon.SetRelayChatID(c, claudeResponse.Id)
if claudeInfo.Usage == nil {
claudeInfo.Usage = &dto.Usage{}
}
@@ -811,7 +813,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.GetCacheCreationTotalTokens()
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Usage.GetCacheCreation1hTokens()
}


+ 27
- 11
relay/channel/codex/adaptor.go View File

@@ -138,9 +138,21 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.RelayMode != relayconstant.RelayModeResponses && info.RelayMode != relayconstant.RelayModeResponsesCompact {
return "", errors.New("codex channel: only /v1/responses and /v1/responses/compact are supported")
}
path := "/backend-api/codex/responses"

key := strings.TrimSpace(info.ApiKey)
if strings.HasPrefix(key, "{") {
// OAuth mode: route to ChatGPT backend API
path := "/backend-api/codex/responses"
if info.RelayMode == relayconstant.RelayModeResponsesCompact {
path = "/backend-api/codex/responses/compact"
}
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, path, info.ChannelType), nil
}

// API key mode: route to standard /v1/responses
path := "/v1/responses"
if info.RelayMode == relayconstant.RelayModeResponsesCompact {
path = "/backend-api/codex/responses/compact"
path = "/v1/responses/compact"
}
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, path, info.ChannelType), nil
}
@@ -149,11 +161,20 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
channel.SetupApiRequestHeader(info, c, req)

key := strings.TrimSpace(info.ApiKey)
if !strings.HasPrefix(key, "{") {
return errors.New("codex channel: key must be a JSON object")
if strings.HasPrefix(key, "{") {
return setupOAuthHeader(req, key)
}

oauthKey, err := ParseOAuthKey(key)
// Simple API key mode
req.Set("Authorization", "Bearer "+key)
if info.IsStream {
req.Set("Accept", "text/event-stream")
}
return nil
}

func setupOAuthHeader(req *http.Header, rawKey string) error {
oauthKey, err := ParseOAuthKey(rawKey)
if err != nil {
return err
}
@@ -178,13 +199,8 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
req.Set("originator", "codex_cli_rs")
}

// chatgpt.com/backend-api/codex/responses is strict about Content-Type.
// Clients may omit it or include parameters like `application/json; charset=utf-8`,
// which can be rejected by the upstream. Force the exact media type.
req.Set("Content-Type", "application/json")
if info.IsStream {
req.Set("Accept", "text/event-stream")
} else if req.Get("Accept") == "" {
if req.Get("Accept") == "" {
req.Set("Accept", "application/json")
}



+ 17
- 11
relay/channel/openai/chat_via_responses.go View File

@@ -52,17 +52,25 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
}

if err := common.Unmarshal(body, &responsesResp); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
apiErr := types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
apiErr.UpstreamBody = service.TruncateBody(string(body))
return nil, apiErr
}

if oaiError := responsesResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
apiErr := types.WithOpenAIError(*oaiError, resp.StatusCode)
apiErr.UpstreamBody = service.TruncateBody(string(body))
return nil, apiErr
}

relaycommon.SetRelayChatID(c, responsesResp.ID)

chatId := helper.GetResponseID(c)
chatResp, usage, err := service.ResponsesResponseToChatCompletionsResponse(&responsesResp, chatId)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
apiErr := types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
apiErr.UpstreamBody = service.TruncateBody(string(body))
return nil, apiErr
}

if usage == nil || usage.TotalTokens == 0 {
@@ -435,6 +443,10 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo

case "response.completed":
if streamResp.Response != nil {
if streamResp.Response.ID != "" {
responseId = streamResp.Response.ID
relaycommon.SetRelayChatID(c, responseId)
}
if streamResp.Response.Model != "" {
model = streamResp.Response.Model
}
@@ -484,14 +496,8 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
sentStop = true
}

case "response.error", "response.failed":
if streamResp.Response != nil {
if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" {
streamErr = types.WithOpenAIError(*oaiErr, http.StatusInternalServerError)
return false
}
}
streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError)
case "response.error", "response.failed", "error":
streamErr = handleResponsesStreamError(streamResp, data)
return false

default:


+ 3
- 0
relay/channel/openai/relay-openai.go View File

@@ -189,6 +189,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
applyUsagePostProcessing(info, usage, common.StringToByteSlice(lastStreamData))

HandleFinalResponse(c, info, lastStreamData, responseId, createAt, model, systemFingerprint, usage, containStreamUsage)
relaycommon.SetRelayChatID(c, responseId)

return usage, nil
}
@@ -225,6 +226,8 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}

relaycommon.SetRelayChatID(c, simpleResponse.Id)

if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}


+ 17
- 5
relay/channel/openai/relay_responses.go View File

@@ -28,12 +28,18 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
err = common.Unmarshal(responseBody, &responsesResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
apiErr := types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
apiErr.UpstreamBody = service.TruncateBody(string(responseBody))
return nil, apiErr
}
if oaiError := responsesResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
apiErr := types.WithOpenAIError(*oaiError, resp.StatusCode)
apiErr.UpstreamBody = service.TruncateBody(string(responseBody))
return nil, apiErr
}

relaycommon.SetRelayChatID(c, responsesResponse.ID)

if responsesResponse.HasImageGenerationCall() {
c.Set("image_generation_call", true)
c.Set("image_generation_call_quality", responsesResponse.GetQuality())
@@ -78,10 +84,10 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp

var usage = &dto.Usage{}
var responseTextBuilder strings.Builder
var streamErr *types.NewAPIError

helper.StreamScannerHandler(c, resp, info, func(data string) bool {

// 检查当前数据是否包含 completed 状态和 usage 信息
var streamResponse dto.ResponsesStreamResponse
if err := common.UnmarshalJsonStr(data, &streamResponse); err == nil {
sendResponsesStreamData(c, streamResponse, data)
@@ -102,6 +108,7 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
}
}
relaycommon.SetRelayChatID(c, streamResponse.Response.ID)
if streamResponse.Response.HasImageGenerationCall() {
c.Set("image_generation_call", true)
c.Set("image_generation_call_quality", streamResponse.Response.GetQuality())
@@ -109,10 +116,8 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
}
}
case "response.output_text.delta":
// 处理输出文本
responseTextBuilder.WriteString(streamResponse.Delta)
case dto.ResponsesOutputTypeItemDone:
// 函数调用处理
if streamResponse.Item != nil {
switch streamResponse.Item.Type {
case dto.BuildInCallWebSearchCall:
@@ -123,6 +128,9 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
}
}
}
case "response.error", "response.failed", "error":
streamErr = handleResponsesStreamError(streamResponse, data)
return false
}
} else {
logger.LogError(c, "failed to unmarshal stream response: "+err.Error())
@@ -130,6 +138,10 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
return true
})

if streamErr != nil {
return nil, streamErr
}

if usage.CompletionTokens == 0 {
// 计算输出文本的 token 数量
tempStr := responseTextBuilder.String()


Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save