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