215 lines
9.9 KiB
Markdown
215 lines
9.9 KiB
Markdown
# 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}}` 嵌套不引入资源耗尽 |
|