| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
e6b4a06557 |
feat(logs): 隐藏使用日志中的分组信息展示
使用注释方式隐藏分组列和分组倍率标签,便于后续恢复: 1. 注释 UsageLogsColumnDefs.jsx 中的分组列定义 2. 注释 useUsageLogsData.jsx 中的 COLUMN_KEYS.GROUP 和默认可见性配置 3. 将计费详情中的"分组倍率"改为"倍率" 后端计费逻辑不变,仍正常应用分组倍率。 Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com> |
4 days ago |
|
|
f9eeb7e47e |
fix(billing): 修复 Claude 计费明细未应用用户渠道折扣的问题
renderClaudeModelPrice 函数缺少 userChannelRatio 参数声明,导致运行时 ReferenceError;同时价格计算未乘以用户倍率,明细文本也未显示折扣信息。 Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com> |
4 days ago |
|
|
f7ff684d0d |
feat(pricing): 渠道定价展示用户折扣价格
登录用户查看渠道定价时,按用户分组倍率和渠道个人倍率计算实际折扣价 并在价格列展示划线原价与折扣价,新增 /pricing/user/*model 接口。 Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com> |
4 days ago |
|
|
e1b6aff7e1 |
fix(log): 对普通用户隐藏 Chat ID 和 Upstream ID
后端 GetUserLogs 返回前清空 chat_id/upstream_id(omitempty 不输出), 前端搜索框和展开行详情加 isAdminUser 守卫。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
5 days ago |
|
|
02e0940555 |
fix(playground): 清空互斥模型默认列表
初始不预填任何模型,由管理员在运营设置页面手动配置。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
5 days ago |
|
|
2d1a8d2f28 |
fix: 设置 chat_id + 优化日志查询参数构建
- Claude 和 OpenAI Responses 转发中设置 RelayChatID - 日志查询使用 URLSearchParams 替代手动字符串拼接 - 日志查询日期范围改为可选,不再强制默认今天 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
5 days ago |
|
|
3e86f523db |
feat(playground): temperature/top_p 参数互斥设置
部分上游模型不允许同时设置 temperature 和 top_p,新增管理员可配置的 模型前缀列表,匹配的模型在 Playground 中自动互斥切换两个参数。 - 后端新增 PlaygroundSetting 配置 + GET /api/playground/config 公开端点 - 运营设置页面新增 Playground 互斥模型前缀编辑 textarea - Playground 自动检测模型名匹配,启用一个参数自动禁用另一个 - 默认包含 deepseek-v4-flash/pro、deepseek-reasoner、o1-/o3-/o4-、gpt-5 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
5 days ago |
|
|
d26ad9e635 |
feat: 渠道-模型级联选择 + 隐藏首页统计卡片
- UserRatioSection 模型输入改为级联下拉,选渠道后自动加载模型列表 - models 信息嵌入 channelOptions 消除冗余 channelMap 状态 - 注释掉首页 HeroSection 统计数据卡片 Co-Authored-By: Claude <noreply@anthropic.com> |
6 days ago |
|
|
bb708510fe |
docs: 用户倍率渠道-模型级联选择器设计文档
Co-Authored-By: Claude <noreply@anthropic.com> |
6 days ago |
|
|
b026f6bc04 |
feat: 用户倍率前端展示 + 渠道下拉选择 + 上游调试日志
- UserRatioSection 渠道 ID 输入改为带搜索的下拉选择器 - 使用日志详情、计费过程、展开行均显示用户倍率 - renderLogContent suffix 统一追加,消除 4 处重复 - 提取 dumpUpstreamRequest 辅助函数,debug 日志走 SysLog Co-Authored-By: Claude <noreply@anthropic.com> |
6 days ago |
|
|
9dbf3365fe |
feat(pricing): 添加用户-模型-渠道倍率功能
为每个用户添加独立于全局定价的倍率乘数,支持按 (userId, model, channel) 三维 精确控制用户级别定价。倍率以乘法叠加在现有 modelRatio × groupRatio 之上。 - 新增 user_channel_ratios 表,写入内存缓存(写穿透模式) - 新增 /api/user_channel_ratio/ CRUD API(AdminAuth) - 计费路径集成:compatible_handler、PostClaudeConsumeQuota、 PostWssConsumeQuota、PostAudioConsumeQuota、calculateAudioQuota - 前端:EditUserModal 内嵌 UserRatioSection 组件 - 修复 Update() 缓存 key 零值 bug(先加载再更新) - 修复 PreWssConsumeQuota 缺少 UserChannelRatio 导致归零 - 修复 ModelPriceHelperPerCall 缺少默认值 1.0 Co-Authored-By: Claude <noreply@anthropic.com> |
6 days ago |
|
|
17a0c2c26a |
refactor(playground): 隐藏对话页面的分组选择框
Co-Authored-By: Claude <noreply@anthropic.com> |
1 week ago |
|
|
b033edec56 |
fix: pending sync records 队列被 quota=0 旧记录阻塞
GetPendingRecordsForSync 添加 quota > 0 过滤条件,避免因上游超时 产生的 quota=0 旧记录(4月29日网络超时遗留)占满批次,阻塞新 的正常的扣费同步到 master 节点。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
1 week ago |
|
|
8945f8c39e |
fix: synced 用户剩余额度取 synced_quota + 记录 chat ID
- 前端用户列表:synced 用户显式取 synced_quota 字段作为剩余额度 - 各 OpenAI handler 中记录 relayChatID 用于日志追踪 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
1 week ago |
|
|
e41f2951f7 | fix: preserve relay body and capture invalid responses body | 1 week ago |
|
|
b2cb7bcf60 | feat: record chat and upstream ids in usage logs | 1 week ago |
|
|
caf457ce9e |
feat: 错误时记录上游响应体,支持流式
- 提取 TruncateBody 公共函数,截断到 2KB 避免日志过大 - 新增 handleResponsesStreamError 统一 SSE 错误提取逻辑 - 流式/非流式 Responses API 错误路径均捕获 UpstreamBody - 添加 upstream_body 和 truncate_body 单元测试 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
1 week ago |
|
|
575f84064e |
feat: codex API key 模式 + 前端凭证回显修复
- codex adaptor 支持 API key 和 OAuth 两种认证模式 - 提取 setupOAuthHeader 和 shouldUseChatCompletionsViaResponses - 修复编辑页 codex_credential_mode 切换/回显不同步问题 - ResponsesStreamResponse 添加 Error 字段支持独立错误事件 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
1 week ago |
|
|
ca91922904 | docs: add log chat/upstream id design | 1 week ago |
|
|
332d5a62f2 | feat: support codex api key credentials | 1 week ago |
|
|
1c0898257c | feat: add redemption remarks | 2 weeks ago |
|
|
f18e51e3a9 | feat: add redemption remark support | 2 weeks ago |
|
|
fae24fdf37 | test: stabilize channel affinity usage cache tests | 2 weeks ago |
|
|
fc0257f6ca | chore: ignore local worktrees | 2 weeks ago |
|
|
d8272e7707 |
feat(channel): 添加渠道"对外名称"(public_name)字段
为 Channel 模型新增 public_name 字段,让管理员可以为每个渠道 设置用户可见的友好名称(如"标准通道"、"高速通道"),替代前端 硬编码的"通道一/二/三"。Playground 通道选择器展示对外名称, "渠道"统一改为"通道"。新建渠道时对外名称必填,编辑时可选。 Co-Authored-By: Claude <noreply@anthropic.com> |
2 weeks ago |
|
|
73d10b5799 |
feat: 错误日志记录上游 request-id 和响应体,Playground 渠道路由改为 header 传递
- 错误日志新增 upstream_request_id(从 Anthropic/OpenAI 响应 header 提取)和 upstream_body(截断 2KB) - 修复 RelayErrorHandler 内部 WithOpenAIError/NewOpenAIError 分支丢失上游字段的 bug - Playground 渠道和分组改为通过 X-Channel-Id/X-Group header 传递,而非 body 字段 - Distributor 中间件支持从 header 回退读取 channel_id 和 group Co-Authored-By: Claude <noreply@anthropic.com> |
2 weeks ago |
|
|
5b8303ae81 |
fix: 登录后跳转来源页,充值金额校验优化
- LoginForm: 登录成功后跳转回登录前的页面,而非固定 /console - RechargeCard: 输入金额低于最低值时显示警告提示 - .dockerignore: 排除 .claude、.plans、脚本等非项目文件 Co-Authored-By: Claude <noreply@anthropic.com> |
2 weeks ago |
|
|
8bb2883d7e |
feat(home): 首页定价卡片添加"去体验"按钮,跳转 Playground
- PricingCardView 新增 showTryButton 属性,点击跳转 Playground 并预选模型 - 移除 PricingCardView 中未使用的 props (selectedGroup, currency 等) - 首页页脚路由重命名: terms→user-agreement, usage-policy→privacy-policy - i18n: "操练场"更名为"对话" Co-Authored-By: Claude <noreply@anthropic.com> |
2 weeks ago |
|
|
a0ac37b2a9 |
fix: 替换 println 为 SysLog,修复 logger nil context 崩溃
- logger: 增加 ctx nil 检查,避免系统级日志 panic - relay: 将 DebugEnabled 下的 println 替换为 SysLog - price: 修复渠道定价在 ChannelMeta 未初始化时的获取失败问题 Co-Authored-By: Claude <noreply@anthropic.com> |
2 weeks ago |
|
|
74cc8c0d56 |
feat(playground): 添加渠道选择功能,支持指定渠道体验模型
- 新增 /api/user/model_channels 接口,返回模型可用渠道及默认渠道 - Playground 设置面板添加渠道选择下拉框 - Distribute 中间件支持从请求体读取 channel_id 指定渠道 - 支持 URL 参数 ?model=xxx 直接选择模型 Co-Authored-By: Claude <noreply@anthropic.com> |
2 weeks ago |
|
|
a34d44a824 |
fix(user): 修复从节点同步用户余额显示为 0 的问题
同步用户(synced)的实际余额存储在 synced_quota 字段, 但用户列表/详情接口返回的是 quota 字段(值为 0)。 新增 ApplySyncedQuota 方法,在 API 返回前将 synced_quota 赋值给 quota,使前端能正确显示余额。 同时优化 GetUserModels:map 去重替代 O(n) 线性扫描, 禁用模型查询移至 model 层,新增 StringsSubtract 工具函数。 Co-Authored-By: Claude <noreply@anthropic.com> |
2 weeks ago |
|
|
967309fb56 |
feat: 添加邮箱后缀注册额度规则功能
管理员可在用户管理页面配置「邮箱后缀→初始额度」映射规则, 用户注册时根据邮箱后缀自动匹配并发放对应额度(替代默认额度)。 - 新增 email_quota_rule 数据表 + CRUD API(管理员权限) - 内存缓存匹配,启动时加载,增删改时刷新 - 注册流程 Insert/InsertWithTx/FinalizeOAuthUserCreation 同步支持 - 前端用户管理页新增 Tab 展示规则管理卡片 - 含 24 个测试(model 16 + controller 8) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
2 weeks ago |
|
|
83f767b40b |
Merge branch 'worktree-feat-captcha'
# Conflicts: # web/src/components/topup/RechargeCard.jsx |
2 weeks ago |
|
|
d24d68a916 |
fix(i18n): 修复令牌和充值页面货币显示不一致问题
- 令牌创建:快捷选项标签根据系统货币设置动态生成,替换硬编码美元 - 充值页面:输入框显示值与预设卡片统一换算为本地货币,内部值仍为原始单位 - 使用 getQuotaPerUnit() 替代魔数 500000 - 消除 RechargeCard 中 getCurrencyConfig() 的 N+1 重复调用 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
2 weeks ago |
|
|
fe88bc7758 | Merge branch 'worktree-feat-captcha' | 2 weeks ago |
|
|
1912dd3b72 |
feat: 添加图片验证码功能,防止脚本批量注册
- 后端:新增 GET /api/captcha 接口,使用 base64Captcha 生成图片验证码 - 后端:发送邮箱验证码时校验图片验证码(CaptchaEnabled 开关控制) - 前端:注册表单邮箱验证码前增加图片验证码输入,支持点击刷新 - 管理后台:系统设置新增「图片验证码」开关 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
2 weeks ago |
|
|
20163dbad5 |
fix(legal): 法律文档页面切换语言时强制重新加载内容
给 DocumentRenderer 添加 key={lang} 属性,确保切换语言时组件
重新挂载并从 API 获取对应语言的内容,而非使用缓存内容。
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
2 weeks ago |
|
|
56e8d14485 |
fix: 语言切换后 TextArea 内容丢失
添加 useEffect 在 editingLang 变化时同步 form values, 确保重新挂载的 TextArea 能从 React state 中读取正确内容。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
2 weeks ago |
|
|
65bd1dabb8 |
fix: 法律文档编辑器切换语言时 TextArea 内容未刷新
给每个 TextArea 添加 key={editingLang} 强制组件在语言切换时重新挂载。
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
2 weeks ago |
|
|
934b4b10a7 |
feat: 法律文档双语配置(中/英)+ 精简语言支持至中英双语
- LegalSettings 字段拆分为 _zh/_en 双语对 - API 支持 ?lang= 参数返回对应语言内容 - 后台设置页每个文档配语言切换按钮(中文/English) - 前端页面根据 UI 语言自动请求对应内容 - 移除 fr/ru/ja/vi/zh-TW 语言支持 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
2 weeks ago |
|
|
676902ed58 |
Merge branch 'worktree-feat-terms-usage-policy'
# Conflicts: # web/src/i18n/locales/en.json # web/src/i18n/locales/fr.json # web/src/i18n/locales/ja.json # web/src/i18n/locales/ru.json # web/src/i18n/locales/vi.json # web/src/i18n/locales/zh-CN.json # web/src/i18n/locales/zh-TW.json |
2 weeks ago |
|
|
2757f77011 |
feat(i18n): 补全服务条款和使用政策的多语言翻译
在 zh-CN、en、zh-TW、ja、fr、ru、vi 七个语言文件中添加 服务条款和使用政策的完整翻译 key。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
2 weeks ago |
|
|
65c6446a30 | Merge branch 'worktree-feat-terms-usage-policy' | 2 weeks ago |
|
|
1cc75aa9a2 |
feat: 服务条款和使用政策页面,支持后台 Markdown 配置
复用现有 LegalSettings 模式,新增 TermsOfService 和 UsagePolicy 字段, 添加 /terms 和 /usage-policy 前端页面及 API 端点。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
2 weeks ago |
|
|
ed195cada5 |
fix(frontend): sort_order 默认值显示为"未设置",编辑弹窗增加提示
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
3 weeks ago |
|
|
d5157a779c |
fix(pricing): 统一 sort_order 排序逻辑,有 meta 但未设置的不优先
之前有 meta 记录但 sort_order=999999 的模型排在没 meta 记录的模型前面, 导致部分"未设置排序"的模型仍然挤在前面。现在统一处理:不论是否有 meta 记录,sort_order=999999 的都视为未设置,排在后面。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
3 weeks ago |
|
|
54e53108ec |
fix(sort): sort_order 默认值改为 999999,简化排序逻辑
未设置排序的记录 sort_order=999999 自然排在后面,无需 CASE WHEN。 - GORM 默认值 default:0 → default:999999 - 数据库迁移:将现有 sort_order=0 的记录更新为 999999 - 回退 CASE WHEN 排序逻辑,恢复简单的 sort_order ASC, id ASC - 前端编辑弹窗默认值同步改为 999999 - 表格列中 999999 显示为空(表示未设置) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
3 weeks ago |
|
|
3b99c83e32 |
fix(sort): sort_order=0 的记录排到最后,非0值按升序排列
模型和供应商查询统一排序规则:sort_order=0 视为未设置排到最后, 非0值按升序排列。涉及 GetAllModels、SearchModels、GetAllVendors、 SearchVendors 以及 pricing 的内存排序。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
3 weeks ago |
|
|
518f1ab87f |
refactor(frontend): 移除拖拽排序,改为 sort_order 数值输入
回退 @dnd-kit 拖拽实现,改为在编辑弹窗中直接设置 sort_order 数值: - 模型编辑弹窗新增「排序」InputNumber 字段 - 供应商编辑弹窗新增「排序」InputNumber 字段 - 模型表格新增 sort_order 显示列 - 移除 @dnd-kit 依赖及所有 DnD 相关代码 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
3 weeks ago |
|
|
65880fc69b |
feat(frontend): Vendor Tab 和 Model 表格支持拖拽排序,移除定价页字母排序
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
3 weeks ago |
|
|
5e0f38d26c |
chore(frontend): 安装 @dnd-kit 拖拽排序库
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
3 weeks ago |
|
|
e507897f21 |
feat(pricing): updatePricing 按 sort_order 排序 vendors 和 models
- vendorsList 按 vendor.SortOrder 升序排列,相同则按 ID 排序 - pricingMap 按 model.SortOrder 升序排列,相同则按模型名字典序 - 修复默认通道设置的 API 错误响应未正确展示的问题 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
3 weeks ago |
|
|
749064932c |
feat: 新增 PUT /api/vendors/reorder 和 /api/models/reorder 批量排序接口
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
3 weeks ago |
|
|
f8859b7c35 |
feat: Vendor 和 Model 添加 sort_order 字段,查询按 sort_order ASC 排序
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
3 weeks ago |
|
|
3a29f3772f |
feat(channel): 添加模型默认通道功能,支持优先路由和卡片标识
- 新增 is_default 字段和缓存层,支持管理员为模型指定默认通道 - Distribute 中间件优先级调整:Token 指定 → 默认通道 → 亲和性 → 随机 - 模型定价卡片和详情弹窗展示默认通道 amber 标识 - 管理后台定价页面新增星标切换默认通道 - 新增 set_default / clear_default API 和 6 个单元测试 - 简化卡片价格显示(移除内联缓存价格,改为详情弹窗展示) Co-Authored-By: Claude <noreply@anthropic.com> |
3 weeks ago |
|
|
93e331b624 |
feat: 支持 LOGO_FILE_PATH 环境变量指定本地 Logo,优化定价与语言设置
- 新增 LOGO_FILE_PATH 环境变量,优先级高于数据库配置,支持本地文件服务 - 渠道定价高级字段(缓存/图片/音频)不再回退全局默认值,未设置直接返回 0 - 缓存价格单位从表头移到具体价格值,新增渠道 ID 复制功能 - 修复默认语言在用户已有偏好时仍被覆盖的问题 - img 标签统一添加 referrerPolicy/crossOrigin 防止跨域问题 - 新增缓存倍率和最小余额阈值的详细说明文本 Co-Authored-By: Claude <noreply@anthropic.com> |
3 weeks ago |
|
|
94f24b29f7 |
style: 首页文案"全球"改为"顶级"
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
3 weeks ago |
|
|
f0f0215b20 |
fix(pricing): 修复缓存倍率写入默认值1和缓存创建token丢失
1. CopyGlobalPricing 检查 ratio 查找的 bool 返回值,未找到时写 0 而非写入 fallback 值 1,避免覆盖全局正确的 0.1 2. Claude 响应使用 GetCacheCreationTotalTokens() 替代直接读 CacheCreationInputTokens,兼容新版本子对象格式 3. 新增 GetAudioRatioV2/GetAudioCompletionRatioV2 带 bool 返回 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
3 weeks ago |
|
|
387f6c1ae0 |
feat(pricing): 定价数据源切换到渠道表,新增缓存价格展示
- 后端 pricing API 从 channel_pricings 表获取实际定价,选取最便宜渠道 - 提取 applyGlobalDefault 辅助函数消除全局回退逻辑重复 - price.go 重构扩展比率为局部变量,简化回退逻辑 - 定价卡片新增缓存读取/创建价格,改为两行布局防止溢出 - ChannelPricingCard 缓存列显示实际价格而非倍率 - 修复移动端 hero 区域 padding 过大 - 默认标签页标题改为 Loading... - 新增缓存读取/创建 i18n 翻译(7 语言) Co-Authored-By: Claude <noreply@anthropic.com> |
3 weeks ago |
|
|
00058cd671 |
refactor(channel-pricing): 提取辅助方法消除重复代码,修复缓存一致性
- 提取 PriceData.ApplyChannelPricingRatios 消除 ModelPriceHelper 和 UpdatePriceDataForChannelPricing 中重复的 ~30 行比率回退逻辑 - 提取 ChannelPricing.ApplyFields 消除 controller 中 4 处相同的字段赋值 - 提取 setCache/removeCache 辅助函数统一写穿透缓存操作 - 将 claudeCacheCreation1hMultiplier 常量移至 types 包避免循环依赖 - 修复 BatchUpsertChannelPricing 成功后未刷新内存缓存的 bug Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
3 weeks ago |
|
|
42082311d3 |
fix: 修复默认语言不生效的问题
移除 PageLayout useEffect 中多余的 localStorage 语言恢复逻辑。 i18next-browser-languagedetector 在初始化时已自动处理 localStorage, 同步的 changeLanguage 调用会覆盖异步 loadStatus 设置的管理员默认语言。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
3 weeks ago |
|
|
9eb58c684e |
feat: 后台设置默认语言
管理员可在系统设置页面配置全局默认语言,未登录用户和未设置 语言偏好的已登录用户将强制使用该语言,忽略浏览器语言检测。 - common/constants.go: 新增 DefaultLanguage 变量 - model/option.go: 新增 DefaultLanguage option handler - controller/misc.go: /api/status 暴露 default_language - SystemSetting.jsx: 添加语言下拉框到通用设置 - PageLayout.jsx: 未登录用户应用默认语言 - UserContext.jsx: 无偏好用户回退到默认语言 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
3 weeks ago |
|
|
a2b27ea2a6 |
docs: 后台设置默认语言实施计划
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
3 weeks ago |
|
|
436fcb405b |
docs: 后台设置默认语言功能设计文档
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
3 weeks ago |
|
|
8d22c4e80e |
style(home): 临时隐藏首页工具链/核心价值/工作流/生态伙伴 section
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
3 weeks ago |
|
|
355757404b | merge: feat/channel-pricing-extended → master | 3 weeks ago |
|
|
235f7c6e5f |
refactor(channel-pricing): 提取 ParseTagIds 辅助函数 + 移除调试 console.log
- 将 tag ID 解析逻辑提取为 model.ParseTagIds,消除 model 层和 controller 层重复代码
- 统一 TrimSpace 处理(之前 controller 版本漏了)
- 移除 ChannelPricingView 残留的 console.log('Expanded keys:')
- 删除多余空行
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
3 weeks ago |
|
|
f3577590bc |
test(channel-pricing): 添加 API 端到端集成测试脚本
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
3 weeks ago |
|
|
129e17ac42 |
feat(channel-pricing): 前端支持缓存/图片/音频倍率编辑和展示
- ChannelPricingView.jsx: initValues 添加 5 个高级比例字段 + 按量计费模式下新增高级比例表单区块 - ChannelPricingCard.jsx: tableData 映射 cache_ratio/cache_creation_ratio + 条件列展示 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
3 weeks ago |
|
|
c84c26b5a2 |
feat(channel-pricing): GetChannelPricingByModelWithChannelInfo 支持 CASE WHEN 回退 + 扩展字段
在 SQL 查询中使用 CASE WHEN > 0 回退策略,让渠道定价的扩展比率 (cache_ratio, cache_creation_ratio, image_ratio, audio_ratio, audio_completion_ratio) 在未设置时自动回退到全局默认值, 而不是像 COALESCE 那样被零值拦截。同时更新 GROUP BY 子句包含所有 新选择的列,确保 MySQL 兼容。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
3 weeks ago |
|
|
d5d4714908 |
test(channel-pricing): 缓存写穿 + 字段默认值单元测试
验证 ChannelPricing 的 Insert/Update/Delete 操作正确更新内存缓存, 以及未设置的扩展字段(CacheRatio 等)默认为零值。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
3 weeks ago |
|
|
646f37dd4b |
feat(channel-pricing): Controller 扩展 API 支持新字段 + 输入校验 + 操作日志
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
3 weeks ago |
|
|
3a24fd598f |
feat(channel-pricing): ModelPriceHelper + UpdatePriceDataForChannelPricing 适配新签名,支持扩展比率覆盖
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
3 weeks ago |
|
|
e5f029f91e |
feat(channel-pricing): InitDB 启动时全量加载渠道定价缓存
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
3 weeks ago |
|
|
2d4c73d3aa |
refactor(channel-pricing): 结构体新增扩展字段 + 缓存重写为全量加载写穿
- ChannelPricing 结构体新增 5 个扩展计费字段: cache_ratio, cache_creation_ratio, image_ratio, audio_ratio, audio_completion_ratio - 缓存机制从 TTL+惰性加载重写为全量加载+写穿模式,消除 DB 查询延迟 - Insert/Update/Delete 改为写穿缓存(直接更新内存,不再整表失效) - 新增 LoadChannelPricingCache 启动时全量加载函数 - 删除 RefreshChannelPricingCache/InvalidateChannelPricingCache(不再需要) - BatchUpsertChannelPricing DoUpdates 列表同步新增 5 字段 - GetEffectivePricing 签名改为 (*ChannelPricing, bool)(调用方适配在后续 Task) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
3 weeks ago |
|
|
156618fdad |
merge: feat/alipay-payment → master
支付宝当面付扫码支付集成 + 代码重构 Co-Authored-By: Claude <noreply@anthropic.com> |
3 weeks ago |
|
|
b514a0798b |
refactor(payment): 合并微信/支付宝支付重复代码,删除冗余文件
- 提取通用 calcPayMoney/calcMinTopup 函数,消除微信和支付宝控制器中的重复计费逻辑 - 合并 RechargeAlipay/RechargeWechat 为 rechargeByQRCodePayment 内部函数,删除 model/topup_alipay.go - 复用已有的 wrapAsPEM 替换支付宝专用的 wrapAlipayPublicKey - 删除被 QRCodePayModal 替代的 WechatPayQRCodeModal.jsx - 修复 controller/topup.go 中支付宝代码块的缩进错误 净减 264 行代码。 Co-Authored-By: Claude <noreply@anthropic.com> |
3 weeks ago |
| @@ -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 | |||
| @@ -36,3 +36,4 @@ | |||
| # ============================================ | |||
| # Mark web frontend as vendored so GitHub recognizes this as a Go project | |||
| electron/** linguist-vendored | |||
| .dockerignore text eol=lf | |||
| @@ -23,6 +23,7 @@ plans | |||
| docs/plans/ | |||
| CLAUDE.md | |||
| .claude | |||
| .worktrees/ | |||
| logs/ | |||
| docs/superpowers | |||
| @@ -0,0 +1,7 @@ | |||
| # backend-dev - 发现索引 | |||
| > 纯索引——每个条目应简短(Status + Report 链接 + Summary)。 | |||
| --- | |||
| <初始为空,工作中填写> | |||
| @@ -0,0 +1,7 @@ | |||
| # backend-dev - 工作日志 | |||
| > 用于上下文恢复。压缩/重启后先读此文件。 | |||
| --- | |||
| <初始为空,工作中填写> | |||
| @@ -0,0 +1,7 @@ | |||
| # 支付宝后端 - 发现记录 | |||
| > 此任务开发中的技术发现。 | |||
| --- | |||
| <初始为空> | |||
| @@ -0,0 +1,7 @@ | |||
| # 支付宝后端 - 工作日志 | |||
| > 上下文恢复时只需读此文件。 | |||
| --- | |||
| <初始为空> | |||
| @@ -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 已在项目中 | |||
| @@ -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 审查 | |||
| @@ -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 角色(全栈) | |||
| @@ -0,0 +1,7 @@ | |||
| # alipay-payment - 发现与技术记录 | |||
| > 由团队智能体自动更新。每条标注来源。 | |||
| --- | |||
| <工作中添加条目> | |||
| @@ -0,0 +1,7 @@ | |||
| # frontend-dev - 发现索引 | |||
| > 纯索引——每个条目应简短。 | |||
| --- | |||
| <初始为空,工作中填写> | |||
| @@ -0,0 +1,7 @@ | |||
| # frontend-dev - 工作日志 | |||
| > 用于上下文恢复。 | |||
| --- | |||
| <初始为空,工作中填写> | |||
| @@ -0,0 +1,7 @@ | |||
| # 支付宝前端 - 发现记录 | |||
| > 此任务开发中的技术发现。 | |||
| --- | |||
| <初始为空> | |||
| @@ -0,0 +1,7 @@ | |||
| # 支付宝前端 - 工作日志 | |||
| > 上下文恢复时只需读此文件。 | |||
| --- | |||
| <初始为空> | |||
| @@ -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 遵循现有命名规范 | |||
| @@ -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 审查 | |||
| @@ -0,0 +1,16 @@ | |||
| # alipay-payment - 进度日志 | |||
| > 按时间线记录。每条记录谁做了什么。 | |||
| --- | |||
| ## 2026-04-14 Session 1 — 团队搭建 | |||
| ### 已完成 | |||
| - [x] 读取设计文档 | |||
| - [x] 确认团队配置:3 角色(backend-dev, frontend-dev, reviewer) | |||
| - [x] 创建规划文件和目录结构 | |||
| ### 待办 | |||
| - [ ] 启动团队成员 | |||
| - [ ] 开始并行开发 | |||
| @@ -0,0 +1,7 @@ | |||
| # reviewer - 发现索引 | |||
| > 纯索引。 | |||
| --- | |||
| <初始为空,工作中填写> | |||
| @@ -0,0 +1,5 @@ | |||
| # reviewer - 工作日志 | |||
| --- | |||
| <初始为空> | |||
| @@ -0,0 +1,16 @@ | |||
| # reviewer - 任务计划 | |||
| > 角色: 代码审查 | |||
| > 状态: pending | |||
| > 分配的任务: 等待 backend-dev 和 frontend-dev 完成后进行代码审查 | |||
| ## 任务 | |||
| - [ ] 审查 backend-dev 支付宝后端代码(安全 + 质量) | |||
| - [ ] 审查 frontend-dev 支付宝前端代码(质量 + 体验) | |||
| ## 备注 | |||
| - 支付涉及资金安全,重点关注:签名验签、金额处理、幂等性、并发安全 | |||
| - 后端审查重点:回调验签、金额单位(元 vs 分)、订单幂等 | |||
| - 前端审查重点:二维码模态框重构兼容性、支付状态轮询 | |||
| @@ -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(并行开发)。 | |||
| @@ -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 的渠道 → 显示"通道一/二/三..." | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -0,0 +1 @@ | |||
| {"reason":"idle timeout","timestamp":1777286270028} | |||
| @@ -0,0 +1 @@ | |||
| 6600 | |||
| @@ -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) | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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 // 是否启用邮箱域名限制 | |||
| @@ -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)) | |||
| @@ -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, | |||
| }) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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(), "当前凭证方式不支持查看用量") | |||
| } | |||
| @@ -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()) | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| @@ -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) | |||
| } | |||
| @@ -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{ | |||
| @@ -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, | |||
| }) | |||
| } | |||
| @@ -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, | |||
| }, | |||
| }) | |||
| } | |||
| @@ -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) | |||
| @@ -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"]) | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| @@ -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, | |||
| })) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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": "", | |||
| @@ -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, | |||
| }) | |||
| } | |||
| @@ -189,6 +189,7 @@ | |||
| | key | string | 兑换码(32字符,唯一) | | |||
| | status | int | 状态:1=启用,2=已使用,3=已禁用 | | |||
| | name | string | 兑换码名称 | | |||
| | remark | string | 备注 | | |||
| | quota | int | 额度值 | | |||
| | created_time | int64 | 创建时间 | | |||
| | redeemed_time | int64 | 兑换时间 | | |||
| @@ -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) | |||
| @@ -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 表) | |||
| @@ -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 | |||
| @@ -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`) | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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= | |||
| @@ -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) | |||
| @@ -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)) | |||
| @@ -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 != "" { | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -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> | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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")) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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)) | |||
| } | |||
| } | |||
| @@ -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 | |||
| }) | |||
| } | |||
| @@ -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": | |||
| @@ -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). | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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, "应选最便宜的按次渠道") | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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,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 | |||
| @@ -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 { | |||
| @@ -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 | |||
| } | |||
| @@ -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,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 | |||
| }) | |||
| } | |||
| @@ -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() | |||
| @@ -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() | |||
| } | |||
| @@ -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") | |||
| } | |||
| @@ -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: | |||
| @@ -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) | |||
| } | |||
| @@ -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() | |||