# 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()` 的经典"检查后使用"竞态: ```python 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`(共享随机数生成器)与"单据是否已存在"耦合**: ```python 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` → 只消费剩余几次 → **后续新单参数全不同** **修复**:每张单用**只依赖单号**的独立随机源: ```python 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() 取名,理论上有竞态) |