# dealerhub · AGI 自主迭代报告(第 2 轮) > **接续**:第 1 轮(`PROGRESS_AGI_ITERATION_1.md`)修复 9 个缺陷后,本轮处理其"下一轮候选"里的 > 全局异常处理器与前端校验,并在过程中**又发现 3 个新缺陷**(含一个"前端校验形同虚设"的隐蔽问题)。 > **回归**:410 passed / 2 skipped(第 1 轮 393 → +17),`check` 0 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 元**的销售单并成功过账。 **教训**: 1. UI 控件的 `min/max` 是"输入辅助",**不是校验**——它会掩盖而非暴露非法输入; 2. 正确做法:`min` 允许非法值(这里设 0),让非法值如实呈现,**由显式校验函数拦截并标红**; 3. **前端校验永远不能作为唯一防线**——后端必须独立校验同类约束。 修复后实测:数量值如实显示 `0`、输入框标红、提交被拦并提示"需 ≥0.001 且 ≤10 亿"。 --- ## 二、修复内容 ### 1. 全局异常处理器(`apps/core/exceptions.py`) 之前每个视图都要手写 try/except 映射业务异常,**漏一处就是 500**。现在收敛到一处: ```python 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`) ```python 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 金额、数量上限、正常路径回归 | **兜底安全性专项**: ```python 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 | | 视图层异常处理 | 每处手写 | 每处手写 | **统一收敛** | **累计修复的资损/高危问题**: 1. 负数量出库(凭空造库存) 2. 负单价(倒贴出货) 3. 极小金额单据(0.0016 元) 4. 0 金额单据 5. 6 个页面 500(缺列) 6. 未生成迁移导致风控规则失效 --- ## 七、方法论沉淀(本轮更新) 1. **UI 控件的约束 ≠ 校验**。`el-input-number` 的 `min` 会"纠正"输入而不是拒绝, 让非法值在用户眼中消失却在系统里生效——比不校验更危险(因为看起来有保护)。 2. **前端校验的唯一价值是"体验"**,安全性必须由后端独立保证。 本轮三个缺陷都是"前端看似有校验、后端也没有"的组合。 3. **异常处理集中化能消除一整类漏网**。统一 handler 之后, 新写的视图天然获得正确的 4xx/5xx 语义,不会因为"忘了 try/except"而 500。 4. **兜底 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` | 套餐变更 | 设计三条原则: 1. **只记"有后果"的操作**(不是所有 CRUD,否则淹没在噪音里) 2. **写入失败绝不影响主业务**——整个 `log()` 包在 try/except 里,失败只记 warning 3. **携带足够追责上下文**:谁、何时、对什么、改了什么 **`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 | | 异常处理 | 各处手写 | 各处手写 | **统一收敛** | 统一收敛 | | 关键操作追溯 | ❌ 无 | ❌ 无 | ❌ 无 | **✅ 完整** | **三轮修复的资损/高危问题汇总**: 1. 负数量出库(凭空造库存) 2. 负单价(倒贴出货) 3. 极小金额单据(0.0016 元) 4. 0 金额单据 5. 6 个页面 500(缺列) 6. 未生成迁移导致风控规则失效 7. 前端校验被控件掩盖(看似有保护,实际没有) 8. 关键操作无审计(合规缺口) **工程方法沉淀**: - 守门测试必须先验证它能失败(pytest 版迁移检查假通过两次) - UI 控件的约束 ≠ 校验(`min` 会掩盖而非暴露非法输入) - 前端校验只负责体验,安全必须后端独立保证 - 异常处理集中化能消除一整类漏网 - 小步验证:一次改 4 个文件后一个语法错误让 211 个测试同时失败