306 lines
12 KiB
Markdown
306 lines
12 KiB
Markdown
# 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() 取名,理论上有竞态) |
|