fix: add ledger refund uniqueness guard
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-02 08:37
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billing', '0002_exchangerate_pricingrule'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='pointsledger',
|
||||
constraint=models.UniqueConstraint(fields=('ref_call', 'change_type'), name='unique_ledger_change_type_per_call'),
|
||||
),
|
||||
]
|
||||
@@ -192,6 +192,10 @@ class PointsLedger(models.Model):
|
||||
condition=~Q(points_delta=0),
|
||||
name="points_ledger_points_delta_non_zero",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=("ref_call", "change_type"),
|
||||
name="unique_ledger_change_type_per_call",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=("user", "created_at")),
|
||||
|
||||
@@ -143,6 +143,41 @@ class BillingCoreModelTests(TestCase):
|
||||
balance_after=-1,
|
||||
)
|
||||
|
||||
def test_points_ledger_allows_consume_and_refund_but_rejects_duplicate_refund(self):
|
||||
call = CallRecord.objects.create(
|
||||
user=self.user,
|
||||
operation_type=CallRecord.OperationType.TITLE,
|
||||
alias="title-standard",
|
||||
model_used="gpt-5.5",
|
||||
points_cost=2,
|
||||
status=CallRecord.Status.FAILED,
|
||||
)
|
||||
|
||||
PointsLedger.objects.create(
|
||||
user=self.user,
|
||||
change_type=PointsLedger.ChangeType.CONSUME,
|
||||
points_delta=-2,
|
||||
balance_after=98,
|
||||
ref_call=call,
|
||||
)
|
||||
PointsLedger.objects.create(
|
||||
user=self.user,
|
||||
change_type=PointsLedger.ChangeType.REFUND,
|
||||
points_delta=2,
|
||||
balance_after=100,
|
||||
ref_call=call,
|
||||
)
|
||||
|
||||
with self.assertRaises(IntegrityError):
|
||||
with transaction.atomic():
|
||||
PointsLedger.objects.create(
|
||||
user=self.user,
|
||||
change_type=PointsLedger.ChangeType.REFUND,
|
||||
points_delta=2,
|
||||
balance_after=102,
|
||||
ref_call=call,
|
||||
)
|
||||
|
||||
def test_billing_models_are_registered_in_admin(self):
|
||||
self.assertIn(UserWallet, admin.site._registry)
|
||||
self.assertIn(ApiKey, admin.site._registry)
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
| 生成返回方式 | 同步 HTTP(无任务队列) | 已定 | MVP 简化;图片接口需调大网关/服务超时 |
|
||||
| 任务队列 | 暂不引入(Celery/RQ) | 待定 | V2 异步化时再评估 |
|
||||
| 部署方式 | Docker + Gunicorn(gthread) + Nginx,单体 | 待定 | MVP 先 `runserver`;生产 Nginx 按路径把 `/api/generate/*`(图片长请求)与用户端页面**分流到不同 gunicorn/worker 池**,避免图片阻塞拖慢页面(见 `04-architecture.md` 5.1) |
|
||||
| 测试 | Django 自带 `manage.py test`(unittest)/ 可选 pytest-django | 已定 | 先用内置 test runner,重点覆盖计费与回调 |
|
||||
| 测试 | Django 自带 `manage.py test`(unittest)/ 可选 pytest-django | 已定 | 先用内置 test runner,重点覆盖计费与回调;涉及 `select_for_update` 的并发扣点测试必须在 MySQL 上跑,SQLite 会忽略行锁导致假绿 |
|
||||
| 依赖管理 | 系统 Python 3.12 + pip + `requirements.txt` + `pyproject.toml` | 已定 | 不使用虚拟环境;Windows 用 `py -3.12`,Unix/WSL 用 `python3.12`;运行依赖仍由 `requirements.txt` 管理,`pyproject.toml` 只落地 `requires-python` 元数据;init 会显式校验解释器版本在 `>=3.12,<3.14` |
|
||||
|
||||
## 二、决策记录与演进
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@
|
||||
| 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() 原子扣减;并发测试不超扣、不为负;失败退点写流水;含测试 | 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 |
|
||||
| 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` 留证据 | BLOCKED |
|
||||
|
||||
## Phase 3 · 对外 API 与充值
|
||||
|
||||
|
||||
@@ -12,16 +12,16 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-02
|
||||
- 阶段:Phase 2 计费核心已完成 T-203;下一步进入 Phase 3 的 T-301 API Key 鉴权
|
||||
- 阶段:Phase 2 计费核心已完成 T-203;T-204 的 DB 约束加固已完成但完整全量测试受远程 MySQL 稳定性阻塞;该审核任务无 P1、不阻塞 T-301
|
||||
- 技术栈:系统 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-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 服务断言。
|
||||
- 生产代码:已有最小 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-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund
|
||||
- 测试:T-204 直接验证通过:`py_compile`、`manage.py check`、`makemigrations --check`、`migrate`、`showmigrations billing`、新增直写约束测试 1 test OK、`manage.py test apps.billing --noinput --keepdb`(16 tests OK)、`BillingServiceTests + ConcurrentDebitTests`(5 tests OK)、`compileall apps`、`git diff --check`(仅 Windows CRLF 提示)、`./init.ps1`。全量 `manage.py test --noinput --keepdb` 两次仍在 `apps.ai` 测试类建连接时因远程 MySQL `43.128.3.240:3306` 超时失败,未取得单次全绿。
|
||||
- 数据: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。支付商户密钥/证书仍缺真实值,但不阻塞 T-301;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-302/T-403。远程 MySQL 对全量测试存在间歇连接超时/重置,必要时重试或分 app 验证。
|
||||
- 当前 blocker:T-204 的 P2-2(完整 `manage.py test` 单次全绿)被远程 MySQL 间歇连接超时阻塞;T-204 的 P2-1 代码加固和 billing 测试已完成。支付商户密钥/证书仍缺真实值,但不阻塞 T-301;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-302/T-403。
|
||||
|
||||
## 当前目录要点
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
- 已完成: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 全量测试偶发连接超时/重置。
|
||||
- 当前 blocker:T-204 因远程 MySQL 全量测试未单次全绿处于 `BLOCKED`;P2-1 已完成,P2-2 待稳定 MySQL 验证。
|
||||
- 下一个可领取任务:**T-301 API Key 鉴权(DRF Authentication)**。
|
||||
|
||||
## 当前可运行内容
|
||||
@@ -75,7 +75,7 @@ python3.12 manage.py smoke_ai_generation image --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-301)。
|
||||
4. 在 `docs/06-tasks.md` 查看 T-204 blocker;若不等待稳定 MySQL 全量绿灯,可领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-301)。
|
||||
5. 将该任务状态改为 `DOING`。
|
||||
|
||||
## 维护规则
|
||||
|
||||
+27
@@ -499,3 +499,30 @@
|
||||
- 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)。
|
||||
|
||||
## 2026-07-02 T-204 Phase 2 计费核心审核加固
|
||||
|
||||
- 状态:BLOCKED(P2-1 已完成;P2-2 完整 `manage.py test` 单次全绿被远程 MySQL 连接稳定性阻塞)
|
||||
- 变更:
|
||||
- `apps/billing/models.py`:给 `PointsLedger` 增加 MySQL 可落地的 `UniqueConstraint(fields=("ref_call", "change_type"), name="unique_ledger_change_type_per_call")`,作为同一调用重复写同类型流水的 DB 级兜底;没有使用 MySQL 不支持的 partial unique / 条件唯一约束。
|
||||
- 新增迁移 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,已应用到当前 MySQL。
|
||||
- `apps/billing/tests.py`:新增直写测试,确认同一 `CallRecord` 的一条 `CONSUME` + 一条 `REFUND` 可共存,第二条 `REFUND` 抛 `IntegrityError`。
|
||||
- `docs/03-tech-stack.md`:测试说明补充「涉及 `select_for_update` 的并发扣点测试必须在 MySQL 上跑,SQLite 会忽略行锁导致假绿」。
|
||||
- `docs/06-tasks.md` / `docs/current-state.md`:记录 T-204 的 P2-1 完成、P2-2 因远程 MySQL 全量测试未单次全绿而阻塞;T-204 无 P1,不阻塞 T-301。
|
||||
- 验证:
|
||||
- `py -3.12 -m py_compile apps\billing\models.py apps\billing\tests.py`:通过。
|
||||
- `py -3.12 manage.py check`:通过,0 issues。
|
||||
- `py -3.12 manage.py makemigrations --check`:通过,No changes detected。
|
||||
- `py -3.12 manage.py migrate`:通过,应用 `billing.0003_pointsledger_unique_ledger_change_type_per_call`。
|
||||
- `py -3.12 manage.py showmigrations billing`:通过,`billing.0001/0002/0003` 均为 `[X]`。
|
||||
- `py -3.12 manage.py test apps.billing.tests.BillingCoreModelTests.test_points_ledger_allows_consume_and_refund_but_rejects_duplicate_refund --noinput --keepdb`:通过,1 test OK。
|
||||
- `py -3.12 manage.py test apps.billing.tests.BillingServiceTests apps.billing.tests.ConcurrentDebitTests --noinput --keepdb`:通过,5 tests OK。
|
||||
- `py -3.12 manage.py test apps.billing --noinput --keepdb`:通过,16 tests OK。
|
||||
- `Test-NetConnection 43.128.3.240 -Port 3306`:通过,`TcpTestSucceeded=True`。
|
||||
- `py -3.12 -m compileall apps`:通过。
|
||||
- `git diff --check`:通过,仅 Windows CRLF 提示。
|
||||
- `./init.ps1`:通过,依赖同步与基础检查正常。
|
||||
- `py -3.12 manage.py test --noinput --keepdb`:两次未作为绿灯;一次发现 44 tests,跑到 42 tests 后 `apps.ai.tests.AiModelEncryptionTests` 建连接超时;一次跑到 34 tests 后 `apps.ai` 多个 TestCase 建连接超时。失败点均为远程 MySQL `43.128.3.240:3306` 连接超时。
|
||||
- `py -3.12 manage.py test apps.users apps.billing --noinput --keepdb`:未作为绿灯;跑到 17/18 后 `ConcurrentDebitTests.setUp` 建连接超时。
|
||||
- 阻塞:当前没有代码阻塞;P2-1 已完成且相关测试通过。P2-2 需要稳定 MySQL 环境完成一次完整全量测试单次绿灯,当前远程实例仍在多连接/长测试期间间歇超时。
|
||||
- 下一步:若先处理 T-204 blocker,需要提供/切换稳定 MySQL 8.4 测试环境后重跑 `py -3.12 manage.py test --noinput`;若接受 T-204 无 P1 且不阻塞后续,可进入 T-301 API Key 鉴权。
|
||||
|
||||
Reference in New Issue
Block a user