Files
Jyotisha/TASK-billing-pricing-20260830.md
T
Jesse_Chen 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

23 KiB
Raw Blame History

任务书 · 计费闭环与功能级定价(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:572rectificationBillingRequestId() 让 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.tsauthorizeUsage 类型里、在 src/app/api/admin/products/route.ts:11 的 enum 里、在 product_entitlements 的种子数据里 —— 唯独没有任何代码调用它。

还有一个结构问题:定价当前绑在模型上,不是绑在功能上authorize_usagep_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。

让步顺序:钱不出错 > 数据不损坏 > 功能与测试不回归 > 可验证的改进 > 成本 > 代码整洁

开工前置

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 / maxcost_microusdinput_tokensoutput_tokensduration_ms
  2. src/app/api/rectification/agent/route.tscomplete 回调,确认 usage.inputTokens 是单次 agent run 还是 case 累计。把结论写进 PROGRESS,如果是单次,说明账本低估了校正成本,需要在任务 2 里一并修成累计。
  3. 不要为了这个任务改任何价格、任何 schema。

验收

  • 端点返回真实数据,PROGRESS 里贴出 chat.standardrectification 的 p50/p95 成本(脱敏,只要聚合值)。
  • report.full 的行数为 0 被明确记录 —— 这是任务 1 上线后才会有数的已知空缺。
  • 校正 token 口径的结论写进 PROGRESS。
  • 聚合端点的返回形状要一次设计到位:任务 6 的测算页直接消费它,所以除了均值还必须给出 p50 / p95 / maxruns,缺数据时返回明确的空态而不是 0。

任务 1–5 在任务 0 出数之前不得写入任何价格数字,但可以先做结构(表、函数、接线),价格字段留空或用占位符并在 PROGRESS 标注。


任务 1P0)· 报告接入计费

事实

resolveReportCreatesrc/lib/personal-report-route-core.ts:203)在 countCreatedToday() >= dailyLimit 之后直接进入 deps.persistence.createGenerating(createInput),全程没有 authorizeUsage。报告生成是异步的:路由入队,personal-report-worker-core.ts 消费。

src/mastra/personal-report.ts:385logTelemetry 只把 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 的两个终态接上结算:
    • completeReportReadycompleteUsagecostMicrousdinputCostMicrousdPerMillion / outputCostMicrousdPerMillion 计算,算法照抄 src/app/api/rectification/agent/route.ts:595-603
    • completeReportFailedreleaseUsage,理由用稳定错误码。
  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 是 120260806040000_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_keymodel_tiercredit_costversionstatusenabledeffective_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.standardchat.premiumrectificationreport.full 四个 featureKey 接过来。report.exportprofile.extra 建表时预留,本轮不接线。

验收

  • 改一个功能的积分价,不需要发版、不需要改模型配置。
  • 变更留下 admin 审计记录。
  • 缺配置时请求明确报错,不会按 1 积分放行。
  • npm run db:migrate:check 在隔离 Docker PostgreSQL 里 apply / check / reapply 三次均为 0。

任务 4P1)· 接上下文缓存

事实

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-1073bootstrap
  • 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_yearlyrectificationreport.full 权益行移除(改为单独计费)。移除前确认没有存量订阅正依赖这些配额;有的话必须给出兼容处理,不能让在期会员的权益凭空消失 —— 这一条属于红线 2。
  2. billingLimit 的语义要在代码注释和 admin 文案里写清楚:它是熔断,不是对用户承诺的配额。"随便聊"对外表述为不限总量,实现上靠 minuteLimit + dayLimit 兜住真人上限,billingLimit 只拦异常账号。
  3. 触发 fair_use_billing_period 时的用户提示不得写成"配额用完了" —— 与"随便聊"的售卖承诺矛盾。文案交产品定,代码留可配置。
  4. 售价(billing_products.price_cents)本轮不要动,等产品侧给最终数字。

验收

  • 迁移幂等,db:migrate:check 三次通过。
  • 在期会员的权益变更有明确的兼容路径并在 PROGRESS 写清。
  • 公平使用被触发时返回的 reasonretry_after_seconds 正确,前端提示不与"随便聊"冲突。

任务 6P1)· 管理端定价测算页

为什么放在管理端而不是做成一次性文档

定价不是定一次就完了:模型换一家、上下文缓存接上(任务 4)、用量画像随用户增长漂移,任何一个变化都会让上一版的毛利表作废。所以测算必须长在系统里、读实时数据,而不是躺在某份 md 里。

放哪里

产品要求放在"模型那里"。做成独立的 Refine resourcenav 位置紧挨模型配置:

  • 资源注册:src/components/admin/admin-app.tsxresources 数组,插在 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_keyruns / p50 / p95 / max 的 token 数与 cost_microusd
  3. 当前定价 —— 任务 3 的 feature_pricing,以及 billing_products.price_centsproduct_entitlements.metadata 里的 minuteLimit / dayLimit / billingLimit

三者都有现成的 admin 端点或表,不要新建业务表。若需要新端点,只加只读聚合,沿用 queryAdminRows + adminErrorResponse 的既有形状。

三组输入(可调,默认值全部来自上面的实时数据)

内容 默认值来源
A 模型单价 每百万 token 的输入价 / 输出价 当前 published 模型配置
B 用量画像 每个功能一次调用的 in / out token;会员月均对话条数的分布(4 档:占比 × 条数) 账本 p50(分布权重无实测时留空,见下)
C 定价 每功能积分价、会员月卡/年卡售价、minuteLimit / dayLimit / billingLimit、固定成本(VPS + Supabase,元/月) feature_pricingbilling_products

积分与人民币的换算固定为 1 元 = 10 积分,这条是产品已定的商业规则,做成常量并注明,不要做成可调滑块。

实时输出

  1. 单功能表chat.standard / chat.premium / rectification / report.full 各自的单位成本、单位售价、毛利率。
  2. 会员档表:按 B 组的分布算加权平均成本与平均毛利;再按 billingLimit 打满算最坏成本与最坏毛利。两行都要出——平均值永远好看,风险全在最坏那行,页面不能只显示平均值。
  3. 盈亏平衡:固定成本 ÷ 单张月卡毛利 = 需要多少张月卡。
  4. 容量上限:按 CONSULTATION_DOMAIN_DURATION_MS21ssrc/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 的失败清单要与基线逐条比对,确认无新增。