12 KiB
dealerhub · AGI 自主迭代报告(第 3 轮 · PG 并发 + 数据补全 + 审计闭环)
承接:第 1/2 轮修完 13 个缺陷后,本轮处理其"下一轮候选"里的 P0(真实 PostgreSQL 并发验证) 与 P1(审计查询入口),并按操作员要求补全演示数据。 结果:PG 并发验证抓到 1 个生产级竞态 bug;演示数据从 5 商品/6 单据扩到 22 商品 / 15 客户 / 约 90 张单据 / 6 个月趋势;审计从"只写不读"补成完整闭环。 回归:SQLite 440 passed / PostgreSQL 443 passed(含并发用例),
check0 issues。
一、P0 · PostgreSQL 并发验证(抓到生产级 bug)
背景
第 1/2 轮的并发用例在 SQLite 下永远跳过(数据库级写锁,多线程不可靠)。 本轮发现本机已有 PostgreSQL 16(端口 5433),于是搭了完整的 PG 测试链路:
config/settings/pgtest.py PG 测试配置(继承 test.py 的加速设置)
scripts/setup_pg.py 一键建库 + 迁移(幂等,含密码探测)
conftest.py 非 PG 后端自动跳过 @pytest.mark.postgres
pytest.ini 注册 postgres marker
抓到的 bug:并发首次入库的竞态
IntegrityError: 重复键违反唯一约束
"inventory_stock_tenant_id_warehouse_id_product_id_uniq"
DETAIL: 键值"(tenant_id, warehouse_id, product_id)=(11, 11, 13)" 已经存在
根因:_get_locked() 的经典"检查后使用"竞态:
stock = Stock.objects.select_for_update().filter(...).first()
if stock is None: # ← 两个事务同时发现"不存在"
stock = Stock.objects.create(...) # ← 都去创建 → 撞唯一约束
关键点:select_for_update() 对"不存在的行"无法加锁——没有行可锁。
所以"首次并发入库同一商品"必然踩到。
修复(apps/inventory/services.py):
- 创建路径改用
get_or_create - 外层包
transaction.atomic()保存点 —— 否则IntegrityError会把外层事务 标记为 aborted,后续任何查询都报TransactionManagementError - 捕获
IntegrityError后重取(此时对手已提交,行必然存在)
_get_locked_batch()(批次账)有完全相同的问题,一并修复。
验证结果
并发出库:成功 3 次,失败 3 次({'InsufficientStock'}),剩余库存 2.0000
6 个并发请求抢 20 件库存(每次 6 件)→ 3 成功 / 3 被拒 / 剩余 2 —— 数学精确,零超卖。
新增用例:test_concurrent_oversell_blocked_across_products(3 商品 × 4 线程并发抢库存)。
二、演示数据补全(从"能跑"到"能演示")
扩充前后对比
| 维度 | 扩充前 | 扩充后 |
|---|---|---|
| 商品 | 5 | 22(4 品类 / 6 品类管理 / 3 档税率) |
| 客户 | 5 | 15(信用三档:高 5 / 中 7 / 低 3) |
| 销售单 | 6 | 约 90(10 张剧本 + 76 张历史) |
| 数据跨度 | 单月 | 6 个月(报表趋势有形状) |
| 库存流水 | 少量 | 200+ 条 |
| 批次账 | 2 | 10(含近效期与正常期) |
| 历史回款 | 0 | 28 笔(AI 回款周期因子的数据源) |
| 商城账号 | 5 | 15 |
数据设计要点
商品档案特意覆盖系统能力矩阵:
- 多单位换算(瓶/箱、袋/箱、瓶/桶)
- 批次效期(鲜奶 21 天、酸奶 21 天、面包 7 天)
- 称重商品(散装冰糖、散装大米,基本单位 kg)
- 三档税率(13% 标准 / 9% 农产品 / 0)
- 最低售价管控(高毛利品与促销品)
客户画像为 AI 风控服务:
- 优质客户(额度 4-8 万):历史与近期回款都 7-15 天
- 普通客户:20-30 天,持平
- 风险户 C1005:历史 10-15 天很快,近期拖到 60-85 天 → 三重因子命中
验证的风险分区分度:
城东批发部(风险户) 75.0 high 未结 ¥4,484.00 ← 目标效果
大学城食堂采购 38.6 low 未结 ¥16,906.50
好邻居便利店 连锁 35.0 low 未结 ¥5,154.00
扩充前的失败版本:所有客户都是 35.0 low(因为没有任何回款历史, AI 的"回款周期漂移"因子永远"样本不足")——这是数据设计缺失,不是算法问题。
报表验证
商品销售排行 TOP5 客户贡献排行 TOP5
1. 光明 优倍鲜奶 ¥9,755 1. 大学城食堂采购 ¥13,906 (5 单)
2. 金龙鱼调和油 ¥6,821 2. 西城区实验中学 ¥11,690 (9 单)
3. 金龙鱼大米 ¥6,448 3. 社区便利-张老板 ¥6,794 (7 单)
应收账龄(五桶有分布) 销售月度趋势
0-30: ¥42,616 ███████ 2026-04: 13 单 ¥2,629
31-60: ¥11,454 ██ 2026-05: 19 单 ¥6,850
61-90: ¥7,578 █ 2026-06: 13 单 ¥7,830
91-180: ¥17,723 ███ 2026-07: 15 单 ¥5,854
181-365:¥8,000 █ 2026-08: 21 单 ¥20,492
三、修复 seed_demo 幂等(隐蔽的非确定性)
症状
test_seed_demo_idempotent 失败:重复执行后销售单从 86 变成 96。
排查过程(值得记录)
我先后假设了 5 个原因,逐个修但都没解决:
- 占位单方案(失败时留 cancelled 单)→ 无效
- 去掉外层
@transaction.atomic→ 无效 - 历史库存只灌注一次 → 无效
- 限制历史单只给高额度客户 → 无效
- 历史单用
force=True绕过额度 → 无效
转折点是放弃推理、改用数据说话:写临时脚本对比两次执行的具体单号差异, 再打印跳过原因,终于看到真相:
· 历史单 XS-H202608-016 跳过:CreditLimitExceeded ...
· 历史单 XS-H202607-010 跳过:CreditLimitExceeded ...
但真正的根因比这更深 —— rng(共享随机数生成器)与"单据是否已存在"耦合:
for seq in range(1, n_bills + 1):
if SalesBill.objects.filter(bill_no=bill_no).exists():
continue # ← 跳过时**不消耗 rng**
customer = rng.choice(...) # ← 后续单拿到的随机值全部错位
第一次执行:所有单都新建 → 消费 N 次 rng
第二次执行:已存在的单 continue → 只消费剩余几次 → 后续新单参数全不同
修复:每张单用只依赖单号的独立随机源:
def _rng_for(bill_no: str):
return random.Random(f"dealerhub-seed-{bill_no}")
这样任何一张单的参数都是确定的,与"之前有没有单被跳过"完全无关。
教训:
- 幂等操作里不能有共享的可变状态(随机数生成器、计数器、游标)
- 幂等要按"输入"确定,不能按"执行顺序"确定
- 推理卡住时尽早转向数据(打印具体差异 > 反复猜测)
四、审计闭环(从"只写不读"到可视可查)
第 3 轮把审计写进了库,但没有查询入口 —— 写了看不到等于没写。
新增 API(apps/core/audit_views.py)
GET /api/v1/core/audit-logs/ 列表(按动作/时间/目标筛选)
GET /api/v1/core/audit-logs/summary/ 按动作统计(含高风险计数)
GET /api/v1/core/audit-logs/timeline/ 单张单据的完整轨迹
设计要点:
- 动作有中文标签(过账/管控放行/改价/套餐变更)+ 风险分级(high/medium/low/info)
- 只读:没有任何写接口(测试专门验证 POST/PUT/DELETE 被拒)——证据链完整
- 租户隔离;未知租户 400
踩到并修复的三个问题
1. 排序不稳定:同一秒内的多条记录 created_at 相同,-created_at 单独排序
返回顺序任意(实测 SEQ4, SEQ2, SEQ3, ...)→ 改为 -created_at, -id 双键;
轨迹用 id 正序(还原真实操作先后)。
2. 轨迹割裂:强制放行的 target_type 是 credit_limit(管控类型),
按 target_type=SalesBill 查轨迹时看不到那次放行 —— 而那恰恰是最需要追溯的动作。
修复:轨迹查询加 Q(detail__bill_no=target_id) 双向串联。
3. 审计信息不完整:放行记录的 detail 里没有 bill_no,导致上面的串联失效。
补上 bill_no 并加测试守住。
测试发现的"反直觉但正确"的顺序
轨迹时间线:['force', 'post'] ← 放行在前,过账在后
初看像 bug,实际是真实执行顺序:confirm_sales_bill 先做额度校验(可能放行),
再做库存扣减、生成应收,最后才写"过账"审计。
测试里明确记录这个事实,避免后人误改。
前端(AuditLogs.vue)
- 操作轨迹标签:动作/时间/单号筛选 + 列表 + 点击单号打开轨迹抽屉(时间线)
- 动作统计标签:总操作数 + 高风险计数 + 各动作占比条
- 实测:267 条记录渲染正常;筛"管控放行"→ 1 条, 详情显示"客户 C1005 | 额度 ¥6000 | 已用 ¥4484";轨迹抽屉正常
五、验证汇总
双后端回归
$ DJANGO_SETTINGS_MODULE=config.settings.test python -m pytest tests/
440 passed, 3 skipped in 47.14s # 3 个 PG 专用用例自动跳过
$ DJANGO_SETTINGS_MODULE=config.settings.pgtest python -m pytest tests/
443 passed in 90.49s # 含全部并发用例,零跳过
$ python manage.py check
System check identified no issues (0 silenced).
$ python scripts/check_migrations.py
✓ 无待生成迁移 · ✓ 所有迁移已应用 · ✓ 51 个模型字段一致 · ✓ 7 张关键表可查
新增测试(本轮 +23)
| 模块 | 用例 | 覆盖 |
|---|---|---|
test_concurrency.py |
+1 | PG 下多商品并发抢库存(此前 2 个用例转为 PG 专用) |
test_demo.py |
+3 / 改 3 | 数据规模、跨月份、AI 风险区分度、幂等 |
test_audit_api.py |
16 | 列表/筛选/统计/轨迹/隔离/只读/租户校验 |
scripts/setup_pg.py |
— | 一键 PG 环境(含密码探测) |
六、变更文件
backend/
├── config/settings/pgtest.py [新] PG 测试配置
├── scripts/setup_pg.py [新] PG 一键准备(探测+建库+迁移)
├── conftest.py [改] 非 PG 后端自动跳过 postgres 用例
├── pytest.ini [改] 注册 marker
├── apps/inventory/services.py [改] 修复并发创建竞态(保存点 + get_or_create)
├── apps/core/audit_views.py [新] 审计查询 API(3 端点)
├── apps/core/urls.py [改] 挂载审计路由
├── apps/sales/services.py [改] 放行审计补 bill_no
├── apps/core/management/commands/seed_demo.py [改] 22 商品/15 客户/历史单/回款画像
├── apps/core/management/commands/seed_demo.py [改] 幂等修复(独立 rng)
└── tests/ test_audit_api.py(16) · test_concurrency.py(+1) · test_demo.py(+3)
frontend/src/
├── pages/AuditLogs.vue [新] 操作轨迹页(列表+统计+轨迹抽屉)
├── router/index.js · layouts/MainLayout.vue [改] +/audit-logs
frontend/dist/ [重建]
七、三轮迭代累计
| 指标 | 迭代前 | 第 1 轮 | 第 2 轮 | 第 3 轮 |
|---|---|---|---|---|
| 测试用例 | 348 | 393 | 421 | 443(PG)/ 440(SQLite) |
| 修复缺陷 | — | 10 | 13 | 16 |
| 数据库后端 | SQLite | SQLite | SQLite | SQLite + PostgreSQL |
| 演示数据 | 5 商品 | 5 商品 | 5 商品 | 22 商品 / 90 单据 |
| 关键操作追溯 | ❌ | ❌ | ✅ 写 | ✅ 写 + 查 |
累计修复的高危问题:
- 负数量出库(凭空造库存)
- 负单价(倒贴出货)
- 极小金额 / 0 金额单据
- 6 个页面 500(缺列)
- 迁移未生成导致风控失效
- 前端校验被控件掩盖
- 关键操作无审计
- 并发首次入库竞态(PG 实测)
- seed 幂等被共享 rng 破坏
八、下一轮候选
| 优先级 | 项目 | 说明 |
|---|---|---|
| P0 | 生产库切到 PG | 本机已跑通,服务器(192.168.5.7)也应切 PG;SQLite 不适合生产并发 |
| P1 | 前端所有表单校验审计 | 用第 2 轮的方法(填非法值看是否真被拦)逐个页面过一遍 |
| P1 | 审计日志保留策略 | 量大后需归档/清理策略(当前无限增长) |
| P2 | 性能基线 | 万级商品/单据下的列表与报表响应 |
| P2 | 商城 token 过期体验 | 72 小时有效期,需验证过期后的引导 |
| P2 | 并发场景扩展 | 额度并发占用、单据号并发生成(_generate_bill_no 用 count() 取名,理论上有竞态) |