# 客户对账单 API 分页与性能评估 本文档评估当前 `/api/v1/customers//statements/` 接口的分页能力与性能瓶颈,供前后端共同讨论优化方案。 --- ## 1. 当前现状 ### 接口行为 - 每次请求返回**该客户的全部对账记录**(销售单 + 退货单 + 收款单 + 外部业务依据) - 无分页参数,无日期过滤 - 排序固定:`occurred_at → recorded_at → source_id` 倒序 - 每条记录包含 `cumulative_amount`(滚动累计)和 `arrears_amount`(实时欠款) ### 数据规模参考 | 客户 | 销售单 | 退货单 | 收款单 | 总记录数 | 响应体积(估算) | |------|--------|--------|--------|----------|-----------------| | 木棉 | ~200 | 1 | ~50 | ~250 | ~100KB | | 腾飞纺织 | ~1,500 | 9 | ~92 | ~1,600 | ~800KB | | 张晓鹏 | ~3,100 | 6 | ~219 | ~3,350 | ~2MB | --- ## 2. 为什么当前无法直接分页 核心原因:**滚动累计字段(`cumulative_amount` / `arrears_amount`)依赖前序所有记录的计算结果。** ``` 结欠[n] = 结欠[n-1] + 本行应收 - 本行已收 ``` 要展示第 N 页的 `arrears_amount`,必须先计算前 N-1 页所有记录的累加值。这意味着: - 不能简单用 DB 的 `OFFSET/LIMIT` - 不能跳页 - 后端必须从第一条开始逐条计算 --- ## 3. 性能瓶颈分析 每次 API 调用的执行路径: ``` 1. 4 次 DB 查询(sales / returns / receipts / external_statements) └── 每次都 prefetch_related('items__product') 加载全部明细 2. Python 内存中合并 + 排序全部记录 3. 逐条遍历计算 running totals 4. 全量序列化为 JSON(含 items 明细数组) 5. HTTP 响应传输 ``` 对于张晓鹏(3350 条记录): - DB 查询:~200ms(4 次查询 + prefetch) - Python 排序 + 计算:~50ms - JSON 序列化:~300ms - 网络传输(2MB):取决于带宽 **`StatementRecordView`(单条查询)更严重**:为了查 1 条记录,构建了完整对账单再过滤。 --- ## 4. 优化方案对比 ### 方案 A:游标分页(推荐短期方案) **原理**:前端传 `page_size` + 上一页最后一条的 `cumulative_amount` 作为 cursor,后端从 cursor 继续累加。 ``` GET /customers/6/statements/?page_size=50 GET /customers/6/statements/?page_size=50&cursor=eyJjdW11bGF0aXZlIjoiMzE4OTEuMDAiLCJsYXN0X2lkIjoxODE0MX0= ``` | 优点 | 缺点 | |------|------| | 减少响应体积 | 后端仍需全量查询(但只序列化一页) | | 前端按需加载 | 不能跳页,只能顺序翻页 | | 向后兼容(不传参数 = 全量) | 需要前端配合改造 | **后端改动**:中等。排序 + running total 计算后,只返回 cursor 之后的 N 条。 --- ### 方案 B:日期范围过滤 **原理**:前端传 `date_from` / `date_to`,后端只查询范围内的记录。 ``` GET /customers/6/statements/?date_from=2026-01-01&date_to=2026-05-18 ``` | 优点 | 缺点 | |------|------| | 实现简单 | running total 仍需从历史第一条开始算 | | 减少返回数据量 | 或者放弃 running total 的准确性 | | 前端可做"按年/按月"切换 | | **后端改动**:低。在 DB 查询加 `occurred_at` 过滤即可。但 `cumulative_amount` 需要决定: - 选项 1:从第一条算到 date_to(准确但慢) - 选项 2:只在返回范围内累计(快但不连续) --- ### 方案 C:延迟加载 items 明细 **原理**:默认不返回 `items` 数组,前端需要时单独请求。 ``` GET /customers/6/statements/ → 不含 items GET /customers/6/statements/?include_items=true → 含 items(当前行为) GET /statements/record/?...&include_items=true → 单条含 items ``` | 优点 | 缺点 | |------|------| | 响应体积减少 50%+ | 前端需要额外请求获取明细 | | 后端改动极小 | 如果前端表格需要展开明细,交互变复杂 | | 完全向后兼容 | | **后端改动**:极低。序列化时根据参数决定是否包含 items。 --- ### 方案 D:预计算快照(长期方案) **原理**:同步时预计算每条记录的 `cumulative_amount` / `arrears_amount` 存入 DB,查询时直接分页。 | 优点 | 缺点 | |------|------| | 真正的 DB 级分页 | 需要新表或新字段 | | 查询性能最优 | 数据一致性维护复杂(任何单据变动需重算) | | 支持跳页 | 开发成本高 | **后端改动**:高。需要设计快照表 + 触发重算机制。 --- ### 方案 E:响应缓存 **原理**:对账单结果缓存 N 秒(或按数据版本号缓存),重复请求直接返回。 | 优点 | 缺点 | |------|------| | 零前端改动 | 数据有延迟(缓存过期前看不到最新) | | 后端改动极小 | 大客户首次请求仍然慢 | | 对高频刷新场景效果显著 | | --- ## 5. 推荐实施路径 ``` 第一步(立即可做):方案 C — 默认不返回 items,减少 50%+ 响应体积 第二步(短期):方案 B — 加日期范围过滤,前端做"按月/按年"切换 第三步(中期):方案 A — 游标分页,前端改为滚动加载 第四步(按需):方案 E — 缓存,应对高频刷新 ``` --- ## 6. 需要前端确认的问题 1. **对账单表格是否需要一次展示全部记录?** 还是可以接受分页/滚动加载? 2. **items 明细是否默认展示?** 还是用户点击展开时才加载? 3. **是否需要"按月/按年"切换?** 如果需要,running total 是否可以只在当前范围内累计? 4. **`cumulative_amount` / `arrears_amount` 是否是必须字段?** 如果前端不使用这两个字段,分页就变得简单很多。 5. **导出 Excel 功能是否需要全量数据?** 如果需要,导出可以走单独的异步接口。 --- ## 7. 当前接口响应结构(供参考) ```json { "counterparty": 6, "counterparty_name": "张晓鹏", "records": [ { "source_type": "external_sales_order", "source_label": "外部销售单", "source_id": 18141, "occurred_at": "2026-05-18", "recorded_at": "2026-05-18T21:45:03Z", "status": 2, "status_label": "已同步", "positive_amount": "2436.00", "negative_amount": "0.00", "cumulative_amount": "31891.00", "current_balance": "780454.00", "arrears_amount": "748563.00", "remarks": "...", "items": [ ... ] } ], "summary": { "positive_total": "26981509.80", "negative_total": "26201055.80" } } ```