diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index 3ed3e00..0640714 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -1096,7 +1096,8 @@ "identity-access::数据范围拒绝", "order-refund-exchange::订单、退款与换货状态门禁", "order-refund-exchange::代理退款查询按所属店铺隔离", - "order-refund-exchange::退款终态事实与失败分类" + "order-refund-exchange::退款终态事实与失败分类", + "order-refund-exchange::退款展示当前退款套餐用量" ], "classification": "behavior" }, @@ -1108,7 +1109,8 @@ "identity-access::数据范围拒绝", "order-refund-exchange::订单、退款与换货状态门禁", "order-refund-exchange::代理退款查询按所属店铺隔离", - "order-refund-exchange::退款终态事实与失败分类" + "order-refund-exchange::退款终态事实与失败分类", + "order-refund-exchange::退款展示当前退款套餐用量" ], "classification": "behavior" }, diff --git a/docs/verification/context-reset/requirement-evidence.json b/docs/verification/context-reset/requirement-evidence.json index 9a48405..e208690 100644 --- a/docs/verification/context-reset/requirement-evidence.json +++ b/docs/verification/context-reset/requirement-evidence.json @@ -3327,5 +3327,36 @@ ], "exit_status": 0 } + }, + { + "capability": "order-refund-exchange", + "requirement": "退款展示当前退款套餐用量", + "spec": "openspec/specs/order-refund-exchange/spec.md", + "entries": [ + "/api/admin/refunds", + "/api/admin/refunds/{id}" + ], + "handler_consumer_job": [ + "internal/routes/refund.go", + "internal/handler/admin/refund.go", + "internal/exporter/refund_scene.go" + ], + "application_service_query": [ + "internal/service/refund/package_usage.go" + ], + "domain_state_amount": [ + "internal/model/dto/refund_dto.go", + "internal/model/package.go" + ], + "store_migration_config": [ + "internal/model/package.go" + ], + "verification": { + "command": "grep -n 'func (s \\*Service) loadRefundPackageUsages' internal/service/refund/package_usage.go", + "literal_output": [ + "internal/service/refund/package_usage.go:33:func (s *Service) loadRefundPackageUsages(ctx context.Context, refunds []*model.RefundRequest) (map[uint]refundPackageUsage, error) {" + ], + "exit_status": 0 + } } ] diff --git a/internal/exporter/refund_scene.go b/internal/exporter/refund_scene.go index 4b1d81d..862c387 100644 --- a/internal/exporter/refund_scene.go +++ b/internal/exporter/refund_scene.go @@ -2,6 +2,7 @@ package exporter import ( "context" + "strconv" "time" "gorm.io/gorm" @@ -38,7 +39,8 @@ func (s *RefundDataSource) Count(ctx context.Context, params ExportParams) (int, // Headers 返回退款记录导出表头。 func (s *RefundDataSource) Headers(context.Context, ExportParams) ([]string, error) { return []string{ - "退款单号", "代理店铺名称", "关联的支付订单号", "资产类型", "资产标识", "套餐名称", "原订单金额(元)", + "退款单号", "代理店铺名称", "关联的支付订单号", "资产类型", "资产标识", "套餐名称", + "当前退款套餐已用量(MB)", "当前退款套餐总量(MB)", "原订单金额(元)", "实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款方式", "冻结实收金额(元)", "渠道退款状态", "渠道退款流水号", "渠道退款金额(元)", "失败分类", "异常标记", "退款原因", "审批备注", @@ -80,6 +82,10 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse o.total_amount AS original_amount, o.actual_paid_amount AS refundable_amount, COALESCE(pu.package_name, items.package_names, '') AS package_name, + -- 当前退款套餐用量:与展示口径一致,按冻结套餐记录 → 订单主套餐 → 订单任一套餐 + -- 取唯一一条,且不按套餐状态过滤(退款后套餐已失效仍需展示其用量)。 + COALESCE(usage.data_usage_mb, 0) AS refund_package_used_mb, + COALESCE(usage.data_limit_mb, 0) AS refund_package_total_mb, COALESCE(ac.username, '') AS submitter_name, ai.provider AS approval_provider, ai.status AS approval_status, @@ -96,6 +102,21 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse FROM tb_order_item AS oi WHERE oi.order_id = r.order_id AND oi.deleted_at IS NULL ) AS items ON TRUE`). + Joins(`LEFT JOIN LATERAL ( + SELECT candidate.data_usage_mb, candidate.data_limit_mb + FROM tb_package_usage AS candidate + WHERE candidate.deleted_at IS NULL + AND ( + (r.package_usage_id IS NOT NULL AND candidate.id = r.package_usage_id AND candidate.order_id = r.order_id) + OR (candidate.order_id = r.order_id) + ) + ORDER BY + CASE WHEN r.package_usage_id IS NOT NULL AND candidate.id = r.package_usage_id THEN 0 + WHEN candidate.master_usage_id IS NULL THEN 1 + ELSE 2 END, + candidate.id ASC + LIMIT 1 + ) AS usage ON TRUE`). Order("r.id ASC"). Limit(limit). Offset(offset) @@ -112,6 +133,8 @@ func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offse formatRefundAssetType(item.OrderType), item.AssetIdentifier, item.PackageName, + strconv.FormatInt(item.RefundPackageUsedMB, 10), + strconv.FormatInt(item.RefundPackageTotalMB, 10), formatOptionalMoneyYuan(item.OriginalAmount), formatMoneyYuan(item.ActualReceivedAmount), formatOptionalMoneyYuan(item.RefundableAmount), @@ -167,6 +190,8 @@ type refundExportRow struct { OrderType string `gorm:"column:order_type"` AssetIdentifier string `gorm:"column:asset_identifier"` PackageName string `gorm:"column:package_name"` + RefundPackageUsedMB int64 `gorm:"column:refund_package_used_mb"` + RefundPackageTotalMB int64 `gorm:"column:refund_package_total_mb"` OriginalAmount *int64 `gorm:"column:original_amount"` ActualReceivedAmount int64 `gorm:"column:actual_received_amount"` RefundableAmount *int64 `gorm:"column:refundable_amount"` diff --git a/internal/model/dto/refund_dto.go b/internal/model/dto/refund_dto.go index c1208ac..f5f78a0 100644 --- a/internal/model/dto/refund_dto.go +++ b/internal/model/dto/refund_dto.go @@ -60,15 +60,19 @@ type RefundListRequest struct { // RefundResponse 退款申请详情响应 type RefundResponse struct { - ID uint `json:"id" description:"退款申请ID"` - RefundNo string `json:"refund_no" description:"退款单号"` - OrderID uint `json:"order_id" description:"关联订单ID"` - OrderNo string `json:"order_no" description:"订单号"` - AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(卡为 ICCID,设备优先使用 VirtualNo,缺失时使用 IMEI)"` - AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"` - IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"` - DeviceID *uint `json:"device_id,omitempty" description:"设备ID"` - PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"` + ID uint `json:"id" description:"退款申请ID"` + RefundNo string `json:"refund_no" description:"退款单号"` + OrderID uint `json:"order_id" description:"关联订单ID"` + OrderNo string `json:"order_no" description:"订单号"` + AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(卡为 ICCID,设备优先使用 VirtualNo,缺失时使用 IMEI)"` + AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"` + IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"` + DeviceID *uint `json:"device_id,omitempty" description:"设备ID"` + PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"` + // 当前退款套餐用量:按冻结套餐使用记录 → 订单主套餐 → 订单任一套餐的优先级解析, + // 只用于展示、查询与导出,不参与退款金额校验、套餐失效或佣金回溯。 + RefundPackageUsedMB int64 `json:"refund_package_used_mb" description:"当前退款套餐已用量(MB,真实流量;解析不到套餐时为0)"` + RefundPackageTotalMB int64 `json:"refund_package_total_mb" description:"当前退款套餐总量(MB,真实流量;解析不到套餐时为0)"` ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"` ShopName string `json:"shop_name,omitempty" description:"店铺名称"` ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"` diff --git a/internal/service/refund/package_usage.go b/internal/service/refund/package_usage.go new file mode 100644 index 0000000..3bf27d1 --- /dev/null +++ b/internal/service/refund/package_usage.go @@ -0,0 +1,136 @@ +package refund + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// refundPackageUsage 是退款展示所需的当前套餐用量。 +type refundPackageUsage struct { + UsedMB int64 + TotalMB int64 +} + +// loadRefundPackageUsages 批量解析每个退款申请的「当前退款套餐」用量。 +// +// 定位口径与退款套餐失效保持一致,按优先级取唯一一条: +// 1. 退款申请冻结的套餐使用记录(package_usage_id); +// 2. 该退款关联订单下主套餐使用记录(master_usage_id 为空); +// 3. 该退款关联订单下任一套餐使用记录。 +// +// 同一优先级内按使用记录标识升序取第一条,保证同一退款每次返回相同结果。 +// 不按当前生效套餐或当前世代推断,也不跨订单取套餐:换货后权益仍保留原订单关系, +// 按世代推断会指向错误的资产。 +// +// 用量的性质是「当前可取得」:不按套餐状态过滤。退款完成后套餐已转已失效, +// 此时仍需展示其用量;若按状态过滤,退款完成后字段会集体归零,与用途矛盾。 +// +// 实现固定为两次查询(按标识、按订单),与退款条数无关;解析不到时返回 0 而非报错, +// 使展示增强不阻断列表、详情或导出。 +func (s *Service) loadRefundPackageUsages(ctx context.Context, refunds []*model.RefundRequest) (map[uint]refundPackageUsage, error) { + result := make(map[uint]refundPackageUsage, len(refunds)) + usageIDs := make([]uint, 0, len(refunds)) + orderIDs := make([]uint, 0, len(refunds)) + seenUsage := make(map[uint]struct{}, len(refunds)) + seenOrder := make(map[uint]struct{}, len(refunds)) + for _, refund := range refunds { + if refund == nil || refund.ID == 0 { + continue + } + result[refund.ID] = refundPackageUsage{} + if refund.PackageUsageID != nil && *refund.PackageUsageID > 0 { + if _, exists := seenUsage[*refund.PackageUsageID]; !exists { + seenUsage[*refund.PackageUsageID] = struct{}{} + usageIDs = append(usageIDs, *refund.PackageUsageID) + } + } + if refund.OrderID > 0 { + if _, exists := seenOrder[refund.OrderID]; !exists { + seenOrder[refund.OrderID] = struct{}{} + orderIDs = append(orderIDs, refund.OrderID) + } + } + } + if len(usageIDs) == 0 && len(orderIDs) == 0 { + return result, nil + } + + // 第一次查询:按套餐使用记录标识取冻结记录。 + byUsageID := make(map[uint]*model.PackageUsage, len(usageIDs)) + // 第二次查询:按订单取全部候选,再在内存中按优先级择一。 + byOrderID := make(map[uint][]*model.PackageUsage, len(orderIDs)) + + if len(usageIDs) > 0 { + var usages []model.PackageUsage + if err := s.db.WithContext(ctx). + Select("id", "order_id", "master_usage_id", "data_usage_mb", "data_limit_mb"). + Where("id IN ?", usageIDs).Find(&usages).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款套餐使用记录失败") + } + for index := range usages { + byUsageID[usages[index].ID] = &usages[index] + } + } + if len(orderIDs) > 0 { + var usages []model.PackageUsage + if err := s.db.WithContext(ctx). + Select("id", "order_id", "master_usage_id", "data_usage_mb", "data_limit_mb"). + Where("order_id IN ?", orderIDs). + Order("id ASC").Find(&usages).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款订单套餐使用记录失败") + } + for index := range usages { + usage := &usages[index] + byOrderID[usage.OrderID] = append(byOrderID[usage.OrderID], usage) + } + } + + for _, refund := range refunds { + if refund == nil || refund.ID == 0 { + continue + } + usage := resolveRefundPackageUsage(refund, byUsageID, byOrderID) + if usage == nil { + continue + } + result[refund.ID] = refundPackageUsage{UsedMB: usage.DataUsageMB, TotalMB: usage.DataLimitMB} + } + return result, nil +} + +// resolveRefundPackageUsage 按优先级为单个退款申请择一当前套餐使用记录。 +func resolveRefundPackageUsage( + refund *model.RefundRequest, + byUsageID map[uint]*model.PackageUsage, + byOrderID map[uint][]*model.PackageUsage, +) *model.PackageUsage { + // 优先级一:冻结的套餐使用记录,且必须属于该退款关联订单(防止跨订单取值)。 + if refund.PackageUsageID != nil && *refund.PackageUsageID > 0 { + if usage, exists := byUsageID[*refund.PackageUsageID]; exists && usage.OrderID == refund.OrderID { + return usage + } + } + // 优先级二:订单主套餐;优先级三:订单任一套餐。候选已按标识升序,取首个命中。 + candidates := byOrderID[refund.OrderID] + if len(candidates) == 0 { + return nil + } + for _, usage := range candidates { + if usage.MasterUsageID == nil { + return usage + } + } + return candidates[0] +} + +// applyRefundPackageUsage 把解析结果写入响应字段。 +func applyRefundPackageUsage(response *dto.RefundResponse, usage refundPackageUsage) { + if response == nil { + return + } + response.RefundPackageUsedMB = usage.UsedMB + response.RefundPackageTotalMB = usage.TotalMB +} diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 3fa524e..67ceb3d 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -257,6 +257,10 @@ func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.Re if err != nil { return nil, err } + packageUsages, err := s.loadRefundPackageUsages(ctx, requests) + if err != nil { + return nil, err + } items := make([]dto.RefundResponse, 0, len(requests)) for _, r := range requests { @@ -264,6 +268,7 @@ func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.Re item.SubmitterName = submitterNames[r.Creator] applyApprovalSummary(item, approvalSummaries, r) item.Attempts = attemptResponses[r.ID] + applyRefundPackageUsage(item, packageUsages[r.ID]) items = append(items, *item) } @@ -314,6 +319,11 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, er return nil, err } resp.Attempts = attemptResponses[refund.ID] + packageUsages, err := s.loadRefundPackageUsages(ctx, []*model.RefundRequest{refund}) + if err != nil { + return nil, err + } + applyRefundPackageUsage(resp, packageUsages[refund.ID]) return resp, nil } diff --git a/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/.openspec.yaml b/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/.openspec.yaml new file mode 100644 index 0000000..a40cb63 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-14 diff --git a/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/design.md b/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/design.md new file mode 100644 index 0000000..3a38d60 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/design.md @@ -0,0 +1,64 @@ +## 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「退款订单自动关联的唯一套餐」不符。 +- 既有 `InvalidatePackagesForRefund`(`internal/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_id` 与 `order_id`,再批量查询套餐使用记录,最后在内存中按决策 1 的顺序择一」实现: + +- 一次查询 `WHERE id IN (…)` 覆盖来自 `package_usage_id` 的候选; +- 一次查询 `WHERE order_id IN (…)` 覆盖订单候选; +- 内存中按 `id ASC` 取每个退款申请的第一条命中。 + +因此每页列表与每次导出分片固定为 2 次套餐查询,与退款条数无关。 + +详情为单条退款,复用同一批量函数即可,无需特例。 + +## Risks / Trade-offs + +- **[导出列数变化]** 新增两列会改变既有导出文件的列次序,下游若有按列序解析的脚本会错位 → 缓解:两列插入在既有「套餐名称」之后、`refundExportRow` 与表头同步调整;本次为新增列,按列名解析的下游不受影响。 +- **[多套餐订单取主套餐]** 订单意外存在多条套餐使用记录时只展示主套餐用量 → 缓解:与 PRD 2.3.16 的单套餐前提一致;若后续放开多套餐,需单独 Change 重新定义展示口径。 +- **[不按状态过滤]** 已失效套餐仍展示用量 → 这是刻意的:退款完成后正是需要回看被退套餐的用量,加状态过滤会使字段在退款后归零。 diff --git a/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/proposal.md b/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/proposal.md new file mode 100644 index 0000000..0b8c05e --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/proposal.md @@ -0,0 +1,24 @@ +## Why + +PRD §2.3.1 要求退款管理列表、详情与导出新增「当前退款套餐已用量」和「当前退款套餐总量」,供审批与运营判断退款合理性;已归档的 `add-refund-methods-and-original-route-refunds` 漏掉了这两个纯展示字段,当前列表、详情与导出均不返回套餐使用情况。 + +## What Changes + +- 退款申请列表、详情与导出新增两个展示字段:当前退款套餐的真实已用量与真实总量(单位 MB)。 +- 「当前退款套餐」按既定退款失效口径解析:优先取退款申请冻结的套餐使用记录;未冻结时取该退款关联订单的套餐使用记录(优先主套餐,其次任一),与 `InvalidatePackagesForRefund` 的定位口径一致。 +- 两个字段只读展示:**BREAKING** 无(纯新增响应字段)。不改变退款金额校验、套餐失效、接驳下一套餐、停机评估、佣金回溯或渠道退款任何规则。 +- 套餐使用记录缺失或已物理删除时两个字段返回 0,不阻断列表、详情或导出。 + +## Capabilities + +### New Capabilities + +- 无。 + +### Modified Capabilities + +- `order-refund-exchange`: 退款列表、详情与导出的可观察字段增加当前退款套餐的真实已用量与总量。 + +## Impact + +影响退款查询投影与导出场景:`internal/model/dto/refund_dto.go`、`internal/service/refund/service.go`(列表与详情投影)、`internal/service/refund/attempt_query.go` 同目录新增套餐使用批量读取、`internal/exporter/refund_scene.go`。无迁移、无接口路径变化、无第三方交互;导出新增两列会改变既有导出文件的列次序与列数。 diff --git a/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/specs/order-refund-exchange/spec.md b/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/specs/order-refund-exchange/spec.md new file mode 100644 index 0000000..25289e2 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/specs/order-refund-exchange/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: 退款展示当前退款套餐用量 + +退款管理列表、详情与导出 SHALL 返回「当前退款套餐已用量」与「当前退款套餐总量」两个字段,取值为该套餐使用记录当前可取得的真实已用量与真实总量(单位 MB)。两个字段 MUST 仅用于展示、查询与导出,MUST NOT 参与或改变退款金额校验、冻结实收金额、套餐失效、接续下一套餐、停机评估、佣金回溯或渠道退款任何规则。 + +「当前退款套餐」MUST 按与退款套餐失效一致的口径解析,且 MUST 按下列优先级取唯一一条:退款申请冻结的套餐使用记录(其 `package_usage_id`);该退款关联订单下 `master_usage_id` 为空的主套餐使用记录;该退款关联订单下任一套餐使用记录。同一优先级内按使用记录标识升序取第一条,保证同一退款每次返回相同结果。解析 MUST NOT 按当前生效套餐或当前世代推断,也 MUST NOT 跨订单取套餐。 + +套餐使用记录不存在、已被物理删除或字段为空时,两个字段 SHALL 返回 0,且 MUST NOT 因此阻断列表、详情或导出。列表返回 MUST NOT 因逐条查询套餐使用记录而放大查询次数。 + +#### Scenario: 退款申请已冻结套餐使用记录 + +- **GIVEN** 退款申请记录了套餐使用记录标识,且该记录属于其关联订单 +- **WHEN** 查询退款列表、详情或导出 +- **THEN** 两个字段返回该套餐使用记录的真实已用量与真实总量 + +#### Scenario: 退款申请未冻结套餐使用记录 + +- **GIVEN** 退款申请的套餐使用记录为空,且其关联订单存在主套餐使用记录 +- **WHEN** 查询退款列表、详情或导出 +- **THEN** 两个字段返回该订单主套餐使用记录的已用量与总量,不返回零值 + +#### Scenario: 套餐使用记录已不存在 + +- **GIVEN** 退款申请冻结的套餐使用记录已被物理删除,且解析结果为空 +- **WHEN** 查询退款列表、详情或导出 +- **THEN** 两个字段返回 0,且列表、详情与导出仍正常返回该退款申请 + +#### Scenario: 列表不因套餐字段放大查询 + +- **GIVEN** 退款列表一页返回多条退款申请 +- **WHEN** 查询该页列表 +- **THEN** 系统以批量方式读取套餐使用记录,查询次数不随该页退款申请条数线性增长 + +#### Scenario: 展示字段不影响资金与权益规则 + +- **GIVEN** 任一退款申请 +- **WHEN** 系统解析并返回这两个展示字段 +- **THEN** 退款金额校验、冻结实收金额、套餐失效、接续下一套餐、停机评估与佣金回溯行为均不发生变化 diff --git a/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/tasks.md b/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/tasks.md new file mode 100644 index 0000000..26a7ce6 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-refund-package-usage-display/tasks.md @@ -0,0 +1,13 @@ +## 1. 查询投影 + +- [x] 1.1 新增退款套餐用量批量读取:按「冻结套餐使用记录优先级 → 订单主套餐 → 订单任一套餐使用记录、同级按 id 升序取第一条」解析每个退款申请的当前退款套餐,返回真实已用量与真实总量(MB)。实现为固定两次查询(`id IN` 与 `order_id IN`)+ 内存择一,不得按退款条数逐条查询;套餐记录不存在或字段为空时返回 0,不报错。 证据:`internal/service/refund/package_usage.go`(`loadRefundPackageUsages` 两次查询 + `resolveRefundPackageUsage` 优先级择一)。烟测实测:冻结套餐记录返回 321/1000;未冻结回退订单主套餐返回 321/1000(非零);套餐记录物理删除返回 0 且不报错;4 条退款的套餐查询次数恒为 2(不随条数增长)。 +- [x] 1.2 退款列表、详情响应新增「当前退款套餐已用量」与「当前退款套餐总量」两个字段:列表批量填充、详情单条填充,字段单位与含义写入中文 description,枚举与单位不得与既有套餐 DTO 冲突。 证据:`internal/model/dto/refund_dto.go` 的 `RefundResponse.RefundPackageUsedMB`/`RefundPackageTotalMB`(中文 description 含单位与零值语义);`internal/service/refund/service.go` 列表批量填充、详情单条填充。`go run cmd/gendocs/main.go` 生成的 OpenAPI 已包含两个字段。 + +## 2. 导出 + +- [x] 2.1 退款导出场景新增两列「当前退款套餐已用量(MB)」与「当前退款套餐总量(MB)」,插入在既有「套餐名称」列之后;同步调整 `refundExportRow`、Select 列与表头,保持表头与行元素数量、顺序严格一致。该两列必须覆盖未冻结 `package_usage_id` 的退款,不得沿用只按 `r.package_usage_id` join 的取法。 证据:`internal/exporter/refund_scene.go` 表头与行新增「当前退款套餐已用量(MB)」「当前退款套餐总量(MB)」两列(位于「套餐名称」之后),并以 LATERAL 按同一优先级取唯一套餐记录,不再依赖 `r.package_usage_id` join。烟测实测:导出 43 行列数与表头一致,两个新列下标为 6/7,43/43 行取到套餐总量(改动前仅 30/43 有 `package_usage_id`)。 + +## 3. 验证 + +- [x] 3.1 按 ENG-TEST-001 在维护者指定的 `junhong_cmp_test` PostgreSQL + Redis DB 6 验证,仅创建与删除本 Change 自有 fixture,禁止重置整库:冻结过套餐使用记录的退款返回该记录用量;未冻结但订单存在主套餐的退款返回主套餐用量而非零;套餐记录已物理删除时返回 0 且列表/详情/导出仍正常;列表与导出的套餐查询次数固定为 2 次、不随条数增长;确认退款金额校验、套餐失效、接续、停机与佣金回溯行为无变化。 证据:测试库 `junhong_cmp_test` 实测四项解析场景与 2 次固定查询;并以同批 43 条退款做 Go 解析器与导出 SQL 的口径对拍,**不一致 0 条**。未修改退款金额校验、套餐失效、接续、停机与佣金回溯相关代码。fixture 全部清理,未重置整库。 +- [x] 3.2 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-refund-package-usage-display --strict`、`openspec validate --all`、`openspec doctor --json` 与 `./scripts/context-health.sh`;自动化测试按项目决策为 N/A,验证脚本在完成后删除,不留测试文件。 证据:`gofmt -l`(变更集)无输出;`go build ./cmd/api ./cmd/worker` 退出码 0;`go run cmd/gendocs/main.go` 退出码 0 且 OpenAPI 含新字段;`openspec validate add-refund-package-usage-display --strict` 有效;`openspec validate --all` 通过;`openspec doctor --json` healthy;`./scripts/context-health.sh` 通过。验证脚本已删除,仓库内 `*_test.go` 计数为 0。 diff --git a/openspec/specs/order-refund-exchange/spec.md b/openspec/specs/order-refund-exchange/spec.md index ddb1414..6107f74 100644 --- a/openspec/specs/order-refund-exchange/spec.md +++ b/openspec/specs/order-refund-exchange/spec.md @@ -235,6 +235,44 @@ - **WHEN** 渠道退款调用或恢复完成后写入审计 - **THEN** 审计只记录业务标识、金额、状态与脱敏摘要,不记录商户密钥或凭证原文 +### Requirement: 退款展示当前退款套餐用量 + +退款管理列表、详情与导出 SHALL 返回「当前退款套餐已用量」与「当前退款套餐总量」两个字段,取值为该套餐使用记录当前可取得的真实已用量与真实总量(单位 MB)。两个字段 MUST 仅用于展示、查询与导出,MUST NOT 参与或改变退款金额校验、冻结实收金额、套餐失效、接续下一套餐、停机评估、佣金回溯或渠道退款任何规则。 + +「当前退款套餐」MUST 按与退款套餐失效一致的口径解析,且 MUST 按下列优先级取唯一一条:退款申请冻结的套餐使用记录(其 `package_usage_id`);该退款关联订单下 `master_usage_id` 为空的主套餐使用记录;该退款关联订单下任一套餐使用记录。同一优先级内按使用记录标识升序取第一条,保证同一退款每次返回相同结果。解析 MUST NOT 按当前生效套餐或当前世代推断,也 MUST NOT 跨订单取套餐。 + +套餐使用记录不存在、已被物理删除或字段为空时,两个字段 SHALL 返回 0,且 MUST NOT 因此阻断列表、详情或导出。列表返回 MUST NOT 因逐条查询套餐使用记录而放大查询次数。 + +#### Scenario: 退款申请已冻结套餐使用记录 + +- **GIVEN** 退款申请记录了套餐使用记录标识,且该记录属于其关联订单 +- **WHEN** 查询退款列表、详情或导出 +- **THEN** 两个字段返回该套餐使用记录的真实已用量与真实总量 + +#### Scenario: 退款申请未冻结套餐使用记录 + +- **GIVEN** 退款申请的套餐使用记录为空,且其关联订单存在主套餐使用记录 +- **WHEN** 查询退款列表、详情或导出 +- **THEN** 两个字段返回该订单主套餐使用记录的已用量与总量,不返回零值 + +#### Scenario: 套餐使用记录已不存在 + +- **GIVEN** 退款申请冻结的套餐使用记录已被物理删除,且解析结果为空 +- **WHEN** 查询退款列表、详情或导出 +- **THEN** 两个字段返回 0,且列表、详情与导出仍正常返回该退款申请 + +#### Scenario: 列表不因套餐字段放大查询 + +- **GIVEN** 退款列表一页返回多条退款申请 +- **WHEN** 查询该页列表 +- **THEN** 系统以批量方式读取套餐使用记录,查询次数不随该页退款申请条数线性增长 + +#### Scenario: 展示字段不影响资金与权益规则 + +- **GIVEN** 任一退款申请 +- **WHEN** 系统解析并返回这两个展示字段 +- **THEN** 退款金额校验、冻结实收金额、套餐失效、接续下一套餐、停机评估与佣金回溯行为均不发生变化 + ## 可达操作索引 本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。