318 lines
12 KiB
Markdown
318 lines
12 KiB
Markdown
# 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 个测试同时失败
|