Files
junhong_cmp_fiber/internal/service/refund/service.go
break 1aa4eacee2
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m26s
feat(退款分佣): 佣金回溯明细替换全额失效并补齐读侧与导出
用 PRD 2.14 语义整体替换退款佣金「整单全额失效」实现:原佣金保持已发放不变,
回溯事实落在新表 tb_commission_clawback_record 的负数、不可提现明细上。

- 新增成对迁移 000220 建 tb_commission_clawback_record,唯一约束
  (refund_id, original_commission_id) 为权威幂等键,附店铺+时间/原佣金/订单索引。
- 回溯用例(internal/service/refund/clawback.go):准入仅由退款申请状态、审批异常
  标记与退款方式决定;金额按分整数计算,分母取冻结实收(缺失回落审批尝试)、
  分子原路取渠道成功金额,乘法用 math/big 中间量,舍入差自末条起向前补差;
  终态判据要求订单佣金已离开待计算且不存在 status IN (1,2,99) 的记录。
- 三层幂等:唯一约束兜底、佣金行行锁 + 钱包乐观锁、commission_deducted 仅作投影
  并带 WHERE commission_deducted = false 条件置位;闭合三结果为已回溯、无需回溯、
  审批异常转人工。
- 事务内顺序固定:锁提现申请行 → 锁尝试行 → 解冻冻结 → 置驳回 → 插回溯明细 →
  扣 balance(允许为负)→ 写负数流水 → 审计;删除旧全额失效写入与其两个审计调用点,
  refund.invalidate_commission 仅保留常量与注册供历史审计读取。
- 读侧:佣金明细列表 status 筛选透传,两表 UNION ALL 合并分页并以 source ASC 作
  末位次序键;新增佣金明细详情接口并同步路由与 OpenAPI 装配。
- 导出:新增 commission_record 场景(白名单、exporter 注册、DTO oneof、DataSource
  与列定义),粒度为佣金记录,原佣金与回溯各一行,金额保持分且可为负。
- 新增退款佣金回溯周期补偿任务(@every 1m / MaxRetry(3) / Timeout(10m) /
  Unique(10m),独立队列),保留启动时补偿扫描,判据与既有实现一致。

Refs: AUG26-012
2026-09-14 13:40:34 +08:00

1440 lines
58 KiB
Go
Raw Permalink 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 提供退款申请的业务逻辑服务
// 包含退款申请的创建、审批、拒绝、退回、重新提交等完整生命周期管理
// 审批通过后异步执行佣金回扣和退款后资产处理
package refund
import (
"context"
"fmt"
"math/rand"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
employeecollectionapp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
refundchannelapp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
packageSvc "github.com/break/junhong_cmp_fiber/internal/service/package"
)
// Service 退款业务服务
// 负责退款申请的 CRUD、审批流程、佣金回扣和退款后资产处理
type Service struct {
db *gorm.DB
refundStore *postgres.RefundStore
orderStore *postgres.OrderStore
commissionRecordStore *postgres.CommissionRecordStore
agentWalletStore *postgres.AgentWalletStore
agentWalletTransactionStore *postgres.AgentWalletTransactionStore
stopResumeService *iotCardSvc.StopResumeService
deviceService *deviceSvc.Service
packageActivationService *packageSvc.ActivationService
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
assetWalletStore *postgres.AssetWalletStore
agentWalletRefundService *walletapp.RefundService
refundApprovalCreation *refundapprovalapp.CreationService
notificationOutbox *outbox.Repository
auditWriter *audit.Writer
logger *zap.Logger
paymentMerchantRuntime *merchantpayment.RuntimeLoader
refundOffset *employeecollectionapp.RefundOffsetService
channelRefund *refundchannelapp.Service
}
// SetChannelRefundService 注入渠道原路退款用例。
func (s *Service) SetChannelRefundService(service *refundchannelapp.Service) {
s.channelRefund = service
}
// SetEmployeeCollectionRefundOffset 注入员工代收款账单退款冲销用例。
func (s *Service) SetEmployeeCollectionRefundOffset(offset *employeecollectionapp.RefundOffsetService) {
s.refundOffset = offset
}
// New 创建退款业务服务实例
func New(
db *gorm.DB,
refundStore *postgres.RefundStore,
orderStore *postgres.OrderStore,
commissionRecordStore *postgres.CommissionRecordStore,
agentWalletStore *postgres.AgentWalletStore,
agentWalletTransactionStore *postgres.AgentWalletTransactionStore,
stopResumeService *iotCardSvc.StopResumeService,
deviceService *deviceSvc.Service,
packageActivationService *packageSvc.ActivationService,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
assetWalletStore *postgres.AssetWalletStore,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
refundStore: refundStore,
orderStore: orderStore,
commissionRecordStore: commissionRecordStore,
agentWalletStore: agentWalletStore,
agentWalletTransactionStore: agentWalletTransactionStore,
stopResumeService: stopResumeService,
deviceService: deviceService,
packageActivationService: packageActivationService,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
assetWalletStore: assetWalletStore,
logger: logger,
}
}
// SetAgentWalletRefundService 注入代理主钱包统一退款回充用例。
func (s *Service) SetAgentWalletRefundService(service *walletapp.RefundService) {
s.agentWalletRefundService = service
}
// SetRefundApprovalCreationService 注入退款企微审批申请用例。
func (s *Service) SetRefundApprovalCreationService(service *refundapprovalapp.CreationService) {
s.refundApprovalCreation = service
}
// SetNotificationOutbox 注入退款完成后的可靠店铺通知 Outbox。
func (s *Service) SetNotificationOutbox(repository *outbox.Repository) {
s.notificationOutbox = repository
}
// SetLifecycleAudit 注入退款完整业务链统一审计 Writer。
func (s *Service) SetLifecycleAudit(writer *audit.Writer) {
s.auditWriter = writer
}
// Create 创建退款申请
// 校验订单存在且已支付、派生并冻结权威实收金额、判定可选退款方式,随后原子创建退款申请、
// 审批尝试记录与企业微信审批实例。实收金额由系统派生,提交人填写无效。
func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
// 校验订单存在且已支付
order, err := s.orderStore.GetByID(ctx, req.OrderID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
if order.PaymentStatus != model.PaymentStatusPaid {
if order.PaymentStatus == model.PaymentStatusRefunded {
return nil, errors.New(errors.CodeInvalidStatus, "订单已退款,无法重复申请退款")
}
return nil, errors.New(errors.CodeInvalidStatus, "仅已支付订单可申请退款")
}
decision, err := s.decideRefundMethods(ctx, order)
if err != nil {
return nil, err
}
if err := validateRefundMethod(decision, req.Method); err != nil {
return nil, err
}
if err := validateFrozenRefundAmount(req.RequestedRefundAmount, decision.FrozenActualReceivedAmount); err != nil {
return nil, err
}
refundVoucherKey, err := normalizeRefundVoucherKey(req.RefundVoucherKey)
if err != nil {
return nil, err
}
if err := validateCustomerAccountMaterial(req.Method, req.CustomerAccountInfo, refundVoucherKey); err != nil {
return nil, err
}
// 从订单获取 shop_id优先使用 SellerShopID代理商买家使用 BuyerID
var shopID *uint
if order.SellerShopID != nil {
shopID = order.SellerShopID
} else if order.BuyerType == model.BuyerTypeAgent {
shopID = &order.BuyerID
}
refund := &model.RefundRequest{
RefundNo: generateRefundNo(),
OrderID: req.OrderID,
OrderNo: order.OrderNo,
OrderType: order.OrderType,
AssetIdentifier: order.AssetIdentifier,
IotCardID: order.IotCardID,
DeviceID: order.DeviceID,
PackageUsageID: req.PackageUsageID,
ShopID: shopID,
// 实收金额与冻结额一律取系统派生值,忽略提交人传入的金额。
ActualReceivedAmount: decision.FrozenActualReceivedAmount,
FrozenActualReceivedAmount: decision.FrozenActualReceivedAmount,
RequestedRefundAmount: req.RequestedRefundAmount,
Method: req.Method,
CustomerAccountInfo: req.CustomerAccountInfo,
RefundVoucherKey: refundVoucherKey,
RefundReason: req.RefundReason,
Status: model.RefundStatusPending,
}
refund.Creator = userID
refund.Updater = userID
if s.refundApprovalCreation == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
attempt := buildAttemptFromDecision(decision, refund, decision.ChannelConfig, time.Now())
result, err := s.refundApprovalCreation.Execute(ctx, refundapprovalapp.CreateCommand{
Refund: refund, Order: order, SubmitterAccountID: userID, Attempt: attempt,
})
if err != nil {
failedRefund := *refund
failedRefund.ID = 0
failedRefund.ApprovalInstanceID = nil
s.recordRefundFailure(ctx, constants.AuditActionRefundCreated, "提交退款申请失败", &failedRefund, order, err)
return nil, err
}
resp := buildRefundResponse(result.Refund)
resp.SubmitterName = result.SubmitterName
resp.ApprovalProvider = constants.IntegrationProviderWeCom
resp.ApprovalStatus = &result.ApprovalStatus
resp.ApprovalStatusName = constants.GetApprovalStatusName(result.ApprovalStatus)
return resp, nil
}
// List 分页查询退款申请列表
func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.RefundListResponse, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
OrderBy: "tb_refund_request.created_at DESC",
}
if opts.Page == 0 {
opts.Page = 1
}
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
filters := &postgres.RefundListFilters{
Status: req.Status,
OrderID: req.OrderID,
ShopID: req.ShopID,
AssetIdentifier: req.AssetIdentifier,
}
requests, total, err := s.refundStore.List(ctx, opts, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询退款申请列表失败")
}
submitterNames, err := s.loadSubmitterNames(ctx, refundSubmitterIDs(requests))
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款提交人失败")
}
approvalSummaries, err := s.loadApprovalSummaries(ctx, requests)
if err != nil {
return nil, err
}
attemptResponses, err := s.loadAttemptResponses(ctx, requests)
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 {
item := buildRefundResponse(r)
item.SubmitterName = submitterNames[r.Creator]
applyApprovalSummary(item, approvalSummaries, r)
item.Attempts = attemptResponses[r.ID]
applyRefundPackageUsage(item, packageUsages[r.ID])
items = append(items, *item)
}
return &dto.RefundListResponse{
Items: items,
Total: total,
Page: opts.Page,
Size: opts.PageSize,
}, nil
}
// GetByID 根据 ID 查询退款申请详情
// TriggerApproval 为历史退款申请主动补发企业微信审批。
func (s *Service) TriggerApproval(ctx context.Context, id uint) (*dto.RefundResponse, error) {
if s.refundApprovalCreation == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
refund, err := s.refundStore.GetByIDForOperation(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
}
result, err := s.refundApprovalCreation.TriggerHistorical(ctx, refund.ID)
if err != nil {
return nil, err
}
resp := buildRefundResponse(result.Refund)
resp.SubmitterName = result.SubmitterName
resp.ApprovalProvider = constants.IntegrationProviderWeCom
resp.ApprovalStatus = &result.ApprovalStatus
resp.ApprovalStatusName = constants.GetApprovalStatusName(result.ApprovalStatus)
return resp, nil
}
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, error) {
refund, err := s.refundStore.GetByID(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
}
resp := buildRefundResponse(refund)
resp.SubmitterName = s.loadSubmitterNameBestEffort(ctx, refund.Creator)
approvalSummaries, err := s.loadApprovalSummaries(ctx, []*model.RefundRequest{refund})
if err != nil {
return nil, err
}
applyApprovalSummary(resp, approvalSummaries, refund)
attemptResponses, err := s.loadAttemptResponses(ctx, []*model.RefundRequest{refund})
if err != nil {
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
}
// Approve 审批通过退款申请
// 条件更新 WHERE status=1设置审批信息
// 事务提交成功后异步执行佣金回扣和退款后资产处理
func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRequest) error {
if !legacyRefundManualEnabled() {
return errors.New(errors.CodeInvalidStatus, "退款人工审批入口已停用,请查看企业微信审批状态")
}
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
if err := ensureRefundProcessor(ctx); err != nil {
return err
}
refund, err := s.refundStore.GetByIDForOperation(ctx, id)
if err != nil {
return errors.New(errors.CodeNotFound, "退款申请不存在")
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: refund.RefundNo})
if refund.Status != model.RefundStatusPending {
businessErr := errors.New(errors.CodeInvalidStatus, "仅待审批状态可审批通过")
s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, nil, businessErr)
return businessErr
}
if refund.ApprovalInstanceID != nil {
businessErr := errors.New(errors.CodeInvalidStatus, "该退款申请由企业微信审批决定,不能人工审批")
s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, nil, businessErr)
return businessErr
}
now := time.Now()
approvedAmount := refund.RequestedRefundAmount
if req.ApprovedRefundAmount != nil {
approvedAmount = *req.ApprovedRefundAmount
}
order, err := s.orderStore.GetByID(ctx, refund.OrderID)
if err != nil {
businessErr := errors.New(errors.CodeNotFound, "订单不存在")
s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, nil, businessErr)
return businessErr
}
if err := validateApprovedRefundAmount(approvedAmount, refund.RequestedRefundAmount, order); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, order, err)
return err
}
// 事务内同步更新退款状态、订单支付状态和钱包回款,避免订单已退款但资金未退回。
beforeRefund := refundAuditState(refund)
beforeOrder := map[string]any{"payment_status": order.PaymentStatus}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 条件更新同时约束 approval_instance_id IS NULL已关联审批实例的申请由企业微信决定
// 本地人工通过一律拒绝,并与 Reject/Return 的守卫保持一致ENG-CONC-001
result := tx.Model(&model.RefundRequest{}).
Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending).
Updates(map[string]any{
"status": model.RefundStatusApproved,
"processor_id": userID,
"processed_at": now,
"approved_refund_amount": approvedAmount,
"remark": req.Remark,
"updater": userID,
"updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeInternalError, result.Error, "审批退款申请失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInvalidStatus, "该退款申请由企业微信审批决定,或状态已变更,不能人工审批")
}
orderResult := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", refund.OrderID, model.PaymentStatusPaid).
Updates(map[string]any{
"payment_status": model.PaymentStatusRefunded,
"updated_at": now,
})
if orderResult.Error != nil {
return errors.Wrap(errors.CodeInternalError, orderResult.Error, "更新订单退款状态失败")
}
if orderResult.RowsAffected == 0 {
return errors.New(errors.CodeInvalidStatus, "订单状态已变更,无法执行退款审批")
}
if err := s.preparePaymentRefundCredentials(ctx, tx, order.ID); err != nil {
return err
}
if err := s.refundWalletPayment(ctx, tx, refund, order, approvedAmount, userID); err != nil {
return err
}
if err := s.appendCompletedNotification(ctx, tx, refund); err != nil {
return err
}
if err := commissiondelivery.AppendRefundCommissionDeduct(ctx, tx, outbox.NewRepository(), refund.ID, refund.OrderID); err != nil {
return err
}
if err := commissiondelivery.AppendRefundAssetProcess(ctx, tx, outbox.NewRepository(), refund.ID, refund.OrderID); err != nil {
return err
}
return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundApproved, "通过退款审批",
"refund:"+strconv.FormatUint(uint64(refund.ID), 10)+":approved", beforeRefund, beforeOrder, "退款已通过")
})
if err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, order, err)
return err
}
return nil
}
// AppendCompletedNotification 在同一事务内补写退款完成通知事实。
// 供渠道原路退款在渠道明确成功时调用:该方式的退款完成时点晚于企微通过。
func (s *Service) AppendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error {
return s.appendCompletedNotification(ctx, tx, refund)
}
// appendCompletedNotification 在退款业务事务内幂等写入目标店铺通知。
func (s *Service) appendCompletedNotification(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) error {
if refund == nil || refund.ShopID == nil {
return nil
}
if s.notificationOutbox == nil {
return errors.New(errors.CodeInternalError, "退款通知 Outbox 未配置")
}
refundID := fmt.Sprintf("%d", refund.ID)
eventID := "refund:" + refundID + ":completed"
_, err := s.notificationOutbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: eventID, EventType: constants.OutboxEventTypeAdminDynamicNotification,
PayloadVersion: constants.NotificationPayloadVersionV1,
AggregateType: "refund", AggregateID: refundID,
ResourceType: constants.NotificationRefTypeRefund, ResourceID: refundID,
BusinessKey: eventID,
Payload: notificationapp.AdminDynamicPayload{
TargetKind: constants.NotificationTargetKindShop, TargetID: *refund.ShopID,
NotificationType: constants.NotificationTypeRefundCompleted, TemplateData: map[string]string{},
RefType: constants.NotificationRefTypeRefund, RefID: refundID, RefKey: refund.RefundNo,
},
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入退款完成通知事件失败")
}
return nil
}
// refundWalletPayment 处理钱包支付订单的退款回款。
func (s *Service) refundWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error {
if amount == 0 || order.PaymentMethod != model.PaymentMethodWallet {
return nil
}
switch order.BuyerType {
case model.BuyerTypeAgent:
return s.refundAgentWalletPayment(ctx, tx, refund, order, amount, operatorID)
case model.BuyerTypePersonal:
return s.refundAssetWalletPayment(ctx, tx, refund, order, amount, operatorID)
default:
return errors.New(errors.CodeInvalidParam, "不支持的钱包退款买家类型")
}
}
// refundAgentWalletPayment 将代理钱包支付的订单退款退回原扣款主钱包。
func (s *Service) refundAgentWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error {
if s.agentWalletRefundService == nil {
return errors.New(errors.CodeInternalError, "代理主钱包退款能力未配置")
}
legacyPayerShopID, legacyRelatedShopID, _ := resolveAgentWalletRefundShopID(order)
legacyDeductAmount := order.TotalAmount
if order.ActualPaidAmount != nil && *order.ActualPaidAmount > 0 {
legacyDeductAmount = *order.ActualPaidAmount
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
correlationID := refund.RefundNo
if correlationID == "" {
correlationID = order.OrderNo
}
remark := fmt.Sprintf("订单%s退款退回预充值钱包", order.OrderNo)
assetType, assetID, assetIdentifier := buildRefundWalletTransactionAssetSnapshot(order)
_, err := s.agentWalletRefundService.RefundInTx(ctx, tx, walletapp.RefundCommand{
OrderID: order.ID, RefundID: refund.ID, Amount: amount,
LegacyPayerShopID: legacyPayerShopID, LegacyDeductAmount: legacyDeductAmount,
LegacyRelatedShopID: legacyRelatedShopID, UserID: operatorID, Creator: operatorID,
AssetType: assetType, AssetID: assetID, AssetIdentifier: assetIdentifier,
Remark: remark, RequestID: requestID, CorrelationID: correlationID,
})
return err
}
// resolveAgentWalletRefundShopID 兼容旧订单:没有扣款流水时按订单角色推导原扣款店铺。
func resolveAgentWalletRefundShopID(order *model.Order) (uint, *uint, error) {
if order.PurchaseRole == model.PurchaseRolePurchaseForSubordinate {
if order.OperatorID == nil || *order.OperatorID == 0 {
return 0, nil, errors.New(errors.CodeInternalError, "代理代购订单缺少原扣款代理")
}
relatedShopID := order.BuyerID
return *order.OperatorID, &relatedShopID, nil
}
if order.OperatorID != nil && *order.OperatorID > 0 && order.OperatorType == model.OperatorAccountTypeAgent {
return *order.OperatorID, nil, nil
}
if order.BuyerID == 0 {
return 0, nil, errors.New(errors.CodeInternalError, "订单买家店铺为空")
}
return order.BuyerID, nil, nil
}
// refundAssetWalletPayment 将个人资产钱包支付的订单退款退回原资产钱包。
func (s *Service) refundAssetWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error {
var existing model.AssetWalletTransaction
err := tx.WithContext(ctx).
Where("reference_type = ? AND reference_no = ? AND transaction_type = ? AND status = ?",
constants.ReferenceTypeRefund, refund.RefundNo, constants.AssetTransactionTypeRefund, constants.TransactionStatusSuccess).
First(&existing).Error
if err == nil {
if existing.Amount != amount {
return errors.New(errors.CodeConflict, "退款申请已存在不一致的资产钱包回款流水")
}
return nil
}
if err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeDatabaseError, err, "复核资产钱包退款流水失败")
}
wallet, err := s.resolveAssetRefundWallet(tx, order)
if err != nil {
return err
}
balanceBefore := wallet.Balance
if err := s.assetWalletStore.AddBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "退回资产钱包失败")
}
refType := constants.ReferenceTypeRefund
refNo := refund.RefundNo
remark := fmt.Sprintf("订单%s退款退回资产钱包", order.OrderNo)
transaction := &model.AssetWalletTransaction{
AssetWalletID: wallet.ID,
ResourceType: wallet.ResourceType,
ResourceID: wallet.ResourceID,
UserID: operatorID,
TransactionType: constants.AssetTransactionTypeRefund,
Amount: amount,
BalanceBefore: balanceBefore,
BalanceAfter: balanceBefore + amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceNo: &refNo,
Remark: &remark,
Creator: operatorID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建资产钱包退款流水失败")
}
return nil
}
// resolveAssetRefundWallet 优先按原扣款流水定位回款资产钱包。
func (s *Service) resolveAssetRefundWallet(tx *gorm.DB, order *model.Order) (*model.AssetWallet, error) {
var deductTx model.AssetWalletTransaction
err := tx.Where("reference_type = ? AND reference_no = ? AND transaction_type = ?",
constants.ReferenceTypeOrder, order.OrderNo, constants.AssetTransactionTypeDeduct).
Order("id ASC").
First(&deductTx).Error
if err == nil {
return lockAssetWalletByID(tx, deductTx.AssetWalletID)
}
if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原资产钱包扣款流水失败")
}
resourceType, resourceID, err := resolveOrderAssetWalletResource(order)
if err != nil {
return nil, err
}
return lockAssetWalletByResource(tx, resourceType, resourceID)
}
// resolveOrderAssetWalletResource 从订单载体推导资产钱包归属。
func resolveOrderAssetWalletResource(order *model.Order) (string, uint, error) {
switch order.OrderType {
case model.OrderTypeSingleCard:
if order.IotCardID == nil || *order.IotCardID == 0 {
return "", 0, errors.New(errors.CodeInternalError, "单卡订单缺少卡ID")
}
return constants.AssetWalletResourceTypeIotCard, *order.IotCardID, nil
case model.OrderTypeDevice:
if order.DeviceID == nil || *order.DeviceID == 0 {
return "", 0, errors.New(errors.CodeInternalError, "设备订单缺少设备ID")
}
return constants.AssetWalletResourceTypeDevice, *order.DeviceID, nil
default:
return "", 0, errors.New(errors.CodeInvalidParam, "未知订单类型")
}
}
// lockAssetWalletByID 锁定资产钱包。
func lockAssetWalletByID(tx *gorm.DB, walletID uint) (*model.AssetWallet, error) {
var wallet model.AssetWallet
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", walletID).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWalletNotFound, "资产钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定资产钱包失败")
}
return &wallet, nil
}
// lockAssetWalletByResource 按资产归属锁定资产钱包。
func lockAssetWalletByResource(tx *gorm.DB, resourceType string, resourceID uint) (*model.AssetWallet, error) {
var wallet model.AssetWallet
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWalletNotFound, "资产钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定资产钱包失败")
}
return &wallet, nil
}
// Reject 审批拒绝退款申请
// 条件更新 WHERE status=1
func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectRefundRequest) error {
if !legacyRefundManualEnabled() {
return errors.New(errors.CodeInvalidStatus, "退款人工审批入口已停用,请查看企业微信审批状态")
}
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
if err := ensureRefundProcessor(ctx); err != nil {
return err
}
var refund model.RefundRequest
var order model.Order
now := time.Now()
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Where("id = ?", id).First(&refund).Error; err != nil {
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
}
if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
}
beforeRefund := refundAuditState(&refund)
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending).
Updates(map[string]any{
"status": model.RefundStatusRejected,
"processor_id": userID,
"processed_at": now,
"reject_reason": req.RejectReason,
"updater": userID,
"updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeInternalError, result.Error, "拒绝退款申请失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
}
return s.appendRefundAudit(ctx, tx, id, constants.AuditActionRefundRejected, "拒绝退款审批",
"refund:"+strconv.FormatUint(uint64(id), 10)+":rejected", beforeRefund, nil, "退款已拒绝")
})
if err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundRejected, "拒绝退款审批失败", &refund, &order, err)
}
return err
}
func legacyRefundManualEnabled() bool {
cfg := config.Get()
return cfg == nil || cfg.Approval.LegacyRefundManualEnabled
}
// Return 退回退款申请
// 条件更新 WHERE status=1退回后可重新提交
func (s *Service) Return(ctx context.Context, id uint, req *dto.ReturnRefundRequest) error {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
if err := ensureRefundProcessor(ctx); err != nil {
return err
}
var refund model.RefundRequest
var order model.Order
now := time.Now()
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Where("id = ?", id).First(&refund).Error; err != nil {
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
}
if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
}
beforeRefund := refundAuditState(&refund)
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending).
Updates(map[string]any{
"status": model.RefundStatusReturned,
"processor_id": userID,
"processed_at": now,
"remark": req.Remark,
"updater": userID,
"updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeInternalError, result.Error, "退回退款申请失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试")
}
return s.appendRefundAudit(ctx, tx, id, constants.AuditActionRefundReturned, "退回退款申请", "", beforeRefund, nil, "退款申请已退回")
})
if err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundReturned, "退回退款申请失败", &refund, &order, err)
}
return err
}
// ensureRefundProcessor 确保只有平台侧账号可以处理审批类动作。
func ensureRefundProcessor(ctx context.Context) error {
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform {
return nil
}
return errors.New(errors.CodeForbidden, "无权限处理退款申请")
}
// Resubmit 重新提交退款申请
// 条件更新 WHERE status=4已退回修改部分字段后重新进入待审批状态
// Resubmit 修改并重提未成功退款申请
// POST /api/admin/refunds/:id/resubmit
// 仅已拒绝、已退回或原路退款失败且不存在审批异常的申请可修改重提;每次重提新增一条不可变
// 审批尝试记录与一个新的企业微信审批实例,历史材料与审批结果不被覆盖。金额与方式的变更
// 必须重新审批,不得沿用旧实例。
func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefundRequest) error {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
refund, err := s.refundStore.GetByIDForOperation(ctx, id)
if err != nil {
return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请")
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: refund.RefundNo})
if err := s.resubmitPrecheck(refund); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err)
return err
}
order, err := s.orderStore.GetByID(ctx, refund.OrderID)
if err != nil {
businessErr := errors.New(errors.CodeNotFound, "订单不存在")
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, businessErr)
return businessErr
}
decision, err := s.decideRefundMethods(ctx, order)
if err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
return err
}
// 方式缺省沿用原方式,金额缺省沿用原金额。
method := refund.Method
if req.Method != nil && strings.TrimSpace(*req.Method) != "" {
method = *req.Method
}
if err := validateRefundMethod(decision, method); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
return err
}
requestedRefundAmount := refund.RequestedRefundAmount
if req.RequestedRefundAmount != nil {
requestedRefundAmount = *req.RequestedRefundAmount
}
if err := validateFrozenRefundAmount(requestedRefundAmount, decision.FrozenActualReceivedAmount); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
return err
}
refundVoucherKey := refund.RefundVoucherKey
if req.RefundVoucherKey != nil {
normalized, normErr := normalizeRefundVoucherKey(*req.RefundVoucherKey)
if normErr != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, normErr)
return normErr
}
refundVoucherKey = normalized
}
refundReason := refund.RefundReason
if req.RefundReason != nil {
refundReason = *req.RefundReason
}
customerAccountInfo := refund.CustomerAccountInfo
if req.CustomerAccountInfo != nil {
customerAccountInfo = *req.CustomerAccountInfo
}
if err := validateCustomerAccountMaterial(method, customerAccountInfo, refundVoucherKey); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err)
return err
}
// 重提的实收金额重新派生并冻结;提交人传入的实收金额一律忽略。
updated := *refund
updated.Method = method
updated.RequestedRefundAmount = requestedRefundAmount
updated.FrozenActualReceivedAmount = decision.FrozenActualReceivedAmount
updated.ActualReceivedAmount = decision.FrozenActualReceivedAmount
updated.RefundVoucherKey = refundVoucherKey
updated.RefundReason = refundReason
updated.CustomerAccountInfo = customerAccountInfo
updated.Creator = userID
attempt := buildAttemptFromDecision(decision, &updated, decision.ChannelConfig, time.Now())
if s.refundApprovalCreation == nil {
return errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if _, err := s.refundApprovalCreation.Resubmit(ctx, id, refundapprovalapp.ResubmitCommand{
Refund: &updated, Attempt: attempt,
}); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
return err
}
return nil
}
// resubmitPrecheck 校验退款申请是否处于可重提状态且不存在审批异常。
// 企业微信通过后撤销的申请标记异常并禁止自动重提,只能由超级管理员线下处理。
func (s *Service) resubmitPrecheck(refund *model.RefundRequest) error {
if refund == nil {
return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请")
}
if refund.AnomalyFlag != 0 {
return errors.New(errors.CodeInvalidStatus, "该退款申请存在审批异常,需超级管理员线下处理,不支持重提")
}
for _, status := range model.RefundResubmittableStatuses() {
if refund.Status == status {
return nil
}
}
return errors.New(errors.CodeInvalidStatus, "当前状态不允许重新提交退款申请")
}
// pendingWithdrawalReject 是一条待审提现的本次拒绝事实。
// releaseAmount 是本次实际释放额:接入企业微信审批的申请取尝试记录事实,
// 从未关联审批实例的存量申请取申请金额;已结算尝试的本次释放额为 0。
type pendingWithdrawalReject struct {
Withdrawal model.CommissionWithdrawalRequest
Before map[string]any
ReleaseAmount int64
}
// collectPendingWithdrawalRejects 在钱包加锁之前锁定该店铺全部待审提现并算出本次释放额。
// 加锁顺序申请行FOR UPDATE→ 尝试行FOR UPDATE钱包行留给调用方最后加锁。
func (s *Service) collectPendingWithdrawalRejects(
ctx context.Context,
tx *gorm.DB,
shopID uint,
) ([]pendingWithdrawalReject, error) {
var withdrawals []model.CommissionWithdrawalRequest
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalStatusPending).
Order("id ASC").Find(&withdrawals).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待审核佣金提现失败")
}
if len(withdrawals) == 0 {
return nil, nil
}
requestIDs := make([]uint, 0, len(withdrawals))
for i := range withdrawals {
requestIDs = append(requestIDs, withdrawals[i].ID)
}
releasedAmounts, err := postgres.ReleaseUnsettledForRequestsInTx(ctx, tx, requestIDs, time.Now().UTC())
if err != nil {
return nil, err
}
withAttempts, err := postgres.CountRequestsWithAttemptsInTx(ctx, tx, requestIDs)
if err != nil {
return nil, err
}
rejects := make([]pendingWithdrawalReject, 0, len(withdrawals))
for i := range withdrawals {
withdrawal := withdrawals[i]
releaseAmount := withdrawal.Amount
if _, hasAttempt := withAttempts[withdrawal.ID]; hasAttempt {
// 接入企业微信审批的申请:释放金额取本次实际释放的尝试事实。
releaseAmount = releasedAmounts[withdrawal.ID]
}
rejects = append(rejects, pendingWithdrawalReject{
Withdrawal: withdrawal, Before: withdrawalRejectState(&withdrawal), ReleaseAmount: releaseAmount,
})
}
return rejects, nil
}
// applyPendingWithdrawalRejects 按本次实际释放额解冻钱包、置驳回并写流水与审计。
// 释放额为 0 时不写无意义的 0 金额流水,审计也不带钱包流水资源。
func (s *Service) applyPendingWithdrawalRejects(
ctx context.Context,
tx *gorm.DB,
wallet *model.AgentWallet,
refund *model.RefundRequest,
rejects []pendingWithdrawalReject,
) error {
if len(rejects) == 0 {
return nil
}
var shop model.Shop
if err := tx.WithContext(ctx).First(&shop, wallet.ShopID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询提现店铺失败")
}
remark := "退款佣金回扣,自动拒绝提现"
for i := range rejects {
reject := &rejects[i]
w := &reject.Withdrawal
releaseAmount := reject.ReleaseAmount
if releaseAmount > 0 {
if err := s.agentWalletStore.UnfreezeBalanceWithTx(ctx, tx, wallet.ID, releaseAmount); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "解冻提现冻结余额失败")
}
}
var transaction *model.AgentWalletTransaction
if releaseAmount > 0 {
// 只有本次确实释放了冻结才写流水,避免产生无意义的 0 金额流水。
refType := constants.ReferenceTypeWithdrawal
transaction = &model.AgentWalletTransaction{
AgentWalletID: wallet.ID, ShopID: wallet.ShopID, UserID: refund.Creator,
TransactionType: constants.AgentTransactionTypeRefund, Amount: releaseAmount,
BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance,
Status: constants.TransactionStatusSuccess, ReferenceType: &refType, ReferenceID: &w.ID,
Remark: &remark, Creator: refund.Creator, ShopIDTag: wallet.ShopID,
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建自动拒绝提现流水失败")
}
}
now := time.Now()
result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}).
Where("id = ? AND status = ?", w.ID, constants.WithdrawalStatusPending).
Updates(map[string]any{
"status": constants.WithdrawalStatusRejected,
"processed_at": now,
"reject_reason": remark,
"updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "拒绝待审核提现失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "提现申请状态已变化")
}
w.Status = constants.WithdrawalStatusRejected
w.ProcessedAt = &now
w.RejectReason = remark
frozenBefore := wallet.FrozenBalance
wallet.FrozenBalance = frozenBefore - releaseAmount
if err := s.appendWithdrawalRejectAudit(ctx, tx, w, wallet, transaction, &shop,
reject.Before, releaseAmount, frozenBefore); err != nil {
return err
}
}
return nil
}
func withdrawalRejectState(w *model.CommissionWithdrawalRequest) map[string]any {
return map[string]any{
"status": w.Status, "amount": w.Amount, "fee": w.Fee, "actual_amount": w.ActualAmount,
"withdrawal_method": w.WithdrawalMethod, "processed_at": w.ProcessedAt, "reject_reason": w.RejectReason,
}
}
func (s *Service) appendWithdrawalRejectAudit(
ctx context.Context,
tx *gorm.DB,
withdrawal *model.CommissionWithdrawalRequest,
wallet *model.AgentWallet,
transaction *model.AgentWalletTransaction,
shop *model.Shop,
before map[string]any,
releaseAmount int64,
frozenBefore int64,
) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "退款统一审计接缝未配置")
}
primary := audit.CommissionWithdrawalResource(withdrawal, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWithdrawalTarget,
before, withdrawalRejectState(withdrawal))
primary.SubjectVisibility = constants.AuditSubjectResult
primary.SubjectSummary = "退款佣金回扣自动拒绝提现"
// 审计前后冻结额一律取本次实际释放额,避免与实际释放不一致甚至为负。
walletResource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalWallet,
map[string]any{"balance": wallet.Balance, "frozen_balance": frozenBefore},
map[string]any{"balance": wallet.Balance, "frozen_balance": frozenBefore - releaseAmount})
walletResource.SubjectVisibility = constants.AuditSubjectInternalOnly
shopResource := audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleWithdrawalShop)
shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources := []audit.ResourceInput{primary, walletResource, shopResource}
if transaction != nil {
transactionResource := audit.AgentWalletTransactionResource(transaction, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalTransaction)
transactionResource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, transactionResource)
}
// 与分销/提现终审路径统一口径:这笔审计属于「要求成功必达」的拒绝事实,
// 必须与业务事实同事务原子提交,因此使用非吞错变体并显式返回错误。
if _, err := s.auditWriter.AppendAndGet(ctx, tx, audit.AppendInput{
EventID: "commission-withdrawal:" + strconv.FormatUint(uint64(withdrawal.ID), 10) + ":refund-rejected",
ActionCode: constants.AuditActionCommissionWithdrawalRejected, Summary: "退款佣金回扣自动拒绝提现",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: withdrawal.WithdrawalNo,
Metadata: map[string]any{"amount": withdrawal.Amount, "released_amount": releaseAmount, "status": withdrawal.Status},
Resources: resources,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入退款自动拒绝提现审计失败")
}
return nil
}
// handleRefundAssetProcessing 幂等处理退款后的资产状态。
// 包括退款套餐精准失效、尝试接续待生效主套餐和必要时停机;全部完成后才设置完成标记。
func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint) {
logger := s.logger
// 查询退款单
var refund model.RefundRequest
if err := s.db.Where("id = ?", refundID).First(&refund).Error; err != nil {
logger.Error("退款资产处理:查询退款单失败", zap.Uint("refund_id", refundID), zap.Error(err))
return
}
if refund.AssetReset {
return
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDRefundAssetPostProcessing,
ActorName: "退款资产自动后处理任务", Source: constants.AuditSourceWorker, CorrelationID: refund.RefundNo,
})
// 查询关联订单
var order model.Order
if err := s.db.Where("id = ?", refund.OrderID).First(&order).Error; err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundAssetProcessed, "完成退款资产后处理失败", &refund, nil, err)
logger.Error("退款资产处理:查询订单失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err))
return
}
recordFailure := func(err error) {
s.recordRefundFailure(ctx, constants.AuditActionRefundAssetProcessed, "完成退款资产后处理失败", &refund, &order, err)
}
// 确定资产类型和 ID
var assetType string
var assetID uint
switch order.OrderType {
case model.OrderTypeSingleCard:
if order.IotCardID == nil {
recordFailure(errors.New(errors.CodeInternalError, "退款单卡订单缺少资产ID"))
logger.Error("退款资产处理:单卡订单缺少 iot_card_id", zap.Uint("order_id", order.ID))
return
}
assetType = "iot_card"
assetID = *order.IotCardID
case model.OrderTypeDevice:
if order.DeviceID == nil {
recordFailure(errors.New(errors.CodeInternalError, "退款设备订单缺少资产ID"))
logger.Error("退款资产处理:设备订单缺少 device_id", zap.Uint("order_id", order.ID))
return
}
assetType = "device"
assetID = *order.DeviceID
default:
recordFailure(errors.New(errors.CodeInvalidParam, "退款订单资产类型无效"))
logger.Error("退款资产处理:未知订单类型", zap.String("order_type", order.OrderType))
return
}
// 1. 按退款单精准失效套餐(仅处理本次退款订单关联套餐)
if s.packageActivationService == nil {
businessErr := errors.New(errors.CodeServiceUnavailable, "退款套餐处理能力未配置")
recordFailure(businessErr)
logger.Error("退款资产处理:套餐激活服务未注入",
zap.Uint("refund_id", refund.ID),
zap.Uint("order_id", order.ID))
return
}
if err := s.packageActivationService.InvalidatePackagesForRefund(ctx, assetType, assetID, order.ID, refund.ID, refund.RefundNo, refund.PackageUsageID); err != nil {
recordFailure(err)
fields := []zap.Field{
zap.String("asset_type", assetType),
zap.Uint("asset_id", assetID),
zap.Uint("order_id", order.ID),
zap.Uint("refund_id", refund.ID),
zap.String("refund_no", refund.RefundNo),
zap.Error(err),
}
if refund.PackageUsageID != nil {
fields = append(fields, zap.Uint("package_usage_id", *refund.PackageUsageID))
}
logger.Error("退款资产处理:退款套餐精准失效失败", fields...)
return
}
// 2. 尝试按购买顺序接续待生效主套餐
if _, err := s.packageActivationService.ActivateNextPendingMainPackage(ctx, assetType, assetID); err != nil {
recordFailure(err)
logger.Error("退款资产处理:接续激活待生效套餐失败",
zap.String("asset_type", assetType),
zap.Uint("asset_id", assetID),
zap.Uint("refund_id", refund.ID),
zap.Error(err))
return
}
hasActiveMain, err := s.packageActivationService.HasActiveMainPackage(ctx, assetType, assetID)
if err != nil {
recordFailure(err)
logger.Error("退款资产处理:查询生效主套餐失败",
zap.String("asset_type", assetType),
zap.Uint("asset_id", assetID),
zap.Uint("refund_id", refund.ID),
zap.Error(err))
return
}
if !hasActiveMain {
// 3. 无可用主套餐时才停机;退款不再重置世代或重建钱包。
if !s.stopAsset(ctx, assetType, assetID) {
recordFailure(errors.New(errors.CodeServiceUnavailable, "退款资产停机处理失败"))
return
}
}
// 4. 标记退款后资产处理已完成
if err := s.markRefundAssetProcessed(ctx, refundID); err != nil {
recordFailure(err)
logger.Error("退款资产处理:更新处理标记失败", zap.Uint("refund_id", refundID), zap.Error(err))
}
}
// stopAsset 根据资产类型执行停机操作
func (s *Service) stopAsset(ctx context.Context, assetType string, assetID uint) bool {
logger := s.logger
switch assetType {
case "iot_card":
// 单卡停机:需要先查卡获取 iccid
card, err := s.iotCardStore.GetByID(ctx, assetID)
if err != nil {
logger.Error("退款资产处理:查询卡信息失败", zap.Uint("card_id", assetID), zap.Error(err))
return false
}
if s.stopResumeService == nil {
logger.Error("退款资产处理:单卡停机服务未注入", zap.Uint("card_id", assetID))
return false
}
if err := s.stopResumeService.ManualStopCard(ctx, card.ICCID); err != nil {
logger.Error("退款资产处理:单卡停机失败", zap.String("iccid", card.ICCID), zap.Error(err))
return false
}
case "device":
if s.stopResumeService == nil {
logger.Error("退款资产处理:设备停机服务未注入", zap.Uint("device_id", assetID))
return false
}
var cards []model.IotCard
if err := s.db.WithContext(ctx).Model(&model.IotCard{}).
Joins("JOIN tb_device_sim_binding binding ON binding.iot_card_id = tb_iot_card.id AND binding.deleted_at IS NULL").
Where("binding.device_id = ? AND binding.bind_status = ?", assetID, constants.BindStatusBound).
Find(&cards).Error; err != nil {
logger.Error("退款资产处理:查询设备绑定卡失败", zap.Uint("device_id", assetID), zap.Error(err))
return false
}
for _, card := range cards {
if err := s.stopResumeService.ManualStopCard(ctx, card.ICCID); err != nil {
logger.Error("退款资产处理:设备绑定卡停机失败",
zap.Uint("device_id", assetID), zap.Uint("card_id", card.ID), zap.Error(err))
return false
}
}
}
return true
}
// generateRefundNo 生成退款单号
// 格式RF + 日期时间 + 6位随机数
func generateRefundNo() string {
now := time.Now()
randomNum := rand.Intn(1000000)
return fmt.Sprintf("RF%s%06d", now.Format("20060102150405"), randomNum)
}
// validateRequestedRefundAmountByOrder 校验申请退款金额不得超过订单实收金额
func validateRequestedRefundAmountByOrder(requestedRefundAmount int64, order *model.Order) error {
if order.ActualPaidAmount == nil {
return errors.New(errors.CodeInternalError, "订单实收金额不能为空")
}
if requestedRefundAmount > *order.ActualPaidAmount {
return errors.New(errors.CodeInvalidParam, "申请退款金额不能大于订单实收金额")
}
return nil
}
// validateApprovedRefundAmount 校验审批退款金额不能超过申请金额和订单实收金额。
func validateApprovedRefundAmount(approvedAmount int64, requestedRefundAmount int64, order *model.Order) error {
if approvedAmount < 0 {
return errors.New(errors.CodeInvalidParam, "审批退款金额不能小于0")
}
if approvedAmount > requestedRefundAmount {
return errors.New(errors.CodeInvalidParam, "审批退款金额不能大于申请退款金额")
}
return validateRequestedRefundAmountByOrder(approvedAmount, order)
}
func normalizeRefundVoucherKey(keys []string) (model.StringJSONBArray, error) {
if len(keys) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "退款申请必须上传退款凭证")
}
if len(keys) > 5 {
return nil, errors.New(errors.CodeInvalidParam, "退款凭证最多上传5个")
}
normalized := make(model.StringJSONBArray, 0, len(keys))
for _, key := range keys {
key = strings.TrimSpace(key)
if key == "" {
return nil, errors.New(errors.CodeInvalidParam, "退款凭证对象存储 Key 不能为空")
}
normalized = append(normalized, key)
}
return normalized, nil
}
// buildRefundResponse 将退款 Model 转换为 DTO 响应
func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
assetType := ""
if r.OrderType == model.OrderTypeSingleCard {
assetType = "card"
} else if r.OrderType == model.OrderTypeDevice {
assetType = "device"
}
resp := &dto.RefundResponse{
ID: r.ID,
RefundNo: r.RefundNo,
OrderID: r.OrderID,
OrderNo: r.OrderNo,
AssetIdentifier: r.AssetIdentifier,
AssetType: assetType,
IotCardID: r.IotCardID,
DeviceID: r.DeviceID,
PackageUsageID: r.PackageUsageID,
ShopID: r.ShopID,
ShopName: r.ShopName,
ActualReceivedAmount: r.ActualReceivedAmount,
RequestedRefundAmount: r.RequestedRefundAmount,
ApprovedRefundAmount: r.ApprovedRefundAmount,
RefundVoucherKey: []string(r.RefundVoucherKey),
RefundReason: r.RefundReason,
Status: r.Status,
StatusName: constants.GetRefundStatusName(r.Status),
Method: r.Method,
MethodName: constants.RefundMethodName(r.Method),
FrozenActualReceivedAmount: r.FrozenActualReceivedAmount,
CustomerAccountInfo: r.CustomerAccountInfo,
ChannelRefundStatus: r.ChannelRefundStatus,
ChannelRefundStatusName: constants.RefundChannelStatusName(r.ChannelRefundStatus),
ChannelRefundNo: r.ChannelRefundNo,
ChannelRefundRequestNo: r.ChannelRefundRequestNo,
ChannelRefundAmount: r.ChannelRefundAmount,
FailureReason: r.FailureReason,
FailureReasonName: constants.RefundFailureReasonName(r.FailureReason),
FailureMessage: r.FailureMessage,
AnomalyFlag: r.AnomalyFlag,
AnomalyReason: r.AnomalyReason,
LatestAttemptID: r.LatestAttemptID,
LatestApprovalInstanceID: r.LatestApprovalInstanceID,
Attempts: []dto.RefundAttemptResponse{},
ProcessorID: r.ProcessorID,
RejectReason: r.RejectReason,
Remark: r.Remark,
CommissionDeducted: r.CommissionDeducted,
AssetReset: r.AssetReset,
SubmitterID: r.Creator,
ApprovalInstanceID: r.ApprovalInstanceID,
Creator: r.Creator,
Updater: r.Updater,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: r.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if r.ProcessedAt != nil {
resp.ProcessedAt = r.ProcessedAt.Format("2006-01-02 15:04:05")
}
if r.ChannelRefundedAt != nil {
resp.ChannelRefundedAt = r.ChannelRefundedAt.Format("2006-01-02 15:04:05")
}
return resp
}
type approvalSummary struct {
Provider string
Status int
}
func (s *Service) loadApprovalSummaries(ctx context.Context, refunds []*model.RefundRequest) (map[uint]approvalSummary, error) {
ids := make([]uint, 0, len(refunds))
seen := make(map[uint]struct{}, len(refunds))
for _, refund := range refunds {
if refund == nil || refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID == 0 {
continue
}
id := *refund.ApprovalInstanceID
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
summaries := make(map[uint]approvalSummary, len(ids))
if len(ids) == 0 {
return summaries, nil
}
var instances []model.ApprovalInstance
if err := s.db.WithContext(ctx).Select("id", "provider", "status").Where("id IN ?", ids).Find(&instances).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款审批状态失败")
}
for _, instance := range instances {
summaries[instance.ID] = approvalSummary{Provider: instance.Provider, Status: instance.Status}
}
return summaries, nil
}
// applyApprovalSummary 用审批实例摘要回填响应的审批状态。
// 优先取最新审批尝试关联的实例;最新实例摘要缺失或存量数据未写入时回退主表关联实例。
func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]approvalSummary, refund *model.RefundRequest) {
if response == nil || refund == nil {
return
}
summary, exists := summaries[refund.LatestApprovalInstanceID]
if !exists && refund.ApprovalInstanceID != nil {
summary, exists = summaries[*refund.ApprovalInstanceID]
}
if !exists {
return
}
status := summary.Status
response.ApprovalProvider = summary.Provider
response.ApprovalStatus = &status
response.ApprovalStatusName = constants.GetApprovalStatusName(status)
}
func refundSubmitterIDs(requests []*model.RefundRequest) []uint {
ids := make([]uint, 0, len(requests))
for _, request := range requests {
if request != nil && request.Creator > 0 {
ids = append(ids, request.Creator)
}
}
return ids
}
func (s *Service) loadSubmitterNames(ctx context.Context, ids []uint) (map[uint]string, error) {
accounts, err := postgres.NewAccountStore(s.db, nil).GetDisplayAccountsByIDs(ctx, ids)
if err != nil {
return nil, err
}
names := make(map[uint]string, len(accounts))
for _, account := range accounts {
names[account.ID] = account.Username
}
return names, nil
}
func (s *Service) loadSubmitterNameBestEffort(ctx context.Context, id uint) string {
names, err := s.loadSubmitterNames(ctx, []uint{id})
if err != nil {
s.logger.Warn("查询退款提交人失败", zap.Uint("submitter_id", id), zap.Error(err))
return ""
}
return names[id]
}
// buildRefundWalletTransactionAssetSnapshot 从订单中生成退款流水的资产快照。
func buildRefundWalletTransactionAssetSnapshot(order *model.Order) (string, uint, string) {
if order == nil {
return "", 0, ""
}
switch order.OrderType {
case model.OrderTypeSingleCard:
if order.IotCardID == nil {
return constants.AssetTypeIotCard, 0, order.AssetIdentifier
}
return constants.AssetTypeIotCard, *order.IotCardID, order.AssetIdentifier
case model.OrderTypeDevice:
if order.DeviceID == nil {
return constants.AssetTypeDevice, 0, order.AssetIdentifier
}
return constants.AssetTypeDevice, *order.DeviceID, order.AssetIdentifier
default:
return "", 0, order.AssetIdentifier
}
}