docs: add phase 2 billing review

This commit is contained in:
QiuSW
2026-07-02 16:33:31 +08:00
parent 826f5f685b
commit 3afa5284c7
11 changed files with 151 additions and 24 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
## 当前状态
Phase 2 已完成 T-202:用户钱包、API Key、点数流水、调用记录、计费规则、汇率模型与计费计算已落地。下一步是 T-203 并发安全扣点 / 退点。详见 [`docs/current-state.md`](docs/current-state.md)。
Phase 2 计费核心已完成:用户钱包、API Key、点数流水、调用记录、计费规则、汇率、计费计算、并发安全扣点与失败退点已落地。下一步是 T-301 API Key 鉴权。详见 [`docs/current-state.md`](docs/current-state.md)。
> ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
+3 -3
View File
@@ -38,14 +38,14 @@
## 当前阶段
当前项目处于:**Phase 2 计费核心**。T-202 PricingRule / ExchangeRate 模型与计费计算已完成,下一步进入 T-203 并发安全扣点 / 退点。
当前项目处于:**Phase 3 对外 API 与充值起步**。Phase 2 计费核心已完成到 T-203:PricingRule / ExchangeRate、计费计算、并发安全扣点与失败退点已落地。下一步进入 T-301 API Key 鉴权。
优先路径:
1. Phase 0:Django 骨架可运行、**自定义 User 模型在首次迁移前定好**、django-admin 可登录;T-004 审核修补项已完成。
2. Phase 1:最高风险功能原型 —— T-101/T-102/T-103/T-104/T-105 已完成 provider 层、模型配置表、别名解析、配置审计、录制标题/图片 smoke 与审核修补;真实图片同步耗时待配置 Fernet 主密钥、AiModel/ModelAlias 与真实上游后在 T-302/T-403 前补测。
3. Phase 2:计费核心 —— T-201 已完成 UserWallet/ApiKey/PointsLedger/CallRecord;T-202 已完成计费规则、汇率与计算函数;下一步 T-203 并发安全扣点。
4. Phase 3:对外 API 与充值 —— Key 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。
3. Phase 2:计费核心 —— T-201/T-202/T-203 已完成 UserWallet/ApiKey/PointsLedger/CallRecord、计费规则、汇率、计费计算、并发安全扣点与失败退点。
4. Phase 3:对外 API 与充值 —— 下一步 T-301 Key 鉴权,随后生成接口、余额查询、充值回调、扫码下单与轮询。
5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。
6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。
+3 -3
View File
@@ -160,7 +160,7 @@ CREATE TABLE exchange_rate (
);
```
T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。T-103 已补 `AiConfigAuditLog`,admin 里保存/删除模型配置或能力别名时自动写审计日志。T-202 已实现 `PricingRule` / `ExchangeRate` 与 `apps.billing.pricing` 计算函数:定价按 `operation_type + alias + resolution` 查 active 规则,优先 exact resolution,再回退到空 resolution 默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,后续若要强制数据库层约束可在 T-401 评估触发器或约束表。
T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。T-103 已补 `AiConfigAuditLog`,admin 里保存/删除模型配置或能力别名时自动写审计日志。T-202 已实现 `PricingRule` / `ExchangeRate` 与 `apps.billing.pricing` 计算函数:定价按 `operation_type + alias + resolution` 查 active 规则,优先 exact resolution,再回退到空 resolution 默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`。T-203 已实现 `apps.billing.services`:`precharge_call()` 锁 `UserWallet` 行预扣并写 pending 调用与 consume 流水;`mark_call_success()` 确认成功不再改余额;`refund_call_points()` 锁调用记录并幂等退点,写 refund 流水。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,后续若要强制数据库层约束可在 T-401 评估触发器或约束表。
> 可选增强(接口预留、MVP 不实现):`account_alias_permission`(按账号授权可用别名,防止调用方点用未授权/昂贵模型);别名按比例分流到多个模型(灰度/AB/故障转移)。适配器接口需为此留口子。
@@ -260,7 +260,7 @@ CREATE TABLE call_record (
- `payment_user_id`、`payment_txn_no` 为对账预留,字段先建。
- `recharge_order.exchange_rate` 与 `points_granted` 在下单时写入,状态为 `pending` 时也必须有值;支付回调金额必须与订单金额一致,入账时不得按新的汇率重算。
- `call_record.status` 状态机为 `pending -> success / failed`。上游失败退点后仍保持 `failed`,退款流水通过 `points_ledger(change_type=refund, ref_call_id=call_record.id)` 关联,不单独增加 `refunded` 状态,避免调用结果与账务动作混在一个字段里。
- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;`ref_order_id` 在充值订单模型落地前保持索引化数值引用。
- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;T-203 已落地扣点/退点服务;`ref_order_id` 在充值订单模型落地前保持索引化数值引用。
## 四、计费时序(核心,务必照此实现)
@@ -350,7 +350,7 @@ CREATE TABLE call_record (
1. Django + DRF 骨架可运行,django-admin 可登录(Phase 0)。
2. 移植并跑通一次 AI 调用(标题 / 图片)原型(Phase 1)。
3. 计费:点数扣减(并发安全)+ 计费规则 + 调用记录(Phase 2)。
3. 计费:点数扣减(并发安全)+ 计费规则 + 调用记录(Phase 2,T-201~T-203 已完成)。
4. 对外 API 鉴权 + 余额查询 + 充值回调(验签、幂等)入账(Phase 3)。
5. 运营后台完善、完整验收、部署(Phase 4)。
+2 -1
View File
@@ -43,7 +43,8 @@
| --- | --- | --- | --- | --- |
| T-201 | User / UserWallet / ApiKey / PointsLedger / CallRecord 模型 | T-002 | 表结构符合 `04-architecture.md`;`UserWallet.points_balance>=0` 约束;ApiKey **哈希存储**(key_hash+key_prefix,明文只创建时返回);CallRecord 含 `user`/`api_key`/`alias`/`model_used`;调用结果只存 `result_ref`/摘要,不 dump provider `raw`、base64 图片或敏感上游字段;admin 注册 | DONE |
| T-202 | PricingRule / ExchangeRate 模型 + 计费计算 | T-201, T-102 | **按「操作 + 能力别名(+ 可选分辨率)」定价**;换底层模型不影响计费;缺规则返回 `no_pricing_rule` | DONE |
| T-203 | 并发安全扣点 / 退点(billing 层) | T-201 | 锁 `UserWallet` 行或 F() 原子扣减;并发测试不超扣、不为负;失败退点写流水;含测试 | TODO |
| T-203 | 并发安全扣点 / 退点(billing 层) | T-201 | 锁 `UserWallet` 行或 F() 原子扣减;并发测试不超扣、不为负;失败退点写流水;含测试 | DONE |
| T-204 | Phase 2 计费核心审核加固 | T-203 | 按 [`phase-2-review.md`](phase-2-review.md) 处理 **P2**(`points_ledger` 加 MySQL 可落地的 `ref_call + change_type` 复合唯一约束,作为「每 `ref_call` 最多一条 REFUND」DB 兜底,**不要使用 MySQL 不支持的 partial unique / 条件唯一约束**;补直写 `IntegrityError` 测试,并确认同一调用的 CONSUME 与 REFUND 可共存;在稳定 MySQL 上跑一次完整 `test` 全绿留证,并在测试文档标注「并发测试须在 MySQL 上跑,SQLite 会假绿」);P3 已挂到 T-401/T-501 或备忘。**无 P1**,不阻塞 T-301;`check`/`test`/`init` 全绿并在 `../progress.md` 留证据 | TODO |
## Phase 3 · 对外 API 与充值
+1
View File
@@ -22,6 +22,7 @@
- [任务看板](06-tasks.md):按依赖拆分的小任务,agent 每轮只做一个。
- [Phase 0 骨架审核](phase-0-review.md):T-001~003 代码审核结论与修补清单(P1/P2/P3),对应任务 T-004。
- [Phase 1 AI 层审核](phase-1-review.md):T-101~104 代码审核结论与修补清单(P1/P2/P3),对应任务 T-105;重点提示图片同步风险未退与 `parameters` 越权计费隐患。
- [Phase 2 计费核心审核](phase-2-review.md):T-201~203 代码审核结论(资金安全逐条核对)与加固清单(**零 P1**,P2/P3),对应任务 T-204。
- [API 合约](api.md):对外接口、支付回调、AI 调用模块合约、错误码。
- [路由与页面结构](routes.md):API 路由与 django-admin 后台职责。
- [环境变量与配置](env.md):Django、数据库、AI 密钥加密、支付、对象存储等配置项。
+8 -2
View File
@@ -116,14 +116,17 @@
}
```
## 计费模块合约(`apps/billing.pricing`)
## 计费模块合约(`apps.billing`)
T-202 后,计费计算已有独立模块,供后续扣点、生成接口和充值下单调用:
T-202 后,计费计算已有独立模块;T-203 后,扣点/退点也收敛到 billing 层,供后续生成接口和充值下单调用:
```python
calculate_points_cost(operation_type: str, alias: str, resolution: str | None = None) -> int
quote_recharge_points(amount, currency: str = "CNY", at=None) -> RechargeQuote
calculate_points_granted(amount, currency: str = "CNY", at=None) -> int
precharge_call(..., user, points_cost: int, operation_type: str, alias: str, ...) -> CallCharge
mark_call_success(call_record: CallRecord, ...) -> CallRecord
refund_call_points(call_record: CallRecord, ...) -> RefundResult
```
要点:
@@ -133,6 +136,9 @@ calculate_points_granted(amount, currency: str = "CNY", at=None) -> int
- 缺计费规则抛 `NoPricingRuleError(code="no_pricing_rule")`,API 层应翻译为上方同名错误码。
- `ExchangeRate` 按 `currency + effective_from` 取当前 active 汇率;充值下单时应锁定当时的 `exchange_rate` / `points_granted` 到订单,回调入账不得按新汇率重算。
- 金额换点数采用 `floor(amount * points_per_unit)`,点数为整数。
- `precharge_call()` 使用事务 + `select_for_update()` 锁 `UserWallet` 行;余额不足抛 `InsufficientPointsError(code="insufficient_points")`,不创建 `CallRecord`、不写 `PointsLedger`、不调上游。
- 预扣成功后写 `CallRecord(status=pending)` 与 `PointsLedger(change_type=consume, points_delta=-N)`;上游成功只更新调用记录,余额不再变化。
- 上游失败调用 `refund_call_points()`:同一 `CallRecord` 只写一条 `refund` 流水,重复调用不会重复加点;成功调用不能走失败退点。
## 支付充值(自助扫码:微信 V3 native + 支付宝当面付)
+9 -9
View File
@@ -12,16 +12,16 @@
## 当前快照
- 日期:2026-07-02
- 阶段:Phase 2 计费核心已完成 T-202;下一步进入 T-203 并发安全扣点 / 退点
- 阶段:Phase 2 计费核心已完成 T-203;下一步进入 Phase 3 的 T-301 API Key 鉴权
- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings;用户端(模板 SSR/Bootstrap/allauth) 后续任务落地;详见 `03-tech-stack.md`
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移
- 测试:T-202 范围验证通过:`py_compile`、`manage.py check`、`makemigrations --check`、`migrate`、`showmigrations billing`、`manage.py test apps.billing --noinput --keepdb`(10 tests)、`manage.py test apps.users --noinput --keepdb`(2 tests)、`compileall apps`、`git diff --check`(仅 Windows CRLF 提示)、`./init.ps1`。全量 `manage.py test --noinput --keepdb` 当前发现 38 tests,但两次在远程 MySQL `43.128.3.240:3306` 连接超时/重置处失败;`apps.ai` 单独测试也在远程连接重建处超时,已跑过测试无断言失败。
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点
- 测试:T-203 直接验证通过:`py_compile`、`manage.py test apps.billing.tests.BillingServiceTests --noinput --keepdb`(4 tests)、`manage.py test apps.billing.tests.ConcurrentDebitTests --noinput --keepdb`(1 test,2 线程并发扣点)、`manage.py check`、`makemigrations --check`、`compileall apps`、`git diff --check`(仅 Windows CRLF 提示)、`./init.ps1`。完整 `apps.billing` / 全量 `manage.py test` 当前仍受远程 MySQL `43.128.3.240:3306` 间歇连接超时/重置影响;已确认失败点为连接/测试库清理问题,不是 T-203 服务断言。
- 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`);真实 `ai_models.json` 不提交,需通过 `import_ai_models` 命令加密导入
- 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test`
- 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;注册不送点数。详见 `04-architecture.md` 与 2026-06-29 / 2026-07-01 的 `progress.md` 决策
- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算
- 当前 blocker:无代码 blocker。支付商户密钥/证书仍缺真实值,但不阻塞当前 Phase 2;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-302/T-403。远程 MySQL 对全量测试存在间歇连接超时/重置,必要时重试或分 app 验证。
- 当前 blocker:无代码 blocker。支付商户密钥/证书仍缺真实值,但不阻塞 T-301;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-302/T-403。远程 MySQL 对全量测试存在间歇连接超时/重置,必要时重试或分 app 验证。
## 当前目录要点
@@ -33,7 +33,7 @@
| `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` |
| `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密 |
| `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi |
| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py` |
| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py`;T-203 已新增 `apps/billing/services.py` |
| `manage.py` | 已有 | T-001 创建 |
| `tests/` | 待建 | 随各任务补充 |
@@ -41,10 +41,10 @@
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算。
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点。
- 正在进行:无。
- 当前 blocker:无代码 blocker;远程 MySQL 全量测试偶发连接超时/重置。
- 下一个可领取任务:**T-203 并发安全扣点 / 退点(billing 层)**。
- 下一个可领取任务:**T-301 API Key 鉴权(DRF Authentication)**。
## 当前可运行内容
@@ -68,14 +68,14 @@ python3.12 manage.py smoke_ai_generation title --recorded
python3.12 manage.py smoke_ai_generation image --recorded
```
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库存在间歇超时,必要时用 `--keepdb` 验证。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再用同一 smoke 命令去掉 `--recorded` 跑真实标题/图片。
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库存在间歇超时,必要时用 `--keepdb` 验证。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再用同一 smoke 命令去掉 `--recorded` 跑真实标题/图片。
## 开始编码前检查
1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
2. 读 `docs/00-ai-start-here.md`。
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。
4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-203)。
4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-301)。
5. 将该任务状态改为 `DOING`。
## 维护规则
+79
View File
@@ -0,0 +1,79 @@
# Phase 2 计费核心审核报告(T-201 ~ T-203)
> 审核人:Claude Code(全栈视角)|日期:2026-07-02|结论:**验收通过,质量为三阶段最高,零 P1**。
> Phase 2 是全项目**资金安全最敏感**的部分(`05-coding-rules.md` §8、`04-architecture.md` 第四节)。逐条比对硬约束后:先扣后调/失败必退/并发安全/只追加留痕**全部正确落地**,并有**真线程并发测试在 MySQL 上跑过**。仅剩少量 P2/P3 加固项。
> 本文面向 codex 执行:每条修补项给出「症状 / 位置 / 怎么改 / 怎么验证」。修补任务见 [`06-tasks.md`](06-tasks.md) 的 **T-204**。
## 一、验收核对(全部达标)
| 任务 | 验收要点 | 结果 |
| --- | --- | --- |
| T-201 | User/UserWallet/ApiKey/PointsLedger/CallRecord;`points_balance>=0` 约束;ApiKey 哈希存储;CallRecord 只存 `result_ref`/摘要不 dump raw;admin 注册 | ✅ |
| T-202 | 按「操作 + 能力别名(+ 可选分辨率)」定价;换底层模型不影响计费;缺规则 `no_pricing_rule` | ✅ |
| T-203 | 锁 `UserWallet` 行原子扣减;并发不超扣/不为负;失败退点写流水;含测试 | ✅ |
### §8 资金安全硬约束逐条核对
| §8 约束 | 落地 | 证据 |
| --- | --- | --- |
| 并发安全扣点(事务 + `select_for_update` + DB `>=0`) | ✅ | `services.precharge_call` 事务内锁 wallet 行;`user_wallet` 有 DB 级 `CheckConstraint(points_balance>=0)`(迁移 `0003`) |
| 先扣后调、失败必退 | ✅ | `precharge_call`(扣)/ `mark_call_success` / `refund_call_points`(退)三段与 4.1 时序一一对应 |
| 全程留痕 | ✅ | 每次扣/退写 `PointsLedger`(含 `balance_after`、`ref_call`),建 `CallRecord`;两表 admin **只读**(只追加) |
| 失败退点幂等 | ✅ | `refund_call_points` 先锁 `CallRecord` 行,再查是否已有 REFUND 流水,已退则返回 `refunded=False` 不重复退 |
| 资金改动必须有测试 | ✅ | 并发扣点(真线程)、失败退点幂等、余额不足不写账,均有专测 |
## 二、做对的(勿在修补中回退)
1. **计费时序与 4.1 完全一致**:`precharge_call` = 事务A(锁 wallet→查余额→扣减→建 pending CallRecord + consume Ledger);`refund_call_points` = 事务B(退点 + refund Ledger + 置 failed);`mark_call_success` 只改状态不动余额。三个原语干净可组合,留给 T-302 编排。
2. **真并发测试(非模拟)**:`ConcurrentDebitTests` 用 `TransactionTestCase` + `threading.Barrier` 强制两线程同时扣同一钱包(余额 30 / 各扣 30),断言**恰好 1 成功 1 `insufficient_points`、终态 0、1 条 consume 流水、无负余额**。且**在 MySQL `test_cmhub` 上单独跑过**(progress `ConcurrentDebitTests ... 1 test OK`)——`select_for_update` 在 MySQL 真生效,不是 SQLite 上的假绿。这条是 §8 最关键的证据。
3. **退点幂等双重保障**:锁 `CallRecord` 行序列化并发退点 + 查 REFUND 流水去重;测试断言二次退点 `refunded=False`、REFUND 流水恒为 1 条。
4. **DB 级约束齐全(不只靠 CHECK)**:`user_wallet.points_balance>=0`、`call_record.points_cost>=0`、`points_ledger.balance_after>=0` 且 `points_delta!=0`、`pricing_rule.points_cost>0` + `unique(operation_type,alias,resolution)`、`exchange_rate.points_per_unit>0`——全部落到迁移文件(DB 级),锁 + 约束「带保险的双保险」,符合 §8「不能只靠 CHECK」。
5. **后台无法绕过账本**:`UserWallet` admin 的 `points_balance` **只读**、禁增删 → 运营改不了余额,逼所有余额变动走计费层;`PointsLedger`/`CallRecord` admin 全只读(只追加)。这是 §8「只追加/留痕」在 UI 层的正确收口。
6. **ApiKey 安全存储**:`sk_cmhub_` + `secrets.token_urlsafe(32)`;`sha256` 哈希、`key_prefix` 便于检索、`hmac.compare_digest` 定长比对防时序侧信道;admin `key_hash` 脱敏、禁新增/删除(创建走自助 T-502)。测试断言明文不入 `__dict__`。
7. **计费与底层模型解耦**:`PricingRule(operation_type, alias, resolution)` 定价,换 `ModelAlias→AiModel` 映射不影响价格(专测验证);分辨率精确规则优先、空串 `""` 作 wildcard 兜底;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`。
8. **金额用 Decimal + floor**:`quote_recharge_points` 用 `Decimal(str(amount)) × points_per_unit` 后 `ROUND_FLOOR`,与「floor(amount×rate)」一致,规避浮点误差;汇率取「active 且 effective_from<=now 的最新一条」。
9. **CallRecord 落实 Phase 1 P3**:无 `raw`/`provider_raw` 字段(专测断言),只有 `result_ref` + `result_summary`,不整包 dump 上游响应。
10. **错误码与 `api.md` 对齐**:`insufficient_points`→402、`no_pricing_rule`→400;`api.md` 已记录 `precharge_call` 用 `select_for_update`、余额不足不建 CallRecord/不写 Ledger/不调上游。
## 三、修补清单
### P1 · 现在改
**无。** 资金核心零 P1 是很好的信号——先扣后调、失败必退、并发锁、只追加留痕、DB 约束全部到位且有真并发测试。以下均为加固/收尾,不涉及正确性缺陷。
### P2 · 建议处理
#### P2-1 退点幂等缺 DB 级兜底约束
- **症状**:退点幂等目前靠「锁 `CallRecord` 行 + 查 REFUND 流水」保证,锁内安全、测试也覆盖。但 `points_ledger` **没有** 一条「每个 `ref_call` 最多一条 REFUND」的 DB 约束做 backstop。若将来某个代码路径**没先锁 `CallRecord`** 就写 refund 流水(例如后台脚本、批量补偿),双退不会被数据库拦住。
- **位置**:`apps/billing/models.py:PointsLedger.Meta.constraints`。
- **怎么改**:不要用 `UniqueConstraint(..., condition=Q(change_type="refund"))` 这类 partial unique;MySQL 不支持通用条件唯一索引,Django 在 MySQL 上无法按预期落地。T-204 采用 MySQL 可执行的普通复合唯一约束:`UniqueConstraint(fields=["ref_call", "change_type"], name="unique_ledger_change_type_per_call")`。这会保证同一 `CallRecord` 最多一条同类型流水;当前业务模型下每次调用只应有一条 `consume` 和最多一条 `refund`,符合账务口径。`ref_call=NULL` 的充值/订单流水在 MySQL 唯一索引下仍可多条存在,不影响充值流水。
- **验证**:迁移生成该复合唯一约束;新增测试:绕过服务层直接连写两条同 `ref_call` 的 REFUND,第二条抛 `IntegrityError`;同时确认同一 `ref_call` 的一条 CONSUME + 一条 REFUND 可以共存。
#### P2-2 全量测试从未一次跑绿(受远程 MySQL 不稳定影响)
- **症状**:因远程 MySQL `43.128.3.240:3306` 间歇超时/重置,完整 `manage.py test`(43 tests)**从未在单次运行里全绿**;codex 靠分 app / 分测试类子集逐一通过来佐证。最关键的并发测试已单独在 MySQL 通过,但缺一次「完整一遍绿灯」,跨测试隔离性未在单次验证。
- **位置**:测试运行环境 / `docs/03-tech-stack.md` 测试说明。
- **怎么改**:在稳定 DB(本地 MySQL 8.4,或修复/更换远程实例)上跑一次完整 `manage.py test` 并把结果记进 `progress.md`;并在测试文档显式标注「**并发测试必须在 MySQL 上跑**,SQLite 无行锁会假绿」,防止将来有人用 SQLite 误判。
- **验证**:`progress.md` 出现一条完整 43+ tests 单次全绿记录。
### P3 · 登记 / 后续任务(不在 T-204 硬性范围)
- **手工调点服务未实现**(§8「运营手工调整必须填原因」):模型层已备好(`PointsLedger` ADJUST 类型的 `clean()` 强制要求 `reason`),wallet admin 已锁死不可直接改余额(安全的中间态);但还缺一个 billing 层 `adjust_points()`(锁 wallet + 写 adjust 流水 + reason)供后台用。→ 登记 **T-401**。
- **wallet 在扣点热路径 `get_or_create`**:`precharge_call` 用 `select_for_update().get_or_create(user=...)`,会在「钱包缺失」时静默建零钱包再报余额不足,掩盖异常。应保证注册(T-501)时建钱包;billing 层遇缺失可考虑显式报错。→ 登记 **T-501**。
- **锁顺序约定**:`refund_call_points` 锁序为 (CallRecord → wallet),`precharge_call` 只锁 wallet。当前无跨序死锁;但将来若新增「先锁 wallet 再锁 CallRecord」路径需警惕死锁。→ 作为约定登记。
- **只追加靠 admin + 无 UPDATE 代码保证**:`PointsLedger`/`CallRecord` 的不可改目前由 admin 只读 + 服务层不写 update 保证,DB 层无触发器阻止 UPDATE/DELETE。Django 层已足够,登记备忘即可。
- **`OperationType` 双份枚举**:`apps.ai.ModelAlias.OperationType` 与 `apps.billing.CallRecord.OperationType` 各定义一份 title/image,语义重复。极小的 DRY 隐患,后续可抽公共枚举。
## 四、说明:未本地复跑
审核机(WSL)无 `python3.12`,**未本地复跑**。本报告基于:静态审查(`users/models`、`billing/models`、`services`、`pricing`、两 app 的 `admin`、`tests`、全部迁移、`settings`)+ codex 执行记录(T-201 34 tests、T-202 billing 10 tests、T-203 `BillingServiceTests` 4 + `ConcurrentDebitTests` 1 均**单独在 MySQL 通过**;完整套件因远程 MySQL 连接问题未单次全绿,失败点均为连接/建库超时而非断言失败,见 P2-2)。
T-204 处理后,请重跑 `check`/`test`/`init` 并把证据记入 `progress.md`(`06-tasks.md` 使用规则第 5 条)。
## 五、T-204 完成定义
- **P2-1** 已处理:`points_ledger` 加 MySQL 可落地的 `ref_call + change_type` 复合唯一约束,作为「每个调用最多一条 REFUND」的 DB 级兜底;禁止使用 MySQL 不支持的 partial unique;补绕服务层直写的 `IntegrityError` 测试,并确认同一调用的 CONSUME 与 REFUND 可共存。
- **P2-2** 已处理:在稳定 MySQL 上跑一次完整 `manage.py test` 全绿并留证;测试文档标注「并发测试须在 MySQL 上跑」。
- **P3** 各项已在 `06-tasks.md` 对应任务(T-401 / T-501)或作为约定/备忘登记,不遗失。
- `check` 0 issues、`test` 全绿、`init` 通过,证据入 `progress.md`。
+3 -3
View File
@@ -1,7 +1,7 @@
# cmhub 项目介绍(给管理层)
> 面向决策与汇报的项目概览。技术细节见同目录架构与需求文档。
> 日期:2026-07-02 | 阶段:Phase 2 计费核心(T-202 已完成)
> 日期:2026-07-02 | 阶段:Phase 3 起步(T-301 API Key 鉴权)
## 一句话概括
@@ -102,8 +102,8 @@
- M1 骨架已完成:Django + admin + 自定义 User + MySQL 8.4 已跑通。
- Phase 1 已完成 T-101~T-104:AI Provider 适配器、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析、配置审计与录制标题生成 smoke 已落地。
- T-202 已完成:PricingRule / ExchangeRate 与计费计算已落地,按操作、能力别名和可选分辨率计算点数成本。
- 下一步是 T-203:实现并发安全扣点 / 失败退点,保证余额不超扣、不为负。
- Phase 2 计费核心已完成:PricingRule / ExchangeRate、计费计算、并发安全扣点与失败退点已落地,并覆盖并发测试。
- 下一步是 T-301:实现对外 API Key 鉴权,确保生成/余额接口只认 Key、不认 Web session。
---
*更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。*
+2 -2
View File
@@ -1,6 +1,6 @@
# cmhub · 一页汇报版
> 自助用户端 + 计费型 AI 能力网关 + 运营后台 | 2026-07-02 | Phase 2 计费核心
> 自助用户端 + 计费型 AI 能力网关 + 运营后台 | 2026-07-02 | Phase 3 起步
## 电梯陈述(30 秒)
@@ -40,7 +40,7 @@
## 进度
M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;T-202 已落地计费规则、汇率和计算函数。下一步做并发安全扣点 / 退点。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。
M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;Phase 2 计费核心已完成到并发安全扣点 / 退点。下一步做 API Key 鉴权。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。
---
*详见 `project-brief.md`(完整介绍)。*
+40
View File
@@ -459,3 +459,43 @@
- 定价规则绑定能力别名字符串,不外键到具体 `AiModel`;后台切换 `ModelAlias` 指向不改变价格。
- T-202 不实现扣点、退点、充值订单或 API 编排;这些留给 T-203/T-304/T-305/T-302。
- 下一步:领取 T-203 并发安全扣点 / 退点(billing 层)。
## 2026-07-02 T-203 并发安全扣点 / 退点(billing 层)
- 状态:DONE
- 变更:
- 新增 `apps/billing/services.py`:`precharge_call()` 在事务内 `select_for_update()` 锁 `UserWallet` 行,余额足够才预扣,写 `CallRecord(status=pending)` 与 `PointsLedger(consume, -N)`;余额不足抛 `InsufficientPointsError(code="insufficient_points")`,不写调用/流水。
- 新增 `mark_call_success()`:上游成功后只更新调用记录为 `success` 与结果摘要/耗时,不再改余额。
- 新增 `refund_call_points()`:失败退点锁 `CallRecord`,同一调用只写一条 `refund` 流水并恢复余额;重复调用幂等,不重复加点;成功调用不能走失败退点。
- 扩展 `apps/billing/tests.py`:覆盖预扣写账、余额不足不写账、失败退点幂等、成功后拒绝失败退点、2 线程并发扣同一钱包不超扣不为负。测试数据改成唯一用户名/别名,避免远程 `test_cmhub` 断线残留造成重复键假失败。
- 同步更新 `README.md`、`docs/00-ai-start-here.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`、`docs/project-brief.md`、`docs/project-onepager.md`:Phase 2 完成,下一步 T-301。
- 验证:
- `py -3.12 -m py_compile apps\billing\services.py apps\billing\tests.py`:通过。
- `py -3.12 manage.py test apps.billing.tests.BillingServiceTests --noinput --keepdb`:通过,4 tests OK。
- `py -3.12 manage.py test apps.billing.tests.ConcurrentDebitTests --noinput --keepdb`:通过,1 test OK。
- `py -3.12 manage.py check`:通过,0 issues。
- `py -3.12 manage.py makemigrations --check`:通过,No changes detected。
- `py -3.12 -m compileall apps`:通过。
- `git diff --check`:通过,仅 Windows CRLF 提示。
- `./init.ps1`:通过,依赖同步与基础检查正常。
- `py -3.12 manage.py test --noinput --keepdb`:未作为绿灯;发现 43 tests,跑到 42 tests 后远程 MySQL 连接超时,导致并发测试线程拿不到连接,测试库 flush/teardown 也断线。
- `py -3.12 manage.py test apps.billing --noinput --keepdb` / 不带 `--keepdb`:未作为绿灯;远程 MySQL 在 `PricingCalculationTests` 或测试库创建/销毁阶段超时/断线。T-203 新增的服务测试与并发测试已单独通过。
- 阻塞:无代码阻塞。远程 MySQL `43.128.3.240:3306` 对完整测试仍间歇超时/重置,并且失败后可能保留脏 `test_cmhub`;后续建议优先用 `--keepdb` + 唯一测试数据分 app 验证,必要时清理测试库后再跑全量。
- 决策:
- T-203 只做 billing 层扣点/退点服务,不做 DRF 认证、生成接口编排、充值订单或支付回调。
- 失败退点以 `CallRecord` 行锁 + 已有 refund 流水检查实现幂等,不给 `CallRecord` 增加 refunded 状态。
- 下一步:领取 T-301 API Key 鉴权(DRF Authentication)。
## 2026-07-02 Phase 2 计费核心审核(Claude Code review,非任务)
- 状态:DONE(审核完成)
- 变更:新增 `docs/phase-2-review.md`;`06-tasks.md` 新增 T-204 加固任务;`docs/README.md` 导航登记。
- 审核结论:**T-201~203 验收全部达标,质量为三阶段最高,零 P1**。§8 资金安全硬约束逐条核对通过——先扣后调/失败必退(precharge/mark_success/refund 三原语对齐 4.1 时序)、并发安全(事务 + `select_for_update` 锁 wallet + DB 级 `points_balance>=0`)、退点幂等(锁 CallRecord 行 + 查 REFUND 流水去重)、全程留痕(ledger/call_record admin 只读=只追加)、wallet 余额 admin 只读(后台无法绕过账本)、ApiKey sha256+hmac.compare_digest+prefix+hash 脱敏、计费按别名解耦模型 + 分辨率 wildcard、Decimal floor 计费、CallRecord 无 raw 字段、错误码 api.md 对齐(insufficient_points→402/no_pricing_rule→400)。
- 关键证据:`ConcurrentDebitTests` 用真线程 + Barrier 强制并发扣同一钱包,**在 MySQL `test_cmhub` 上单独跑过(1 test OK)**——`select_for_update` 真生效,不是 SQLite 假绿。DB 级 CHECK/Unique 约束全部落到迁移文件。
- 发现(详见 `phase-2-review.md`):
- P1:无。
- P2-1:退点幂等缺 DB 级兜底(无「每 ref_call 最多一条 REFUND」约束,锁内安全但无 backstop);T-204 应加 MySQL 可落地的 `ref_call + change_type` 复合唯一约束 + 直写 IntegrityError 测试,不使用 MySQL 不支持的 partial unique / 条件唯一约束。
- P2-2:完整测试套件从未单次全绿(远程 MySQL 间歇超时/重置),靠分 app 子集通过佐证;建议在稳定 MySQL 上跑一次完整全绿留证,并标注「并发测试须在 MySQL 上跑,SQLite 会假绿」。
- P3(登记):手工调点服务未实现(模型层 reason 守卫已备好、wallet admin 已锁死=安全中间态)→ T-401;扣点热路径 wallet get_or_create 掩盖钱包缺失 → T-501;锁顺序约定;只追加靠 admin+无 update 保证(DB 无触发器);OperationType 双份枚举 DRY。
- 未本地复跑:审核机无 python3.12,结论基于静态审查(users/billing models、services、pricing、两 app admin、tests、全部迁移、settings)+ codex 执行记录(T-201 34 tests / T-202 billing 10 tests / T-203 BillingServiceTests 4 + ConcurrentDebitTests 1 均单独在 MySQL 通过;完整套件因连接问题未单次全绿,失败点均为连接/建库超时非断言失败)。
- 下一步:codex 可领 T-204 做 P2 加固(无 P1,不阻塞 T-301)。