Files
junhong_cmp_fiber/internal/service/refund/package_usage.go
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

137 lines
5.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}