Files
dealerhub/PROGRESS_AGI_ITERATION_3.md

12 KiB
Raw Permalink Blame History

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(含并发用例),check 0 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):

  1. 创建路径改用 get_or_create
  2. 外层包 transaction.atomic() 保存点 —— 否则 IntegrityError 会把外层事务 标记为 aborted,后续任何查询都报 TransactionManagementError
  3. 捕获 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 个原因,逐个修但都没解决:

  1. 占位单方案(失败时留 cancelled 单)→ 无效
  2. 去掉外层 @transaction.atomic → 无效
  3. 历史库存只灌注一次 → 无效
  4. 限制历史单只给高额度客户 → 无效
  5. 历史单用 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 单据
关键操作追溯 ❌ ❌ ✅ 写 ✅ 写 + 查

累计修复的高危问题:

  1. 负数量出库(凭空造库存)
  2. 负单价(倒贴出货)
  3. 极小金额 / 0 金额单据
  4. 6 个页面 500(缺列)
  5. 迁移未生成导致风控失效
  6. 前端校验被控件掩盖
  7. 关键操作无审计
  8. 并发首次入库竞态(PG 实测)
  9. seed 幂等被共享 rng 破坏

八、下一轮候选

优先级 项目 说明
P0 生产库切到 PG 本机已跑通,服务器(192.168.5.7)也应切 PG;SQLite 不适合生产并发
P1 前端所有表单校验审计 用第 2 轮的方法(填非法值看是否真被拦)逐个页面过一遍
P1 审计日志保留策略 量大后需归档/清理策略(当前无限增长)
P2 性能基线 万级商品/单据下的列表与报表响应
P2 商城 token 过期体验 72 小时有效期,需验证过期后的引导
P2 并发场景扩展 额度并发占用、单据号并发生成(_generate_bill_no 用 count() 取名,理论上有竞态)