Files
dealerhub/PROGRESS_AGI_ITERATION_2.md

12 KiB
Raw Permalink Blame History

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。现在收敛到一处:

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
视图层异常处理 每处手写 每处手写 统一收敛

累计修复的资损/高危问题:

  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 个测试同时失败