From d557745db449dde7a4b8e40b136d8b50e60efbba Mon Sep 17 00:00:00 2001 From: docs-maintainer Date: Thu, 21 May 2026 13:58:29 +0000 Subject: [PATCH 1/4] docs(operations): add NewAPI operations manual v1 (TES-70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/operations/ as the consolidated operations handbook for operators and customer-success engineers, derived from the architect's fact-finding report under TES-69. Sections: - 01-platform-setup.md Vendor → Model → Pricing → Group → Channel - 02-customer-onboarding.md User → Token → Topup/Redemption - 03-billing.md Standard / Tiered / Audio / Task with plain-language formulas, raw formulas, and unit conventions - 04-logs-stats.md logs / quota_data tables, query entries, stat semantics - 05-faq.md 13 high-frequency operator scenarios + grey-area action guide - 99-pending-items.md Open tracker for grey areas requiring architect follow-up - README.md Index + reading guide Baseline: origin/main HEAD 2d1ca153. Branch policy follows v5: docs land on feature branch, no direct push to main / develop. Co-authored-by: multica-agent --- docs/operations/01-platform-setup.md | 379 ++++++++++++++++++++++ docs/operations/02-customer-onboarding.md | 219 +++++++++++++ docs/operations/03-billing.md | 281 ++++++++++++++++ docs/operations/04-logs-stats.md | 266 +++++++++++++++ docs/operations/05-faq.md | 236 ++++++++++++++ docs/operations/99-pending-items.md | 178 ++++++++++ docs/operations/README.md | 59 ++++ 7 files changed, 1618 insertions(+) create mode 100644 docs/operations/01-platform-setup.md create mode 100644 docs/operations/02-customer-onboarding.md create mode 100644 docs/operations/03-billing.md create mode 100644 docs/operations/04-logs-stats.md create mode 100644 docs/operations/05-faq.md create mode 100644 docs/operations/99-pending-items.md create mode 100644 docs/operations/README.md diff --git a/docs/operations/01-platform-setup.md b/docs/operations/01-platform-setup.md new file mode 100644 index 000000000000..a405e84056f5 --- /dev/null +++ b/docs/operations/01-platform-setup.md @@ -0,0 +1,379 @@ +# 第 1 章 · 平台搭建上线 + +> 适用对象:第一次接手 NewAPI 运营的同学。 +> 目标:跑完本章后,平台具备「客户拿到令牌即可发起调用」的最小可用状态。 +> +> **执行顺序固定**:供应商 → 模型元数据 → 价格/倍率 → 分组 → 渠道。倒序会导致渠道挂载时找不到分组或模型,必须返工。 +> +> 文中「`文件:行号`」均指仓库 `https://github.com/yujipeng/new-api` 基线 `2d1ca153` 的代码位置。 + +--- + +## 1.0 关键概念速览(动手前先看一眼) + +| 概念 | 一句话定义 | 落库位置 | +|---|---|---| +| 供应商(Vendor) | 模型背后的服务提供商,决定 `/pricing` 页面的图标和归类 | `vendors` 表 | +| 模型(Model) | 模型元数据(描述、图标、端点),不含价格 | `models` 表 | +| 渠道(Channel) | 对接上游 API 的通道,承载 key、base_url、可服务模型与分组 | `channels` 表(派生 `abilities` 表) | +| 用户分组(User Group) | 客户身份所属分组,写在 `users.group` | `users.group` | +| 令牌分组(Token Group) | 令牌可临时切换到的分组,写在 `tokens.group` | `tokens.group` | +| 渠道分组(Channel Group) | 渠道挂在哪些分组下(**逗号分隔多值**) | `channels.group` | +| 分组倍率(GroupRatio) | 每个分组的全局计费倍率 | `options` 表 key=`GroupRatio` | +| 模型倍率(ModelRatio) | 每个模型的计费倍率 | `options` 表 key=`ModelRatio` | + +> ⚠️ **三个 group 不是同一个东西**。详见 1.4 节。 + +派生关系(来自 `model/ability.go:146-185 AddAbilities` / `:193-261 UpdateAbilities`): + +``` +新增/更新 channel + └─ 把 (channel.group 拆开 × channel.models 拆开) 笛卡尔展开 + └─ 同步写入 abilities 表:联合主键 (group, model, channel_id) +``` + +`abilities` 表是路由器查「这个 group 下能服务这个 model 的渠道有哪些」的源头。**不要直接改 `abilities` 表**,所有变更都走 channel 表,由 NewAPI 内部维护一致性。 + +--- + +## 1.1 步骤一:定义供应商与模型元数据 + +> **可选但推荐**:影响 `/pricing` 公开价格页的展示,也是模型管理统一界面的入口。 + +### 1.1.1 添加供应商(Vendor) + +`<截图:系统设置 - 模型管理 - 供应商列表>` + +| 维度 | 内容 | +|---|---| +| 前端路径 | `/system-settings/models/$section`(section = `vendors`) | +| 前端代码 | `web/default/src/features/system-settings/models/` | +| 后端 API | `GET/POST/PUT /api/vendors`(`router/api-router.go:340-363`) | +| 数据库表 | `vendors`(`model/vendor_meta.go:15-24`) | + +`vendors` 表字段: + +| 字段 | 类型 | 含义 | +|---|---|---| +| `id` | int | 主键 | +| `name` | string (unique) | 供应商名(唯一) | +| `description` | string | 描述 | +| `icon` | string | 图标 URL | +| `status` | int | 启停 | +| `created_time` / `updated_time` | int64 | 时间戳 | +| `deleted_at` | gorm soft delete | 软删 | + +### 1.1.2 添加模型(Model 元数据) + +`<截图:系统设置 - 模型管理 - 模型列表 - 编辑模型>` + +| 维度 | 内容 | +|---|---| +| 前端路径 | `/system-settings/models/$section`(section = `models`) | +| 后端 API | `GET/POST/PUT /api/models` | +| 数据库表 | `models`(`model/model_meta.go:23-44`) | + +`models` 表字段: + +| 字段 | 类型 | 含义 | +|---|---|---| +| `id` | int | 主键 | +| `model_name` | string (unique) | 模型名(如 `gpt-4o`) | +| `description` | string | 描述 | +| `icon` | string | 图标 | +| `tags` | string | 标签 | +| `vendor_id` | int | 关联 `vendors.id` | +| `endpoints` | text/JSON | 该模型支持的端点列表 | +| `status` | int | 启停 | +| `sync_official` | bool | 是否同步官方信息 | +| `name_rule` | int | 匹配规则(**关键,见下文**) | + +`name_rule` 取值(`model/model_meta.go:11-16`): + +| 值 | 规则 | 示例 | +|---|---|---| +| `0` | exact(精确匹配) | `gpt-4o` 只匹配 `gpt-4o` | +| `1` | prefix(前缀) | `gpt-4-` 匹配 `gpt-4-0125`、`gpt-4-vision` 等 | +| `2` | contains(包含) | `vision` 匹配所有含 `vision` 的模型名 | +| `3` | suffix(后缀) | `-preview` 匹配所有以 `-preview` 结尾的模型 | + +> 💡 选 `prefix/contains/suffix` 可以一次匹配多个版本号变体,但**仅作用于元数据展示与图标归类**,价格仍按 `ModelRatio` 中实际命中的 key 来算。 + +**注意事项**: +- 上线一个新模型时,本步只是给它建「身份证」,**还没赋予它价格**。价格在 1.2 节配。 +- 不在此处建模型也不会阻断调用——只是 `/pricing` 页与前端图标无法显示。 + +--- + +## 1.2 步骤二:定义模型价格 / 倍率 + +> 这是真正决定**扣多少钱**的步骤。两套并行机制:「按 token 倍率计费」(`UsePrice=false`,默认)与「按次价计费」(`UsePrice=true`)。 + +`<截图:系统设置 - 计费设置 - 模型定价>` + +| 维度 | 内容 | +|---|---| +| 前端路径 | `/system-settings/billing/model-pricing` | +| 前端代码 | `web/default/src/features/system-settings/billing/section-registry.tsx:106-119`(`RatioSettingsCard`) | +| 后端 API | `PUT /api/option/`(统一 option 接口,`controller/option.go`) | +| 数据库表 | `options`(key=ratio 名,value=JSON) | +| 内存结构 | `types.RWMap[string, float64]`,启动时由 `setting/ratio_setting/model_ratio.go:343-352 InitRatioSettings` 灌入 | + +### 1.2.1 倍率类型一览 + +option key 列表(来自 `model/option.go:150-153, 508-509`): + +| key | 含义 | 触发分支 | +|---|---|---| +| `ModelRatio` | 模型按 token 倍率(`UsePrice=false` 路径) | 标准计费 | +| `ModelPrice` | 模型按次价(`UsePrice=true` 路径) | 按次扣 `price × QuotaPerUnit × GroupRatio` | +| `CompletionRatio` | 输出 token 相对输入的倍率 | 标准计费 | +| `CacheRatio` | 缓存命中部分 token 的倍率 | 含缓存的请求 | +| `CreateCacheRatio` | 创建缓存的 token 倍率 | 写缓存场景 | +| `ImageRatio` | 图像 token 倍率 | 图像输入 | +| `AudioRatio` | 音频 token 倍率 | 音频输入 | +| `AudioCompletionRatio` | 音频输出 token 倍率 | 音频输出 | + +### 1.2.2 默认值与重置 + +- 后端预置约 700+ 个常见模型的默认倍率(写死在 `setting/ratio_setting/model_ratio.go:26 defaultModelRatio` 等 map 里)。 +- 重置接口:`controller/pricing.go:79 ResetModelRatio`,前端「恢复默认」按钮调用。 + +### 1.2.3 量纲(必看) + +> 🔴 **`1 USD = QuotaPerUnit = 500000 quota`**(`common/constants.go:62`)。 +> 注释原话:`$0.002 / 1K tokens = 1 quota`。 +> 汇率:`USD2RMB = 7.3`(`setting/ratio_setting/model_ratio.go:13`)。 + +前端展示量纲在 `/system-settings/billing/currency` 配置: +- `QuotaPerUnit` — 1 美元等于多少 quota(默认 500000) +- `USDExchangeRate` — 美元汇率(默认 7.3) +- `DisplayInCurrencyEnabled` — 是否按货币显示 +- `general_setting.quota_display_type` — 显示口径 + +### 1.2.4 分级(tiered)计费 + +> 当一个模型既要按用量阶梯扣,又要支持自定义表达式时使用。 + +- 设计文档:仓库内 `pkg/billingexpr/expr.md`(**配置前必读**)。 +- 配置位置:`setting/billing_setting`。 +- 触发条件:模型挂 `BillingMode='tiered_expr'` + `BillingExpr=<表达式>`。 +- 运行时入口:`service/tiered_settle.go:95 TryTieredSettle`,在标准计费完成后调用,命中则**覆盖** `summary.Quota`(`service/text_quota.go:340-346`、`service/quota.go:202-205`)。 +- 一次请求内倍率不变:`relayInfo.TieredBillingSnapshot.ExprString` 在请求前置阶段冻结。 + +⚠️ 未注册到 ModelRatio 的模型扣费行为存在不确定性,详见末尾追踪表 [#1](99-pending-items.md#1-未注册模型默认倍率)。 + +--- + +## 1.3 步骤三:定义分组(**重点章节**) + +NewAPI 共有 **3 层 group 概念**,全部以 JSON 形式存于 `options` 表,**没有独立 `groups` 表**。 + +### 1.3.1 三种 group 的角色对比 + +| 概念 | 谁在用 | 存哪里 | 默认值 | +|---|---|---|---| +| **User Group**(用户分组) | 客户身份,决定可见分组与默认计费倍率 | `users.group`(varchar(64),`model/user.go:43`) | `default` | +| **Token Group**(令牌分组) | 客户在不同令牌上**临时切换**分组(如客户买了 vip 但临时想用 svip) | `tokens.group`(`model/token.go:29`) | `''`(空 = 沿用 user 分组) | +| **Channel Group**(渠道分组 / Ability Group) | 渠道挂在哪些分组下 | `channels.group`(**逗号分隔多个**,`model/channel.go:40`),并派生进 `abilities.group` | `default` | + +**运行时解析**(`middleware/auth.go:382-399`): + +``` +默认 usingGroup = users.group +若 tokens.group != '' 且 tokens.group ∈ UserUsableGroups[users.group]: + usingGroup ← tokens.group +最终 usingGroup 写入请求上下文 ContextKeyUsingGroup +``` + +### 1.3.2 与分组相关的所有 option key + +| option key | 类型 | 含义 | 文件 | +|---|---|---|---| +| `GroupRatio` | `{groupName: ratio}` | 每个分组的全局计费倍率 | `setting/ratio_setting/group_ratio.go:18` | +| `TopupGroupRatio` | `{groupName: ratio}` | 充值时的分组倍率(影响到账金额折算) | `setting/operation_setting/` | +| `GroupGroupRatio` | `{userGroup: {usingGroup: ratio}}` | 用户分组 × 临时切换到的分组的特殊倍率矩阵 | `setting/ratio_setting/group_ratio.go:26, 93-103` | +| `UserUsableGroups` | `{groupName: 描述}` | 用户/令牌可选分组的白名单 + 描述文案 | `setting/user_usable_group.go:10` | +| `group_ratio_setting.group_special_usable_group` | `{userGroup: {"+:name"/"-:name"/"name": desc}}` | 按用户分组追加 / 移除 / 重命名可见分组 | `setting/ratio_setting/group_ratio.go:28, 38` | +| `AutoGroups` | `[group, ...]` | `tokens.group="auto"` 时自动跨组重试的分组列表 | `setting/auto_group.go` | + +### 1.3.3 分组倍率取数逻辑 + +`service/group.go:59 GetUserGroupRatio(userGroup, group)`: + +``` +先查 GroupGroupRatio[userGroup][group] + └─ 没有则 fallback GroupRatio[group] + └─ 还是没有则返回 1.0(且打 SysLog 警告) +``` + +⚠️ **GroupRatio 缺省返回 1**:未注册的 group 被消费时**不报错也不阻断**,会按原价计。详见末尾追踪表 [#3](99-pending-items.md#3-未注册-groupratio-默默按-1-倍计费)。 + +### 1.3.4 前端入口 + +`<截图:系统设置 - 计费设置 - 分组定价 - 倍率矩阵编辑>` + +| 入口 | 路径 | 用途 | +|---|---|---| +| 分组定价 | `/system-settings/billing/group-pricing` | 编辑 GroupRatio / GroupGroupRatio / UserUsableGroups | +| 倍率矩阵 | `/system-settings/models/group-ratio-form.tsx`(`section-registry.tsx:120-134`) | 可视化矩阵编辑 GroupGroupRatio | + +| API | 用途 | +|---|---| +| `GET /api/group/`(admin) | 列所有分组名(`controller/group.go:14`) | +| `GET /api/user/groups` 或 `/api/user/self/groups` | 用户视角可见分组 + 倍率(`controller/group.go:26`) | +| `PUT /api/option/` | 保存上述任意 option key | + +### 1.3.5 操作准则 + +> 🔴 **更新 `GroupRatio` 时必须同步更新 `UserUsableGroups`**。 +> `middleware/auth.go:391-396` 要求 `tokens.group` 同时在两套配置里出现,否则用户报 403(除 `auto` 分组外)。 +> 详见末尾追踪表 [#5](99-pending-items.md#5-groupratio-与-userusablegroups-必须双写)。 + +> ⚠️ **默认 SVIP 分组无法直接使用**:后端默认在 `GroupRatio` 注册了 `default/vip/svip`(值都为 1),但 `UserUsableGroups` 默认只放 `default/vip`(`setting/user_usable_group.go:10-13`)。也就是说**SVIP 默认不可被令牌切换到**,需运营手动加入 `UserUsableGroups`。详见末尾追踪表 [#6](99-pending-items.md#6-svip-默认半启用)。 + +--- + +## 1.4 步骤四:添加渠道(Channel) + +> 现在终于可以把上游 API key 接进来了。 + +`<截图:渠道管理 - 添加渠道 - 表单>` + +| 维度 | 内容 | +|---|---| +| 前端路径 | `/channels`(**要求 admin 角色**,`web/default/src/routes/_authenticated/channels/index.tsx:35`) | +| 前端代码 | `web/default/src/features/channels/index.tsx` | +| 数据库表 | `channels`(`model/channel.go:23-60`),派生表 `abilities`(`model/ability.go:16-24`) | + +### 1.4.1 后端 API 一览 + +| API | 用途 | 文件 | +|---|---|---| +| `GET /api/channel/` | 列出渠道 | `router/api-router.go:218-260` | +| `POST /api/channel/` | 新增 | | +| `PUT /api/channel/` | 更新 | | +| `DELETE /api/channel/:id` | 删除 | | +| `POST /api/channel/batch` | 批量操作 | | +| `GET /api/channel/test/:id` | 测试连通性 | `controller/channel-test.go` | +| `POST /api/channel/fix` | 重建 abilities 表 | 对应 `model/ability.go:287 FixAbility` | +| `POST /api/channel/multi_key/manage` | 多 key 模式管理 | | +| `POST /api/channel/copy/:id` | 复制渠道 | | +| `POST /api/channel/batch/tag` | 批量打 tag | | +| `POST /api/channel/tag/disabled`、`/tag/enabled` | 按 tag 启停 | | + +### 1.4.2 `channels` 表关键字段 + +| 字段 | 类型 | 含义 | +|---|---|---| +| `id` | int | 主键 | +| `type` | int | 渠道供应商枚举(`constant.ChannelType*`) | +| `key` | string | 上游 API key(**支持多 key 模式**) | +| `name` | string | 渠道展示名 | +| `status` | int | 1=启用 | +| `weight` | int | 加权随机时的权重 | +| `models` | string | 逗号分隔的支持模型列表 | +| `group` | string | 逗号分隔的支持分组列表 | +| `priority` | int | 优先级(高优先先选) | +| `base_url` | string | 上游 API 基础 URL | +| `other` | string | Azure 版本 / Gemini api_version 等 | +| `model_mapping` | text(JSON) | 模型名映射(如客户请求 `gpt-4o-2024` 映射成上游 `gpt-4o`) | +| `status_code_mapping` | text | 上游错误码到 NewAPI 错误码的映射 | +| `param_override` | text | 请求参数覆盖 | +| `header_override` | text | 请求 header 覆盖 | +| `channel_info` | text(JSON) | 多 key 模式下各 key 的状态 | +| `setting` / `settings` | text | 渠道级配置(保留两套字段) | +| `used_quota` | int | 累计消耗 quota(统计用) | +| `balance` | float | 上游账户余额(拉取式,**不参与 NewAPI 内部扣费**) | +| `balance_updated_time` | int64 | 余额最近更新时间 | +| `response_time` | int | 上次测试响应时长 | +| `test_time` | int64 | 上次测试时间 | +| `tag` | string | 标签(用于批量操作) | +| `remark` | string | 备注 | +| `openai_organization` | string | OpenAI 组织 ID(仅 OpenAI 类) | +| `test_model` | string | 测试用模型 | +| `auto_ban` | int | 上游错误时是否自动封禁 | +| `created_time` | int64 | | + +### 1.4.3 `abilities` 派生表(不直接改!) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `group` | string | 联合主键之一 | +| `model` | string | 联合主键之一 | +| `channel_id` | int | 联合主键之一 | +| `enabled` | bool | 渠道是否启用(与 `channels.status` 联动,`UpdateAbilityStatus` 在 `model/channel.go:263`) | +| `priority` | int | 同 `channels.priority` | +| `weight` | int | 同 `channels.weight` | +| `tag` | string | | + +`FixAbility`(`model/ability.go:287-341`)= **truncate `abilities` 表 + 按所有 channel 重建**。前端「修复」按钮调用,路径 `POST /api/channel/fix`。 + +> ⚠️ 高并发下 `FixAbility` 与渠道写入并发,可能出现短暂不一致。详见末尾追踪表 [#4](99-pending-items.md#4-fixability-高并发不一致)。 + +### 1.4.4 完整调用链速记 + +> 客户端请求是怎么落到具体渠道并扣费的(运营排错时的心智模型): + +``` +1. 客户端 Authorization: Bearer sk-xxx + └─ middleware.TokenAuth (middleware/auth.go:280-407) + ├─ 解 token,校验 IP / group / model_limits + └─ 写 ctx:ContextKeyUsingGroup, token_id, user_id, ... + +2. middleware.Distribute (middleware/distributor.go:30-165) + └─ 取请求 model + └─ service.CacheGetRandomSatisfiedChannel + └─ 在 abilities 表按 (group + model) 找 priority 最高的一组 + └─ 在该组内按 weight 加权随机选一个 channel + └─ 写 ctx:通道信息 + +3. relay/* 各 adapter + ├─ 转发到上游 API + ├─ 计算计费(详见第 3 章) + └─ 写日志(详见第 4 章) +``` + +--- + +## 1.5 验收:平台是否可用? + +跑完前 4 步后,做一次「冒烟自查」: + +| 检查项 | 通过标准 | +|---|---| +| `/system-settings/models/vendors` 至少有一条供应商 | 表 `vendors` 行数 > 0 | +| `/system-settings/billing/model-pricing` 中目标模型有非默认(或显式默认)倍率 | `options` 表 `key='ModelRatio'` 含目标模型 | +| `/system-settings/billing/group-pricing` 中目标分组在 `GroupRatio` 与 `UserUsableGroups` 都存在 | 见 1.3.5 准则 | +| `/channels` 列表中渠道 `status=1` 且 `models` / `group` 都包含目标值 | 表 `channels.status=1` | +| `abilities` 表能查到 `(group, model, channel_id)` 行 | 见 1.4.3 | +| 渠道测试按钮(`/api/channel/test/:id`)返回成功 | response_time 写入 `channels` | + +冒烟通过 = 平台可对外提供调用,进入第 2 章「客户接入」。 + +--- + +## 参考代码索引(按出现顺序) + +- `model/ability.go:16-24, 146-185, 193-261, 287-341` — abilities 表与派生逻辑 +- `model/vendor_meta.go:15-24` — vendors 表 +- `model/model_meta.go:11-16, 23-44` — models 表与 name_rule +- `model/channel.go:23-60, 263, 824` — channels 表与状态联动 +- `model/option.go:150-153, 508-509` — option key 注册 +- `model/user.go:24-56` — users 表 +- `model/token.go:14-32` — tokens 表 +- `setting/ratio_setting/model_ratio.go:13, 26, 343-352, 403-417` — 默认倍率与 InitRatioSettings +- `setting/ratio_setting/group_ratio.go:12-16, 18, 26, 28, 38, 84-103` — 分组倍率 +- `setting/user_usable_group.go:10-13` — 用户可用分组 +- `controller/option.go` — option PUT 入口 +- `controller/group.go:14, 26` — group 列表 API +- `controller/pricing.go:79` — 重置默认倍率 +- `middleware/auth.go:280-407, 382-399, 391-396` — token 鉴权与 group 解析 +- `middleware/distributor.go:30-165, 57-75` — 渠道分发与 model_limits 校验 +- `service/group.go:59` — 分组倍率取数 +- `router/api-router.go:218-260, 340-363` — channel / vendor / model API 注册 +- `web/default/src/routes/_authenticated/channels/index.tsx:35` — 渠道页面路由 +- `web/default/src/features/system-settings/billing/section-registry.tsx:43, 82-104, 106-119, 120-134` — 计费设置面板 +- `web/default/src/features/system-settings/models/` — 模型管理面板 +- `pkg/billingexpr/expr.md` — 分级计费表达式设计文档 +- `common/constants.go:62` — `QuotaPerUnit = 500000` diff --git a/docs/operations/02-customer-onboarding.md b/docs/operations/02-customer-onboarding.md new file mode 100644 index 000000000000..7efa9f965ff7 --- /dev/null +++ b/docs/operations/02-customer-onboarding.md @@ -0,0 +1,219 @@ +# 第 2 章 · 客户接入 + +> 适用对象:第 1 章已完成,平台可用。本章把客户带到「拿到 sk-xxx 即可发请求」的状态。 +> +> 步骤:用户身份建档 → 创建令牌 → 充值 / 兑换码到账。 + +--- + +## 2.1 步骤一:建立用户身份 + +### 2.1.1 三种创建方式 + +| 来源 | 入口 | 备注 | +|---|---|---| +| 运营手动新增 | `/users` 后台(admin) | 用于内部账号、托管客户 | +| 用户自助注册 | `/(auth)/sign-up` 前端注册页 | 支持邮箱密码 | +| 第三方 OAuth | `/oauth/$provider` | github / discord / oidc / wechat / telegram / linux_do | + +### 2.1.2 后端 API + +`<截图:用户管理 - 新建用户 / 编辑用户>` + +| API | 用途 | 文件 | +|---|---|---| +| `GET /api/user/` | 列用户(admin) | `router/api-router.go:64-180`、`controller/user.go` | +| `POST /api/user/` | 新增用户 | | +| `PUT /api/user/` | 更新用户 | | +| `DELETE /api/user/:id` | 删除用户 | | +| `POST /api/user/manage` | 封禁 / 解封 / 提权 / 降权 | | + +### 2.1.3 `users` 表关键字段 + +`model/user.go:24-56` + +**身份字段**: + +| 字段 | 类型 | 含义 | +|---|---|---| +| `id` | int | 主键 | +| `username` | string (unique) | 登录名 | +| `password` | string | 加密密码 | +| `display_name` | string | 显示名 | +| `email` | string | 邮箱 | +| `role` | int | **1=common, 10=admin, 100=root** | +| `status` | int | 启停 / 封禁 | +| `github_id` / `discord_id` / `oidc_id` / `wechat_id` / `telegram_id` / `linux_do_id` | string | 各 OAuth 关联 | +| `access_token` | string | 系统管理用的内部 token(**与计费 token 是两回事**) | +| `stripe_customer` | string | Stripe 客户号(如启用 stripe 充值) | + +**计费 / 统计字段**: + +| 字段 | 类型 | 含义 | +|---|---|---| +| `quota` | int | 剩余额度(**实时扣减**,单位 quota) | +| `used_quota` | int | 累计消费 quota | +| `request_count` | int | 累计请求数 | +| `group` | string (varchar(64), default `default`) | 用户分组 | +| `aff_code` | string | 推广码 | +| `aff_count` | int | 已推广人数 | +| `aff_quota` | int | 推广奖励额度(可提现部分) | +| `aff_history` | int / json | 推广历史额度(DB 字段名 `aff_history`,JSON 字段 `aff_history_quota`) | +| `inviter_id` | int | 邀请人 user_id | + +**时间与设置**: + +| 字段 | 类型 | 含义 | +|---|---|---| +| `created_at` | int64 | | +| `last_login_at` | int64 | | +| `deleted_at` | gorm soft delete | 软删 | +| `setting` | text(JSON, `dto.UserSetting`) | 含 `record_ip_log`、`notify_type`、`quota_warning_threshold` 等 | + +> ⚠️ **`users.setting.record_ip_log` 默认 false**。运营按 IP 排错前需要让用户在个人设置中开启,或通过后台批改 `users.setting`。详见末尾追踪表 [#7](99-pending-items.md#7-iplog-默认不记)。 + +### 2.1.4 操作准则 + +- 给客户分配分组时**先确认分组在 `GroupRatio` 与 `UserUsableGroups` 都注册了**(见 1.3.5)。 +- `role=100`(root)只能由数据库或安装时确定,不要在普通运维流程中授予。 +- 删除用户为软删,`deleted_at` 不为 NULL;如需硬删需 DBA 介入。 + +--- + +## 2.2 步骤二:用户创建令牌(sk-xxx) + +> 令牌(Token)是客户调用 OpenAI 兼容接口时 `Authorization: Bearer sk-xxx` 的真身。 + +`<截图:令牌管理 - 新建令牌 - 表单>` + +| 维度 | 内容 | +|---|---| +| 前端路径 | `/keys`(用户 / 管理员都可访问,`web/default/src/routes/_authenticated/keys/index.tsx`) | +| 后端 API | `POST/PUT/DELETE/GET /api/token/`,`POST /api/token/:id/key` 拿明文 key(`router/api-router.go:261-273`) | +| 数据库表 | `tokens`(`model/token.go:14-32`) | + +### 2.2.1 `tokens` 表字段 + +| 字段 | 类型 | 含义 | +|---|---|---| +| `id` | int | 主键 | +| `user_id` | int (索引) | 所属用户 | +| `key` | string (varchar(128), unique) | 实际的 sk-xxx | +| `status` | int | 启停 | +| `name` | string | 令牌名 | +| `created_time` | int64 | | +| `accessed_time` | int64 | 最近调用时间 | +| `expired_time` | int64 | 过期时间戳;`-1` = **永不过期** | +| `remain_quota` | int | 剩余额度(**实时扣减**) | +| `unlimited_quota` | bool | 是否不限额(不限额则不扣 `remain_quota`,但仍扣 `users.quota`) | +| `model_limits_enabled` | bool | 是否启用模型白名单 | +| `model_limits` | text | 逗号分隔模型白名单 | +| `allow_ips` | text | 回车分隔的 IP 白名单 | +| `used_quota` | int | 累计消费 | +| `group` | string | 令牌级分组(**可覆盖用户分组**) | +| `cross_group_retry` | bool | `group="auto"` 时是否跨组重试 | +| `deleted_at` | gorm soft delete | 软删 | + +### 2.2.2 令牌可用性约束(鉴权两道关) + +**第一道:分组校验**(`middleware/auth.go:380-398`): + +``` +若 tokens.group != '': + 校验 tokens.group ∈ UserUsableGroups[users.group] ← 不通过 = 403 + 校验 GroupRatio[tokens.group] 存在 ← 不通过 = 403 + (auto 分组例外) +``` + +**第二道:模型白名单**(`middleware/distributor.go:57-75`): + +``` +若 tokens.model_limits_enabled = true: + 若 tokens.model_limits = '' → 403 "token model limit is empty, all models are not allowed" + 若 请求模型 ∉ tokens.model_limits → 403 +``` + +> ⚠️ **`model_limits_enabled=true` 但 `model_limits=''` 是常见误操作**:前端创建令牌时勾选启用却忘填模型,整个 token 不可用且报错信息不直观。运营提醒客户:要么不启用白名单,要么至少填一个模型。详见末尾追踪表 [#8](99-pending-items.md#8-modellimits-为空-403)。 + +### 2.2.3 操作准则 + +- 永久令牌(`expired_time = -1`)请确保配额合理,避免长期失控。 +- 令牌的 `remain_quota` 与用户 `quota` 是**两个余额**:每次调用同时扣两边。`unlimited_quota=true` 时不扣令牌,但仍扣用户。 +- IP 白名单 `allow_ips` 可以一行一个 IP 或 CIDR;空 = 不限制。 + +--- + +## 2.3 步骤三:充值与兑换码(让用户拥有 quota) + +### 2.3.1 用户自助充值 + +`<截图:控制台 - 充值页>` + +| 维度 | 内容 | +|---|---| +| 前端路径 | `/console/topup`(`web/default/src/routes/console/topup.tsx`) | +| 后端 controller | `controller/topup.go`(含 epay / stripe / creem / waffo 多支付方式) | +| 数据库表 | `topup`(`model/topup.go`) | + +充值时的分组倍率:option key `TopupGroupRatio`,运营可对不同分组做「打折充值」。 + +> ⚠️ `TopupGroupRatio` 在 `controller/topup.go` 的具体生效分支当前**待二次确认**。详见末尾追踪表 [#2](99-pending-items.md#2-topupgroupratio-生效路径)。 + +### 2.3.2 兑换码(运营批量发码) + +`<截图:兑换码管理 - 批量生成 - 列表>` + +| 维度 | 内容 | +|---|---| +| 前端路径 | `/redemption-codes`(admin) | +| 后端 controller | `controller/redemption.go` | +| 数据库表 | `redemptions`(`model/redemption.go`) | + +兑换码生成后,用户在 `/console/topup` 输入即可兑换 quota。 + +### 2.3.3 充值 / 兑换的余额体现 + +充值或兑换成功 → 直接增加 `users.quota`,并在 `logs` 表写入 `type=1`(Topup)一条流水(详见第 4 章)。 + +--- + +## 2.4 验收:客户是否可调用? + +| 检查项 | 通过标准 | +|---|---| +| 用户已建立、`status=1`、`role` 合理(普通客户 = 1) | `users` 表存在该行 | +| 用户 `quota > 0`(除非走 `unlimited_quota` 令牌) | `users.quota` | +| 令牌已建立、`status=1`、未过期、`remain_quota > 0` | `tokens` 表 | +| 若设置了 `tokens.group`:`UserUsableGroups[users.group]` 与 `GroupRatio` 都包含 | 见 2.2.2 | +| 若设置了 `tokens.model_limits_enabled=true`:`model_limits` 非空 | 见 2.2.2 | +| 若设置了 `tokens.allow_ips`:客户出口 IP 在列表内 | | +| `/channels` 至少有一条渠道支持 `(usingGroup, 请求模型)` 组合 | `abilities` 表存在对应行 | + +跑一次 `curl` 自测: + +```bash +curl https://your-newapi-host/v1/chat/completions \ + -H "Authorization: Bearer sk-xxx" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": "ping"}] + }' +``` + +返回 200 + 模型回答 = 客户可调用,进入第 3 章「计费规则」。 + +--- + +## 参考代码索引 + +- `model/user.go:24-56` — users 表结构 +- `model/token.go:14-32` — tokens 表结构 +- `controller/user.go` — 用户 CRUD +- `controller/topup.go` — 充值入口 +- `controller/redemption.go` — 兑换码 +- `middleware/auth.go:280-407, 380-398` — token 鉴权 + 分组校验 +- `middleware/distributor.go:57-75` — 模型白名单校验 +- `router/api-router.go:64-180, 261-273` — user / token API 注册 +- `web/default/src/routes/_authenticated/keys/index.tsx` — 令牌前端页 +- `web/default/src/routes/console/topup.tsx` — 充值前端页 diff --git a/docs/operations/03-billing.md b/docs/operations/03-billing.md new file mode 100644 index 000000000000..0f7b10b270a6 --- /dev/null +++ b/docs/operations/03-billing.md @@ -0,0 +1,281 @@ +# 第 3 章 · 计费规则 + +> 适用对象:客户已能调用,本章解释「每次调用到底扣了多少 quota」。 +> +> 运营常被问到的问题都在这里:人话版公式、原始公式、量纲、特殊场景。 + +--- + +## 3.0 量纲(**先记住这三条**) + +> 🔴 量纲是计费理解的地基,看不懂下面的公式 = 先回来看这三条。 + +| 表达 | 含义 | +|---|---| +| **`1 USD = QuotaPerUnit = 500000 quota`** | 1 美元 = 50 万 quota。源码注释 `$0.002 / 1K tokens = 1 quota`(`common/constants.go:62`) | +| **`1 quota ≈ $0.000002`** | 反过来看,量纲非常小 | +| **`USD2RMB = 7.3`** | 默认人民币汇率(`setting/ratio_setting/model_ratio.go:13`),可在 `/system-settings/billing/currency` 调整 | + +举例: + +- 客户消费了 `1000 quota` ≈ `$0.002` ≈ `¥0.0146` +- 给客户充值 `$10` = 给 `users.quota` 增加 `5,000,000` + +> 前端显示时是否换算成美元 / 人民币,由 `/system-settings/billing/currency` 的 `DisplayInCurrencyEnabled` + `general_setting.quota_display_type` 决定。 + +--- + +## 3.1 主计费入口(文本类) + +> **所有 OpenAI / Claude / Gemini 的文本与 chat completions 都走这一个函数**:`service/text_quota.go:159 calculateTextQuotaSummary`。 + +### 3.1.1 人话版(**给运营 / 客户的解释口径**) + +NewAPI 的标准计费可以拆成三层: + +``` +单次扣费 quota + = 输入贡献 + 输出贡献 + 工具调用附加费 + 音频输入附加费 + = (输入 token × ModelRatio) + (输出 token × CompletionRatio × ModelRatio) + + 工具调用次数费用 + 音频独立计费 + × GroupRatio(分组倍率) + × 一连串 OtherRatios(其他倍率,目前通常为 1) +``` + +**关键点(运营要会说清)**: + +1. **输入和输出价格不同**:`CompletionRatio` 是输出倍率,一般 > 1(输出比输入贵)。 +2. **缓存命中部分另算**:缓存命中的 token 走 `CacheRatio`,写入缓存的部分走 `CacheCreationRatio`,一般比标准价便宜。 +3. **图像、音频 token 独立计价**:从 prompt_tokens 中扣除后单独乘 `ImageRatio` / `AudioRatio`。 +4. **工具调用按次收费**:每个工具按 `(toolPrice / 1000) × callCount × GroupRatio × QuotaPerUnit` 算。 +5. **GroupRatio 包揽客户级折扣**:VIP / SVIP / 内部分组的整体打折就靠它。 +6. **最低收 1 quota**:单次计算结果 ≤ 0 但模型有价格时,强制扣 1 quota(避免免费调用)。 + +> 「客户问:为什么我的发票里输出比输入多很多?」→ 给他看 `CompletionRatio`。 +> 「客户问:缓存命中怎么省钱?」→ 给他看 `CacheRatio`。 +> 「客户问:我是 VIP 怎么还扣这么多?」→ 检查 `GroupRatio['vip']` 是否真的小于 1。 + +### 3.1.2 原始公式(按倍率计费分支,`UsePrice=false`) + +证据:`service/text_quota.go:228-290` + +``` +ratio = ModelRatio × GroupRatio (line 228) + +baseTokens = PromptTokens + - CachedTokens (非 Claude 语义时) + - CachedCreationTokens (非 Claude 语义时) + - ImageTokens + - AudioTokens (有单独定价时) + +promptQuota = baseTokens + + CachedTokens × CacheRatio + + CachedCreationTokens × CacheCreationRatio + + ImageTokens × ImageRatio + + (Claude split) + CC5m × CacheCreation5mRatio + + CC1h × CacheCreation1hRatio + +completionQuota = CompletionTokens × CompletionRatio + +toolSurchargeQuota = Σ(toolPrice × callCount ÷ 1000) × GroupRatio × QuotaPerUnit + (text_quota.go:84-138) + +audioInputQuota = AudioInputPrice ÷ 1e6 × AudioTokens × GroupRatio × QuotaPerUnit + +quota = round( (promptQuota + completionQuota) × ratio + + toolSurchargeQuota + + audioInputQuota ) + × Π OtherRatios (line 281-285) + +if quota <= 0 and ratio != 0: + quota = 1 (line 287-289 兜底) +``` + +### 3.1.3 原始公式(按次价分支,`UsePrice=true`) + +证据:`service/text_quota.go:291-301` + +``` +quota = round( ModelPrice × QuotaPerUnit × GroupRatio + + toolSurchargeQuota + + audioInputQuota ) + × Π OtherRatios +``` + +按次价场景:模型在 `ModelPrice` 中显式注册了价格,**整请求只按这个价**收费,与 token 数无关(但仍叠加工具与音频费)。 + +> ⚠️ `OtherRatios` 连乘当前没有赋值路径、没有前端入口,**疑似预留字段,运营无需配置**。详见末尾追踪表 [#9](99-pending-items.md#9-otherratios-疑似预留)。 + +--- + +## 3.2 音频 / Realtime 计费 + +> WebSocket 实时通道、音频 HTTP 接口走独立计费函数。 + +### 3.2.1 人话版 + +``` +音频请求的 quota + = (文本输入 token + 文本输出 token × CompletionRatio + + 音频输入 token × AudioRatio + + 音频输出 token × AudioRatio × AudioCompletionRatio) + × ModelRatio × GroupRatio +``` + +要点: + +1. 音频也分输入 / 输出,**双重叠加倍率**:音频输出 = `AudioRatio × AudioCompletionRatio`。 +2. 文本和音频混合时,分开累加再统一乘 `ModelRatio × GroupRatio`。 + +### 3.2.2 原始公式 + +证据:`service/quota.go:50 calculateAudioQuota` + +``` +ratio = ModelRatio × GroupRatio +quota = ratio × ( textInputTokens + + textOutputTokens × CompletionRatio + + audioInputTokens × AudioRatio + + audioOutputTokens × AudioRatio × AudioCompletionRatio ) +``` + +入口函数: + +| 入口 | 文件 | +|---|---| +| WSS 实时(如 OpenAI Realtime) | `service/quota.go:157 PostWssConsumeQuota` | +| 音频 HTTP(Whisper / TTS) | `service/quota.go:279 PostAudioConsumeQuota` | + +--- + +## 3.3 异步任务计费(Midjourney / Suno / 视频) + +| 模块 | 文件 | +|---|---| +| 主入口 | `service/task_billing.go`(301 行) | +| 写日志 | `model.RecordTaskBillingLog`(`model/log.go:273`) | + +特点: + +- 任务(Midjourney / Suno / 视频)是异步生成,计费时机在**任务完成回调**而非请求时。 +- 落表与文本类相同:`logs` 表 + 更新 `users.used_quota` / `tokens.used_quota` / `channels.used_quota`。 +- 计费维度通常是「按次」或「按时长」,由各任务 adapter 决定。 + +--- + +## 3.4 分级(tiered)计费 + +> 当一个模型既要按用量阶梯扣,又要支持自定义表达式时使用。 + +### 3.4.1 触发条件 + +模型挂上 `BillingMode='tiered_expr'` + `BillingExpr=<表达式>`。 + +### 3.4.2 运行时 + +证据:`service/tiered_settle.go:95 TryTieredSettle` + +``` +1. 走完标准计费(3.1 / 3.2)得到 summary.Quota +2. 调用 TryTieredSettle: + ├─ 若模型未挂 BillingMode='tiered_expr' → 跳过 + └─ 若已挂: + ├─ 用 relayInfo.TieredBillingSnapshot.ExprString 求值 + └─ ★ 覆盖 summary.Quota(不是叠加)★ +``` + +调用点:`service/text_quota.go:340-346`、`service/quota.go:202-205`。 + +### 3.4.3 一次请求倍率不变 + +`TieredBillingSnapshot.ExprString` 在请求前置阶段**冻结**,即使运营在请求处理过程中改了表达式,本次请求仍按冻结的版本算。 + +### 3.4.4 配置入口与文档 + +- **表达式设计文档**:仓库内 `pkg/billingexpr/expr.md`(**配置前必读**)。 +- **配置代码**:`setting/billing_setting`。 + +--- + +## 3.5 预扣 / 反扣 / 退款(钱什么时候真的扣) + +> 「客户说扣费时机不对」时,对照下面这张表。 + +| 步骤 | 函数 | 文件:行 | +|---|---|---| +| 预扣额度(信任阈值以上则跳过) | `PreConsumeQuota` | `service/pre_consume_quota.go:33` | +| 预扣实操:扣 token | `PreConsumeTokenQuota` → `model.DecreaseTokenQuota` | `service/quota.go:382` / `model/token.go:405` | +| 失败返还预扣 | `ReturnPreConsumedQuota` → `PostConsumeQuota(-quota)` | `service/pre_consume_quota.go:17` | +| 实结:扣用户 quota | `model.DecreaseUserQuota` | `model/user.go:911` | +| 实结:累加用户 used | `model.UpdateUserUsedQuotaAndRequestCount`(支持 `BatchUpdateEnabled` 批量异步) | `model/user.go:963` | +| 实结:累加渠道 used | `model.UpdateChannelUsedQuota` | `model/channel.go:824` | +| 实结:扣令牌余量 | `model.DecreaseTokenQuota` | `model/token.go:405` | +| 订阅模式扣费 | `model.PostConsumeUserSubscriptionDelta` | `service/quota.go:406-431`(`relayInfo.BillingSource == BillingSourceSubscription`) | +| 配额预警通知 | `checkAndSendQuotaNotify` / `checkAndSendSubscriptionQuotaNotify` | `service/quota.go:452-546` | + +### 3.5.1 客户层心智模型 + +``` +请求到达 + ├─ PreConsumeQuota 估扣(防止超额提前拒) + │ └─ 若超信任阈值:跳过预扣(信任客户不会跑路) + ├─ 转发到上游 + ├─ 上游返回:拿到真实 token 数 + ├─ 计算真实 quota(3.1 / 3.2 / 3.3) + ├─ 若分级计费触发 → TryTieredSettle 覆盖 + ├─ 若有订阅 → PostConsumeUserSubscriptionDelta + └─ 否则 → 标准实结: + ├─ DecreaseUserQuota + ├─ DecreaseTokenQuota + ├─ UpdateChannelUsedQuota + └─ RecordConsumeLog(写流水,详见第 4 章) +``` + +### 3.5.2 失败场景 + +- 上游返回错误 → 仅返还预扣,**不写 Consume 流水**(写 Error 流水,`logs.type=5`)。 +- 部分上游 / 部分失败 → 取上游真实返回的 token 计费。 + +--- + +## 3.6 量纲一览(再来一次,方便对账) + +| 单位 | 等值 | +|---|---| +| 1 quota | $0.000002 | +| 1 USD | 500000 quota | +| 1 RMB | 500000 / 7.3 ≈ 68493 quota(默认汇率) | +| 1K tokens(按 $0.002 / 1K 基准) | 1 quota | + +--- + +## 3.7 落表(计费数据持久化) + +| 表.字段 | 含义 | 更新时机 | +|---|---|---| +| `users.quota` | 用户剩余额度 | 实结时减 | +| `users.used_quota` | 用户累计消费 | 实结时加(可批量异步) | +| `users.request_count` | 用户累计请求数 | 实结时 +1 | +| `tokens.remain_quota` | 令牌剩余额度 | 实结时减(`unlimited_quota=true` 时跳过) | +| `tokens.used_quota` | 令牌累计消费 | 实结时加 | +| `channels.used_quota` | 渠道累计消费 | 实结时加 | +| `logs.quota` | 单次调用的 quota 数(**审计源**) | 实结时插入一行 `type=2` | +| `quota_data.quota` | 用户 × 模型 × 小时聚合 quota | 异步刷盘(详见第 4 章) | + +--- + +## 参考代码索引 + +- `common/constants.go:62` — `QuotaPerUnit = 500000` +- `setting/ratio_setting/model_ratio.go:13` — `USD2RMB` +- `service/text_quota.go:84-138, 159, 228-290, 291-301, 340-346` — 文本计费主入口 +- `service/quota.go:50, 157, 202-205, 242, 279, 363, 382, 406-431, 452-546` — 音频 / 实时 / 实结 / 通知 +- `service/tiered_settle.go:95` — 分级计费 +- `service/task_billing.go` — 任务计费 +- `service/pre_consume_quota.go:17, 33` — 预扣 / 返还 +- `model/user.go:911, 963` — 用户 quota 增减 +- `model/token.go:405` — 令牌 quota 减 +- `model/channel.go:824` — 渠道累计 +- `model/log.go:208, 273` — 流水写入 +- `pkg/billingexpr/expr.md` — 分级计费表达式设计 diff --git a/docs/operations/04-logs-stats.md b/docs/operations/04-logs-stats.md new file mode 100644 index 000000000000..5579d30dc871 --- /dev/null +++ b/docs/operations/04-logs-stats.md @@ -0,0 +1,266 @@ +# 第 4 章 · 日志与数据看板 + +> 适用对象:客户已可调用并扣费,本章解释「调用之后的数据落到哪、怎么查、怎么聚合」。 +> +> 三件事:单笔流水(`logs` 表)、小时级聚合(`quota_data` 表)、运营辅助(渠道亲和度)。 + +--- + +## 4.1 单笔流水:`logs` 表 + +> 这是 NewAPI 的「主账本」——所有计费、充值、管理、错误事件都在这里。 +> +> **可单独使用 `LOG_DB`**:日志库可与主库分离,避免互相影响(`model/log.go:20-42`)。 + +### 4.1.1 表结构 + +| 字段 | 类型 | 含义 | +|---|---|---| +| `id` | int | 主键 | +| `user_id` | int | 复合索引 | +| `created_at` | int64 | unix 秒 | +| **`type`** | int | **日志类型,下文详解** | +| `content` | text | 人类可读说明(含倍率 / 价格摘要) | +| `username` | string | 冗余(写入时打平) | +| `token_name` | string | 冗余 | +| `model_name` | string | 冗余(被 `gpt-4-gizmo-*` 等折叠,`service/text_quota.go:378-385`) | +| `quota` | int | 单次扣费 quota | +| `prompt_tokens` | int | 上游返回的输入 token 数 | +| `completion_tokens` | int | 上游返回的输出 token 数 | +| `use_time` | int | 调用耗时(秒) | +| `is_stream` | bool | 是否流式 | +| `channel_id` | int | 渠道 | +| `channel_name` | string (`gorm:"->"` 虚拟列) | 查询时 join 填充 | +| `token_id` | int | 令牌 | +| `group` | string | **最终生效的 usingGroup**(不是 user.group / channel.group) | +| `ip` | string | 客户端 IP(**默认不记**,见下文) | +| `request_id` | string | NewAPI 自生成 | +| `upstream_request_id` | string | 上游 API 返回的 request id | +| `other` | text(JSON) | 详细计费信息(见 4.1.3) | + +### 4.1.2 `type` 取值 + +证据:`model/log.go:45-53` + +| `type` | 含义 | 写入函数 | +|---|---|---| +| `1` | **Topup**(充值) | `model.RecordTopupLog`(`model/log.go:119`) | +| `2` | **Consume**(消费,最常见) | `model.RecordConsumeLog`(`model/log.go:208`) | +| `3` | **Manage**(管理操作) | `model.RecordLog` / `RecordLogWithAdminInfo`(`model/log.go:77, 96`) | +| `4` | **System**(系统事件) | `model.RecordLog` | +| `5` | **Error**(错误) | `model.RecordErrorLog`(`model/log.go:147`) | +| `6` | **Refund**(退款) | (由业务流程触发 `RecordLog`,不单独定义函数) | +| — | 任务计费(独立路径,仍写到 `logs`) | `model.RecordTaskBillingLog`(`model/log.go:273`) | + +### 4.1.3 `other` JSON 含什么 + +由 `service/text_quota.go:388-450` 写入,主要包括: + +- 倍率明细:`model_ratio`、`group_ratio`、`completion_ratio`、`cache_ratio`、`image_ratio`、`audio_ratio` 等的实际取值 +- token 拆分:`prompt_tokens` / `completion_tokens` / `cached_tokens` / `cached_creation_tokens` / `image_tokens` / `audio_tokens` +- 工具调用:每个工具的 `price` × `count` +- 分级计费快照:若命中 `tiered_expr`,记录表达式与求值过程 +- 管理员信息(`type=3`):`admin_info` 含操作者 IP、节点名、版本(`RecordLogWithAdminInfo`) + +> 💡 **客户对账要细节时翻 `other`**。这是「为什么扣这么多」的最完整证据。 + +### 4.1.4 `ip` 字段默认不记 + +`model/log.go:155-160, 218-223`:仅当 `users.setting.record_ip_log == true` 时才写。 + +> ⚠️ 运营按 IP 排错前需要: +> 1. 让客户在个人设置开启 IP 日志,或 +> 2. 后台批改 `users.setting.record_ip_log = true`。 +> +> 详见末尾追踪表 [#7](99-pending-items.md#7-iplog-默认不记)。 + +### 4.1.5 日志清理 + +`controller/log.go:153 DeleteHistoryLogs` → `model.DeleteOldLog`: + +- 按 `created_at < target_timestamp` 删除 +- 分批 100 条删除(`model/log.go:518`) +- 前端入口:`/usage-logs` 管理员可见的「清理历史日志」按钮 + +--- + +## 4.2 日志查询 API + 前端入口 + +`<截图:使用日志 - 通用日志列表 - 筛选条件>` + +| 入口 | 路径 | controller | model 层 | +|---|---|---|---| +| 管理员看全量 | `GET /api/log/`(paged) | `controller/log.go:13 GetAllLogs` | `model.GetAllLogs`(`model/log.go:304`) | +| 用户看自己 | `GET /api/log/self` | `controller/log.go:36 GetUserLogs` | `model.GetUserLogs`(`model/log.go:387`) | +| 管理员统计 | `GET /api/log/stat` | `controller/log.go:98 GetLogsStat` | `model.SumUsedQuota`(`model/log.go:451`) | +| 用户自统计 | `GET /api/log/self/stat` | `controller/log.go:125 GetLogsSelfStat` | 同上 | +| 按 token key 查 | `GET /api/log/token` | `controller/log.go:74 GetLogByKey` | `model.GetLogByTokenId`(`model/log.go:71`) | +| 清理 | `DELETE /api/log/` | `controller/log.go:153 DeleteHistoryLogs` | `model.DeleteOldLog`(`model/log.go:518`) | +| ~~`SearchAllLogs` / `SearchUserLogs`~~ | ~~`GET /api/log/search` / `self/search`~~ | **已废弃**(`controller/log.go:58-72`) | | + +### 4.2.1 前端入口 + +| 路径 | 用途 | +|---|---| +| `/usage-logs/common` | 文本类调用日志 | +| `/usage-logs/drawing` | 绘图类(对应 `/api/mj`) | +| `/usage-logs/task` | 异步任务类(对应 `/api/task`) | + +证据:`web/default/src/features/usage-logs/section-registry.tsx:24-43`、`web/default/src/features/usage-logs/api.ts:35-37`。 + +普通用户与管理员**进同一页面**:`buildApiPath` 自动切换 `/api/log/self` vs `/api/log/`。 + +### 4.2.2 过滤维度 + +均支持(用 `LIKE+ESCAPE` 方式做模糊匹配的字段标星): + +- `username` ★ +- `token_name` ★ +- `model_name` ★ +- `channel_id` +- `group`(**最终 usingGroup**) +- `type`(按类型过滤) +- `request_id` +- `upstream_request_id` +- `created_at` 区间(start / end) + +### 4.2.3 `logs.group` 的口径 + +> 🔴 **关键运营提醒**:`logs.group` 写的是**最终 usingGroup**。 +> +> 客户原本在 `vip` 但调用时令牌切到 `svip` → `logs.group = 'svip'`。 +> 想分开看「以 vip 身份切到 svip」与「直接 svip 用户」当前**做不到**——因为 user_group 与 token_group **未单独落库**,只在 `other.group_ratio_special` 命中 GroupGroupRatio 时写一处。 +> +> 详见末尾追踪表 [#10](99-pending-items.md#10-logsgroup-无法区分-user-vs-token-切换)。 + +--- + +## 4.3 统计口径(`SumUsedQuota`) + +> 「客户问消费多少 / RPM / TPM 是多少」时,看这里。 + +证据:`model/log.go:451` + +``` +quota = sum(logs.quota) WHERE type=2 (Consume) + + 任意组合过滤(username / token_name / model_name / channel / group / start / end) + +rpm = count(*) + WHERE type=2 AND created_at >= now-60s + +tpm = sum(prompt_tokens) + sum(completion_tokens) + WHERE type=2 AND created_at >= now-60s +``` + +> ⚠️ **`rpm` / `tpm` 是「最近 60 秒」的截面**,不是用户选定时间区间的平均(`model/log.go:482`)。 +> controller 返回的字段名只叫 `rpm/tpm`,前端可能误解。运营对外解释时务必声明这一点。 +> 详见末尾追踪表 [#11](99-pending-items.md#11-rpmtpm-是-60-秒截面)。 + +--- + +## 4.4 数据看板:`quota_data` 表(小时级聚合) + +`<截图:仪表盘 - 模型 × 小时柱状图>` + +### 4.4.1 表结构 + +证据:`model/usedata.go:13-22` + +| 字段 | 含义 | +|---|---| +| `id` | 主键 | +| `user_id` | 用户 | +| `username` | 冗余 | +| `model_name` | 模型名 | +| `created_at` | **按小时取整**(`model/log.go:60`) | +| `token_used` | 当前 (user, model, hour) 的累计 token | +| `count` | 当前 (user, model, hour) 的累计请求数 | +| `quota` | 当前 (user, model, hour) 的累计 quota | + +### 4.4.2 写入流程 + +``` +每条 Consume 日志(model.RecordConsumeLog 第 254-258 行) + └─ 启动 goroutine 调用 LogQuotaData + └─ 内存累加进 CacheQuotaData map + └─ 每 common.DataExportInterval 分钟(默认 5)刷盘 + └─ 写入 quota_data 表(model/usedata.go:24-32 UpdateQuotaData) +``` + +**开关**:`common.DataExportEnabled`(默认 true)。关掉后看板停止更新,但 `logs` 仍写入。 + +### 4.4.3 查询 API + +| 入口 | 文件 | +|---|---| +| 管理员模型维度 | `GET /api/data/` → `GetAllQuotaDates`(按 model_name + created_at 聚合) | +| 管理员用户维度 | `GET /api/data/users` → `GetQuotaDatesByUser` | +| 用户自己 | `GET /api/data/self` → `GetUserQuotaDates` | + +### 4.4.4 前端入口 + +`/dashboard`(`web/default/src/features/dashboard/`):模型 × 小时柱状图,可选 `quota` / `count` / `token_used` 三个维度。 + +### 4.4.5 表清理 + +> ⚠️ **`quota_data` 表代码中未发现 DELETE 入口**(`model/usedata.go` 全文 138 行,只有 INSERT / UPDATE / SELECT)。 +> 长期运行的实例(5+ 年)这张表会无限增长,需要 DBA 介入做归档。 +> 详见末尾追踪表 [#12](99-pending-items.md#12-quotadata-无清理路径)。 + +--- + +## 4.5 渠道亲和度统计(运营辅助,非财务口径) + +> 用来让「同一用户在短时间内黏到上次成功的 channel」,提升体验与稳定性。 +> **不参与计费**,仅影响路由决策。 + +| 项 | 内容 | +|---|---| +| 实现 | `service/channel_affinity.go`(25.9K) | +| API(admin) | `GET /api/option/channel_affinity_cache`、`GET /api/log/channel_affinity_usage_cache` | +| 用途 | 优化渠道选择,**不写消费日志、不影响 used_quota** | + +--- + +## 4.6 配额变更日志:哪去了? + +> NewAPI **没有独立的 `quota_change_log` 表**。所有 quota 变更体现在三处: + +1. **`users.used_quota` / `users.quota` 的实时增量**(无审计行); +2. **`logs` 表中**的 `type=1`(Topup)/ `type=6`(Refund)/ `type=2`(Consume)/ `type=3`(Manage)行; +3. **管理员后台改额度**:通过 `RecordLogWithAdminInfo` 写到 `logs.other.admin_info`,含操作者 IP、节点名、版本(`model/log.go:96-117`)。 + +运营做审计时: +- 客户主动行为(充值 / 调用 / 退款):直接看 `logs` 对应类型行。 +- 内部调整(手动加减额度):看 `logs.type=3`,并展开 `other.admin_info` 看是谁操作的。 + +--- + +## 4.7 表与统计速查 + +| 表 | 角色 | 是否财务源 | +|---|---|---| +| `users` | 用户余额 / 累计消费 / 请求数 | **是**(`quota`, `used_quota`, `request_count`) | +| `tokens` | 令牌余额 / 分组 / 模型白名单 | **是**(`remain_quota`, `used_quota`) | +| `channels` | 渠道身份 + 累计消耗 | **是**(`used_quota`) | +| `abilities` | (group × model → channel) 派生路由表 | 否(仅路由) | +| `logs` | 单笔流水(消费 / 充值 / 管理 / 错误 / 退款 / 系统 / 任务计费) | **是**(`quota` 是审计源) | +| `quota_data` | 用户 × 模型 × 小时聚合 | 否(看板用,从 logs 派生) | +| `models` | 模型元数据 | 否 | +| `vendors` | 供应商元数据 | 否 | +| `options` | 全部 ratio / group / 系统设置 JSON | **是**(所有倍率定义) | +| `redemptions` | 兑换码 | 充值入口 | +| `topup` | 充值订单 | 充值入口 | + +--- + +## 参考代码索引 + +- `model/log.go:20-42, 45-53, 60, 71, 77, 96-117, 119, 147, 155-160, 208, 218-223, 254-258, 273, 304, 387, 451, 482, 518` — logs 表 / 写入 / 查询 / 清理 / 统计 +- `model/usedata.go:13-22, 24-32` — quota_data 表 +- `controller/log.go:13, 36, 58-72, 74, 98, 125, 153` — 日志查询 / 清理 controller +- `service/text_quota.go:378-385, 388-450` — model_name 折叠 + other JSON +- `service/channel_affinity.go` — 渠道亲和度 +- `web/default/src/features/usage-logs/section-registry.tsx:24-43` — 前端日志入口 +- `web/default/src/features/usage-logs/api.ts:35-37` — 前端 API 路径切换 +- `web/default/src/features/dashboard/` — 看板前端 diff --git a/docs/operations/05-faq.md b/docs/operations/05-faq.md new file mode 100644 index 000000000000..11555ba43996 --- /dev/null +++ b/docs/operations/05-faq.md @@ -0,0 +1,236 @@ +# 第 5 章 · 常见运营场景速查 / 排错 + +> 「客户反馈了 X,我该看哪里、怎么处理?」 +> +> 本章覆盖:① 高频运营场景;② 架构师摸底报告中标记的 13 条「灰区」翻译成的运营行动建议。 +> +> 灰区编号与末尾追踪表(`99-pending-items.md`)一一对应。 + +--- + +## 5.1 高频运营场景 + +### 场景 1:上线一个新模型,全链路要动哪几步? + +1. (可选)在 `/system-settings/models/vendors` 注册供应商。 +2. 在 `/system-settings/models/models` 注册模型元数据,设置 `name_rule` 与 `endpoints`。 +3. **在 `/system-settings/billing/model-pricing` 注册 `ModelRatio`(或 `ModelPrice`)+ `CompletionRatio`**。⚠️ 不注册的后果见灰区 [#1](99-pending-items.md#1-未注册模型默认倍率)。 +4. 在 `/channels` 把新模型加入支持该模型的渠道 `models` 列表,或新增一条专门渠道。 +5. 验证 `abilities` 表已生成 `(group, model, channel_id)` 行(前端「修复」按钮 = `POST /api/channel/fix`)。 +6. 用一个测试令牌 curl 调用,确认 200 + 扣费正确。 + +### 场景 2:上线一个新分组(如 `enterprise`) + +1. 在 `/system-settings/billing/group-pricing` 同时配: + - `GroupRatio['enterprise'] = X.X` + - `UserUsableGroups['enterprise'] = '描述文案'` + - (可选)`GroupGroupRatio[*][enterprise] = ...` 对特定用户分组打折 +2. (可选)配 `TopupGroupRatio['enterprise']` 调整充值打折比例。 +3. 在 `/channels` 把目标渠道的 `group` 字段追加 `enterprise`(逗号分隔)。 +4. 把客户的 `users.group = 'enterprise'`(或在令牌上 `tokens.group = 'enterprise'`)。 + +> 🔴 **千万记得两边都配**(`GroupRatio` + `UserUsableGroups`),否则鉴权 403。详见灰区 [#5](99-pending-items.md#5-groupratio-与-userusablegroups-必须双写)。 + +### 场景 3:客户反馈调用 403 "no permission to use this group" + +排查顺序: + +1. 看令牌的 `tokens.group`。若非空: + - 是否在 `UserUsableGroups[users.group]` 白名单内? + - 是否在 `GroupRatio` 注册了对应倍率? +2. `auto` 分组例外(走 `AutoGroups` 列表),但仍需 `cross_group_retry` 配合。 +3. 用 `GET /api/user/self/groups` 看客户视角能看到哪些分组——能看到的就一定能用。 + +### 场景 4:客户反馈 403 "token model limit is empty" + +证据:`middleware/distributor.go:58-65`。 + +原因:令牌 `model_limits_enabled=true` 但 `model_limits=''`。 + +处理:让客户编辑令牌,要么关闭白名单开关,要么至少填一个模型。详见灰区 [#8](99-pending-items.md#8-modellimits-为空-403)。 + +### 场景 5:客户反馈调用某个模型扣费比预期高 + +排查顺序: + +1. 打开 `/usage-logs/common`,找到对应 `request_id` 的行。 +2. 展开 `other` JSON: + - 看 `model_ratio`、`completion_ratio` 是否符合预期。 + - 看 `group_ratio` 是否是客户预期的分组倍率。 + - 看是否有 `tiered_settle_snapshot` 字段(分级计费覆盖了标准计费)。 + - 看 `tool` / `audio` 附加费是否符合预期。 +3. 对照第 3 章公式手工核算一次,与 `logs.quota` 比对。 +4. **客户分组核对**:`logs.group` = 最终 usingGroup,可能与 `users.group` 不同(令牌切换过)。 + +### 场景 6:客户反馈余额扣完了但找不到对账记录 + +1. `/usage-logs/common` 按 `username` + 时间区间过滤 `type=2` 全量导出。 +2. 若有 Midjourney / Suno / 视频任务:也看 `/usage-logs/task`。 +3. 与 `users.used_quota` 对账,差异 = 期间发生过的退款(`type=6`)或管理员调整(`type=3`)。 +4. 充值差异:查 `type=1` 行。 +5. 长期对账:用 `/dashboard` 看 `quota_data` 模型 × 小时聚合,注意 `quota_data` 至少滞后 `DataExportInterval`(默认 5 分钟)。 + +### 场景 7:客户反馈某次请求很慢 / 超时 + +1. `/usage-logs/common` 看对应 `request_id`: + - `use_time` 字段(秒)。 + - `channel_id` / `channel_name` 是哪个渠道。 +2. 到 `/channels` 看该渠道的 `response_time`、`test_time`,必要时点「测试」重测。 +3. 看渠道亲和度(`/api/option/channel_affinity_cache`)是否把客户卡在了一个慢渠道——可清缓存让重新选路。 + +### 场景 8:客户问「我这个月一共花了多少 RPM / TPM」 + +> 🔴 **`rpm` / `tpm` 是「最近 60 秒」的截面值,不是时间区间的平均值**(灰区 [#11](99-pending-items.md#11-rpmtpm-是-60-秒截面))。 +> +> 想要区间平均:从 `logs` 表自己跑 SQL: +> +> ```sql +> SELECT COUNT(*) / (END - START) AS rpm_avg, +> (SUM(prompt_tokens) + SUM(completion_tokens)) / (END - START) AS tpm_avg +> FROM logs +> WHERE type = 2 AND user_id = ? AND created_at BETWEEN START AND END; +> ``` + +### 场景 9:客户反馈调用了但日志没 IP + +`logs.ip` **默认不写**(灰区 [#7](99-pending-items.md#7-iplog-默认不记))。处理: + +1. 后台改 `users.setting.record_ip_log = true`(PUT `/api/user/`)。 +2. 或让客户在个人设置中打开「记录 IP 日志」。 +3. **历史日志补不回来**,只对开关打开后的新日志生效。 + +### 场景 10:充值到账金额不对 + +1. 看充值订单(`topup` 表)原始金额。 +2. 看 `TopupGroupRatio[users.group]`——这是充值折算倍率。 +3. ⚠️ `TopupGroupRatio` 具体生效分支当前**待二次确认**(灰区 [#2](99-pending-items.md#2-topupgroupratio-生效路径)),如果折算结果不符合预期,记录证据上报架构师。 + +### 场景 11:渠道余额(`channels.balance`)显示为 0,客户调用还能扣费吗? + +**能**。`channels.balance` 是 NewAPI 主动拉取的**上游账户余额**,不参与内部 quota 计算。客户扣的是 `users.quota` / `tokens.remain_quota`,与 `channels.balance` 无关。 + +详见灰区 [#13](99-pending-items.md#13-channelsbalance-不参与计费)。 + +### 场景 12:渠道路由错乱 / `abilities` 与渠道列表不一致 + +1. 找一条具体证据:`(group, model)` 应当命中渠道 A,实际命中渠道 B(或 404 no_satisfied_channel)。 +2. 点 `/channels` 上的「修复」按钮(`POST /api/channel/fix`) → `model/ability.go:287 FixAbility`:truncate + 重建。 +3. ⚠️ `FixAbility` 高并发下与渠道写入并发可能短暂不一致(灰区 [#4](99-pending-items.md#4-fixability-高并发不一致))。建议在低峰期执行。 + +### 场景 13:默认存在 `svip` 分组但客户的令牌切到 svip 就 403 + +原因:后端默认 `GroupRatio` 注册了 `default/vip/svip`,但 `UserUsableGroups` 默认只有 `default/vip`(`setting/user_usable_group.go:10-13`)。 + +处理:在 `/system-settings/billing/group-pricing` 把 `svip` 加入 `UserUsableGroups`。 + +详见灰区 [#6](99-pending-items.md#6-svip-默认半启用)。 + +--- + +## 5.2 灰区 → 运营行动建议汇总表 + +> 架构师摸底报告标记了 13 条「事实模糊、文档冲突或代码未覆盖」点。 +> 下表把每一条翻译成运营层面的「具体该怎么做」。 +> 完整描述与跟踪状态见 [`99-pending-items.md`](99-pending-items.md)。 + +| # | 灰区 | 运营行动建议 | +|---|---|---| +| 1 | 未注册到 `ModelRatio` 的模型扣费行为存在不确定性(疑似 37.5 倍率且仅 `SelfUseMode` 生效) | **上线新模型前必须在 `ModelRatio` / `ModelPrice` 显式注册**。已上线但未注册的模型应立即补录。 | +| 2 | `TopupGroupRatio` 生效路径待二次确认 | 上线新充值倍率前**做一次端到端充值测试**(小额,比如 1 USD),核对到账 quota 是否符合预期。 | +| 3 | 未注册的 group 被消费时默默按 1 倍计费 | **`channels.group` 中出现的每个分组必须先在 `GroupRatio` 注册**。新建渠道时审核分组名拼写。 | +| 4 | `FixAbility` 高并发下可能短暂不一致 | 修复路由错乱时**选低峰期**点「修复」按钮;点完后等 1 分钟再用测试令牌验证一次。 | +| 5 | `GroupRatio` 与 `UserUsableGroups` 必须同时维护 | 配置分组时**两套配置同步更新**,否则用户切换令牌分组时报 403。 | +| 6 | 默认 SVIP 分组「半启用」(在 GroupRatio 但不在 UserUsableGroups) | 想真正启用 SVIP,**手动把 `svip` 加入 `UserUsableGroups`**。 | +| 7 | `logs.ip` 默认不记 | 排错前先确认对应用户 `users.setting.record_ip_log = true`;历史日志补不回来。 | +| 8 | `tokens.model_limits_enabled=true` 但 `model_limits=''` 全模型 403 | 客户创建令牌时**勾选白名单必须至少填一个模型**;运营在前端教程中显著提示。 | +| 9 | `OtherRatios` 连乘当前无赋值路径与配置入口 | **运营无需配置**,疑似预留字段。若发现实际计费里 `OtherRatios ≠ 1`,立即上报架构师。 | +| 10 | `logs.group` 仅记最终 usingGroup,无法区分「用户分组」与「令牌切换分组」 | 按 group 统计时**明示口径**:`logs.group` = 最终生效分组,不区分来源。需要拆分时单独按用户 ID + 令牌 ID 维度聚合。 | +| 11 | `rpm` / `tpm` 是「最近 60 秒」截面值 | 对外解释**永远显式说「最近 60 秒」**;区间 RPM/TPM 需自跑 SQL 计算。 | +| 12 | `quota_data` 表无清理路径 | 定期由 DBA 归档历史数据,避免无限增长。建议每 6 个月清理一次 12 个月以前的行。 | +| 13 | `channels.balance` 不参与扣费 | 客户咨询「渠道余额为 0」时**明确说不影响扣费**;该字段只用于运营监控上游账户是否需要充值。 | + +--- + +## 5.3 排错速查矩阵 + +| 客户反馈 | 第一步看哪里 | 主要可能原因 | +|---|---|---| +| 401 invalid token | 令牌是否过期 / 被禁用 / IP 不在白名单 | `tokens.status`、`expired_time`、`allow_ips` | +| 403 group denied | `tokens.group` 与 `UserUsableGroups` / `GroupRatio` | 灰区 [#5](99-pending-items.md#5-groupratio-与-userusablegroups-必须双写) / [#6](99-pending-items.md#6-svip-默认半启用) | +| 403 model not allowed | 令牌模型白名单 | 灰区 [#8](99-pending-items.md#8-modellimits-为空-403) | +| 404 no satisfied channel | `(usingGroup, model)` 在 `abilities` 找不到行 | 渠道未挂该分组 / 模型;或路由错乱(修复 abilities,灰区 [#4](99-pending-items.md#4-fixability-高并发不一致)) | +| 扣费金额不对 | `logs.other` JSON 倍率明细 | 第 3 章公式 + 客户分组 | +| 看不到 IP | `users.setting.record_ip_log` | 灰区 [#7](99-pending-items.md#7-iplog-默认不记) | +| 充值到账金额不对 | `topup` 表 + `TopupGroupRatio` | 灰区 [#2](99-pending-items.md#2-topupgroupratio-生效路径) | +| 渠道余额 0 但能扣费 | `channels.balance` 与扣费无关 | 灰区 [#13](99-pending-items.md#13-channelsbalance-不参与计费) | +| 看板数据滞后 | `DataExportInterval`(默认 5 分钟)刷盘 | 正常现象 | +| 长期归档需求 | `logs.DeleteOldLog` 可清理;`quota_data` 无清理 | 灰区 [#12](99-pending-items.md#12-quotadata-无清理路径) | + +--- + +## 5.4 常用查询语句(DBA / 运营自助) + +> 直连日志库(`LOG_DB`)跑只读查询。 + +```sql +-- 1. 某用户某天的总消费 +SELECT SUM(quota) AS quota, COUNT(*) AS calls + FROM logs + WHERE type = 2 AND user_id = ? + AND created_at BETWEEN UNIX_TIMESTAMP('2026-05-01') AND UNIX_TIMESTAMP('2026-05-02'); + +-- 2. 按渠道看本周消费 +SELECT channel_id, SUM(quota) AS quota + FROM logs + WHERE type = 2 + AND created_at >= UNIX_TIMESTAMP() - 7*86400 + GROUP BY channel_id + ORDER BY quota DESC; + +-- 3. 找扣费最高的请求 +SELECT id, user_id, model_name, quota, prompt_tokens, completion_tokens, created_at + FROM logs + WHERE type = 2 + ORDER BY quota DESC + LIMIT 50; + +-- 4. 按 usingGroup 看一段时间的消费分布 +SELECT `group`, SUM(quota) AS quota, COUNT(*) AS calls + FROM logs + WHERE type = 2 + AND created_at BETWEEN ? AND ? + GROUP BY `group`; + +-- 5. 看某客户的最近 60 秒真实 RPM(与平台 stat 字段一致) +SELECT COUNT(*) AS rpm, + SUM(prompt_tokens) + SUM(completion_tokens) AS tpm + FROM logs + WHERE type = 2 AND user_id = ? + AND created_at >= UNIX_TIMESTAMP() - 60; +``` + +--- + +## 5.5 升级路径与回滚 + +> 给运营做参考,不替代研发的部署文档。 + +- 修改倍率(`PUT /api/option/`)→ 内存与数据库同步更新,**立即生效**,无需重启。 +- 修改 `users.setting.record_ip_log` → 只对新日志生效。 +- 修改令牌、渠道 → 立即生效,已建立的连接需要新请求才走新配置。 +- 删除分组 → 须先把 `channels.group` / `users.group` / `tokens.group` 中的引用清空再删,否则会产生「未注册分组按 1 倍计费」的悄悄扣费(灰区 [#3](99-pending-items.md#3-未注册-groupratio-默默按-1-倍计费))。 +- 撤销倍率变更 → 在前端编辑回原值即可,或调用 `POST /api/reset_model_ratio` 系列重置接口(`controller/pricing.go:79`)恢复 NewAPI 默认。 + +--- + +## 参考代码索引 + +- `middleware/auth.go:382-399` — token 鉴权 + 分组校验 +- `middleware/distributor.go:58-65` — 模型白名单 403 分支 +- `model/ability.go:287 FixAbility` — abilities 重建 +- `setting/user_usable_group.go:10-13` — UserUsableGroups 默认值 +- `setting/ratio_setting/group_ratio.go:12-16, 84-91` — GroupRatio 默认值与 fallback +- `setting/ratio_setting/model_ratio.go:403-417 GetModelRatio` — 未注册模型 fallback +- `service/channel_affinity.go` — 渠道亲和度 +- `controller/log.go:153 DeleteHistoryLogs` — 日志清理 +- `model/log.go:451, 482` — SumUsedQuota / rpm/tpm 口径 diff --git a/docs/operations/99-pending-items.md b/docs/operations/99-pending-items.md new file mode 100644 index 000000000000..e39044285c96 --- /dev/null +++ b/docs/operations/99-pending-items.md @@ -0,0 +1,178 @@ +# 待确认事项追踪表 + +> 来源:架构师事实层摸底报告(issue [TES-69](mention://issue/7628a2ad-bf09-4050-b197-98910ff11357) 评论 `e2af1c61` 第 4 节) +> 维护规则:每条含 编号 / 现象描述 / 当前手册措辞 / 责任人 / 状态(open / closed) +> 关闭流程:架构师追加证据 → 文档维护专家更新对应章节 + 关闭本表中的条目(状态改 `closed` 并附证据 PR / commit) + +--- + +## 1. 未注册模型默认倍率 + +| 项 | 内容 | +|---|---| +| **现象描述** | `setting/ratio_setting/model_ratio.go:403-417 GetModelRatio` 在模型未注册时返回 `(37.5, operation_setting.SelfUseModeEnabled, name)`,含义是默认 37.5 倍率,且只有 `SelfUseMode` 开启时才算「有定价」。SelfUseMode 关闭时这个 37.5 是否真扣费、`relayInfo.PriceData` 装载逻辑是否会绕开它,**未追完证据**。 | +| **当前手册措辞** | 第 1 章 1.2.4 与第 5 章场景 1 / 灰区 #1:「上线新模型前必须在 ModelRatio 显式注册;未注册时的扣费行为(疑似 37.5 倍率且仅 SelfUseMode 生效)待二次确认。」 | +| **责任人** | 架构师 | +| **状态** | open | +| **闭环需要的证据** | `relayInfo.PriceData` 装载链 + SelfUseMode 关闭时实际走哪条分支 | + +--- + +## 2. TopupGroupRatio 生效路径 + +| 项 | 内容 | +|---|---| +| **现象描述** | option key `TopupGroupRatio` 已注册(`web/default/src/features/system-settings/billing/section-registry.tsx:43`),用于充值打折 / 到账折算,但具体在 `controller/topup.go` 的哪条分支里完成换算**未追完**。 | +| **当前手册措辞** | 第 2 章 2.3.1 与第 5 章场景 10:「用于充值打折 / 到账折算,具体在 `controller/topup.go` 的生效分支待二次确认。」 | +| **责任人** | 架构师 | +| **状态** | open | +| **闭环需要的证据** | `controller/topup.go` 中 `TopupGroupRatio` 被读取的具体函数与分支 | + +--- + +## 3. 未注册 GroupRatio 默默按 1 倍计费 + +| 项 | 内容 | +|---|---| +| **现象描述** | `setting/ratio_setting/group_ratio.go:84-91`:未在 `groupRatioMap` 注册的 group 被消费时,会打 SysLog 并按 1 倍计费,**不报错也不阻断**。 | +| **当前手册措辞** | 第 1 章 1.3.3 + 第 5 章灰区 #3:「`channels.group` 中出现的每个分组必须先在 `GroupRatio` 注册。」 | +| **责任人** | 架构师(确认是否为故意设计) | +| **状态** | open(已有代码证据,但**是否需要把 fallback 改为报错或显式 0** 待产品决定) | +| **闭环需要的证据** | 产品 / 架构师明确:fallback 1 是有意还是 bug | + +--- + +## 4. FixAbility 高并发不一致 + +| 项 | 内容 | +|---|---| +| **现象描述** | `model/ability.go:295-307 FixAbility` 是 truncate `abilities` 表 + 按所有 channel 重建。高并发下若与渠道写入并发,可能短暂不一致。 | +| **当前手册措辞** | 第 1 章 1.4.3 + 第 5 章场景 12 / 灰区 #4:「修复路由错乱时选低峰期点修复按钮;点完后等 1 分钟再用测试令牌验证一次。」 | +| **责任人** | 架构师(评估是否需要加锁或换为增量同步) | +| **状态** | open | +| **闭环需要的证据** | 是否需要排他锁;并发窗口实测时长 | + +--- + +## 5. GroupRatio 与 UserUsableGroups 必须双写 + +| 项 | 内容 | +|---|---| +| **现象描述** | `middleware/auth.go:391-396` 要求 `tokens.group` 同时存在于 `GroupRatio` 与 `UserUsableGroups[user.group]`,但前端在两个 tab 编辑(`/system-settings/billing/group-pricing`),**未做联动校验**。 | +| **当前手册措辞** | 第 1 章 1.3.5 + 第 5 章场景 2 / 场景 3 / 灰区 #5:「配置分组时两套配置同步更新,否则用户切换令牌分组时报 403。」 | +| **责任人** | 架构师 / 前端(评估是否在保存时做联动校验提示) | +| **状态** | open | +| **闭环需要的证据** | 决策:是写到文档强约束就够,还是要在前端加保存校验 | + +--- + +## 6. SVIP 默认半启用 + +| 项 | 内容 | +|---|---| +| **现象描述** | 后端默认 `defaultGroupRatio` 写死 `default/vip/svip = 1`(`setting/ratio_setting/group_ratio.go:12-16`),但 `setting/user_usable_group.go:10-13` 默认只放 `default/vip`。结果是 SVIP 默认存在于 `GroupRatio` 但不在 `UserUsableGroups`,任何用户都无法把令牌切到 svip 直到运营手动添加。 | +| **当前手册措辞** | 第 1 章 1.3.5 + 第 5 章场景 13 / 灰区 #6:「想真正启用 SVIP,手动把 svip 加入 UserUsableGroups。」 | +| **责任人** | 架构师 / 产品(决定是否对齐两份默认值) | +| **状态** | open | +| **闭环需要的证据** | 决策:默认值是否对齐;若不对齐,是否在 `/system-settings/billing/group-pricing` 给运营显式提示 | + +--- + +## 7. iplog 默认不记 + +| 项 | 内容 | +|---|---| +| **现象描述** | `model/log.go:155-160, 218-223`:`logs.ip` 仅在 `users.setting.record_ip_log == true` 时写入,默认 false。 | +| **当前手册措辞** | 第 2 章 2.1.3 + 第 4 章 4.1.4 + 第 5 章场景 9 / 灰区 #7:「排错前先确认对应用户已开启;历史日志补不回来。」 | +| **责任人** | 架构师 / 产品 | +| **状态** | open(更多是产品决策:默认值是否要改为 true,还是仅在管理员后台增加批量开关) | +| **闭环需要的证据** | 决策:默认值是否要改 | + +--- + +## 8. modellimits 为空 403 + +| 项 | 内容 | +|---|---| +| **现象描述** | `middleware/distributor.go:58-65`:`tokens.model_limits_enabled=true` 但 `model_limits=''` 时直接 403 "token model limit is empty, all models are not allowed"——前端创建令牌时若误选启用却忘填模型,整个 token 不可用且报错信息不直观。 | +| **当前手册措辞** | 第 2 章 2.2.2 + 第 5 章场景 4 / 灰区 #8:「客户创建令牌时勾选白名单必须至少填一个模型;运营在前端教程中显著提示。」 | +| **责任人** | 前端(评估保存时是否阻止此组合) | +| **状态** | open | +| **闭环需要的证据** | 决策:前端是否加保存校验 | + +--- + +## 9. OtherRatios 疑似预留 + +| 项 | 内容 | +|---|---| +| **现象描述** | `service/text_quota.go:281-285`:从 `relayInfo.PriceData.OtherRatios` 取值并连乘,但本次未找到给 `OtherRatios` 赋值的代码路径,且没有前端配置入口。 | +| **当前手册措辞** | 第 3 章 3.1.3 + 第 5 章灰区 #9:「代码中有 Π OtherRatios 连乘但无赋值路径与前端入口,疑似预留字段,当前运营无需配置。」 | +| **责任人** | 架构师 | +| **状态** | open | +| **闭环需要的证据** | 是预留 / 已废弃 / 内部隐藏字段中的哪种? | + +--- + +## 10. logsgroup 无法区分 user vs token 切换 + +| 项 | 内容 | +|---|---| +| **现象描述** | `logs.group` 写的是 `usingGroup`(最终生效那个),**未把 user_group / token_group 单独落库**。`other` JSON 里仅在命中 GroupGroupRatio 时存 `group_ratio_special`。管理员按 group 检索时无法直接区分「客户原本属于 vip,但调用时用了 token.group=svip」。 | +| **当前手册措辞** | 第 4 章 4.2.3 + 第 5 章灰区 #10:「按 group 统计时明示口径;需要拆分时单独按用户 ID + 令牌 ID 维度聚合。」 | +| **责任人** | 架构师 / 产品(评估是否扩展 logs schema) | +| **状态** | open | +| **闭环需要的证据** | 是否要新增 `logs.user_group` / `logs.token_group` 字段 | + +--- + +## 11. rpmtpm 是 60 秒截面 + +| 项 | 内容 | +|---|---| +| **现象描述** | `model/log.go:482`:`rpm` / `tpm` 用 `created_at >= now-60s` 计算,是「最近 60 秒」的截面,不是用户选定时间区间的平均。`controller/log.go:113-121` 返回字段名只叫 `rpm/tpm`,前端可能误解为时间区间均值。 | +| **当前手册措辞** | 第 4 章 4.3 + 第 5 章场景 8 / 灰区 #11:「对外解释永远显式说『最近 60 秒』;区间 RPM/TPM 需自跑 SQL。」 | +| **责任人** | 前端(评估是否在 UI 上加 tooltip 说明) | +| **状态** | open | +| **闭环需要的证据** | UI 文案改进决定 | + +--- + +## 12. quotadata 无清理路径 + +| 项 | 内容 | +|---|---| +| **现象描述** | `model/usedata.go` 全文 138 行,只有 INSERT / UPDATE / SELECT,**未发现 DELETE 入口**。运营若做了 5+ 年数据,这张表会无限增长。 | +| **当前手册措辞** | 第 4 章 4.4.5 + 第 5 章灰区 #12:「定期由 DBA 归档历史数据;建议每 6 个月清理一次 12 个月以前的行。」 | +| **责任人** | 架构师 / DBA | +| **状态** | open | +| **闭环需要的证据** | 是否补一个清理接口 / cron job | + +--- + +## 13. channelsbalance 不参与计费 + +| 项 | 内容 | +|---|---| +| **现象描述** | `channels.balance` / `balance_updated_time` 是上游账户余额(通过 `update_balance` API 拉取,`controller/channel-billing.go`),**不参与 NewAPI 内部 quota 计算**。运营看到 channel 余额为 0 ≠ 客户扣费失败。 | +| **当前手册措辞** | 第 1 章 1.4.2 + 第 5 章场景 11 / 灰区 #13:「客户咨询『渠道余额为 0』时明确说不影响扣费;该字段只用于运营监控上游账户是否需要充值。」 | +| **责任人** | (已有事实证据,事实层无需追加) | +| **状态** | closed-by-fact(事实清楚,仅作存档;运营手册中已明示口径) | +| **闭环需要的证据** | — | + +--- + +## 状态汇总 + +| 状态 | 数量 | +|---|---| +| open | 12(#1 ~ #12) | +| closed-by-fact | 1(#13) | + +--- + +## 维护说明 + +- 本表由文档维护专家维护,架构师追加证据后由文档维护专家关闭对应条目并更新正文措辞。 +- 关闭一条 = 在状态列改为 `closed-YYYY-MM-DD` 并保留闭环证据链接(commit / PR / issue 评论)。 +- 运营在排错过程中如果发现新的灰区,**追加为 #14、#15...**,不要改既有编号。 diff --git a/docs/operations/README.md b/docs/operations/README.md new file mode 100644 index 000000000000..7747803b3c92 --- /dev/null +++ b/docs/operations/README.md @@ -0,0 +1,59 @@ +# NewAPI 运营操作手册 + +> 适用版本:仓库基线 `2d1ca153`(2026-05-21) +> 编写源:项目内代码事实层调研(每条结论附「文件:行号」证据)+ 运营场景翻译 +> 适用对象:第一次接手 NewAPI 运营 / 客户成功的同学 +> +> **目标**:照着这份文档,从零完成平台配置、客户接入,并完整理解计费与日志统计。 + +--- + +## 章节速查 + +| # | 文件 | 内容 | +|---|---|---| +| 1 | [`01-platform-setup.md`](01-platform-setup.md) | 平台搭建上线(供应商 → 模型 → 价格 → 分组 → 渠道) | +| 2 | [`02-customer-onboarding.md`](02-customer-onboarding.md) | 客户接入(用户 → 令牌 → 充值 / 兑换码) | +| 3 | [`03-billing.md`](03-billing.md) | 计费规则(标准 / 分级 / 音频 / 任务,含人话版与原始公式 + 量纲) | +| 4 | [`04-logs-stats.md`](04-logs-stats.md) | 日志与数据看板(`logs` / `quota_data` 表 + 前端入口 + 统计口径) | +| 5 | [`05-faq.md`](05-faq.md) | 常见运营场景速查 / 排错(13 条灰区翻译为运营行动建议) | +| 99 | [`99-pending-items.md`](99-pending-items.md) | 待确认事项追踪表(架构师责任人,open / closed 状态) | + +--- + +## 阅读建议 + +- **第一次接手** → 顺序读 1 → 2 → 3 → 4 → 5。 +- **只想配置渠道 / 模型 / 分组** → 读第 1 章。 +- **客户对账 / 排错** → 直接跳到第 5 章,按客户反馈对应「场景 X」。 +- **计费金额疑问** → 第 3 章先看 3.0「量纲」+ 3.1.1「人话版公式」。 +- **后台数据库查询** → 第 4 章 4.7「表与统计速查」+ 第 5 章 5.4「常用查询语句」。 + +--- + +## 关键约束(运营层必须记住) + +1. **量纲:1 USD = 500000 quota**(详见 [`03-billing.md` 3.0](03-billing.md#30-量纲先记住这三条))。 +2. **三层 group 不是同一个东西**:`users.group` / `tokens.group` / `channels.group`,详见 [`01-platform-setup.md` 1.3](01-platform-setup.md#13-步骤三定义分组重点章节)。 +3. **`GroupRatio` 与 `UserUsableGroups` 必须双写**,否则鉴权 403。 +4. **新模型上线必须显式注册 `ModelRatio`**,否则扣费行为不确定(追踪表 #1)。 +5. **`logs.ip` 默认不记**;按 IP 排错前需先开启(追踪表 #7)。 +6. **`rpm` / `tpm` 是「最近 60 秒」截面值**,不是区间平均(追踪表 #11)。 + +--- + +## 文档维护规约 + +- 本手册落在仓库 `docs/operations/` 目录。 +- 文档变更走 `feature/` 分支,**禁止直接 push 到 main / develop**(v5 规约)。 +- 三方一致性反馈(PRD ↔ 文档 ↔ 代码)通过父 issue 评论上报对应责任人。 +- 待确认事项不散落正文,统一进 [`99-pending-items.md`](99-pending-items.md);正文相关位置仅做「⚠️ 详见末尾追踪表 #N」标注。 + +--- + +## 证据基线 + +- 仓库:`https://github.com/yujipeng/new-api` +- 基线:`origin/main` HEAD = `2d1ca153 fix: respect dashboard content visibility settings (#4975)` +- 摸底来源:父 issue [TES-69](mention://issue/7628a2ad-bf09-4050-b197-98910ff11357) 中架构师 `e2af1c61` 评论「NewAPI 运营事实摸底报告」 +- 编写分支:`feature/docs-operations-manual` From f09f6a42b2b0f6bc7b48fede8983cef0738e2d3b Mon Sep 17 00:00:00 2001 From: "jipeng.yu" Date: Thu, 21 May 2026 14:31:47 +0000 Subject: [PATCH 2/4] docs(operations): close 12 pending items with evidence-backed wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 sync per architect's evidence closure (TES-69 comment 87c3958d): - Rewrite 99-pending-items.md: all 12 grey-zone items closed-2026-05-21 with evidence chains + handling outcome; appendix lists 5 known improvements (audio/task gate parity, svip default, three frontend guards, RPM/TPM tooltip, quota_data cleanup) tracked by PJM separately - 03-billing.md #1: add 3.1.4 explaining text-path two gates (SelfUseMode + AcceptUnsetRatioModel) vs audio/task path no-gate asymmetry for unregistered models - 03-billing.md #2 (in 02): rewrite TopupGroupRatio as topup-side discount/markup multiplier, not billing-side; document formula, four payment paths, fallback 1.0 behavior - 03-billing.md #9: rewrite OtherRatios as active dynamic billing multiplier for task/image paths (seconds/size/n), list adapters, clarify text-path no-op semantics; remove "reserved field" wording - 01/02 #3 #4 #5 #6 #7: operational hints planted (group ref check, FixAbility low-peak single-instance, double-write reminder w/ note that automatic linkage check is not yet implemented, svip default workaround, ip log default-false with compliance rationale) - 05-faq #8 errata: actual error message is MsgDistributorTokenModelForbidden ("该 token 不允许使用模型 X"), not "token model limit is empty"; FAQ scene 4 rewritten as full troubleshooting chain - 05-faq #10 #11 #12: planted hints for usingGroup-only logs.group (open other.user_group / other.token_group after schema extension), RPM/TPM as 60s snapshot, quota_data cleanup via DBA - README, 5.2 summary table, 5.3 troubleshooting matrix, and inline anchors updated to match new 99-pending-items.md headings Co-authored-by: multica-agent --- docs/operations/01-platform-setup.md | 30 +++- docs/operations/02-customer-onboarding.md | 46 +++++- docs/operations/03-billing.md | 49 +++++- docs/operations/04-logs-stats.md | 25 ++- docs/operations/05-faq.md | 95 +++++++---- docs/operations/99-pending-items.md | 192 ++++++++++++---------- docs/operations/README.md | 4 +- 7 files changed, 302 insertions(+), 139 deletions(-) diff --git a/docs/operations/01-platform-setup.md b/docs/operations/01-platform-setup.md index a405e84056f5..0e2c693acd7a 100644 --- a/docs/operations/01-platform-setup.md +++ b/docs/operations/01-platform-setup.md @@ -161,7 +161,14 @@ option key 列表(来自 `model/option.go:150-153, 508-509`): - 运行时入口:`service/tiered_settle.go:95 TryTieredSettle`,在标准计费完成后调用,命中则**覆盖** `summary.Quota`(`service/text_quota.go:340-346`、`service/quota.go:202-205`)。 - 一次请求内倍率不变:`relayInfo.TieredBillingSnapshot.ExprString` 在请求前置阶段冻结。 -⚠️ 未注册到 ModelRatio 的模型扣费行为存在不确定性,详见末尾追踪表 [#1](99-pending-items.md#1-未注册模型默认倍率)。 +> 🔴 **未注册到 `ModelRatio` 的模型走两路径不对称扣费**: +> +> - **文本路径**(OpenAI / Claude / Gemini):两道闸门兜底 — ① 系统级 `SelfUseMode` ② 用户级 `AcceptUnsetRatioModel`。两者都关 → 直接 HTTP 拒绝调用;任一开启 → 按 37.5 倍率扣费。 +> - **音频 / Realtime / 异步任务路径**:**无闸门**,未注册模型直接按 37.5 倍率静默扣费(已知不对称)。 +> +> 运营建议:所有上线模型必须在 `/system-settings/billing/model-pricing` 显式注册 `ModelRatio` 或 `ModelPrice`,不要依赖 37.5 默认值。 +> +> 详细公式与代码证据见第 3 章 [3.1.4](03-billing.md#314-未注册到-modelratio-的模型扣费行为两路径不对称),跟踪信息见末尾追踪表 [#1](99-pending-items.md#1-未注册模型默认倍率两路径不对称)。 --- @@ -226,11 +233,18 @@ NewAPI 共有 **3 层 group 概念**,全部以 JSON 形式存于 `options` 表 ### 1.3.5 操作准则 -> 🔴 **更新 `GroupRatio` 时必须同步更新 `UserUsableGroups`**。 +> 🔴 **更新 `GroupRatio` 时必须同步更新 `UserUsableGroups`**(双写约束)。 > `middleware/auth.go:391-396` 要求 `tokens.group` 同时在两套配置里出现,否则用户报 403(除 `auto` 分组外)。 +> **前端自动联动校验尚未实现**,两 tab 分别保存,运营需自行核对。 > 详见末尾追踪表 [#5](99-pending-items.md#5-groupratio-与-userusablegroups-必须双写)。 -> ⚠️ **默认 SVIP 分组无法直接使用**:后端默认在 `GroupRatio` 注册了 `default/vip/svip`(值都为 1),但 `UserUsableGroups` 默认只放 `default/vip`(`setting/user_usable_group.go:10-13`)。也就是说**SVIP 默认不可被令牌切换到**,需运营手动加入 `UserUsableGroups`。详见末尾追踪表 [#6](99-pending-items.md#6-svip-默认半启用)。 +> 🔴 **修改 `GroupRatio` 时必须同步检查所有 `channels.group` 引用**。 +> 未在 `GroupRatio` 注册的 group 名出现在 `channels.group` 中时,调用会按 1 倍**静默计费**(`setting/ratio_setting/group_ratio.go:84-91`,仅打 SysLog,不阻断)。 +> 详见末尾追踪表 [#3](99-pending-items.md#3-未注册-groupratio-默默按-1-倍计费)。 + +> ⚠️ **默认 SVIP 分组无法直接使用**(首次部署 workaround):后端默认在 `GroupRatio` 注册了 `default/vip/svip`(值都为 1),但 `UserUsableGroups` 默认只放 `default/vip`(`setting/user_usable_group.go:10-13`)。也就是说**SVIP 默认不可被令牌切换到**,需运营手动在 `/system-settings/billing/group-pricing` 把 `svip` 加入 `UserUsableGroups`。 +> (后续版本可能对齐默认值,届时本 workaround 自动失效,详见 `99-pending-items.md` 附录 A2。) +> 详见末尾追踪表 [#6](99-pending-items.md#6-svip-默认半启用)。 --- @@ -309,7 +323,15 @@ NewAPI 共有 **3 层 group 概念**,全部以 JSON 形式存于 `options` 表 `FixAbility`(`model/ability.go:287-341`)= **truncate `abilities` 表 + 按所有 channel 重建**。前端「修复」按钮调用,路径 `POST /api/channel/fix`。 -> ⚠️ 高并发下 `FixAbility` 与渠道写入并发,可能出现短暂不一致。详见末尾追踪表 [#4](99-pending-items.md#4-fixability-高并发不一致)。 +> 🔴 **FixAbility 执行期间所有请求会 503**: +> +> - 已加进程级 `sync.Mutex`(`model/ability.go:285 fixLock`) — `TryLock()` 失败直接返回「已经有一个修复任务在运行中」。 +> - 但**无跨进程锁**:单实例 OK;多实例集群下两节点同时点修复仍可能并发。 +> - 单实例下执行期间(清空 → 重建完成)`abilities` 表为空 → `CacheGetRandomSatisfiedChannel` 全部 miss → 所有请求返回 503。 +> +> **运营建议**:选低峰期单实例操作;点完后等 1 分钟再用测试令牌验证一次;集群部署需运维分时(不要同一时刻多节点点修复)。 +> +> 详见末尾追踪表 [#4](99-pending-items.md#4-fixability-高并发不一致)。 ### 1.4.4 完整调用链速记 diff --git a/docs/operations/02-customer-onboarding.md b/docs/operations/02-customer-onboarding.md index 7efa9f965ff7..961397ab14a1 100644 --- a/docs/operations/02-customer-onboarding.md +++ b/docs/operations/02-customer-onboarding.md @@ -70,7 +70,7 @@ | `deleted_at` | gorm soft delete | 软删 | | `setting` | text(JSON, `dto.UserSetting`) | 含 `record_ip_log`、`notify_type`、`quota_warning_threshold` 等 | -> ⚠️ **`users.setting.record_ip_log` 默认 false**。运营按 IP 排错前需要让用户在个人设置中开启,或通过后台批改 `users.setting`。详见末尾追踪表 [#7](99-pending-items.md#7-iplog-默认不记)。 +> ⚠️ **`users.setting.record_ip_log` 默认 false**(合规:GDPR / PIPL 个人信息最小化原则)。运营按 IP 排错前需要让用户在个人设置中开启,或通过后台批改 `users.setting`;历史日志补不回来。详见末尾追踪表 [#7](99-pending-items.md#7-logsip-默认不记)。 ### 2.1.4 操作准则 @@ -129,11 +129,19 @@ ``` 若 tokens.model_limits_enabled = true: - 若 tokens.model_limits = '' → 403 "token model limit is empty, all models are not allowed" - 若 请求模型 ∉ tokens.model_limits → 403 + GetModelLimitsMap() → 解析 tokens.model_limits + 若 model_limits = '' → GetModelLimits() 返回空切片 → limitsMap 为空 map(非 nil) + 若 请求模型 ∉ limitsMap → 403 i18n.MsgDistributorTokenModelForbidden + 「该 token 不允许使用模型 X」 ``` -> ⚠️ **`model_limits_enabled=true` 但 `model_limits=''` 是常见误操作**:前端创建令牌时勾选启用却忘填模型,整个 token 不可用且报错信息不直观。运营提醒客户:要么不启用白名单,要么至少填一个模型。详见末尾追踪表 [#8](99-pending-items.md#8-modellimits-为空-403)。 +> ⚠️ **`model_limits_enabled=true` 但 `model_limits=''` 是常见误操作**: +> +> 前端创建令牌时勾选启用却忘填模型 → 内部解析结果是「空 map(非 nil)」→ 任何模型查询都查不到 → 报 `MsgDistributorTokenModelForbidden`「该 token 不允许使用模型 X」。 +> +> (注意:报错文案**不是**「token model limit is empty」,那个分支需要 `limitsMap` 为 nil 才触发,但 `GetModelLimitsMap` 始终返回非 nil map,所以实际不会走到。) +> +> 运营提醒客户:要么不启用白名单,要么至少填一个模型。FAQ 中给出了完整排查链,详见末尾追踪表 [#8](99-pending-items.md#8-model_limits-为空-403错误文案订正) 与第 5 章场景 4。 ### 2.2.3 操作准则 @@ -155,9 +163,35 @@ | 后端 controller | `controller/topup.go`(含 epay / stripe / creem / waffo 多支付方式) | | 数据库表 | `topup`(`model/topup.go`) | -充值时的分组倍率:option key `TopupGroupRatio`,运营可对不同分组做「打折充值」。 +#### `TopupGroupRatio` 折扣 / 加价系数(**充值侧专用**) + +> ✅ **`TopupGroupRatio` 仅影响充值付款金额,不影响调用计费**。计费侧(API 调用扣费)走 `GroupRatio`,两套独立。 + +| 项 | 内容 | +|---|---| +| option key | `TopupGroupRatio` | +| 默认值 | `{default:1, vip:1, svip:1}`(`common/topup-ratio.go:8-12`) | +| 公共定价函数 | `controller/topup.go:148-176 getPayMoney` | +| 公式 | `payMoney = amount × Price × TopupGroupRatio[user.group] × Discount` | +| 量纲 | `amount` = 用户想充的 quota 数量;`Price` = 全局单位价(`operation_setting.Price`);`Discount` = `operation_setting.GetPaymentSetting().AmountDiscount[amount]` 按金额阈值预设折扣 | +| fallback | 未配置的 group 名 → fallback `1.0`(无折扣)+ 写 SysError 日志,**不阻断充值**(`common/topup-ratio.go:32-41`) | +| 防免单 | `topupGroupRatio == 0` 强制设为 1(`controller/topup.go:158-160`) | + +**四条充值路径**(均会读取 `TopupGroupRatio`): + +| 路径 | 文件 | +|---|---| +| epay | `controller/topup.go:206 RequestEpay → getPayMoney` | +| Stripe | `controller/topup_stripe.go:389, 403`(直接 `GetTopupGroupRatio`) | +| Waffo | `controller/topup_waffo.go:82` | +| Waffo-Pancake | `controller/topup_waffo_pancake.go:59` | + +**运营怎么用**: -> ⚠️ `TopupGroupRatio` 在 `controller/topup.go` 的具体生效分支当前**待二次确认**。详见末尾追踪表 [#2](99-pending-items.md#2-topupgroupratio-生效路径)。 +- `TopupGroupRatio['vip'] = 0.9` → vip 用户付 90% 的钱拿到相同 quota(**充值打折**)。 +- `TopupGroupRatio['svip'] = 1.2` → svip 用户付 120% 的钱拿到相同 quota(**加价订阅** / 高级套餐)。 +- 配置入口:`/system-settings/billing/group-pricing` 的 `TopupGroupRatio` 区块;存储于 `options.TopupGroupRatio`。 +- 上线新充值倍率前**做一次端到端小额充值测试**(如 1 USD),核对到账 quota 是否符合预期。 ### 2.3.2 兑换码(运营批量发码) diff --git a/docs/operations/03-billing.md b/docs/operations/03-billing.md index 0f7b10b270a6..9b6434ae8d48 100644 --- a/docs/operations/03-billing.md +++ b/docs/operations/03-billing.md @@ -39,7 +39,7 @@ NewAPI 的标准计费可以拆成三层: = (输入 token × ModelRatio) + (输出 token × CompletionRatio × ModelRatio) + 工具调用次数费用 + 音频独立计费 × GroupRatio(分组倍率) - × 一连串 OtherRatios(其他倍率,目前通常为 1) + × ∏ OtherRatios(任务/图像类的动态参数倍率,详见 3.1.3;文本路径当前为 no-op) ``` **关键点(运营要会说清)**: @@ -105,7 +105,52 @@ quota = round( ModelPrice × QuotaPerUnit × GroupRatio 按次价场景:模型在 `ModelPrice` 中显式注册了价格,**整请求只按这个价**收费,与 token 数无关(但仍叠加工具与音频费)。 -> ⚠️ `OtherRatios` 连乘当前没有赋值路径、没有前端入口,**疑似预留字段,运营无需配置**。详见末尾追踪表 [#9](99-pending-items.md#9-otherratios-疑似预留)。 +### 3.1.3 关于 `OtherRatios`(任务/图像类计费的动态参数倍率) + +> ✅ **运营无需也不能在前端配置 `OtherRatios`**。它由 adapter 根据用户请求参数(视频 `seconds`/`size`、图像 `n` / `prompt_extend`)自动计算。详见末尾追踪表 [#9](99-pending-items.md#9-otherratios任务图像类计费的动态参数倍率)。 + +| 项 | 内容 | +|---|---| +| 类型 | `types.PriceData.OtherRatios map[string]float64`,赋值入口 `AddOtherRatio(key, value)` | +| 任务路径主流程 | `relay/relay_task.go:144-203 RelayTaskSubmit` — 步骤 5 `adaptor.EstimateBilling` 返回参数键值;步骤 6 `Quota *= ratio`(连乘);步骤 11 `AdjustBillingOnSubmit` 校准 | +| 任务路径公式 | **基础 Quota × ∏(OtherRatios)** | +| 实际赋值的 adapter | OpenAI Sora(`relay/channel/task/sora/adaptor.go:97-130`)/ 阿里通义视频(`relay/channel/task/ali/adaptor.go:193`)/ Gemini Veo(`relay/channel/task/gemini/adaptor.go:160`)/ Vertex Veo(`relay/channel/task/vertex/adaptor.go:124`)/ 阿里图像(`relay/channel/ali/image.go:53,58,331,333` + `image_wan.go:37`)/ 通用图像(`relay/image_handler.go:124-125`) | +| 文本路径 | `service/text_quota.go:281-285` 中保留 `Π OtherRatios` 连乘**位**,但当前文本 adapter 未赋值 → 连乘为 no-op(**不是废弃字段**,是统一公式预留位) | +| 透出 | HTTP 头 `X-New-Api-Other-Ratios`(`relay/relay_task.go:234`);日志 `logs.other` 写入命中明细(`service/task_billing.go:26-29, 128-129, 286-289`) | +| 运营提示 | 任务/视频/图像扣费高于预期时,先看 `logs.other.OtherRatios` 字段确认命中了哪些动态倍率(`seconds × size × ...`) | + +### 3.1.4 未注册到 ModelRatio 的模型扣费行为(**两路径不对称**) + +> 🔴 **结论先行**:所有上线模型必须在 `ModelRatio`(或 `ModelPrice`)显式注册。不要依赖 37.5 默认值。详见末尾追踪表 [#1](99-pending-items.md#1-未注册模型默认倍率两路径不对称)。 + +证据:`setting/ratio_setting/model_ratio.go:403-417 GetModelRatio` 在模型未注册时返回 `(37.5, operation_setting.SelfUseModeEnabled, name)`。第二个返回值是「闸门」,**不是「真的按 37.5 扣」**。 + +**文本路径(OpenAI / Claude / Gemini):两道闸门** + +证据:`relay/helper/price.go:95-104, 182-191`。装载顺序: + +``` +未注册模型请求到达 + ├─ 闸门 1(系统级):SelfUseModeEnabled? + │ ├─ 是 → 按 37.5 倍率计费 + │ └─ 否 ↓ + ├─ 闸门 2(用户级):dto.UserSetting.AcceptUnsetRatioModel? + │ ├─ 是 → 按 37.5 倍率计费 + │ └─ 否 ↓ + └─ 返回 modelPriceNotConfiguredError(relay/helper/price.go:20-33) + └─ HTTP 拒绝调用 +``` + +**音频 / Realtime / 异步任务路径:无闸门** + +证据:`service/quota.go:109 modelRatio, _, _ := ratio_setting.GetModelRatio(modelName)`、`service/task_billing.go:258`、`controller/task_video.go:159`。这些路径调用 `GetModelRatio` 时**忽略 success 返回值**,未注册模型直接按 37.5 倍率静默扣费,绕过两道闸门。 + +**运营建议**: + +1. 任何上线模型在 `/system-settings/billing/model-pricing` 中显式注册 `ModelRatio` 或 `ModelPrice`。 +2. 不要在生产环境长期开启系统级 `SelfUseMode`(仅自用 / 调试场景使用)。 +3. 不要替客户开启用户级 `AcceptUnsetRatioModel`,让客户自主决定是否接受未配置价格的模型。 +4. 「音频/任务路径无闸门」是已知不对称,将由后续修复(`99-pending-items.md` 附录 A1)对齐。 --- diff --git a/docs/operations/04-logs-stats.md b/docs/operations/04-logs-stats.md index 5579d30dc871..17e53891ec5c 100644 --- a/docs/operations/04-logs-stats.md +++ b/docs/operations/04-logs-stats.md @@ -72,7 +72,7 @@ > 1. 让客户在个人设置开启 IP 日志,或 > 2. 后台批改 `users.setting.record_ip_log = true`。 > -> 详见末尾追踪表 [#7](99-pending-items.md#7-iplog-默认不记)。 +> 详见末尾追踪表 [#7](99-pending-items.md#7-logsip-默认不记)。 ### 4.1.5 日志清理 @@ -129,9 +129,11 @@ > 🔴 **关键运营提醒**:`logs.group` 写的是**最终 usingGroup**。 > > 客户原本在 `vip` 但调用时令牌切到 `svip` → `logs.group = 'svip'`。 -> 想分开看「以 vip 身份切到 svip」与「直接 svip 用户」当前**做不到**——因为 user_group 与 token_group **未单独落库**,只在 `other.group_ratio_special` 命中 GroupGroupRatio 时写一处。 +> 想分开看「以 vip 身份切到 svip」与「直接 svip 用户」当前**做不到** — 因为 user_group 与 token_group **未单独落库**,只在 `other.group_ratio_special` 命中 GroupGroupRatio 时写一处。 > -> 详见末尾追踪表 [#10](99-pending-items.md#10-logsgroup-无法区分-user-vs-token-切换)。 +> **追溯切组路径**:打开日志详情查 `other.user_group / other.token_group`(**待 logs.other 扩展后可用**,详见 `99-pending-items.md` 附录 A 跟踪 issue)。当前期间,按用户 ID + 令牌 ID 维度聚合是 workaround。 +> +> 详见末尾追踪表 [#10](99-pending-items.md#10-logsgroup-仅记-usinggroup)。 --- @@ -152,8 +154,9 @@ tpm = sum(prompt_tokens) + sum(completion_tokens) WHERE type=2 AND created_at >= now-60s ``` -> ⚠️ **`rpm` / `tpm` 是「最近 60 秒」的截面**,不是用户选定时间区间的平均(`model/log.go:482`)。 -> controller 返回的字段名只叫 `rpm/tpm`,前端可能误解。运营对外解释时务必声明这一点。 +> ⚠️ **`rpm` / `tpm` 是「截面值,按最近 60 秒计算」**(`model/log.go:482`),不是用户选定时间区间的平均。 +> controller 返回的字段名只叫 `rpm/tpm`,前端可能误解。**运营对外解释时务必显式说「按最近 60 秒计算」**。 +> 区间均值需自跑 SQL(见第 5 章 5.4 常用查询)。 > 详见末尾追踪表 [#11](99-pending-items.md#11-rpmtpm-是-60-秒截面)。 --- @@ -203,9 +206,15 @@ tpm = sum(prompt_tokens) + sum(completion_tokens) ### 4.4.5 表清理 -> ⚠️ **`quota_data` 表代码中未发现 DELETE 入口**(`model/usedata.go` 全文 138 行,只有 INSERT / UPDATE / SELECT)。 -> 长期运行的实例(5+ 年)这张表会无限增长,需要 DBA 介入做归档。 -> 详见末尾追踪表 [#12](99-pending-items.md#12-quotadata-无清理路径)。 +> ⚠️ **`quota_data` 表暂无清理入口,长生命周期实例需 DBA 手动维护**: +> +> - `model/usedata.go` 全文 138 行,只有 INSERT / UPDATE / SELECT(`grep -n "DELETE|Delete|truncate"` 0 匹配)。 +> - `controller/log.go:153 DeleteHistoryLogs` 只清 `logs` 表,不动 `quota_data`。 +> - 量级评估:每用户每模型每小时 1 行,10000 用户 × 20 模型 × 24h × 365 天 ≈ 17 亿行;**1 年以内通常无影响,5+ 年级别会达瓶颈**。 +> - 建议每 6 个月由 DBA 手动归档 12 个月以前的行。 +> +> 后续将由代码层补清理接口(`99-pending-items.md` 附录 A5),届时本提示失效。 +> 详见末尾追踪表 [#12](99-pending-items.md#12-quota_data-表无清理路径)。 --- diff --git a/docs/operations/05-faq.md b/docs/operations/05-faq.md index 11555ba43996..b00fd49a0d34 100644 --- a/docs/operations/05-faq.md +++ b/docs/operations/05-faq.md @@ -14,7 +14,7 @@ 1. (可选)在 `/system-settings/models/vendors` 注册供应商。 2. 在 `/system-settings/models/models` 注册模型元数据,设置 `name_rule` 与 `endpoints`。 -3. **在 `/system-settings/billing/model-pricing` 注册 `ModelRatio`(或 `ModelPrice`)+ `CompletionRatio`**。⚠️ 不注册的后果见灰区 [#1](99-pending-items.md#1-未注册模型默认倍率)。 +3. **在 `/system-settings/billing/model-pricing` 注册 `ModelRatio`(或 `ModelPrice`)+ `CompletionRatio`**。⚠️ 不注册的后果(两路径不对称扣费)见灰区 [#1](99-pending-items.md#1-未注册模型默认倍率两路径不对称)。 4. 在 `/channels` 把新模型加入支持该模型的渠道 `models` 列表,或新增一条专门渠道。 5. 验证 `abilities` 表已生成 `(group, model, channel_id)` 行(前端「修复」按钮 = `POST /api/channel/fix`)。 6. 用一个测试令牌 curl 调用,确认 200 + 扣费正确。 @@ -41,13 +41,23 @@ 2. `auto` 分组例外(走 `AutoGroups` 列表),但仍需 `cross_group_retry` 配合。 3. 用 `GET /api/user/self/groups` 看客户视角能看到哪些分组——能看到的就一定能用。 -### 场景 4:客户反馈 403 "token model limit is empty" +### 场景 4:客户反馈 403「该 token 不允许使用模型 X」(`MsgDistributorTokenModelForbidden`) -证据:`middleware/distributor.go:58-65`。 +> ✅ **错误文案订正**:实际报错文案是 i18n key `MsgDistributorTokenModelForbidden`(中文「该 token 不允许使用模型 X」),**不是**「token model limit is empty」。详见末尾追踪表 [#8](99-pending-items.md#8-model_limits-为空-403错误文案订正)。 -原因:令牌 `model_limits_enabled=true` 但 `model_limits=''`。 +证据:`middleware/distributor.go:57-75`、`model/token.go:343-350 GetModelLimitsMap`。 -处理:让客户编辑令牌,要么关闭白名单开关,要么至少填一个模型。详见灰区 [#8](99-pending-items.md#8-modellimits-为空-403)。 +**排查链**(按顺序逐项核对): + +1. **看令牌是否启用了模型白名单**:`tokens.model_limits_enabled = true`? + - `false` → 不应该报这个错,转去看场景 3 / 5。 +2. **若启用,看 `tokens.model_limits` 是否为空字符串**: + - 是 → 客户最常见的误操作。让客户编辑令牌,要么关闭白名单开关,要么至少填一个模型。 +3. **若 `model_limits` 非空,看请求模型是否在白名单内**: + - 不在 → 提醒客户在 `/keys` 页把目标模型加入 `model_limits` 列表。 + - 注意 `model_mapping` / `name_rule`:客户请求的 model 可能被 channel 映射成另一名字,模型白名单按**用户请求的原名**匹配。 + +> ℹ️ 历史上版本曾出现过「token model limit is empty」字样,但当前实现里 `GetModelLimitsMap` 始终返回非 nil 空 map,不会走到该分支。如果在生产环境真的看到这条文案,请反馈架构师。 ### 场景 5:客户反馈调用某个模型扣费比预期高 @@ -91,19 +101,39 @@ > WHERE type = 2 AND user_id = ? AND created_at BETWEEN START AND END; > ``` -### 场景 9:客户反馈调用了但日志没 IP +### 场景 9:客户反馈调用了但日志没 IP(按 IP 排查的前置开关) + +`logs.ip` **默认不写**(合规:GDPR / PIPL 个人信息最小化原则,灰区 [#7](99-pending-items.md#7-logsip-默认不记))。 -`logs.ip` **默认不写**(灰区 [#7](99-pending-items.md#7-iplog-默认不记))。处理: +**按 IP 排查的完整链**: -1. 后台改 `users.setting.record_ip_log = true`(PUT `/api/user/`)。 -2. 或让客户在个人设置中打开「记录 IP 日志」。 +1. **先开启用户级开关**(必须): + - 客户自助:让客户在「个人设置」中打开「记录请求 IP」。 + - 运营批改:直接 `PUT /api/user/` 把 `users.setting.record_ip_log` 改为 `true`。 +2. **等客户重新发起请求** → 新日志的 `logs.ip` 字段才有值。 3. **历史日志补不回来**,只对开关打开后的新日志生效。 +4. 排查完成后**是否关闭**由客户决定;运营不强行关闭。 ### 场景 10:充值到账金额不对 -1. 看充值订单(`topup` 表)原始金额。 -2. 看 `TopupGroupRatio[users.group]`——这是充值折算倍率。 -3. ⚠️ `TopupGroupRatio` 具体生效分支当前**待二次确认**(灰区 [#2](99-pending-items.md#2-topupgroupratio-生效路径)),如果折算结果不符合预期,记录证据上报架构师。 +证据:`controller/topup.go:148-176 getPayMoney`、`common/topup-ratio.go:32-41`。 + +**对账公式**(必须背下来): + +``` +payMoney = amount × Price × TopupGroupRatio[user.group] × Discount +``` + +排查顺序: + +1. **看 `topup` 表的原始订单**:`amount`(用户想充的 quota 数)、对应支付通道。 +2. **看 `TopupGroupRatio[users.group]`**:折扣 / 加价系数。**仅影响付款金额,不影响调用扣费**(详见灰区 [#2](99-pending-items.md#2-topupgroupratio充值折扣加价系数不影响计费))。 + - 例:`TopupGroupRatio['vip'] = 0.9` → vip 用户付 90% 金额拿同样 quota。 + - 未配置 group → fallback `1.0`(无折扣),写 SysError 日志,**不阻断充值**。 + - `topupGroupRatio == 0` 强制设为 1(防免单 bug)。 +3. **看 `Price` 全局单价**(`operation_setting.Price`)。 +4. **看金额阈值预设折扣** `operation_setting.GetPaymentSetting().AmountDiscount[amount]`:常见是「满 100 减 10」类阶梯。 +5. **核对支付通道**:epay / Stripe / Waffo / Waffo-Pancake 四条路径都会读 `TopupGroupRatio`,逻辑一致;若不同通道结果不同,记录证据上报架构师。 ### 场景 11:渠道余额(`channels.balance`)显示为 0,客户调用还能扣费吗? @@ -129,26 +159,27 @@ ## 5.2 灰区 → 运营行动建议汇总表 -> 架构师摸底报告标记了 13 条「事实模糊、文档冲突或代码未覆盖」点。 -> 下表把每一条翻译成运营层面的「具体该怎么做」。 -> 完整描述与跟踪状态见 [`99-pending-items.md`](99-pending-items.md)。 +> 架构师摸底报告标记的 13 条「事实模糊、文档冲突或代码未覆盖」点已**全部闭环**(12 条 `closed-2026-05-21` + 1 条 `closed-by-fact`,详见 [`99-pending-items.md`](99-pending-items.md))。 +> 下表把每一条结论翻译成运营层面的「具体该怎么做」。 -| # | 灰区 | 运营行动建议 | +| # | 闭环结论 | 运营行动建议 | |---|---|---| -| 1 | 未注册到 `ModelRatio` 的模型扣费行为存在不确定性(疑似 37.5 倍率且仅 `SelfUseMode` 生效) | **上线新模型前必须在 `ModelRatio` / `ModelPrice` 显式注册**。已上线但未注册的模型应立即补录。 | -| 2 | `TopupGroupRatio` 生效路径待二次确认 | 上线新充值倍率前**做一次端到端充值测试**(小额,比如 1 USD),核对到账 quota 是否符合预期。 | -| 3 | 未注册的 group 被消费时默默按 1 倍计费 | **`channels.group` 中出现的每个分组必须先在 `GroupRatio` 注册**。新建渠道时审核分组名拼写。 | -| 4 | `FixAbility` 高并发下可能短暂不一致 | 修复路由错乱时**选低峰期**点「修复」按钮;点完后等 1 分钟再用测试令牌验证一次。 | -| 5 | `GroupRatio` 与 `UserUsableGroups` 必须同时维护 | 配置分组时**两套配置同步更新**,否则用户切换令牌分组时报 403。 | -| 6 | 默认 SVIP 分组「半启用」(在 GroupRatio 但不在 UserUsableGroups) | 想真正启用 SVIP,**手动把 `svip` 加入 `UserUsableGroups`**。 | -| 7 | `logs.ip` 默认不记 | 排错前先确认对应用户 `users.setting.record_ip_log = true`;历史日志补不回来。 | -| 8 | `tokens.model_limits_enabled=true` 但 `model_limits=''` 全模型 403 | 客户创建令牌时**勾选白名单必须至少填一个模型**;运营在前端教程中显著提示。 | -| 9 | `OtherRatios` 连乘当前无赋值路径与配置入口 | **运营无需配置**,疑似预留字段。若发现实际计费里 `OtherRatios ≠ 1`,立即上报架构师。 | -| 10 | `logs.group` 仅记最终 usingGroup,无法区分「用户分组」与「令牌切换分组」 | 按 group 统计时**明示口径**:`logs.group` = 最终生效分组,不区分来源。需要拆分时单独按用户 ID + 令牌 ID 维度聚合。 | -| 11 | `rpm` / `tpm` 是「最近 60 秒」截面值 | 对外解释**永远显式说「最近 60 秒」**;区间 RPM/TPM 需自跑 SQL 计算。 | -| 12 | `quota_data` 表无清理路径 | 定期由 DBA 归档历史数据,避免无限增长。建议每 6 个月清理一次 12 个月以前的行。 | +| 1 | 未注册到 `ModelRatio` 的模型:文本路径有「`SelfUseMode` + `AcceptUnsetRatioModel`」两道闸门兜底;音频/Realtime/任务路径**无闸门**,按 37.5 倍率静默扣费 | **上线新模型前必须在 `ModelRatio` / `ModelPrice` 显式注册**。已上线但未注册的模型立即补录。不要依赖 37.5 默认值。 | +| 2 | `TopupGroupRatio` 是**充值折扣 / 加价系数**:`payMoney = amount × Price × TopupGroupRatio × Discount`,仅影响充值付款,不影响调用扣费。fallback 1.0 不阻断充值 | 上线新充值倍率前**做一次端到端小额测试**(如 1 USD),核对到账 quota;解释客户「充值打折」时记得这套与计费侧 `GroupRatio` 是两条独立路径。 | +| 3 | 未注册的 group 被消费时默默按 1 倍计费(fallback 是有意保留,兼容历史迁移) | **`channels.group` 中出现的每个分组必须先在 `GroupRatio` 注册**;新建渠道时审核分组名拼写。修改 `GroupRatio` 时同步检查所有 `channels.group` 引用。 | +| 4 | `FixAbility` 单实例已加 `sync.Mutex`;执行期间所有请求 503;多实例集群无跨进程锁 | 修复路由错乱时**选低峰期 + 单实例操作**;点完后等 1 分钟再用测试令牌验证一次;集群部署需运维分时。 | +| 5 | `GroupRatio` 与 `UserUsableGroups` 必须双写(前端两 tab 分开保存,**自动联动校验尚未实现**) | 配置分组时**两套配置同步更新**,否则用户切换令牌分组时报 403。 | +| 6 | 默认 SVIP 分组「半启用」(在 `GroupRatio` 但不在 `UserUsableGroups`) | 想真正启用 SVIP,**手动把 `svip` 加入 `UserUsableGroups`**(首次部署 workaround,后续版本会对齐默认值)。 | +| 7 | `logs.ip` 默认不记(合规:GDPR / PIPL 最小化原则,保持默认 false) | 排错前先在 `users.setting.record_ip_log` 开启;历史日志补不回来。 | +| 8 | `tokens.model_limits_enabled=true` + `model_limits=''` → 实际报错 `MsgDistributorTokenModelForbidden`(**不是**「token model limit is empty」,文案订正) | 客户创建令牌时**勾选白名单必须至少填一个模型**;FAQ 给出完整排查链。 | +| 9 | `OtherRatios` 是**任务/图像类计费的动态参数倍率**(视频 `seconds`/`size`、图像 `n` 等),由 adapter 自动计算,运营不需配置 | **运营无需也不能配置**;任务扣费高于预期时,看 `logs.other.OtherRatios` 字段确认命中了哪些动态倍率。 | +| 10 | `logs.group` 仅记最终 usingGroup,无独立 `user_group` / `token_group` 字段 | 按 group 统计时**明示口径**:`logs.group` = 最终生效分组,不区分来源。**追溯切组路径需打开日志详情查 `other.user_group / token_group`**(待扩展 other 字段后)。 | +| 11 | `rpm` / `tpm` 是「最近 60 秒」截面值(`model/log.go:482`) | 对外解释**永远显式说「按最近 60 秒计算」**;区间 RPM/TPM 需自跑 SQL 计算。 | +| 12 | `quota_data` 表无 DELETE 入口,需 DBA 手动维护 | **`quota_data` 表暂无清理入口**,长生命周期实例(5+ 年)需 DBA 手动维护,建议每 6 个月归档一次 12 个月以前的行。 | | 13 | `channels.balance` 不参与扣费 | 客户咨询「渠道余额为 0」时**明确说不影响扣费**;该字段只用于运营监控上游账户是否需要充值。 | +> 5 项后续修复建议(PJM 另开 issue 跟踪)见 [`99-pending-items.md` 附录 · 已知改进点](99-pending-items.md#附录--已知改进点pjm-另开-issue-跟踪)。 + --- ## 5.3 排错速查矩阵 @@ -157,14 +188,14 @@ |---|---|---| | 401 invalid token | 令牌是否过期 / 被禁用 / IP 不在白名单 | `tokens.status`、`expired_time`、`allow_ips` | | 403 group denied | `tokens.group` 与 `UserUsableGroups` / `GroupRatio` | 灰区 [#5](99-pending-items.md#5-groupratio-与-userusablegroups-必须双写) / [#6](99-pending-items.md#6-svip-默认半启用) | -| 403 model not allowed | 令牌模型白名单 | 灰区 [#8](99-pending-items.md#8-modellimits-为空-403) | +| 403「该 token 不允许使用模型 X」(`MsgDistributorTokenModelForbidden`) | 令牌模型白名单 | 灰区 [#8](99-pending-items.md#8-model_limits-为空-403错误文案订正) | | 404 no satisfied channel | `(usingGroup, model)` 在 `abilities` 找不到行 | 渠道未挂该分组 / 模型;或路由错乱(修复 abilities,灰区 [#4](99-pending-items.md#4-fixability-高并发不一致)) | | 扣费金额不对 | `logs.other` JSON 倍率明细 | 第 3 章公式 + 客户分组 | -| 看不到 IP | `users.setting.record_ip_log` | 灰区 [#7](99-pending-items.md#7-iplog-默认不记) | -| 充值到账金额不对 | `topup` 表 + `TopupGroupRatio` | 灰区 [#2](99-pending-items.md#2-topupgroupratio-生效路径) | +| 看不到 IP | `users.setting.record_ip_log` | 灰区 [#7](99-pending-items.md#7-logsip-默认不记) | +| 充值到账金额不对 | `topup` 表 + `TopupGroupRatio` | 灰区 [#2](99-pending-items.md#2-topupgroupratio充值折扣加价系数不影响计费) | | 渠道余额 0 但能扣费 | `channels.balance` 与扣费无关 | 灰区 [#13](99-pending-items.md#13-channelsbalance-不参与计费) | | 看板数据滞后 | `DataExportInterval`(默认 5 分钟)刷盘 | 正常现象 | -| 长期归档需求 | `logs.DeleteOldLog` 可清理;`quota_data` 无清理 | 灰区 [#12](99-pending-items.md#12-quotadata-无清理路径) | +| 长期归档需求 | `logs.DeleteOldLog` 可清理;`quota_data` 无清理 | 灰区 [#12](99-pending-items.md#12-quota_data-表无清理路径) | --- diff --git a/docs/operations/99-pending-items.md b/docs/operations/99-pending-items.md index e39044285c96..c66e9afb5e18 100644 --- a/docs/operations/99-pending-items.md +++ b/docs/operations/99-pending-items.md @@ -1,32 +1,35 @@ -# 待确认事项追踪表 +# 灰区证据闭环追踪表 -> 来源:架构师事实层摸底报告(issue [TES-69](mention://issue/7628a2ad-bf09-4050-b197-98910ff11357) 评论 `e2af1c61` 第 4 节) -> 维护规则:每条含 编号 / 现象描述 / 当前手册措辞 / 责任人 / 状态(open / closed) -> 关闭流程:架构师追加证据 → 文档维护专家更新对应章节 + 关闭本表中的条目(状态改 `closed` 并附证据 PR / commit) +> 来源:架构师事实层摸底报告(issue [TES-69](mention://issue/7628a2ad-bf09-4050-b197-98910ff11357) 评论 `e2af1c61`)+ 第二轮证据闭环(评论 `87c3958d`,2026-05-21) +> 维护规则:每条含 编号 / 现象描述 / 当前手册措辞 / 处置结论 / 状态 +> 关闭流程:架构师追加证据 → 文档维护专家更新对应章节 + 关闭本表中的条目(状态改 `closed-YYYY-MM-DD`)。 +> +> 本轮 12 条灰区已 100% 取得证据,全部 `closed-2026-05-21`。第 13 条事实清楚,状态保持 `closed-by-fact`。 +> 5 项后续修复建议另开 issue 由 PJM 跟踪,集中收口在末尾「附录 · 已知改进点」章节。 --- -## 1. 未注册模型默认倍率 +## 1. 未注册模型默认倍率:**两路径不对称** | 项 | 内容 | |---|---| -| **现象描述** | `setting/ratio_setting/model_ratio.go:403-417 GetModelRatio` 在模型未注册时返回 `(37.5, operation_setting.SelfUseModeEnabled, name)`,含义是默认 37.5 倍率,且只有 `SelfUseMode` 开启时才算「有定价」。SelfUseMode 关闭时这个 37.5 是否真扣费、`relayInfo.PriceData` 装载逻辑是否会绕开它,**未追完证据**。 | -| **当前手册措辞** | 第 1 章 1.2.4 与第 5 章场景 1 / 灰区 #1:「上线新模型前必须在 ModelRatio 显式注册;未注册时的扣费行为(疑似 37.5 倍率且仅 SelfUseMode 生效)待二次确认。」 | -| **责任人** | 架构师 | -| **状态** | open | -| **闭环需要的证据** | `relayInfo.PriceData` 装载链 + SelfUseMode 关闭时实际走哪条分支 | +| **现象描述** | `setting/ratio_setting/model_ratio.go:403-417 GetModelRatio` 在模型未注册时返回 `(37.5, operation_setting.SelfUseModeEnabled, name)`。第二个返回值是「闸门」,不是「真正生效」。 | +| **证据链** |
  • **文本路径(OpenAI / Claude / Gemini)有两道闸门**(`relay/helper/price.go:95-104, 182-191`):① 系统级 `SelfUseModeEnabled` ② 用户级 `dto.UserSetting.AcceptUnsetRatioModel`(`dto/user_settings.go:14`)。两者都关 → 返回 `modelPriceNotConfiguredError`(`relay/helper/price.go:20-33`)拒绝调用。
  • **音频/Realtime/异步任务路径**(`service/quota.go:109`、`service/task_billing.go:258`、`controller/task_video.go:159`)调用 `GetModelRatio` 时**忽略 success 返回值**,未注册模型直接按 37.5 倍率静默扣费。
| +| **当前手册措辞** | 第 1 章 1.2.4 + 第 3 章 3.1.4 + 第 5 章场景 1:分文本路径与音频/任务路径分别说明扣费行为。 | +| **处置结论** | 不依赖默认 37.5 倍率,所有上线模型必须在 `ModelRatio` / `ModelPrice` 显式注册。运营无需关心系统级 / 用户级闸门,注册即可避免任何不确定。 | +| **状态** | `closed-2026-05-21` | --- -## 2. TopupGroupRatio 生效路径 +## 2. TopupGroupRatio:**充值折扣/加价系数,不影响计费** | 项 | 内容 | |---|---| -| **现象描述** | option key `TopupGroupRatio` 已注册(`web/default/src/features/system-settings/billing/section-registry.tsx:43`),用于充值打折 / 到账折算,但具体在 `controller/topup.go` 的哪条分支里完成换算**未追完**。 | -| **当前手册措辞** | 第 2 章 2.3.1 与第 5 章场景 10:「用于充值打折 / 到账折算,具体在 `controller/topup.go` 的生效分支待二次确认。」 | -| **责任人** | 架构师 | -| **状态** | open | -| **闭环需要的证据** | `controller/topup.go` 中 `TopupGroupRatio` 被读取的具体函数与分支 | +| **现象描述** | option key `TopupGroupRatio` 用于充值时按用户分组做折算。 | +| **证据链** |
  • 默认 map:`common/topup-ratio.go:8-12` `{default:1, vip:1, svip:1}`,`GetTopupGroupRatio(name)` 未命中 fallback 1.0 + SysError 日志(`common/topup-ratio.go:32-41`),**不阻断充值**。
  • 公共定价函数:`controller/topup.go:148-176 getPayMoney` — `payMoney = amount × Price × TopupGroupRatio × Discount`,`topupGroupRatio == 0` 强制设为 1(line 158-160 防免单 bug)。
  • 四条充值路径调用:epay (`controller/topup.go:206`)、Stripe (`controller/topup_stripe.go:389, 403`)、Waffo (`controller/topup_waffo.go:82`)、Waffo-Pancake (`controller/topup_waffo_pancake.go:59`)。
| +| **当前手册措辞** | 第 2 章 2.3.1 + 第 5 章场景 10:明确为「充值折扣 / 加价系数」,仅影响付款金额,不影响调用计费侧(计费走 `GroupRatio`)。 | +| **处置结论** | 用作充值打折(`<1`)或加价订阅(`>1`);fallback 1.0 是有意设计,未配置 group 不会阻断充值。 | +| **状态** | `closed-2026-05-21` | --- @@ -34,11 +37,11 @@ | 项 | 内容 | |---|---| -| **现象描述** | `setting/ratio_setting/group_ratio.go:84-91`:未在 `groupRatioMap` 注册的 group 被消费时,会打 SysLog 并按 1 倍计费,**不报错也不阻断**。 | -| **当前手册措辞** | 第 1 章 1.3.3 + 第 5 章灰区 #3:「`channels.group` 中出现的每个分组必须先在 `GroupRatio` 注册。」 | -| **责任人** | 架构师(确认是否为故意设计) | -| **状态** | open(已有代码证据,但**是否需要把 fallback 改为报错或显式 0** 待产品决定) | -| **闭环需要的证据** | 产品 / 架构师明确:fallback 1 是有意还是 bug | +| **现象描述** | `setting/ratio_setting/group_ratio.go:84-91`:未注册 group 名 fallback 1.0,仅打 `SysLog`(不是 SysError),不阻断。 | +| **证据链** | 与首轮一致;后端 `controller/channel.go` 中**未发现** channel.group 的白名单校验。 | +| **当前手册措辞** | 第 1 章 1.3.3 + 1.3.5 + 第 5 章场景 5、5.5 升级路径 + 灰区表 #3:「修改 `GroupRatio` 时同步检查所有 `channels.group` 引用,未注册项会按 1 倍静默计费」。 | +| **处置结论** | 保留 fallback 1.0 行为(兼容历史 channel 配置带过期 group 名的迁移期)。建议另开 issue:在 channel 保存接口(`POST/PUT /api/channel/`)加 group 白名单校验。运营手册仅做事实提示。 | +| **状态** | `closed-2026-05-21` | --- @@ -46,11 +49,11 @@ | 项 | 内容 | |---|---| -| **现象描述** | `model/ability.go:295-307 FixAbility` 是 truncate `abilities` 表 + 按所有 channel 重建。高并发下若与渠道写入并发,可能短暂不一致。 | -| **当前手册措辞** | 第 1 章 1.4.3 + 第 5 章场景 12 / 灰区 #4:「修复路由错乱时选低峰期点修复按钮;点完后等 1 分钟再用测试令牌验证一次。」 | -| **责任人** | 架构师(评估是否需要加锁或换为增量同步) | -| **状态** | open | -| **闭环需要的证据** | 是否需要排他锁;并发窗口实测时长 | +| **现象描述** | `model/ability.go:285-341 FixAbility` 是 truncate `abilities` 表 + 按所有 channel 重建。 | +| **证据链** |
  • 已加进程级 `sync.Mutex`:`fixLock` (`model/ability.go:285`) — `TryLock()` 失败即返回「已经有一个修复任务在运行中」。
  • **没有跨进程锁**:单实例 OK,多实例集群下两节点同时点修复仍可能并发。
  • 单实例下:执行期间(清空 → 重建完成)`abilities` 表为空 → `service.CacheGetRandomSatisfiedChannel` 全部 miss → 所有请求 503。
| +| **当前手册措辞** | 第 1 章 1.4.3 + 第 5 章场景 12 + 灰区表 #4:「FixAbility 期间所有请求会暂时 503,建议在低峰期单实例操作;集群部署需运维分时」。 | +| **处置结论** | 不引入跨进程分布式锁(成本高、价值有限)。建议另开 issue:`FixAbility` 注释中说明集群部署约束;远期评估「先建后删」改造。运营手册仅做时段提示。 | +| **状态** | `closed-2026-05-21` | --- @@ -58,11 +61,11 @@ | 项 | 内容 | |---|---| -| **现象描述** | `middleware/auth.go:391-396` 要求 `tokens.group` 同时存在于 `GroupRatio` 与 `UserUsableGroups[user.group]`,但前端在两个 tab 编辑(`/system-settings/billing/group-pricing`),**未做联动校验**。 | -| **当前手册措辞** | 第 1 章 1.3.5 + 第 5 章场景 2 / 场景 3 / 灰区 #5:「配置分组时两套配置同步更新,否则用户切换令牌分组时报 403。」 | -| **责任人** | 架构师 / 前端(评估是否在保存时做联动校验提示) | -| **状态** | open | -| **闭环需要的证据** | 决策:是写到文档强约束就够,还是要在前端加保存校验 | +| **现象描述** | `middleware/auth.go:382-398`:`tokens.group` 必须同时存在于 `service.GetUserUsableGroups(user.group)` 与 `ratio_setting.ContainsGroupRatio(token.group)`,任一缺失即 403(`auto` 分组豁免 GroupRatio 检查)。 | +| **证据链** | 两套配置在 `/system-settings/billing/group-pricing` 不同 tab 分别保存,**前端无联动校验**。 | +| **当前手册措辞** | 第 1 章 1.3.5 + 第 2 章 2.1.4 + 第 5 章场景 2、3 + 灰区表 #5:「保留 GroupRatio ↔ UserUsableGroups 双写提示,标注『自动联动校验尚未实现』」。 | +| **处置结论** | 不在后端强制校验(保留管理员高级用法:内部测试时只配 GroupRatio 不放给用户)。建议另开 issue:前端 `group-pricing` 保存按钮处加联动校验弹窗。运营手册保留双写提示。 | +| **状态** | `closed-2026-05-21` | --- @@ -70,109 +73,128 @@ | 项 | 内容 | |---|---| -| **现象描述** | 后端默认 `defaultGroupRatio` 写死 `default/vip/svip = 1`(`setting/ratio_setting/group_ratio.go:12-16`),但 `setting/user_usable_group.go:10-13` 默认只放 `default/vip`。结果是 SVIP 默认存在于 `GroupRatio` 但不在 `UserUsableGroups`,任何用户都无法把令牌切到 svip 直到运营手动添加。 | -| **当前手册措辞** | 第 1 章 1.3.5 + 第 5 章场景 13 / 灰区 #6:「想真正启用 SVIP,手动把 svip 加入 UserUsableGroups。」 | -| **责任人** | 架构师 / 产品(决定是否对齐两份默认值) | -| **状态** | open | -| **闭环需要的证据** | 决策:默认值是否对齐;若不对齐,是否在 `/system-settings/billing/group-pricing` 给运营显式提示 | +| **现象描述** | `setting/ratio_setting/group_ratio.go:12-16` `defaultGroupRatio = {default:1, vip:1, svip:1}`;`setting/user_usable_group.go:10-13` `userUsableGroups = {default:"默认分组", vip:"vip分组"}`(无 svip)。结果:svip 在 GroupRatio 注册但不在 UserUsableGroups → 用户 token.group="svip" 被 `middleware/auth.go:386` 阻断。 | +| **证据链** | 与首轮一致。 | +| **当前手册措辞** | 第 1 章 1.3.5 + 第 5 章场景 13 + 灰区表 #6:「首次部署需手动追加 svip 到 UserUsableGroups(workaround)」。 | +| **处置结论** | 建议另开 issue:1 行改动 `setting/user_usable_group.go:12` 增加 `"svip": "svip分组"`,对齐两份默认值。运营手册保留首次部署 workaround,作为版本未升级前的过渡指引。 | +| **状态** | `closed-2026-05-21` | --- -## 7. iplog 默认不记 +## 7. logs.ip 默认不记 | 项 | 内容 | |---|---| -| **现象描述** | `model/log.go:155-160, 218-223`:`logs.ip` 仅在 `users.setting.record_ip_log == true` 时写入,默认 false。 | -| **当前手册措辞** | 第 2 章 2.1.3 + 第 4 章 4.1.4 + 第 5 章场景 9 / 灰区 #7:「排错前先确认对应用户已开启;历史日志补不回来。」 | -| **责任人** | 架构师 / 产品 | -| **状态** | open(更多是产品决策:默认值是否要改为 true,还是仅在管理员后台增加批量开关) | -| **闭环需要的证据** | 决策:默认值是否要改 | +| **现象描述** | `model/log.go:155-160, 218-223`:`users.setting.RecordIpLog == true` 才写 `logs.ip`,默认 `false`(`dto/user_settings.go:15`)。 | +| **证据链** | 与首轮一致。 | +| **当前手册措辞** | 第 2 章 2.1.3 + 第 4 章 4.1.4 + 第 5 章场景 9、5.3 排错矩阵 + 灰区表 #7:FAQ 显式提示「按 IP 排查 → 先在用户设置开启『记录请求 IP』,历史日志补不回来」。 | +| **处置结论** | 保持默认 false(合规:GDPR / PIPL 个人信息最小化原则)。运营手册补 FAQ 排查链,不改默认值。 | +| **状态** | `closed-2026-05-21` | --- -## 8. modellimits 为空 403 +## 8. model_limits 为空 403:**错误文案订正** | 项 | 内容 | |---|---| -| **现象描述** | `middleware/distributor.go:58-65`:`tokens.model_limits_enabled=true` 但 `model_limits=''` 时直接 403 "token model limit is empty, all models are not allowed"——前端创建令牌时若误选启用却忘填模型,整个 token 不可用且报错信息不直观。 | -| **当前手册措辞** | 第 2 章 2.2.2 + 第 5 章场景 4 / 灰区 #8:「客户创建令牌时勾选白名单必须至少填一个模型;运营在前端教程中显著提示。」 | -| **责任人** | 前端(评估保存时是否阻止此组合) | -| **状态** | open | -| **闭环需要的证据** | 决策:前端是否加保存校验 | +| **现象描述** | 令牌 `model_limits_enabled=true` 但 `model_limits=''` 时全模型不可用。 | +| **证据链订正** | 首轮文档 / 报告中描述的「token model limit is empty, all models are not allowed」**不会触发**:
  • `middleware/auth.go:421-426`:`ModelLimitsEnabled=true` 时 `c.Set("token_model_limit", token.GetModelLimitsMap())`。
  • `model/token.go:343-350 GetModelLimitsMap`:`model_limits=""` → `GetModelLimits()` 返回空切片 → `limitsMap` 是**空 map(非 nil)**。
  • `middleware/distributor.go:57-74`:`GetContextKey` 返回 `(空map, true)`,**不会**走 line 61-63 的「token model limit is empty」分支;落到 line 71 `tokenModelLimit[matchName]` 不存在 → 抛 `i18n.MsgDistributorTokenModelForbidden`「该 token 不允许使用模型 X」。
| +| **当前手册措辞** | 第 2 章 2.2.2 + 第 5 章场景 4 + 灰区表 #8:FAQ 给出「『不允许使用模型』排查链」(① 检查 token.model_limits_enabled ② 检查 model_limits 是否包含目标模型)。 | +| **处置结论** | 实际效果与原描述一致(任何模型都不可用),但错误文案不同。建议另开 issue:前端 `/keys` 页保存校验,阻止「启用白名单 + 空 model_limits」组合。 | +| **状态** | `closed-2026-05-21` | --- -## 9. OtherRatios 疑似预留 +## 9. OtherRatios:**任务/图像类计费的动态参数倍率** | 项 | 内容 | |---|---| -| **现象描述** | `service/text_quota.go:281-285`:从 `relayInfo.PriceData.OtherRatios` 取值并连乘,但本次未找到给 `OtherRatios` 赋值的代码路径,且没有前端配置入口。 | -| **当前手册措辞** | 第 3 章 3.1.3 + 第 5 章灰区 #9:「代码中有 Π OtherRatios 连乘但无赋值路径与前端入口,疑似预留字段,当前运营无需配置。」 | -| **责任人** | 架构师 | -| **状态** | open | -| **闭环需要的证据** | 是预留 / 已废弃 / 内部隐藏字段中的哪种? | +| **现象描述** | `service/text_quota.go:281-285` 文本路径连乘 `Π OtherRatios`,但首轮未找到赋值路径与前端入口。 | +| **证据链** |
  • **OtherRatios 不是预留字段,而是任务/图像计费的活跃路径**。
  • 类型:`types.PriceData.OtherRatios map[string]float64`,赋值入口 `AddOtherRatio(key, value)`(`relay/relay_task.go:113, 192` 等)。
  • 任务主流程:`relay/relay_task.go:144-203 RelayTaskSubmit` — 步骤 5 `adaptor.EstimateBilling` 返回 `{"seconds": N, "size": M}` 等键值;步骤 6 `Quota *= ratio`(连乘所有 OtherRatios 值);步骤 11 `AdjustBillingOnSubmit` 校准。
  • 实际赋值的 adapter:`relay/channel/task/sora/adaptor.go:97-130`(OpenAI Sora)、`relay/channel/task/ali/adaptor.go:193`(阿里通义视频)、`relay/channel/task/gemini/adaptor.go:160`(Veo)、`relay/channel/task/vertex/adaptor.go:124`(Vertex Veo)、`relay/channel/ali/image.go:53,58,331,333` + `image_wan.go:37`(图像 `n` / `prompt_extend`)、`relay/image_handler.go:124-125`(通用图像 `n`)。
  • 透出:HTTP 头 `X-New-Api-Other-Ratios`(`relay/relay_task.go:234`);日志 `logs.other` 记录命中明细(`service/task_billing.go:26-29, 128-129, 286-289`)。
  • 文本路径中的 for 循环是统一公式预留位,文本 adapter 当前未赋值,连乘为 no-op,但**不是废弃字段**。
| +| **当前手册措辞** | 第 3 章 3.1.3 + 灰区表 #9:明确为「任务/图像类计费的动态参数倍率」,由 adapter 根据用户请求参数(视频 `seconds`/`size`,图像 `n`)自动计算;运营**无需也不能**手动配置;日志 `other` JSON 字段记录明细。 | +| **处置结论** | 无前端入口是设计意图,运营无需配置。 | +| **状态** | `closed-2026-05-21` | --- -## 10. logsgroup 无法区分 user vs token 切换 +## 10. logs.group 仅记 usingGroup | 项 | 内容 | |---|---| -| **现象描述** | `logs.group` 写的是 `usingGroup`(最终生效那个),**未把 user_group / token_group 单独落库**。`other` JSON 里仅在命中 GroupGroupRatio 时存 `group_ratio_special`。管理员按 group 检索时无法直接区分「客户原本属于 vip,但调用时用了 token.group=svip」。 | -| **当前手册措辞** | 第 4 章 4.2.3 + 第 5 章灰区 #10:「按 group 统计时明示口径;需要拆分时单独按用户 ID + 令牌 ID 维度聚合。」 | -| **责任人** | 架构师 / 产品(评估是否扩展 logs schema) | -| **状态** | open | -| **闭环需要的证据** | 是否要新增 `logs.user_group` / `logs.token_group` 字段 | +| **现象描述** | `model/log.go:239 Group: params.Group` 写入 `relayInfo.UsingGroup`(最终生效);`logs.other` 中仅在命中 `GroupGroupRatio` 时写 `group_ratio_special`(`service/text_quota.go:425` 附近),无独立 `user_group` / `token_group` 字段。 | +| **证据链** | 与首轮一致。 | +| **当前手册措辞** | 第 4 章 4.2.3 + 灰区表 #10:「按 group 统计时明示口径;追溯切组路径需打开日志详情查 `other.user_group / token_group`(待扩展 other 字段后)」。 | +| **处置结论** | 建议另开 issue:扩展 `logs.other` JSON 而非 schema(避免三端迁移成本),追加 `user_group` / `token_group` 键。运营手册保留事实并提示「需追溯切组时打开 other 详情」。 | +| **状态** | `closed-2026-05-21` | --- -## 11. rpmtpm 是 60 秒截面 +## 11. rpm/tpm 是 60 秒截面 | 项 | 内容 | |---|---| -| **现象描述** | `model/log.go:482`:`rpm` / `tpm` 用 `created_at >= now-60s` 计算,是「最近 60 秒」的截面,不是用户选定时间区间的平均。`controller/log.go:113-121` 返回字段名只叫 `rpm/tpm`,前端可能误解为时间区间均值。 | -| **当前手册措辞** | 第 4 章 4.3 + 第 5 章场景 8 / 灰区 #11:「对外解释永远显式说『最近 60 秒』;区间 RPM/TPM 需自跑 SQL。」 | -| **责任人** | 前端(评估是否在 UI 上加 tooltip 说明) | -| **状态** | open | -| **闭环需要的证据** | UI 文案改进决定 | +| **现象描述** | `model/log.go:482 rpmTpmQuery.Where("created_at >= ?", time.Now().Add(-60*time.Second).Unix())` 固定 60 秒窗口;`controller/log.go:98-121 GetLogsStat` 直接透出 `rpm/tpm` 字段名。 | +| **证据链** | 与首轮一致。 | +| **当前手册措辞** | 第 4 章 4.3 + 第 5 章场景 8、5.3 排错矩阵 + 灰区表 #11:字段统一标注「截面值,按最近 60 秒计算」;区间均值需自跑 SQL。 | +| **处置结论** | 不改后端语义(兼容已存量调用)。建议另开 issue:前端 `/usage-logs` 在 RPM/TPM 字段旁添加 ⓘ tooltip。 | +| **状态** | `closed-2026-05-21` | --- -## 12. quotadata 无清理路径 +## 12. quota_data 表无清理路径 | 项 | 内容 | |---|---| -| **现象描述** | `model/usedata.go` 全文 138 行,只有 INSERT / UPDATE / SELECT,**未发现 DELETE 入口**。运营若做了 5+ 年数据,这张表会无限增长。 | -| **当前手册措辞** | 第 4 章 4.4.5 + 第 5 章灰区 #12:「定期由 DBA 归档历史数据;建议每 6 个月清理一次 12 个月以前的行。」 | -| **责任人** | 架构师 / DBA | -| **状态** | open | -| **闭环需要的证据** | 是否补一个清理接口 / cron job | +| **现象描述** | `model/usedata.go` 全文 138 行,`grep -n "DELETE\|Delete\|truncate"` 0 匹配;`controller/log.go:153 DeleteHistoryLogs` 只清 `logs` 表。 | +| **证据链** | 量级评估:每用户每模型每小时 1 行,10000 用户 × 20 模型 × 24h × 365 天 ≈ 17 亿行,5 年级会达瓶颈,1 年内通常无影响。优先级 P3。 | +| **当前手册措辞** | 第 4 章 4.4.5 + 第 5 章 5.3 排错矩阵 + 灰区表 #12:「`quota_data` 表暂无清理入口,长生命周期实例需 DBA 手动维护」。 | +| **处置结论** | 建议另开 issue:在 `model/usedata.go` 增加 `DeleteOldQuotaData(targetTimestamp int64) error`(仿 `model/log.go:518 DeleteOldLog`),并在 `DeleteHistoryLogs` 同步调用。优先级 P3。运营手册保留「DBA 手动维护」提示。 | +| **状态** | `closed-2026-05-21` | --- -## 13. channelsbalance 不参与计费 +## 13. channels.balance 不参与计费 | 项 | 内容 | |---|---| -| **现象描述** | `channels.balance` / `balance_updated_time` 是上游账户余额(通过 `update_balance` API 拉取,`controller/channel-billing.go`),**不参与 NewAPI 内部 quota 计算**。运营看到 channel 余额为 0 ≠ 客户扣费失败。 | -| **当前手册措辞** | 第 1 章 1.4.2 + 第 5 章场景 11 / 灰区 #13:「客户咨询『渠道余额为 0』时明确说不影响扣费;该字段只用于运营监控上游账户是否需要充值。」 | -| **责任人** | (已有事实证据,事实层无需追加) | -| **状态** | closed-by-fact(事实清楚,仅作存档;运营手册中已明示口径) | -| **闭环需要的证据** | — | +| **现象描述** | `channels.balance` / `balance_updated_time` 是上游账户余额(通过 `update_balance` API 拉取,`controller/channel-billing.go`),**不参与 NewAPI 内部 quota 计算**。 | +| **当前手册措辞** | 第 1 章 1.4.2 + 第 5 章场景 11、5.3 排错矩阵 + 灰区表 #13:「客户咨询『渠道余额为 0』时明确说不影响扣费;该字段只用于运营监控上游账户是否需要充值」。 | +| **处置结论** | 事实清楚,仅作存档。 | +| **状态** | `closed-by-fact` | --- ## 状态汇总 -| 状态 | 数量 | -|---|---| -| open | 12(#1 ~ #12) | -| closed-by-fact | 1(#13) | +| 状态 | 数量 | 编号 | +|---|---|---| +| `closed-2026-05-21` | 12 | #1 ~ #12 | +| `closed-by-fact` | 1 | #13 | +| `open` | 0 | — | + +12 条灰区已 100% 闭环,无 open 项。 + +--- + +## 附录 · 已知改进点(PJM 另开 issue 跟踪) + +> 架构师在第二轮证据闭环中给出的修复建议清单,**不阻塞本手册门 6**。 +> 由 PJM 另开跟踪 issue,独立于运营手册迭代。 + +| # | 主题 | 关联灰区 | 优先级 | 描述 | +|---|---|---|---|---| +| A1 | 音频/任务路径补 `AcceptUnsetRatioModel` 闸门 | #1 | 中 | 与文本路径对齐,避免未注册模型在音频/Realtime/任务路径下静默按 37.5 倍率扣费。改动点:`service/quota.go:109`、`service/task_billing.go:258`、`controller/task_video.go:159` 调用 `GetModelRatio` 时检查 success 返回值。 | +| A2 | 默认值对齐:`UserUsableGroups` 加 svip | #6 | 低 | `setting/user_usable_group.go:12` 增加 `"svip": "svip分组"`。仅影响数据库 option 表为空的全新部署,已部署实例不受影响。 | +| A3 | 三处前端校验联动 | #3 / #5 / #8 | 中 |
  • `/channels` 保存:要求 `channels.group` ⊂ `GroupRatio` 已注册集合,否则提示。
  • `/keys` 保存:阻止「`model_limits_enabled=true` + 空 `model_limits`」组合。
  • `/system-settings/billing/group-pricing` 保存:检测 GroupRatio 与 UserUsableGroups 不一致时弹窗提示同步。
| +| A4 | UI tooltip:RPM / TPM 字段加截面说明 | #11 | 低 | `web/default/src/features/usage-logs/`:RPM/TPM 字段旁加 ⓘ「按最近 60 秒计算」。 | +| A5 | 数据看板表清理接口 | #12 | P3 | 在 `model/usedata.go` 增加 `DeleteOldQuotaData`,仿 `DeleteOldLog` 分批 100 条删除;`controller/log.go DeleteHistoryLogs` 同步调用,或单独提供 `DELETE /api/data/`。 | + +> 闭环约定:上述任一改进点合入 `main` 后,由 PJM 通知文档维护专家,本表对应行追加 PR 链接 + 状态改 `done-YYYY-MM-DD`;运营手册中相关「workaround」段落同步删除。 --- ## 维护说明 -- 本表由文档维护专家维护,架构师追加证据后由文档维护专家关闭对应条目并更新正文措辞。 -- 关闭一条 = 在状态列改为 `closed-YYYY-MM-DD` 并保留闭环证据链接(commit / PR / issue 评论)。 -- 运营在排错过程中如果发现新的灰区,**追加为 #14、#15...**,不要改既有编号。 +- 本表由文档维护专家维护。 +- 灰区编号一旦确定不再变更;新发现的灰区**追加为 #14、#15...**,不要改既有编号。 +- 改进点编号 A1、A2... 同上。 diff --git a/docs/operations/README.md b/docs/operations/README.md index 7747803b3c92..263d65df97e5 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -17,7 +17,7 @@ | 3 | [`03-billing.md`](03-billing.md) | 计费规则(标准 / 分级 / 音频 / 任务,含人话版与原始公式 + 量纲) | | 4 | [`04-logs-stats.md`](04-logs-stats.md) | 日志与数据看板(`logs` / `quota_data` 表 + 前端入口 + 统计口径) | | 5 | [`05-faq.md`](05-faq.md) | 常见运营场景速查 / 排错(13 条灰区翻译为运营行动建议) | -| 99 | [`99-pending-items.md`](99-pending-items.md) | 待确认事项追踪表(架构师责任人,open / closed 状态) | +| 99 | [`99-pending-items.md`](99-pending-items.md) | 灰区证据闭环追踪表(13 条全部闭环)+ 已知改进点附录(PJM 另开 issue 跟踪) | --- @@ -47,7 +47,7 @@ - 本手册落在仓库 `docs/operations/` 目录。 - 文档变更走 `feature/` 分支,**禁止直接 push 到 main / develop**(v5 规约)。 - 三方一致性反馈(PRD ↔ 文档 ↔ 代码)通过父 issue 评论上报对应责任人。 -- 待确认事项不散落正文,统一进 [`99-pending-items.md`](99-pending-items.md);正文相关位置仅做「⚠️ 详见末尾追踪表 #N」标注。 +- 灰区结论与已知改进点统一进 [`99-pending-items.md`](99-pending-items.md);正文相关位置仅做「⚠️ 详见末尾追踪表 #N」标注。 --- From 07f2840977a80e9ee0b11d71ae6067897d59826f Mon Sep 17 00:00:00 2001 From: "jipeng.yu" Date: Thu, 21 May 2026 14:54:43 +0000 Subject: [PATCH 3/4] docs(operations): fix billing chapter numbering and dangling pending-item ref - 03-billing.md: renumber duplicate 3.1.3 section to 3.1.4/3.1.5 - 01-platform-setup.md: sync anchor #314 -> #315 - 99-pending-items.md: sync section ref 3.1.4 -> 3.1.5 - 04-logs-stats.md: drop dangling ref to non-existent appendix item Co-authored-by: multica-agent --- docs/operations/01-platform-setup.md | 2 +- docs/operations/03-billing.md | 4 ++-- docs/operations/04-logs-stats.md | 2 +- docs/operations/99-pending-items.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/operations/01-platform-setup.md b/docs/operations/01-platform-setup.md index 0e2c693acd7a..6245b0d34eec 100644 --- a/docs/operations/01-platform-setup.md +++ b/docs/operations/01-platform-setup.md @@ -168,7 +168,7 @@ option key 列表(来自 `model/option.go:150-153, 508-509`): > > 运营建议:所有上线模型必须在 `/system-settings/billing/model-pricing` 显式注册 `ModelRatio` 或 `ModelPrice`,不要依赖 37.5 默认值。 > -> 详细公式与代码证据见第 3 章 [3.1.4](03-billing.md#314-未注册到-modelratio-的模型扣费行为两路径不对称),跟踪信息见末尾追踪表 [#1](99-pending-items.md#1-未注册模型默认倍率两路径不对称)。 +> 详细公式与代码证据见第 3 章 [3.1.5](03-billing.md#315-未注册到-modelratio-的模型扣费行为两路径不对称),跟踪信息见末尾追踪表 [#1](99-pending-items.md#1-未注册模型默认倍率两路径不对称)。 --- diff --git a/docs/operations/03-billing.md b/docs/operations/03-billing.md index 9b6434ae8d48..a204d7a95869 100644 --- a/docs/operations/03-billing.md +++ b/docs/operations/03-billing.md @@ -105,7 +105,7 @@ quota = round( ModelPrice × QuotaPerUnit × GroupRatio 按次价场景:模型在 `ModelPrice` 中显式注册了价格,**整请求只按这个价**收费,与 token 数无关(但仍叠加工具与音频费)。 -### 3.1.3 关于 `OtherRatios`(任务/图像类计费的动态参数倍率) +### 3.1.4 关于 `OtherRatios`(任务/图像类计费的动态参数倍率) > ✅ **运营无需也不能在前端配置 `OtherRatios`**。它由 adapter 根据用户请求参数(视频 `seconds`/`size`、图像 `n` / `prompt_extend`)自动计算。详见末尾追踪表 [#9](99-pending-items.md#9-otherratios任务图像类计费的动态参数倍率)。 @@ -119,7 +119,7 @@ quota = round( ModelPrice × QuotaPerUnit × GroupRatio | 透出 | HTTP 头 `X-New-Api-Other-Ratios`(`relay/relay_task.go:234`);日志 `logs.other` 写入命中明细(`service/task_billing.go:26-29, 128-129, 286-289`) | | 运营提示 | 任务/视频/图像扣费高于预期时,先看 `logs.other.OtherRatios` 字段确认命中了哪些动态倍率(`seconds × size × ...`) | -### 3.1.4 未注册到 ModelRatio 的模型扣费行为(**两路径不对称**) +### 3.1.5 未注册到 ModelRatio 的模型扣费行为(**两路径不对称**) > 🔴 **结论先行**:所有上线模型必须在 `ModelRatio`(或 `ModelPrice`)显式注册。不要依赖 37.5 默认值。详见末尾追踪表 [#1](99-pending-items.md#1-未注册模型默认倍率两路径不对称)。 diff --git a/docs/operations/04-logs-stats.md b/docs/operations/04-logs-stats.md index 17e53891ec5c..5d93bc8e5a20 100644 --- a/docs/operations/04-logs-stats.md +++ b/docs/operations/04-logs-stats.md @@ -131,7 +131,7 @@ > 客户原本在 `vip` 但调用时令牌切到 `svip` → `logs.group = 'svip'`。 > 想分开看「以 vip 身份切到 svip」与「直接 svip 用户」当前**做不到** — 因为 user_group 与 token_group **未单独落库**,只在 `other.group_ratio_special` 命中 GroupGroupRatio 时写一处。 > -> **追溯切组路径**:打开日志详情查 `other.user_group / other.token_group`(**待 logs.other 扩展后可用**,详见 `99-pending-items.md` 附录 A 跟踪 issue)。当前期间,按用户 ID + 令牌 ID 维度聚合是 workaround。 +> **追溯切组路径**:打开日志详情查 `other.user_group / other.token_group`。当前期间,按用户 ID + 令牌 ID 维度聚合是 workaround。 > > 详见末尾追踪表 [#10](99-pending-items.md#10-logsgroup-仅记-usinggroup)。 diff --git a/docs/operations/99-pending-items.md b/docs/operations/99-pending-items.md index c66e9afb5e18..25a2d47a2f0f 100644 --- a/docs/operations/99-pending-items.md +++ b/docs/operations/99-pending-items.md @@ -15,7 +15,7 @@ |---|---| | **现象描述** | `setting/ratio_setting/model_ratio.go:403-417 GetModelRatio` 在模型未注册时返回 `(37.5, operation_setting.SelfUseModeEnabled, name)`。第二个返回值是「闸门」,不是「真正生效」。 | | **证据链** |
  • **文本路径(OpenAI / Claude / Gemini)有两道闸门**(`relay/helper/price.go:95-104, 182-191`):① 系统级 `SelfUseModeEnabled` ② 用户级 `dto.UserSetting.AcceptUnsetRatioModel`(`dto/user_settings.go:14`)。两者都关 → 返回 `modelPriceNotConfiguredError`(`relay/helper/price.go:20-33`)拒绝调用。
  • **音频/Realtime/异步任务路径**(`service/quota.go:109`、`service/task_billing.go:258`、`controller/task_video.go:159`)调用 `GetModelRatio` 时**忽略 success 返回值**,未注册模型直接按 37.5 倍率静默扣费。
| -| **当前手册措辞** | 第 1 章 1.2.4 + 第 3 章 3.1.4 + 第 5 章场景 1:分文本路径与音频/任务路径分别说明扣费行为。 | +| **当前手册措辞** | 第 1 章 1.2.4 + 第 3 章 3.1.5 + 第 5 章场景 1:分文本路径与音频/任务路径分别说明扣费行为。 | | **处置结论** | 不依赖默认 37.5 倍率,所有上线模型必须在 `ModelRatio` / `ModelPrice` 显式注册。运营无需关心系统级 / 用户级闸门,注册即可避免任何不确定。 | | **状态** | `closed-2026-05-21` | From 7aa9ae09c289c01f8417810537e86e0daaaa26df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E6=A1=A3=E7=BB=B4=E6=8A=A4=E4=B8=93=E5=AE=B6?= Date: Thu, 21 May 2026 15:18:38 +0000 Subject: [PATCH 4/4] docs(operations): fix 2 dangling 3.1.3 OtherRatios refs to 3.1.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/operations/03-billing.md L42: '详见 3.1.3' → '详见 3.1.4' - docs/operations/99-pending-items.md L114: '第 3 章 3.1.3' → '第 3 章 3.1.4' QA spot-check round 3 caught these two leftover references after the previous renumbering (3.1.3 OtherRatios → 3.1.4). Verified globally: grep -rn '3\.1\.3' docs/operations/ | grep -i OtherRatios → 0 hits Section numbering monotonic 3.1.1 → 3.1.5 in 03-billing.md TES-69 Co-authored-by: multica-agent --- docs/operations/03-billing.md | 2 +- docs/operations/99-pending-items.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/operations/03-billing.md b/docs/operations/03-billing.md index a204d7a95869..c53c166ff764 100644 --- a/docs/operations/03-billing.md +++ b/docs/operations/03-billing.md @@ -39,7 +39,7 @@ NewAPI 的标准计费可以拆成三层: = (输入 token × ModelRatio) + (输出 token × CompletionRatio × ModelRatio) + 工具调用次数费用 + 音频独立计费 × GroupRatio(分组倍率) - × ∏ OtherRatios(任务/图像类的动态参数倍率,详见 3.1.3;文本路径当前为 no-op) + × ∏ OtherRatios(任务/图像类的动态参数倍率,详见 3.1.4;文本路径当前为 no-op) ``` **关键点(运营要会说清)**: diff --git a/docs/operations/99-pending-items.md b/docs/operations/99-pending-items.md index 25a2d47a2f0f..52da07106478 100644 --- a/docs/operations/99-pending-items.md +++ b/docs/operations/99-pending-items.md @@ -111,7 +111,7 @@ |---|---| | **现象描述** | `service/text_quota.go:281-285` 文本路径连乘 `Π OtherRatios`,但首轮未找到赋值路径与前端入口。 | | **证据链** |
  • **OtherRatios 不是预留字段,而是任务/图像计费的活跃路径**。
  • 类型:`types.PriceData.OtherRatios map[string]float64`,赋值入口 `AddOtherRatio(key, value)`(`relay/relay_task.go:113, 192` 等)。
  • 任务主流程:`relay/relay_task.go:144-203 RelayTaskSubmit` — 步骤 5 `adaptor.EstimateBilling` 返回 `{"seconds": N, "size": M}` 等键值;步骤 6 `Quota *= ratio`(连乘所有 OtherRatios 值);步骤 11 `AdjustBillingOnSubmit` 校准。
  • 实际赋值的 adapter:`relay/channel/task/sora/adaptor.go:97-130`(OpenAI Sora)、`relay/channel/task/ali/adaptor.go:193`(阿里通义视频)、`relay/channel/task/gemini/adaptor.go:160`(Veo)、`relay/channel/task/vertex/adaptor.go:124`(Vertex Veo)、`relay/channel/ali/image.go:53,58,331,333` + `image_wan.go:37`(图像 `n` / `prompt_extend`)、`relay/image_handler.go:124-125`(通用图像 `n`)。
  • 透出:HTTP 头 `X-New-Api-Other-Ratios`(`relay/relay_task.go:234`);日志 `logs.other` 记录命中明细(`service/task_billing.go:26-29, 128-129, 286-289`)。
  • 文本路径中的 for 循环是统一公式预留位,文本 adapter 当前未赋值,连乘为 no-op,但**不是废弃字段**。
| -| **当前手册措辞** | 第 3 章 3.1.3 + 灰区表 #9:明确为「任务/图像类计费的动态参数倍率」,由 adapter 根据用户请求参数(视频 `seconds`/`size`,图像 `n`)自动计算;运营**无需也不能**手动配置;日志 `other` JSON 字段记录明细。 | +| **当前手册措辞** | 第 3 章 3.1.4 + 灰区表 #9:明确为「任务/图像类计费的动态参数倍率」,由 adapter 根据用户请求参数(视频 `seconds`/`size`,图像 `n`)自动计算;运营**无需也不能**手动配置;日志 `other` JSON 字段记录明细。 | | **处置结论** | 无前端入口是设计意图,运营无需配置。 | | **状态** | `closed-2026-05-21` |