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
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
# 任务书 · 计费闭环与功能级定价(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 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 不是一个关注点。
|
||||
|
||||
### 数据源(三个,全部服务端算,浏览器不碰价格)
|
||||
|
||||
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` 的失败清单要与基线逐条比对,确认无新增。
|
||||
Reference in New Issue
Block a user