Files
junhong_cmp_fiber/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/design.md
break 67893617fe
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m33s
feat(退款): AUG26-006 补充当前退款套餐已用量与总量
补齐 PRD §2.3.1「退款管理补充字段」:退款列表、详情与导出新增
「当前退款套餐已用量」与「当前退款套餐总量」两个纯展示字段。

- 套餐定位口径与退款套餐失效保持一致,按优先级取唯一一条:
  冻结的 package_usage_id(且属于该订单)→ 订单主套餐 → 订单任一套餐,
  同级按标识升序。不按当前世代或当前生效套餐推断;不按套餐状态过滤,
  使退款后套餐转已失效时仍能回看用量。
- 列表与详情用固定两次查询批量解析(按标识、按订单),查询次数不随条数增长;
  详情复用同一函数。解析不到套餐或记录已物理删除时返回 0,不阻断读取。
- 导出新增两列并改用同一优先级的 LATERAL 取法,不再依赖只按 r.package_usage_id
  的 join——生产库 1296 条退款仅 157 条带该字段,旧取法会让多数行显示零值。
- 不改变退款金额校验、冻结实收、套餐失效、接续、停机与佣金回溯任何规则。

验证:测试库 junhong_cmp_test 实测冻结记录、订单主套餐回退、记录缺失返回 0 三项
解析场景与「4 条退款固定 2 次查询」;并以同批 43 条退款对拍 Go 解析器与导出 SQL,
口径不一致 0 条;导出 43 行列数与表头一致。无迁移、无接口路径变化。
2026-09-14 12:11:55 +08:00

4.9 KiB
Raw Blame History

Context

PRD §2.3.1 要求退款列表、详情与导出展示「当前退款套餐已用量/总量」。这两个字段是纯展示投影,已归档的 add-refund-methods-and-original-route-refunds 未包含它们,见 proposal.md「Why」。

现状事实(开工核对):

  • tb_refund_request.package_usage_id 可空:生产库 1296 条退款仅 157 条有值,测试库 43 条中 30 条有值。因此只读该列会让绝大多数退款显示零值,与 PRD「退款订单自动关联的唯一套餐」不符。
  • 既有 InvalidatePackagesForRefundinternal/service/package/activation_service.go)定位待失效套餐的口径是:优先 id = package_usage_id AND order_id = 订单,为空时取该订单全部套餐使用记录(再连带其加油包)。
  • 导出场景已 LEFT JOIN tb_package_usage AS pu ON pu.id = r.package_usage_id 并已取 pu.package_name,但该 join 只覆盖「已冻结 package_usage_id」的退款同样漏掉多数记录。
  • PackageUsage 的真实流量字段为 data_usage_mb(真已用)与 data_limit_mb(真总量),单位 MB。

Goals / Non-Goals

Goals:

  • 列表、详情与导出稳定返回当前退款套餐的真实已用量与总量,且同一退款多次查询结果一致。
  • 复用既有退款失效的套餐定位口径,使展示与系统实际失效的套餐一致。
  • 列表与导出不因这两个字段引入逐条查询。

Non-Goals:

  • 不新增退款申请时的套餐选择能力,也不改变 package_usage_id 的写入时机。
  • 不展示套餐名称、到期时间等其他套餐信息(导出既有「套餐名称」列保持不变)。
  • 不改变退款金额校验、冻结实收、套餐失效、接续、停机与佣金回溯规则。

Decisions

1. 套餐定位顺序:冻结记录 → 订单主套餐 → 订单任一套餐

展示口径与 InvalidatePackagesForRefund 对齐:

  1. refund.package_usage_id 指向且属于该退款关联订单的套餐使用记录;
  2. 否则该订单下 master_usage_id IS NULL 的主套餐使用记录;
  3. 否则该订单下任一套餐使用记录。

同一优先级内按 id ASC 取第一条。不按当前世代或当前生效套餐推断:换货后权益仍保留原订单关系(既有失效逻辑注释即如此说明),按世代推断会指向错误的资产。

第 3 步兜底只按 order_id 过滤、不按状态过滤,与失效逻辑一致;这也满足 PRD「当前可取得」——退款后套餐已转已失效status=4并写入 refund_id,此时仍需展示其用量。若加状态过滤,退款完成后字段会集体变零,与用途矛盾。

该顺序与失效口径的唯一差异失效会作用于该订单的全部套餐使用记录含加油包而展示取唯一一条「当前退款套餐」。PRD 2.3.16 明确当前业务一张订单仅购买一个套餐,因此取主套餐即代表;多套餐时取主套餐而非拼接,避免把加油包用量混算进主套餐口径。

备选与取舍曾考虑直接返回该订单全部套餐的汇总用量。否决理由PRD 只要求「唯一套餐」的两个字段,汇总会把加油包算入主套餐总量,与失效口径和用户预期都不一致。

2. 空值语义:解析不到即返回 0不阻断读取

套餐使用记录不存在、已物理删除或字段为空时返回 0。理由是这两个字段是展示增强任何情况下都不应让退款列表、详情或导出失败RefundResponse 既有字段多为值类型,用 0 表示「无数据」与既有风格一致。

3. 批量读取避免 N+1

列表与导出按「先收集本次退款申请集合的 package_usage_idorder_id,再批量查询套餐使用记录,最后在内存中按决策 1 的顺序择一」实现:

  • 一次查询 WHERE id IN (…) 覆盖来自 package_usage_id 的候选;
  • 一次查询 WHERE order_id IN (…) 覆盖订单候选;
  • 内存中按 id ASC 取每个退款申请的第一条命中。

因此每页列表与每次导出分片固定为 2 次套餐查询,与退款条数无关。

详情为单条退款,复用同一批量函数即可,无需特例。

Risks / Trade-offs

  • [导出列数变化] 新增两列会改变既有导出文件的列次序,下游若有按列序解析的脚本会错位 → 缓解:两列插入在既有「套餐名称」之后、refundExportRow 与表头同步调整;本次为新增列,按列名解析的下游不受影响。
  • [多套餐订单取主套餐] 订单意外存在多条套餐使用记录时只展示主套餐用量 → 缓解:与 PRD 2.3.16 的单套餐前提一致;若后续放开多套餐,需单独 Change 重新定义展示口径。
  • [不按状态过滤] 已失效套餐仍展示用量 → 这是刻意的:退款完成后正是需要回看被退套餐的用量,加状态过滤会使字段在退款后归零。