12 KiB
dealerhub · AGI 自主迭代报告(第 2 轮)
接续:第 1 轮(
PROGRESS_AGI_ITERATION_1.md)修复 9 个缺陷后,本轮处理其"下一轮候选"里的 全局异常处理器与前端校验,并在过程中又发现 3 个新缺陷(含一个"前端校验形同虚设"的隐蔽问题)。 回归:410 passed / 2 skipped(第 1 轮 393 → +17),check0 issues,迁移全绿,API 权限矩阵无异常。
一、本轮发现的缺陷
| # | 缺陷 | 严重度 | 复现 | 状态 |
|---|---|---|---|---|
| 11 | 前端数量校验被控件掩盖 | 🟠 高 | 数量填 0 → 控件自动改成 0.0001 → 成功过账 0.0016 元的单据 |
✅ 已修 |
| 12 | 后端接受极小金额单据 | 🟠 高 | 数量 0.0001 + 单价 15.5 → 金额 0.0016 元落库并过账 |
✅ 已修 |
| 13 | 0 金额单据可通过 | 🟡 中 | 单价填 0 → 金额 0 的单据 | ✅ 已修 |
缺陷 11 的细节(最值得记录)
我在前端加了"数量必须 > 0"的校验,并把 el-input-number 的 :min 设成 0.0001,
以为双重保险。浏览器实测却发现:填 0 仍然提交成功。
排查结果:el-input-number 的 :min 语义不是"拦截",而是自动纠正——
用户输入 0,控件在 change 时把它改成 0.0001,然后我的 isValidQty 检查的是
"已经过控件修正的值",自然通过。最终落库一张 0.0016 元的销售单并成功过账。
教训:
- UI 控件的
min/max是"输入辅助",不是校验——它会掩盖而非暴露非法输入; - 正确做法:
min允许非法值(这里设 0),让非法值如实呈现,由显式校验函数拦截并标红; - 前端校验永远不能作为唯一防线——后端必须独立校验同类约束。
修复后实测:数量值如实显示 0、输入框标红、提交被拦并提示"需 ≥0.001 且 ≤10 亿"。
二、修复内容
1. 全局异常处理器(apps/core/exceptions.py)
之前每个视图都要手写 try/except 映射业务异常,漏一处就是 500。现在收敛到一处:
REST_FRAMEWORK = {
"EXCEPTION_HANDLER": "apps.core.exceptions.api_exception_handler",
}
映射表:
| 异常 | HTTP | code |
|---|---|---|
InvalidLineQuantity / InvalidLinePrice |
400 | invalid_quantity / invalid_price |
BelowMinPrice |
400 | below_min_price |
InsufficientStock |
400 | insufficient_stock |
CreditLimitExceeded(销售/商城) |
402 | credit_limit_exceeded |
QuotaExceeded(billing/ai) |
403 | quota_exceeded(含 upgrade_url) |
ProductNotAuthorized |
403 | product_not_authorized |
StorefrontError |
400 | invalid_order |
| 未处理异常 | 500 | server_error(不含栈/原始消息) |
兜底安全性:未知异常记完整日志到 dealerhub.api,但返回给客户端的只有
{code, detail, exc_type}——测试专门验证了"异常消息里的数据库连接串不会外泄"。
2. 视图层简化
销售/采购/商城/AI 四个视图模块删除了重复的异常分支,代码更短、行为一致:
apps/sales/views.py −40 行(删 4 个 except 分支)
apps/purchase/views.py −20 行
apps/storefront/views.py −10 行
apps/ai/views.py −16 行
3. 数量/金额上下界(apps/core/services.py)
MIN_LINE_QUANTITY = Decimal("0.001") # 新增:避免极小金额无效单据
MAX_LINE_QUANTITY = Decimal("1000000000")
MAX_LINE_PRICE = Decimal("1000000000")
单据级守门:销售/采购创建时若 总金额 <= 0 → 拒绝(ValueError → 400)。
4. 前端校验修正(销售/采购开单页)
:min="0"(允许非法值如实呈现,不再自动纠正)isValidQty/isValidPrice与后端同口径(≥0.001、≤10 亿、单价 ≥0)- 非法行输入框标红(
.qty-warn) - 提交前本地拦截并给出明确原因(不再让用户等到 400)
三、验证证据
前端校验实测(Playwright)
输入数量 0
→ 输入框显示值:0 (修复前被自动改成 0.0001)
→ 标红:true
→ 点击"保存草稿并过账"
→ 提示:"有 1 行数量不合法(需 ≥0.001 且 ≤10 亿)"
→ 数据库无新单据
脏数据清理
待清理脏单: [('XS202609110001', '0.0016')]
已删除 XS202609110001
剩余单据数: 9(全部为演示数据,无异常金额)
异常金额单据数: 0
回归与健康检查
$ python -m pytest tests/ -p no:cacheprovider
410 passed, 2 skipped in 16.45s
$ python manage.py check
System check identified no issues (0 silenced).
$ python scripts/check_migrations.py
✓ 无待生成迁移 · ✓ 所有迁移已应用 · ✓ 51 个模型字段一致 · ✓ 7 张关键表可查
$ python scripts/audit_api_permissions.py
权限矩阵无明显异常
匿名状态码分布:{200: 2, 400: 2, 401: 31, 403: 3, 404: 95, 405: 4}
四、新增测试
| 模块 | 用例 | 覆盖 |
|---|---|---|
test_exception_handler.py |
12 | 单元级映射(6 类业务异常 + 兜底 500 不泄漏)+ 集成级(4 条真实 API 路径) |
test_boundary_inputs.py |
+5 | 极小金额、0 金额、数量上限、正常路径回归 |
兜底安全性专项:
def test_handler_returns_structured_500_for_unknown(db):
resp = _call(RuntimeError("内部数据库连接串 postgres://user:pw@host/db 泄露了"))
assert resp.status_code == 500
assert resp.json()["code"] == "server_error"
assert "postgres://" not in str(resp.data) # 原始消息不外泄
五、变更文件
backend/
├── apps/core/exceptions.py [新] 全局异常处理器(映射表 + 兜底 500)
├── config/settings/base.py [改] +EXCEPTION_HANDLER 注册
├── apps/core/services.py [改] +MIN_LINE_QUANTITY、InvalidLineAmount
├── apps/sales/services.py [改] 单据总额 > 0 守门
├── apps/purchase/services.py [改] 同上
├── apps/sales/views.py [改] −40 行(异常分支收敛)
├── apps/purchase/views.py [改] −20 行
├── apps/storefront/views.py [改] −10 行
├── apps/ai/views.py [改] −16 行
└── tests/test_exception_handler.py [新] 12 例
frontend/src/
├── pages/SalesBills.vue [改] 校验函数 + min 修正 + 标红 + 提交前拦截
└── pages/PurchaseBills.vue [改] 同上
frontend/dist/ [重建]
六、两轮迭代的累计成果
| 指标 | 迭代前 | 第 1 轮后 | 第 2 轮后 |
|---|---|---|---|
| 测试用例 | 348 | 393 | 410 |
| 已知缺陷修复 | — | 10 | 13 |
| 审计脚本 | 0 | 2 | 2 |
| 视图层异常处理 | 每处手写 | 每处手写 | 统一收敛 |
累计修复的资损/高危问题:
- 负数量出库(凭空造库存)
- 负单价(倒贴出货)
- 极小金额单据(0.0016 元)
- 0 金额单据
- 6 个页面 500(缺列)
- 未生成迁移导致风控规则失效
七、方法论沉淀(本轮更新)
- UI 控件的约束 ≠ 校验。
el-input-number的min会"纠正"输入而不是拒绝, 让非法值在用户眼中消失却在系统里生效——比不校验更危险(因为看起来有保护)。 - 前端校验的唯一价值是"体验",安全性必须由后端独立保证。 本轮三个缺陷都是"前端看似有校验、后端也没有"的组合。
- 异常处理集中化能消除一整类漏网。统一 handler 之后, 新写的视图天然获得正确的 4xx/5xx 语义,不会因为"忘了 try/except"而 500。
- 兜底 500 要区分"给用户看的"和"给运维看的":日志里可以详细,
响应体里只留
code+ 通用提示,避免把内部细节(连接串、栈)带出去。
八、第 3 轮 · 审计日志(本轮追加)
发现的问题
AuditLog 模型在阶段 1 就定义好了(含 tenant/user/action/target/detail/ip/user_agent 完整字段),
但全项目搜索没有一处 AuditLog.objects.create —— 意味着过账、强制放行、改价这些
关键操作没有任何追溯记录。客户问"这张单谁改的价""谁批的超限放行"时答不上来,
对财务/合规场景是硬缺口。
实现
apps/core/audit.py —— 审计服务:
| 语义动作 | 触发点 |
|---|---|
post |
销售/采购过账 |
force |
信用超限强制放行、低于最低售价放行 |
cancel |
单据作废 |
price_override |
改价(记录原价→新价) |
plan_change |
套餐变更 |
设计三条原则:
- 只记"有后果"的操作(不是所有 CRUD,否则淹没在噪音里)
- 写入失败绝不影响主业务——整个
log()包在 try/except 里,失败只记 warning - 携带足够追责上下文:谁、何时、对什么、改了什么
apps/core/context.py —— 请求上下文透传(解决一个真实工程问题):
服务层(confirm_sales_bill 等)是纯函数,签名里加 request 会污染业务 API,
但审计需要 IP/UA。方案是中间件把 request 存进 contextvars.ContextVar
(不是 threading.local——ASGI 下协程会串请求),服务层通过 current_request() 读取。
验证(真实 HTTP)
清空旧审计
建单: 201 XS202609110002
过账: 200 state=confirmed
=== 审计日志(含 IP/UA)===
动作=post 单号=XS202609110002
用户=1 IP=127.0.0.1 UA='AuditVerify/1.0'
detail={'business_action': 'post', 'bill_no': 'XS202609110002',
'amount': '2.0000', 'customer': 'C001', 'lines': 1}
用户、IP、UA、单号、金额、客户、行数全部记录。
过程中的一个教训
接线时我一次改了 4 个文件再跑测试,结果一个 SyntaxError(query_logs 签名里
多写了一个 *)让 211 个测试同时失败。修正后立刻恢复 410 passed。
这印证了前两轮的方法论:小步验证。如果我是"改完所有文件、最后跑一次测试", 这个语法错误会和其他改动混在一起,定位成本高得多。
新增测试(+13)
| 用例 | 覆盖 |
|---|---|
| 销售/采购过账留痕 | 动作、单号、金额、客户正确 |
| 信用超限强制放行留痕 | 高风险动作可追责 |
| 低于最低售价放行留痕 | 记录原价与最低价 |
| 审计失败不影响主业务 | monkeypatch 让 create 抛异常,验证不中断 |
| request 上下文自动提取 | user / IP(含 X-Forwarded-For)/ UA |
| 按单据号查轨迹 | "这张单都发生了什么" |
| 审计租户隔离 | 别家租户看不到 |
| contextvar 透传 | 服务层拿到 IP/UA(无 request 参数) |
| contextvar 清除 | 请求结束后不串号 |
| 无请求上下文 | 定时任务场景仍能写审计 |
九、下一轮候选
| 优先级 | 项目 | 说明 |
|---|---|---|
| P0 | 真实 PostgreSQL 并发验证 | 起 PG 跑 -m postgres 用例,验证行锁下的超卖防护 |
| P1 | 审计日志查询界面 | 后端 query_logs 已就绪,前端加"操作轨迹"页 |
| P1 | 前端所有表单的校验审计 | 用同样方法(填非法值看是否真被拦)逐个页面检查 |
| P2 | 性能基线 | 万级商品/单据下的列表与报表响应时间 |
| P2 | 商城 token 过期与刷新 | 72 小时有效期,需验证过期后的用户体验 |
十、三轮迭代累计成果
| 指标 | 迭代前 | 第 1 轮 | 第 2 轮 | 第 3 轮 |
|---|---|---|---|---|
| 测试用例 | 348 | 393 | 410 | 421 |
| 修复缺陷 | — | 10 | 13 | 13(+1 审计缺失) |
| 审计脚本 | 0 | 2 | 2 | 2 |
| 异常处理 | 各处手写 | 各处手写 | 统一收敛 | 统一收敛 |
| 关键操作追溯 | ❌ 无 | ❌ 无 | ❌ 无 | ✅ 完整 |
三轮修复的资损/高危问题汇总:
- 负数量出库(凭空造库存)
- 负单价(倒贴出货)
- 极小金额单据(0.0016 元)
- 0 金额单据
- 6 个页面 500(缺列)
- 未生成迁移导致风控规则失效
- 前端校验被控件掩盖(看似有保护,实际没有)
- 关键操作无审计(合规缺口)
工程方法沉淀:
- 守门测试必须先验证它能失败(pytest 版迁移检查假通过两次)
- UI 控件的约束 ≠ 校验(
min会掩盖而非暴露非法输入) - 前端校验只负责体验,安全必须后端独立保证
- 异常处理集中化能消除一整类漏网
- 小步验证:一次改 4 个文件后一个语法错误让 211 个测试同时失败