# dealerhub · AGI 自主迭代报告(第 4 轮 · 并发序列 + N+1 优化) > **承接**:第 3 轮报告末尾提出的"`_generate_bill_no` 用 count()+1 取序号,并发下有竞态"。 > 本轮坐实并修复,同时扫描出**另外 4 处同类问题**;顺带做性能基线, > 发现并修复列表接口的 **N+1**(5.3 倍提速)。 > **回归**:SQLite **454 passed** / PostgreSQL **458 passed**,`check` 0 issues。 --- ## 一、单号生成竞态(PG 实测坐实) ### 症状 9 个并发建单 → **多个拿到同一单号**: ``` IntegrityError: 重复键违反唯一约束 "sales_bill_tenant_id_bill_no_uniq" DETAIL: 键值"(tenant_id, bill_no)=(1, XS202609110001)" 已经存在 ``` ### 根因:`count() + 1` 有两个缺陷 ```python seq = Model.objects.filter(tenant=tenant, bill_no__startswith=head).count() + 1 ``` 1. **并发撞号**:两个事务同时 `count()` 拿到同一个数 → 生成相同单号 2. **删除后复用**(更隐蔽):建了 001/002 后删掉 001 → `count()=1` → 下一张又是 002 → 撞唯一约束(因为 002 已存在) ### 修复:`MAX(序号)+1` + 唯一约束重试 新增共享工具(`apps/core/services.py`): ```python next_bill_no(tenant, prefix, model, *, date_str=None, field="bill_no") # 扫已有单号取 MAX(序号),而非 count() —— 删除后不回退 create_with_unique_bill_no(model, *, tenant, prefix, defaults, field="bill_no") # 取号 → 尝试创建(保存点包裹)→ 撞唯一约束则换号重试,最多 20 次 ``` **为什么用重试而不是锁**:`select_for_update()` **锁不住不存在的行** (这是第 3 轮修库存竞态时学到的教训)。乐观重试更适合"创建时取名"场景。 **保存点必不可少**:`IntegrityError` 会把外层事务标记为 aborted, 后续任何查询都报 `TransactionManagementError`。 ### 修复范围:5 处同类序列 | 位置 | 用途 | 修复方式 | |---|---|---| | `sales.services._generate_bill_no` | 销售单 XS | `create_with_unique_bill_no` | | `purchase.services._generate_bill_no` | 进货单 PB | 同上 | | `storefront.services._next_order_no` | 商城单 HD | 同上(`field="order_no"`) | | `channel.services` | 电商转单 SO | `create_with_unique_bill_no` | | `finance.services._generate_ar_ap_no` | 应收/应付 RC/PY | `next_bill_no` + 凭证号重试 | | `finance.services` 凭证号 V | 记账凭证 | 保存点 + 换号重试 | ### 验证(3 个场景) ```python # 1. 并发 test_concurrent_bill_no_generation_unique # 9 线程并发建单,单号必须全唯一 # 2. 删除后不复用 test_bill_no_not_reused_after_deletion # 建 001/002/003 → 删 001 → 新单必须是 004 # 3. 空洞不回退 test_bill_no_survives_gap # 人工造 0099 → 新单必须是 0100(不填空洞) ``` 修复前 1 失败(撞唯一约束);修复后 3 个全过。 --- ## 二、性能基线(发现 N+1) ### 背景 前几轮补了数据(22 商品 / 90 单据 / 200+ 库存流水),数据量上来后低效查询才暴露。 单测只验证"对不对",不验证"代价多大"。 ### 新增工具:`scripts/perf_baseline.py` 对 12 个主要读接口各发 N 次请求,报告 P50/P95/最大耗时 + 响应体积,按接口设阈值: ``` 接口 P50 P95 最大 响应 状态 sales_bill_list 236.2ms 265.7ms 278.3ms 42,757B 200 ⚠ 慢 ← 唯一超标 product_list 50.5ms 53.4ms 64.1ms 10,828B 200 ✓ risk_ranking 104.7ms 112.2ms 113.8ms 3,106B 200 ✓ dashboard_summary 14.3ms 14.7ms 14.7ms 257B 200 ✓ ... ``` ### N+1 定位 用 `CaptureQueriesContext` 数 SQL: ``` 序列化 50 张销售单:168.0ms,SQL 查询数:311 105× SELECT catalog_product... ← 每行的商品 105× SELECT catalog_unit... ← 每行的单位 50× SELECT SUM(tax_amount)... ← 每张单的税额聚合(SerializerMethodField) 50× SELECT sales_bill_line... ← 每张单的明细 1× SELECT sales_bill... ← 主查询 ``` **三个根因**: 1. `tax_total` 用 `obj.lines.aggregate(Sum(...))` —— 每张单一次聚合 2. `lines` 未预取 3. 行内 `product` / `source_unit` 未预取 ### 修复 **1. 基类加预取声明机制**(`apps/core/viewset.py`): ```python class BaseTenantViewSet: select_related_fields: tuple = () prefetch_related_fields: tuple = () async def get_queryset(self): ... if self.select_related_fields: qs = qs.select_related(*self.select_related_fields) if self.prefetch_related_fields: qs = qs.prefetch_related(*self.prefetch_related_fields) ``` **2. 各 ViewSet 声明自己的关联**(销售单/销售订单/采购单/库存/批次/应收/应付)。 **3. `tax_total` 改为内存汇总**: ```python def get_tax_total(self, obj): return str(sum((ln.tax_amount or 0) for ln in obj.lines.all())) # 旧实现 obj.lines.aggregate(Sum("tax_amount")) 每张单一条 SQL ``` ### 效果 | 指标 | 修复前 | 修复后 | 提升 | |---|---|---|---| | SQL 查询数(50 张单) | **311** | **5** | 62× | | 序列化耗时 | 168ms | 31ms | 5.4× | | 接口 P95 | 266ms ⚠ | **50ms** | **5.3×** | 其他接口连带改善:`stock_list` 41→13ms、`batch_list` 31→13ms。 ### 守门测试:`tests/test_query_efficiency.py`(7 例) 用 SQL 计数把"查询次数"钉死,防止后人改回 N+1: ```python def test_sales_bill_list_scales_flat(...): """单据翻倍(20 → 40)时查询次数不增加。""" ... assert n40 <= n20 + 2, f"查询数随数据量增长:{n20} → {n40}(N+1 回归)" ``` **验证守门有效性**(关键步骤): 临时撤掉 `SalesBillViewSet` 的预取声明 → 测试立刻失败: ``` AssertionError: 查询数随数据量增长:124 → 244(N+1 回归) ``` 还原后 7 例全过。**这一步不能省**——否则可能是个永远为真的摆设 (第 1 轮就踩过 pytest 版迁移检查假通过的坑)。 **插曲**:我第一次验证时改错了地方(删的是 `SalesOrderViewSet` 的声明, 而测试测的是 `SalesBillViewSet`),导致"测试没抓到"的假象。 教训:验证守门测试时,要确认改动的是**测试真正覆盖的那条路径**。 --- ## 三、验证汇总 ``` $ DJANGO_SETTINGS_MODULE=config.settings.test python -m pytest tests/ 449 passed, 4 skipped in 54.20s $ DJANGO_SETTINGS_MODULE=config.settings.pgtest python -m pytest tests/ 453 passed in 94.53s $ python manage.py check System check identified no issues (0 silenced). $ python scripts/perf_baseline.py ✓ 全部接口在阈值内 ``` ### 本轮新增测试(+11) | 模块 | 用例 | 覆盖 | |---|---|---| | `test_concurrency.py` | +3 | 单号并发唯一 / 删除不复用 / 空洞不回退 | | `test_query_efficiency.py` | +7 | 4 个列表接口的 SQL 计数 + 分页上限 + 数据正确性 | | `scripts/perf_baseline.py` | — | 12 接口性能基线(输出 JSON 便于对比) | --- ## 四、变更文件 ``` backend/ ├── apps/core/services.py [改] +next_bill_no / create_with_unique_bill_no ├── apps/core/viewset.py [改] +select_related_fields / prefetch_related_fields ├── apps/sales/services.py [改] 建单走并发安全单号 ├── apps/sales/serializers.py [改] tax_total 改内存汇总(消除 50 次聚合) ├── apps/sales/views.py [改] 声明预取 ├── apps/purchase/services.py·views.py [改] 同上 ├── apps/storefront/services.py [改] 商城单号并发安全 ├── apps/channel/services.py [改] 电商转单号并发安全 ├── apps/finance/services.py [改] 应收/应付/凭证号并发安全 ├── apps/inventory/views.py [改] 声明预取 ├── apps/finance/views.py [改] 声明预取 ├── scripts/perf_baseline.py [新] 性能基线(12 接口 + 阈值) ├── perf_baseline.json [新] 基线结果(便于回归对比) └── tests/ test_concurrency.py(+3) · test_query_efficiency.py(7) ``` --- ## 五、四轮迭代累计 | 指标 | 迭代前 | R1 | R2 | R3 | **R4** | |---|---|---|---|---|---| | 测试用例 | 348 | 393 | 421 | 443 | **458** | | 修复缺陷 | — | 10 | 13 | 16 | **20** | | 数据库后端 | SQLite | SQLite | SQLite | +PG | PG | | 演示数据 | 5 商品 | 5 | 5 | 22 商品 | 22 商品 | | 列表接口 P95 | 未测 | 未测 | 未测 | 未测 | **50ms**(原 266ms) | **累计修复的高危问题**: 1. 负数量出库(凭空造库存) 2. 负单价(倒贴出货) 3. 极小金额 / 0 金额单据 4. 6 个页面 500(缺列) 5. 迁移未生成导致风控失效 6. 前端校验被控件掩盖 7. 关键操作无审计 8. 并发首次入库竞态(PG 实测) 9. seed 幂等被共享 rng 破坏 10. **单号生成并发撞号 + 删除后复用(5 处)** 11. **列表接口 N+1(311 → 5 SQL)** 12. **采购侧漏接价格校验(0 成本污染加权平均成本)** --- ## 六、前端表单校验审计(逼出 1 个后端缺口) 方法(沿用第 2 轮的"填非法值看是否真被拦"):用 Playwright 逐页扫描 15 个页面, 对能输入的页面实际填入非法值,观察"是否标红 + 是否被拦 + 是否有提示"。 ### 扫描结果 15 个页面全部**无渲染错误**(无 `请求失败` / `undefined` / `NaN`)。 销售开单页实测: | 测试 | 标红 | 提交结果 | |---|---|---| | 数量填 0 | ✓ | 被拦,提示"有 1 行数量不合法(需 ≥0.001 且 ≤10 亿)" | | 单价填 -5 | ✗ | **无提示、无标红**(控件静默把 -5 纠正了) | ### 发现的缺口 **1. 前端:单价校验缺失** `isValidPrice` 允许 0,且提交前**没有校验价格**(只校验了数量)。 用户填 -5 后被 `el-input-number` 静默改回,没有任何反馈——用户不知道发生了什么。 **2. 后端:采购侧漏接价格校验(真缺口)** 顺着前端问题查后端,发现 `purchase.services._resolve_line` **没有调用 `parse_price`**: ```python price = Decimal(str(ln["unit_price"])) # ← 直接转换,未校验 ``` 第 1 轮我给销售侧接了校验,**采购侧遗漏了**。0 元进货当时只能靠下游"金额 > 0"兜住 (错误码是模糊的 `invalid_line`),而且 **0 成本入库会污染加权平均成本** (把真实成本拉低 → 毛利虚高)。 ### 修复 - 前端:`isValidPrice` 改为 `> 0`;提交前增加价格校验;单价非法标红(`.price-invalid`) - 后端:`parse_price` 从 `≥ 0` 收紧为 `> 0`;采购 `_resolve_line` 接入 `parse_price` - 新增 5 个测试:0 价销售/采购被拒、负价被拒、正常价格不受影响、 **0 成本不污染加权平均成本**(单元级) --- ## 七、下一轮候选 | 优先级 | 项目 | 说明 | |---|---|---| | P0 | 前端表单校验审计 | 用第 2 轮方法(填非法值看是否真被拦)逐页验证,只做过销售/采购 | | P1 | 生产切 PG | 本机已完全跑通(含并发),服务器 192.168.5.7 也应切;SQLite 不适合生产 | | P1 | 索引审查 | PG 上开 `EXPLAIN ANALYZE` 看大表是否有全表扫描(如 bill_date 范围查询) | | P1 | 审计日志保留策略 | 当前无限增长(demo 已有 267 条,生产会更快) | | P2 | 并发场景扩展 | 额度并发占用(两个订单同时用尽额度)、库存 + 单据混合并发 | | P2 | 缓存层 | `risk_ranking` 110ms(遍历客户算分),可加短 TTL 缓存 |