Files
Jyotisha/TASK-billing-pricing-20260830.md
T
Jesse_ChenandClaude Opus 5 e7226bbfb5
Independent Staging Quality Gate / validate (push) Successful in 9m25s
Independent Staging Quality Gate / publish (push) Successful in 9m9s
docs(billing): add pricing and billing closure task brief
Records the billing gaps found by reading the live code paths: report.full
has no authorizeUsage call at all, rectification charges model.creditCost
once per case, and pricing is bound to the model rather than the feature.

Seven tasks, with task 0 as a measurement gate so no price lands in the
database from an estimate. Task 6 adds an admin pricing simulator next to
the model config page so margins stay computable against live data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-08-30 18:24:01 +00:00

297 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 任务书 · 计费闭环与功能级定价(2026-08-30)
基线:`origin/staging` @ `ff5998f1`
**下面所有行号只是线索,请按选择器/函数名定位**,后续提交可能让行号偏移。
## 为什么要做
产品侧已定的商业规则:
1. **1 元人民币 = 10 积分**
2. **生时校正与报告生成单独计费**,不含在会员权益里(会员只享折扣价)。
3. **会员档的卖点是"聊天随便聊"**,即不设总量配额,只做速率与熔断。
代码现状与这三条全部对不上,且有两处是**真金白银的漏洞**:
| 功能 | 代码里实际发生的事 | 位置 |
| --- | --- | --- |
| `chat.standard` | 每条消息扣 `model.creditCost` | `src/app/api/consult/route.ts:369` |
| `rectification` | **整个 case 只扣一次**,扣的还是 `model.creditCost`(默认 1 点) | `src/app/api/rectification/agent/route.ts:572``rectificationBillingRequestId()` 让 request_id 按 case 幂等 |
| `report.full` | **完全没有接计费**。只有 `PERSONAL_REPORT_DAILY_LIMIT` 日限(默认 5 | `src/lib/personal-report-route-core.ts:308` 之后直接 `createGenerating` |
`report.full` 这个 featureKey 已经在 `src/lib/consultation-billing.ts``authorizeUsage` 类型里、在 `src/app/api/admin/products/route.ts:11` 的 enum 里、在 `product_entitlements` 的种子数据里 —— 唯独没有任何代码调用它。
还有一个结构问题:**定价当前绑在模型上,不是绑在功能上**。`authorize_usage``p_credit_cost` 是入参,但两个调用点传的都是 `model.creditCost`。这意味着"一次校正"和"一条对话"在定价上无法区分,而它们的真实成本差约一个数量级。调价必须改模型配置或改代码,做不了产品实验。
## 这不是"加一个价格常量"的任务
不要在路由里写 `const RECTIFICATION_CREDITS = 1980`。定价必须是**服务端可配置的数据**,和 `billing_products` / `product_entitlements` 一样由 admin 改、有版本、有审计。任务 3 是本轮的结构主线,任务 1、2 依赖它。
---
## 硬红线
1. **任务 0 是门控。** 真实单位成本必须先测出来,才允许往数据库里写任何价格数字。**不得把本任务书里出现的任何金额当成已定价格** —— 除了"1 元 = 10 积分"这一条,其余都是待校准的产品输入。
2. **钱的红线:不允许出现"扣了费但服务没交付",也不允许出现"服务交付了但没扣费"。** 每条计费路径都必须是 `reserve → (成功) complete / (失败) release` 的完整三态,任何 early return、抛异常、超时分支都要覆盖到。
3. **所有计费调用必须幂等**,键是 `request_id`。报告用 `payload.requestId`,校正沿用既有的 `rectification:case:<caseId>` 前缀规则,不得新造第二套幂等键。
4. **不得放宽 `authorize_usage` / `complete_usage` / `release_usage` 的任何校验**,包括公平使用检查、配额检查、`model_not_included` 检查。只可收紧。
5. **价格一律不得出现在前端代码里。** 前端只能展示服务端返回的价格。`src/lib/membership.ts` 里已有的 `formatPrice` 是格式化函数,不是价格来源。
6. **生产库的价格不得手改。** 所有价格与公平使用参数变更走 migration 或既有的 `admin_save_product_draft` 审计路径。
7. **不得修改既有测试断言** —— 除非该断言锁的正是本轮要改的缺陷本身;那种情况必须在断言上方注明原值与原因,并在 PROGRESS 单列。
8. 推 staging 前必须 `./node_modules/.bin/tsc --noEmit` 通过。**不要用 `npx tsc`**,本仓库环境下会装到空包 `tsc@2.0.4`
9. **数据库测试必须真跑。** 本轮动表结构和 SQL 函数,`npm run test:db` 需要 Docker。**没有 Docker 就不要推** —— 把环境缺口写进 `BLOCKED.md` 并停下,不允许"本地跑不了所以跳过"。
10. 不得改 `.gitea/workflows/**`。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。
让步顺序:**钱不出错 > 数据不损坏 > 功能与测试不回归 > 可验证的改进 > 成本 > 代码整洁**。
## 开工前置
```bash
git fetch origin --prune
git worktree add -b codex/billing-pricing-20260830 \
../.worktrees/billing-pricing-20260830 origin/staging
```
基线必须是 `origin/staging`。读 `pre_work_error_ledger.md`,跑 `scripts/pre_work_check.py`,读 `frontend/AGENTS.md`。改前在 `docs/BUG_HISTORY.md` 检索计费/积分相关记录。
**先读这几个文件再动手:**
- `src/lib/consultation-billing.ts` —— `authorizeUsage` / `completeUsage` / `releaseUsage` 的客户端封装与 `featureKey` 联合类型
- `supabase/migrations/20260806030000_settle_order_usage_authorization.sql` —— 三个 SQL 函数的真实语义,特别是 260–340 行的公平使用与配额判定顺序
- `supabase/migrations/20260806020000_billing_products_subscriptions.sql:104-155` —— 已种下的商品与权益,含 `minuteLimit` / `dayLimit` / `billingLimit`
- `src/lib/personal-report-route-core.ts:203-330` —— `resolveReportCreate` 的依赖注入形状与日限检查
- `src/lib/personal-report-worker-core.ts` —— `completeReportReady` / `completeReportFailed` 两个终态
- `src/mastra/personal-report.ts:385-405` —— `logTelemetry`,目前 token 用量只 `console.info`,没有回传
- `src/app/api/rectification/agent/route.ts:95-120, 558-610` —— 校正的 reserve / complete / release 现状
---
## 任务 0(P0,门控)· 把真实单位成本测出来
### 事实
`usage_ledger` 已经在记 `input_tokens` / `output_tokens` / `cost_microusd` / `duration_ms`(由 `complete_usage` 写入),`src/app/api/admin/usage/route.ts` 已有只读列表接口。但:
- `report.full` 从未产生过一行(没接计费),所以报告的真实成本**目前无法从账本读出**。
- `rectification` 每个 case 只有一行,`cost_microusd` 由路由自己按 `inputCostMicrousdPerMillion` 算好后写入 —— 需要确认它累加了 case 内**全部轮次**,还是只有最后一次。这一条必须先查清楚,它决定校正成本的真实量级。
### 要做什么
1.`src/app/api/admin/usage/` 下新增一个只读聚合端点(沿用 `requirePermission("billing.orders.read")`),按 `feature_key` 返回近 30 天的 `runs / avg / p50 / p95 / max``cost_microusd``input_tokens``output_tokens``duration_ms`
2.`src/app/api/rectification/agent/route.ts``complete` 回调,确认 `usage.inputTokens` 是单次 agent run 还是 case 累计。**把结论写进 PROGRESS**,如果是单次,说明账本低估了校正成本,需要在任务 2 里一并修成累计。
3. 不要为了这个任务改任何价格、任何 schema。
### 验收
- 端点返回真实数据,PROGRESS 里贴出 `chat.standard``rectification` 的 p50/p95 成本(脱敏,只要聚合值)。
- `report.full` 的行数为 0 被明确记录 —— 这是任务 1 上线后才会有数的已知空缺。
- 校正 token 口径的结论写进 PROGRESS。
- 聚合端点的返回形状要一次设计到位:**任务 6 的测算页直接消费它**,所以除了均值还必须给出 `p50` / `p95` / `max``runs`,缺数据时返回明确的空态而不是 0。
**任务 1–5 在任务 0 出数之前不得写入任何价格数字**,但可以先做结构(表、函数、接线),价格字段留空或用占位符并在 PROGRESS 标注。
---
## 任务 1(P0)· 报告接入计费
### 事实
`resolveReportCreate``src/lib/personal-report-route-core.ts:203`)在 `countCreatedToday() >= dailyLimit` 之后直接进入 `deps.persistence.createGenerating(createInput)`,全程没有 `authorizeUsage`。报告生成是异步的:路由入队,`personal-report-worker-core.ts` 消费。
`src/mastra/personal-report.ts:385``logTelemetry` 只把 token 用量打到 `console.info`,**没有返回给调用方**。报告现在是分章节串行生成(plan → N 章 → 摘要),所以结算需要的是**跨全部章节累加**后的 token 数。
### 要做什么
1.`ReportCreateCoreDeps` 增加一个 `billing` port(可选,测试可省略以保持既有内联测试可跑),形状与 `consultation-billing.ts` 的三个函数一致。**不要在 core 里直接 import Supabase client** —— 这个文件是纯 core,依赖靠注入,保持现有风格。
2. 在日限检查**之后**、`createGenerating` **之前**`authorizeUsage({ featureKey: "report.full", requestId: payload.requestId, ... })`
- 失败且 `reason === "insufficient_credits"` → HTTP **402**,与 `src/app/api/consult/route.ts:451` 的现有约定一致。
- 其他失败 → 503。
- 错误码走 `REPORT_STABLE_CODES`,需要新增就新增,不要复用语义不符的既有码。
3. `createGenerating` 或入队本身失败 → 必须 `releaseUsage`,不能吞掉。
4.`personal-report.ts` 的章节生成把 token 用量**回传**(不是只打日志),在 generation 层累加成一份 `{ inputTokens, outputTokens }`
5. worker 的两个终态接上结算:
- `completeReportReady``completeUsage``costMicrousd``inputCostMicrousdPerMillion` / `outputCostMicrousdPerMillion` 计算,算法照抄 `src/app/api/rectification/agent/route.ts:595-603`
- `completeReportFailed``releaseUsage`,理由用稳定错误码。
6. **重放路径要走通**`replayOrConflict` 命中既有报告时**不得重复扣费**`authorize_usage` 已按 request_id 幂等,但要确认命中的是幂等返回而不是第二次扣款,并写测试锁住)。
### 验收
- 余额为 0 的用户请求报告 → 402,且**没有**创建 `report_document` 行、没有入队。
- 生成成功 → `usage_ledger` 出现一行 `feature_key='report.full'`token 数等于全部章节之和。
- 生成失败 → 积分退回,`credit_transactions` 有对应的释放记录。
- 同一 `requestId` 连续提交两次 → 只扣一次费。
- 会员持有 `report.full` 配额时走 `source='subscription'`,不扣积分。
---
## 任务 2(P0)· 校正按功能定价计费,并修正成本口径
### 事实
`src/app/api/rectification/agent/route.ts:572` 传的是 `creditCost: selectedModel.creditCost`。默认模型的 `credit_cost` 是 1`20260806040000_model_configuration.sql:86` 的列默认值),所以**一整例生时校正和一条对话收一样的钱**。
计费本身的时机是对的:只有 `action === "message"` 才 reserve,且 `rectificationBillingRequestId()``rectification:case:<caseId>` 前缀做 case 级幂等。**这个机制不要改**,要改的只是金额来源。
### 要做什么
1. `creditCost` 改为从任务 3 的功能定价表解析,不再取 `selectedModel.creditCost`
2. 依据任务 0 的结论修正 `complete` 回调的成本口径:如果当前只写了单次 agent run 的 token,改成写入 case 内累计值(`complete_usage` 对同一 request_id 的多次调用行为需要先确认;若它不支持累加,就在应用侧累计后只结算一次)。
3. 首轮 reserve 失败时的用户提示要能区分"积分不足"和"会员配额已用完",不要都返回同一句话。
### 验收
- 一例校正扣的积分等于功能定价表里的值,与对话单价解耦。
- 同一 case 多轮对话仍然只扣一次费(既有幂等不被破坏)。
- `usage_ledger` 里校正行的 token 数与该 case 的真实总消耗一致。
- 既有的校正流程测试全绿。
---
## 任务 3(P0,结构主线)· 功能级定价改为服务端可配置
### 事实
现在没有任何地方能表达"功能 × 模型档位 → 积分价"。`model_config_versions.credit_cost` 只能表达"模型档位 → 积分价"。
### 要做什么
1. 新增 `public.feature_pricing`(或等价命名),至少含:`feature_key``model_tier``credit_cost``version``status``enabled``effective_from`、审计列。**必须沿用本仓库既有模式**:迁移事务化、幂等、开 RLS、authenticated 只读自己该看的、service 角色写、变更走 security definer 函数并带 `p_reason` 审计(照抄 `admin_save_product_draft` 的形状,见 `20260806020000_billing_products_subscriptions.sql:159`)。
2. 服务端解析函数:给定 `featureKey` 与已选模型,返回应扣积分。没有配置时的行为必须是**明确失败**,不是静默回落到 1 —— 静默回落正是当前这个 bug 的成因。
3. 接入 admin 后台(`src/components/admin/` 下已有 `package-management` / `product-management` / `model-management` 的成对形状,照着做)。
4.`chat.standard``chat.premium``rectification``report.full` 四个 featureKey 接过来。`report.export``profile.extra` 建表时预留,本轮不接线。
### 验收
- 改一个功能的积分价,不需要发版、不需要改模型配置。
- 变更留下 admin 审计记录。
- 缺配置时请求明确报错,不会按 1 积分放行。
- `npm run db:migrate:check` 在隔离 Docker PostgreSQL 里 apply / check / reapply 三次均为 0。
---
## 任务 4(P1)· 接上下文缓存
### 事实
`src/lib/agent-generation-settings.ts` 里没有任何 `cache_control` 或缓存相关的 `providerOptions`。而:
- 校正的 system message 每个 attempt 都全量重发绑定 Skill 全文(`skills/jyotish-birth-time-rectification/versions/10.0.9/SKILL.md` = 8,183 字符),见 `src/lib/rectification-agentic/v9/agent-run.ts:1064-1073``bootstrap`
- agentic 循环每一步都是一次完整模型调用,前缀(system + skill + 历史)随步数重复发送。校正每轮 6–12 步(`RECTIFICATION_AGENT_STEP_BUDGETS`),对话 8 步(`AGENT_MAX_STEPS`)。
这个前缀是**逐字稳定**的,是上下文缓存的理想对象。
### 要做什么
1. 查清当前生产 provider 的上下文缓存契约(缓存怎么声明、命中怎么计价、最小可缓存长度),**以 provider 官方文档为准,不要凭记忆写**。`model_providers` 表里有 `provider_type`,需要按 provider 分支处理,不支持缓存的 provider 必须安全降级为现状行为。
2.`agentGenerationSettings` 里加缓存声明,保证 system + skill 前缀落在缓存边界内。
3. 缓存命中率与命中/未命中的 token 数要落进 `usage_ledger`(新增列或写进既有字段的结构化部分,二选一并在 PROGRESS 说明理由)。
### 验收
- 同一 case 的第二轮起,账本能看到缓存命中。
- 校正与对话的 p50 `cost_microusd` 相对任务 0 的基线**有可测量的下降**,降幅写进 PROGRESS。
- 不支持缓存的 provider 行为不变,无报错。
**这个任务是"随便聊"会员档能否成立的成本前提**,产品侧的会员定价要等这里的实测降幅才能定死。
---
## 任务 5(P1)· 会员档参数与"随便聊"的可兑现定义
### 事实
`20260806020000_billing_products_subscriptions.sql:145-152` 种下的公平使用参数:
| 商品 | 售价 | minuteLimit | dayLimit | billingLimit |
| --- | ---: | ---: | ---: | ---: |
| 标准月卡 | ¥99 | 6 | 100 | 2000 |
| 标准年卡 | ¥599 | 6 | 100 | 24000 |
| Pro 月卡(未启用) | ¥299 | 10 | 200 | 5000 |
按任务 0 的实测单价核算,这几个 `billingLimit` 很可能**高于售价本身**,即上限被打满时单个会员是净亏的。三个限制都在 `authorize_usage` 里生效(`20260806030000:267-269`),改它们只需要改 `product_entitlements.metadata`
产品侧已定:会员档卖点是"聊天随便聊",且校正与报告**不含在会员权益内**,只给会员折扣价。
### 要做什么
1. 写一个迁移,按任务 0 的实测成本重设 `minuteLimit` / `dayLimit` / `billingLimit`,并把 `standard_monthly` / `standard_yearly``rectification``report.full` 权益行**移除**(改为单独计费)。移除前确认没有存量订阅正依赖这些配额;有的话必须给出兼容处理,不能让在期会员的权益凭空消失 —— 这一条属于红线 2。
2. `billingLimit` 的语义要在代码注释和 admin 文案里写清楚:它是**熔断**,不是对用户承诺的配额。"随便聊"对外表述为不限总量,实现上靠 `minuteLimit` + `dayLimit` 兜住真人上限,`billingLimit` 只拦异常账号。
3. 触发 `fair_use_billing_period` 时的用户提示不得写成"配额用完了" —— 与"随便聊"的售卖承诺矛盾。文案交产品定,代码留可配置。
4. 售价(`billing_products.price_cents`)本轮**不要动**,等产品侧给最终数字。
### 验收
- 迁移幂等,`db:migrate:check` 三次通过。
- 在期会员的权益变更有明确的兼容路径并在 PROGRESS 写清。
- 公平使用被触发时返回的 `reason``retry_after_seconds` 正确,前端提示不与"随便聊"冲突。
---
## 任务 6(P1)· 管理端定价测算页
### 为什么放在管理端而不是做成一次性文档
定价不是定一次就完了:模型换一家、上下文缓存接上(任务 4)、用量画像随用户增长漂移,任何一个变化都会让上一版的毛利表作废。所以测算必须**长在系统里、读实时数据**,而不是躺在某份 md 里。
### 放哪里
产品要求放在"模型那里"。做成独立的 Refine resourcenav 位置紧挨模型配置:
- 资源注册:`src/components/admin/admin-app.tsx``resources` 数组,插在 `models`(模型配置)与 `model-releases`(模型发布)**之间**`{ name: "pricing-simulator", list: "/admin/pricing-simulator", meta: { label: "定价测算", icon: ... } }`。图标从 `@ant-design/icons` 现有引入里选,不要新引。
- 页面:`src/app/admin/pricing-simulator/page.tsx`,保持本仓库一行页面的写法(照抄 `src/app/admin/models/page.tsx`)。
- 组件:`src/components/admin/pricing-simulator.tsx`,形状照 `model-management.tsx``"use client"` + antd + `adminRequestJson`)。
**不要塞进 `model-management.tsx`。** 那个文件已经够大,且测算要的数据来自三张不同的表,跟模型 CRUD 不是一个关注点。
### 数据源(三个,全部服务端算,浏览器不碰价格)
1. **模型单价** —— `model_config_versions.input_cost_microusd_per_million` / `output_cost_microusd_per_million`,只取 `status='published' and enabled`
2. **实测用量** —— 任务 0 那个聚合端点,按 `feature_key``runs / p50 / p95 / max` 的 token 数与 `cost_microusd`
3. **当前定价** —— 任务 3 的 `feature_pricing`,以及 `billing_products.price_cents``product_entitlements.metadata` 里的 `minuteLimit` / `dayLimit` / `billingLimit`
三者都有现成的 admin 端点或表,**不要新建业务表**。若需要新端点,只加只读聚合,沿用 `queryAdminRows` + `adminErrorResponse` 的既有形状。
### 三组输入(可调,默认值全部来自上面的实时数据)
| 组 | 内容 | 默认值来源 |
| --- | --- | --- |
| A 模型单价 | 每百万 token 的输入价 / 输出价 | 当前 published 模型配置 |
| B 用量画像 | 每个功能一次调用的 in / out token;会员月均对话条数的分布(4 档:占比 × 条数) | 账本 p50(分布权重无实测时留空,见下) |
| C 定价 | 每功能积分价、会员月卡/年卡售价、`minuteLimit` / `dayLimit` / `billingLimit`、固定成本(VPS + Supabase,元/月) | `feature_pricing``billing_products` |
积分与人民币的换算固定为 **1 元 = 10 积分**,这条是产品已定的商业规则,做成常量并注明,不要做成可调滑块。
### 实时输出
1. **单功能表**`chat.standard` / `chat.premium` / `rectification` / `report.full` 各自的单位成本、单位售价、毛利率。
2. **会员档表**:按 B 组的分布算加权平均成本与平均毛利;再按 `billingLimit` 打满算最坏成本与最坏毛利。**两行都要出**——平均值永远好看,风险全在最坏那行,页面不能只显示平均值。
3. **盈亏平衡**:固定成本 ÷ 单张月卡毛利 = 需要多少张月卡。
4. **容量上限**:按 `CONSULTATION_DOMAIN_DURATION_MS`21s`src/mastra/consultation-tools.ts:56`)与 Python 侧单进程串行的事实,算日吞吐上限,并换算成"当前会员用量画像下这台机器能承载多少会员"。这个数跟钱无关但跟定价决策直接相关——承诺"随便聊"之前必须先看它。
### 硬要求
1. **只读。这个页面绝不能写任何价格。** 改价一律走任务 3 的审计路径和 `admin_save_product_draft`。一个测算页面变成调价入口,等于绕过审计,属于红线 6。页面上可以放跳转到对应管理页的链接,不放保存按钮。
2. **实测与假设必须在视觉上可区分。** 每个默认值旁边标注它是来自账本实测还是用户假设;用户拖动滑块后该项要立刻标为"假设"。这一条直接服务于红线 1——不能让人看着一屏估算数字误以为是实测。
3. **账本无数据时显示"无实测数据",不得静默填入估算值。** `report.full` 在任务 1 上线前必然是这个状态,这是正确表现,不是 bug。
4. **不引入新依赖。** 本仓库没有图表库(`package.json` 里只有 antd 5 与 refine),为一个后台页面装 `@ant-design/plots` 不划算。用 antd 的 `Table` / `Statistic` / `Descriptions` / `Progress` 表达即可,需要趋势就用表格。
5. 权限:读账本需要 `billing.orders.read`,读模型价需要 `models.read`。**缺其一时把对应数据块降级为不可用,而不是整页 403** —— 参照 `adminAccessControlProvider` 的既有处理。
6. 计算逻辑放进可单测的纯函数模块(例如 `src/lib/pricing-simulation.ts`),组件只负责取数与渲染。测算公式必须有单元测试,覆盖:分布权重合计不为 1、`billingLimit` 为 null(不限)、模型单价为 0、账本无数据这四种边界。
### 验收
- 页面在 `/admin/pricing-simulator` 可达,nav 上位于「模型配置」与「模型发布」之间。
- 首屏默认值全部来自实时数据;未拖动任何滑块时,显示的毛利率就是当前真实配置下的真实毛利率。
- 改 A/B/C 任一输入,四张输出表实时重算,无需刷新。
- 页面无任何写操作端点调用。
- `src/lib/pricing-simulation.ts` 的单测覆盖上述四种边界,全绿。
---
## 交付
- PROGRESS 写进 `PROGRESS-billing-pricing-20260830.md`,按任务分节,任务 0 的实测数据单列。
- 每个任务一个提交,提交信息说明改的是哪条计费路径。
- 推 staging 前:`./node_modules/.bin/tsc --noEmit` 通过、`npm run lint` 无新增 error、`npm run test:db` 在 Docker 下 fail=0、`npm run build` 通过。
- 全量 `./node_modules/.bin/tsx --test tests/*.test.ts` 的失败清单要与基线逐条比对,确认无新增。