Files
dealerhub/PROGRESS_AGI_ITERATION_2.md

318 lines
12 KiB
Markdown
Raw Permalink 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 自主迭代报告(第 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 个测试同时失败