Files
dealerhub/PROGRESS_AGI_ITERATION_1.md
T

215 lines
9.9 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 自主迭代报告(第 1 轮)
> **触发**:用户要求"agi模式自己测试迭代"。
> **方法**:不写新功能,而是**系统性攻击自己的交付物**——全路由遍历、API 权限矩阵、
> 边界模糊测试、并发不变量、迁移一致性。目标是找出"我之前说的没问题"里没被验证的部分。
> **结果**:发现并修复 **9 个真实缺陷**(含 1 个资损级漏洞 + 1 个漏钱缺口),
> 新增 3 个测试模块(+45 用例)与 2 个可复用审计脚本。
> **回归**:393 passed / 2 skipped(PG 专用),`check` 0 issues,迁移全绿。
---
## 一、发现的缺陷清单
| # | 缺陷 | 严重度 | 影响 | 状态 |
|---|---|---|---|---|
| 1 | **负数量/零数量可建单** | 🔴 资损级 | 负数量出库 = **凭空增加库存**(反向出库);0 数量产生空单 | ✅ 已修 |
| 2 | **负单价可建单** | 🔴 资损级 | 倒贴出货,单据金额为负 | ✅ 已修 |
| 3 | **6 个页面 500**(dev 库缺 `tax_rate` 列) | 🟠 阻断 | 商品/库存/批次/销售/采购/商城订单页全部打不开 | ✅ 已修 |
| 4 | **`notify.AlertRule.rule_type` 漏生成迁移** | 🟠 隐患 | B1 的 `risk_score` 规则类型未落库,风控预警可能失效 | ✅ 已修 |
| 5 | **`page=0`/`page=-1` → 500** | 🟠 稳定性 | 负索引切片抛 ValueError | ✅ 已修 |
| 6 | **`page_size=0/-5` → 异常** | 🟡 稳定性 | 空切片/异常 | ✅ 已修 |
| 7 | **`lines` 传非数组 → 500** | 🟠 稳定性 | `'str' object has no attribute 'get'` | ✅ 已修 |
| 8 | **`lines` 元素非 dict → 500** | 🟠 稳定性 | 同上,在 `_load_refs` 阶段崩 | ✅ 已修 |
| 9 | **`round_to` 任意值被接受** | 🟡 数据正确性 | 抹零精度未白名单校验,非法值参与金额计算 | ✅ 已修 |
| 10 | **超大数量(21 位)** | 🟡 稳定性 | Decimal 精度/计算异常风险 | ✅ 已修(上限 10 亿) |
> 注:1/2/9/10 都属同一根因——**`resolve_line_quantity` / `resolve_line_price` 完全没有输入校验**,
> 只做了类型转换。修复集中在这两个函数,所有业务线(销售/采购/商城)同时受益。
---
## 二、修复内容
### 1. 输入校验层(`apps/core/services.py`)
新增三个校验异常与配套解析器,**收敛为单一入口**:
```python
parse_quantity(product, raw) # 非空 / 数字 / 正数 / ≤10 亿 / 有限数
parse_price(product, raw) # 数字 / ≥0 / ≤10 亿 / 有限数
resolve_round_to(raw) # 白名单 {0.01, 0.1, 1},其余拒绝
```
异常类型:`InvalidLineQuantity` / `InvalidLinePrice`,均携带 `product_code` + `reason`,
视图层翻译为 **400 + `code`**(前端可判别、可提示具体哪一行错)。
### 2. 视图层结构预校验(销售/采购建单)
在触碰数据库前先校验 `lines` 的结构(数组 + 每项是 dict + 有 product),
避免下游 `.get()` 抛 AttributeError → 500。
### 3. 分页防护(`apps/core/viewset.py`)
`page < 1` 归一为 1;`page_size < 1` 回落默认值。负索引不再进入切片。
### 4. 迁移补齐
- `catalog/sales/purchase` 的税率迁移:**之前只生成没应用**(dev 库缺列 → 6 页面 500)
- `notify/0003_alter_alertrule_rule_type`:B1 加 `risk_score` 时**漏了 makemigrations**
---
## 三、新增的审计工具(可复用)
### `scripts/check_migrations.py` —— 迁移健康检查
四项检查:待生成迁移 / 未应用迁移 / 模型字段 vs 真实库列 / 关键表存在性。
**为什么必须是脚本而不是 pytest**:我一开始写成 pytest 用例,然后用"注入一个未迁移字段"
来验证它能否抓到——**结果两次都假通过**。原因是 pytest-django 的测试库按**模型**建表
并绕过迁移状态,`MigrationAutodetector` 在 pytest 进程里返回空。
改成独立脚本后,同样的注入被立刻抓到:
```
[1] 待生成的迁移(makemigrations --check)
✗ 存在未生成的迁移:
Migrations for 'catalog':
apps\catalog\migrations\0004_product_probe_field.py
+ Add field probe_field to product
```
**这是本轮最重要的方法论收获**:守门测试必须**先验证它能抓到问题**,否则就是一个
不报错的摆设。项目里已经有两个这样的反面例子(pytest 版迁移检查、最初的二维码假设)。
### `scripts/audit_api_permissions.py` —— API 权限矩阵探测
枚举全部 URL(137 个可达端点),用四种身份发 GET,汇总状态码矩阵,标记异常:
```
匿名状态码分布:{200: 2, 400: 2, 401: 31, 403: 3, 404: 95, 405: 4}
正常请求状态码分布:{200: 24, 400: 3, 401: 2, 403: 3, 404: 95, 405: 10}
```
**结论:权限边界干净** —— 匿名只有 2 个公开端点可访问(ping / billing-plans),
31 个被 401 拦,无越权、无 500。
被 JWT 拒绝的 5 个端点都是**正确行为**(openapi 用 API Key、storefront 用商城 token)。
---
## 四、新增测试模块
| 模块 | 用例 | 覆盖 |
|---|---|---|
| `test_migration_integrity.py` | 5 | 模型字段可查询性、新 app 表存在性、迁移目录结构 |
| `test_concurrency.py` | 11(2 skip) | 库存不超卖/不为负、过账幂等、失败整单回滚、F() 自增不丢更新、可用量锁定 |
| `test_boundary_inputs.py` | 33 | 数量/单价/抹零/分页/畸形 lines/SQL 元字符/超长输入/AI 接口/商城接口 |
**并发测试的设计取舍**:SQLite 用数据库级写锁(实测 `database table is locked`),
真并发在测试里不可靠。因此拆成两层:
- **可跑的**:顺序化的"超量请求"(等价于并发中第二个请求的语义)+ F() 表达式不丢更新
- **PG 专用**:`@pytest.mark.postgres` 多线程用例,默认 skip,连 PG 时验证真并发
---
## 五、验证证据
### 修复前后对比(真实 HTTP,非单元测试)
```
=== 修复前:负数量会 201 建单(资损漏洞)===
=== 修复后验证 ===
quantity=-1 → HTTP 400
quantity=0 → HTTP 400
quantity=abc → HTTP 400
quantity=999999999999999999999 → HTTP 400
unit_price=-5 → HTTP 400
unit_price=abc → HTTP 400
round_to=abc → HTTP 400
page=0 → HTTP 200 (修复前 500)
page=-1 → HTTP 200 (修复前 500)
page=abc → HTTP 200
page_size=-5 → HTTP 200 (修复前异常)
```
### 资损漏洞封堵确认
```
库存基线 on_hand = 74.0000
尝试用负数量过账 → HTTP 400
库存复查 on_hand = 74.0000
✓ 库存未被篡改(资损漏洞已封堵)
```
### 全路由遍历(修复后)
```
products rows=3 hasError=false
stocks rows=2 hasError=false
batches rows=2 hasError=false
sales-bills rows=3 hasError=false
purchase-bills rows=1 hasError=false
storefront-admin rows=2 hasError=false
errorCount: 0
```
### 回归
```
$ python -m pytest tests/ -p no:cacheprovider
393 passed, 2 skipped in 15.56s
$ python manage.py check
System check identified no issues (0 silenced).
$ python scripts/check_migrations.py
✓ 无待生成迁移 · ✓ 所有迁移已应用 · ✓ 51 个模型字段一致 · ✓ 7 张关键表可查
```
---
## 六、变更文件
```
backend/
├── apps/core/services.py [改] +parse_quantity/parse_price/resolve_round_to
│ +InvalidLineQuantity/InvalidLinePrice
├── apps/core/viewset.py [改] 分页 page/page_size 下界防护
├── apps/sales/views.py [改] lines 结构预校验 + 数量/单价异常 → 400
├── apps/purchase/views.py [改] 同上
├── apps/sales/services.py [改] round_to 白名单校验
├── apps/storefront/services.py [改] lines 结构校验
├── apps/website/migrations/ [新] 目录 + __init__(结构一致性)
├── apps/website/models.py [新] 说明模块(无模型)
├── apps/notify/migrations/0003_* [新] rule_type 选项补齐
├── scripts/check_migrations.py [新] 迁移健康检查(4 项)
├── scripts/audit_api_permissions.py [新] API 权限矩阵探测
└── tests/
├── test_migration_integrity.py [新] 5 例
├── test_concurrency.py [新] 11 例(2 PG-skip)
└── test_boundary_inputs.py [新] 33 例
```
---
## 七、方法论沉淀
1. **"能跑通" ≠ "没漏洞"**。前几轮我用 curl 验证了"正常路径能跑",
但**畸形输入完全没测**——8 个缺陷都是畸形输入触发的。
2. **守门测试必须先验证它能失败**。pytest 版迁移检查假通过两次,
教训是:任何"守卫"都要用注入故障的方式证明它有效,否则是安慰剂。
3. **同一根因要一次性收敛**。数量/单价/抹零三个漏洞都源于"只转换不校验",
集中在 `core/services` 修,销售/采购/商城三条业务线同时受益。
4. **资损级校验要单独标记**。数量与单价的非负校验不是"输入合法性"问题,
是"防止凭空造库存/倒贴出货"的资金安全问题,值得在代码里显式写明。
---
## 八、下一轮迭代候选
| 优先级 | 项目 | 说明 |
|---|---|---|
| P0 | 真实 PostgreSQL 并发验证 | 起 PG 容器跑 `-m postgres` 用例,验证行锁下的超卖防护 |
| P0 | 前端表单侧校验 | 后端已拦,但前端应即时提示(避免用户填完才 400) |
| P1 | 全局异常处理器 | 把 `InvalidLineQuantity` 等统一映射,减少每个视图的 try/except |
| P1 | 审计日志接入 | 关键写操作(过账/改价/放行)记 AuditLog,支持事后追溯 |
| P2 | 性能基线 | 万级商品/单据下的列表与报表响应时间 |
| P2 | 打印模板注入面复测 | 用户可编辑模板,需再确认 `{{#each}}` 嵌套不引入资源耗尽 |