Files
dealerhub/PROGRESS_AGI_ITERATION_3.md
T

306 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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() 取名,理论上有竞态) |