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
23 KiB
任务书 · 计费闭环与功能级定价(2026-08-30)
基线:origin/staging @ ff5998f1。
下面所有行号只是线索,请按选择器/函数名定位,后续提交可能让行号偏移。
为什么要做
产品侧已定的商业规则:
- 1 元人民币 = 10 积分。
- 生时校正与报告生成单独计费,不含在会员权益里(会员只享折扣价)。
- 会员档的卖点是"聊天随便聊",即不设总量配额,只做速率与熔断。
代码现状与这三条全部对不上,且有两处是真金白银的漏洞:
| 功能 | 代码里实际发生的事 | 位置 |
|---|---|---|
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 依赖它。
硬红线
- 任务 0 是门控。 真实单位成本必须先测出来,才允许往数据库里写任何价格数字。不得把本任务书里出现的任何金额当成已定价格 —— 除了"1 元 = 10 积分"这一条,其余都是待校准的产品输入。
- 钱的红线:不允许出现"扣了费但服务没交付",也不允许出现"服务交付了但没扣费"。 每条计费路径都必须是
reserve → (成功) complete / (失败) release的完整三态,任何 early return、抛异常、超时分支都要覆盖到。 - 所有计费调用必须幂等,键是
request_id。报告用payload.requestId,校正沿用既有的rectification:case:<caseId>前缀规则,不得新造第二套幂等键。 - 不得放宽
authorize_usage/complete_usage/release_usage的任何校验,包括公平使用检查、配额检查、model_not_included检查。只可收紧。 - 价格一律不得出现在前端代码里。 前端只能展示服务端返回的价格。
src/lib/membership.ts里已有的formatPrice是格式化函数,不是价格来源。 - 生产库的价格不得手改。 所有价格与公平使用参数变更走 migration 或既有的
admin_save_product_draft审计路径。 - 不得修改既有测试断言 —— 除非该断言锁的正是本轮要改的缺陷本身;那种情况必须在断言上方注明原值与原因,并在 PROGRESS 单列。
- 推 staging 前必须
./node_modules/.bin/tsc --noEmit通过。不要用npx tsc,本仓库环境下会装到空包tsc@2.0.4。 - 数据库测试必须真跑。 本轮动表结构和 SQL 函数,
npm run test:db需要 Docker。没有 Docker 就不要推 —— 把环境缺口写进BLOCKED.md并停下,不允许"本地跑不了所以跳过"。 - 不得改
.gitea/workflows/**。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。
让步顺序:钱不出错 > 数据不损坏 > 功能与测试不回归 > 可验证的改进 > 成本 > 代码整洁。
开工前置
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/billingLimitsrc/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 内全部轮次,还是只有最后一次。这一条必须先查清楚,它决定校正成本的真实量级。
要做什么
- 在
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。 - 读
src/app/api/rectification/agent/route.ts的complete回调,确认usage.inputTokens是单次 agent run 还是 case 累计。把结论写进 PROGRESS,如果是单次,说明账本低估了校正成本,需要在任务 2 里一并修成累计。 - 不要为了这个任务改任何价格、任何 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 数。
要做什么
- 给
ReportCreateCoreDeps增加一个billingport(可选,测试可省略以保持既有内联测试可跑),形状与consultation-billing.ts的三个函数一致。不要在 core 里直接 import Supabase client —— 这个文件是纯 core,依赖靠注入,保持现有风格。 - 在日限检查之后、
createGenerating之前 调authorizeUsage({ featureKey: "report.full", requestId: payload.requestId, ... })。- 失败且
reason === "insufficient_credits"→ HTTP 402,与src/app/api/consult/route.ts:451的现有约定一致。 - 其他失败 → 503。
- 错误码走
REPORT_STABLE_CODES,需要新增就新增,不要复用语义不符的既有码。
- 失败且
createGenerating或入队本身失败 → 必须releaseUsage,不能吞掉。- 让
personal-report.ts的章节生成把 token 用量回传(不是只打日志),在 generation 层累加成一份{ inputTokens, outputTokens }。 - worker 的两个终态接上结算:
completeReportReady→completeUsage,costMicrousd按inputCostMicrousdPerMillion/outputCostMicrousdPerMillion计算,算法照抄src/app/api/rectification/agent/route.ts:595-603。completeReportFailed→releaseUsage,理由用稳定错误码。
- 重放路径要走通:
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 级幂等。这个机制不要改,要改的只是金额来源。
要做什么
creditCost改为从任务 3 的功能定价表解析,不再取selectedModel.creditCost。- 依据任务 0 的结论修正
complete回调的成本口径:如果当前只写了单次 agent run 的 token,改成写入 case 内累计值(complete_usage对同一 request_id 的多次调用行为需要先确认;若它不支持累加,就在应用侧累计后只结算一次)。 - 首轮 reserve 失败时的用户提示要能区分"积分不足"和"会员配额已用完",不要都返回同一句话。
验收
- 一例校正扣的积分等于功能定价表里的值,与对话单价解耦。
- 同一 case 多轮对话仍然只扣一次费(既有幂等不被破坏)。
usage_ledger里校正行的 token 数与该 case 的真实总消耗一致。- 既有的校正流程测试全绿。
任务 3(P0,结构主线)· 功能级定价改为服务端可配置
事实
现在没有任何地方能表达"功能 × 模型档位 → 积分价"。model_config_versions.credit_cost 只能表达"模型档位 → 积分价"。
要做什么
- 新增
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)。 - 服务端解析函数:给定
featureKey与已选模型,返回应扣积分。没有配置时的行为必须是明确失败,不是静默回落到 1 —— 静默回落正是当前这个 bug 的成因。 - 接入 admin 后台(
src/components/admin/下已有package-management/product-management/model-management的成对形状,照着做)。 - 把
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)。
这个前缀是逐字稳定的,是上下文缓存的理想对象。
要做什么
- 查清当前生产 provider 的上下文缓存契约(缓存怎么声明、命中怎么计价、最小可缓存长度),以 provider 官方文档为准,不要凭记忆写。
model_providers表里有provider_type,需要按 provider 分支处理,不支持缓存的 provider 必须安全降级为现状行为。 - 在
agentGenerationSettings里加缓存声明,保证 system + skill 前缀落在缓存边界内。 - 缓存命中率与命中/未命中的 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。
产品侧已定:会员档卖点是"聊天随便聊",且校正与报告不含在会员权益内,只给会员折扣价。
要做什么
- 写一个迁移,按任务 0 的实测成本重设
minuteLimit/dayLimit/billingLimit,并把standard_monthly/standard_yearly的rectification与report.full权益行移除(改为单独计费)。移除前确认没有存量订阅正依赖这些配额;有的话必须给出兼容处理,不能让在期会员的权益凭空消失 —— 这一条属于红线 2。 billingLimit的语义要在代码注释和 admin 文案里写清楚:它是熔断,不是对用户承诺的配额。"随便聊"对外表述为不限总量,实现上靠minuteLimit+dayLimit兜住真人上限,billingLimit只拦异常账号。- 触发
fair_use_billing_period时的用户提示不得写成"配额用完了" —— 与"随便聊"的售卖承诺矛盾。文案交产品定,代码留可配置。 - 售价(
billing_products.price_cents)本轮不要动,等产品侧给最终数字。
验收
- 迁移幂等,
db:migrate:check三次通过。 - 在期会员的权益变更有明确的兼容路径并在 PROGRESS 写清。
- 公平使用被触发时返回的
reason与retry_after_seconds正确,前端提示不与"随便聊"冲突。
任务 6(P1)· 管理端定价测算页
为什么放在管理端而不是做成一次性文档
定价不是定一次就完了:模型换一家、上下文缓存接上(任务 4)、用量画像随用户增长漂移,任何一个变化都会让上一版的毛利表作废。所以测算必须长在系统里、读实时数据,而不是躺在某份 md 里。
放哪里
产品要求放在"模型那里"。做成独立的 Refine resource,nav 位置紧挨模型配置:
- 资源注册:
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 不是一个关注点。
数据源(三个,全部服务端算,浏览器不碰价格)
- 模型单价 ——
model_config_versions.input_cost_microusd_per_million/output_cost_microusd_per_million,只取status='published' and enabled。 - 实测用量 —— 任务 0 那个聚合端点,按
feature_key给runs / p50 / p95 / max的 token 数与cost_microusd。 - 当前定价 —— 任务 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 积分,这条是产品已定的商业规则,做成常量并注明,不要做成可调滑块。
实时输出
- 单功能表:
chat.standard/chat.premium/rectification/report.full各自的单位成本、单位售价、毛利率。 - 会员档表:按 B 组的分布算加权平均成本与平均毛利;再按
billingLimit打满算最坏成本与最坏毛利。两行都要出——平均值永远好看,风险全在最坏那行,页面不能只显示平均值。 - 盈亏平衡:固定成本 ÷ 单张月卡毛利 = 需要多少张月卡。
- 容量上限:按
CONSULTATION_DOMAIN_DURATION_MS(21s,src/mastra/consultation-tools.ts:56)与 Python 侧单进程串行的事实,算日吞吐上限,并换算成"当前会员用量画像下这台机器能承载多少会员"。这个数跟钱无关但跟定价决策直接相关——承诺"随便聊"之前必须先看它。
硬要求
- 只读。这个页面绝不能写任何价格。 改价一律走任务 3 的审计路径和
admin_save_product_draft。一个测算页面变成调价入口,等于绕过审计,属于红线 6。页面上可以放跳转到对应管理页的链接,不放保存按钮。 - 实测与假设必须在视觉上可区分。 每个默认值旁边标注它是来自账本实测还是用户假设;用户拖动滑块后该项要立刻标为"假设"。这一条直接服务于红线 1——不能让人看着一屏估算数字误以为是实测。
- 账本无数据时显示"无实测数据",不得静默填入估算值。
report.full在任务 1 上线前必然是这个状态,这是正确表现,不是 bug。 - 不引入新依赖。 本仓库没有图表库(
package.json里只有 antd 5 与 refine),为一个后台页面装@ant-design/plots不划算。用 antd 的Table/Statistic/Descriptions/Progress表达即可,需要趋势就用表格。 - 权限:读账本需要
billing.orders.read,读模型价需要models.read。缺其一时把对应数据块降级为不可用,而不是整页 403 —— 参照adminAccessControlProvider的既有处理。 - 计算逻辑放进可单测的纯函数模块(例如
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的失败清单要与基线逐条比对,确认无新增。