Files
junhong_cmp_fiber/internal/service/refund/service.go
break ba0855d9eb feat(退款): AUG26-006 退款方式选择与原路退款
按 PRD 2.3/2.4/2.5 落地套餐退款的方式矩阵与原路渠道退款:

- 退款申请派生并冻结权威实收金额(线上取原成功支付记录,钱包/线下取订单实际收款),
  提交人不可填写或修改;按来源支付方式生成可选方式矩阵并在创建、提交、执行前重复校验。
- 审批切换为「每次提交一条不可变审批尝试记录 + 独立企业微信审批实例」,业务标识取尝试
  记录主键;终态消费按尝试记录优先、退款申请兜底双读,兼容存量无实例与已关联实例申请。
  新增活动退款部分唯一索引 (order_id) WHERE status IN (1,5,6)。
- 本地人工终审保持既有开关,补齐通过入口的 approval_instance_id IS NULL 守卫,使三个
  入口一致拒绝已关联审批实例的申请;重提按尝试模式重写(仅已拒绝/已退回/原路失败且无异常)。
- 权益时点:企微通过事务写退款终态、按方式确定的订单态、钱包回款、员工账单冲销与可靠
  失效事实;套餐失效/接续/停机仍由既有可靠机制最终一致执行,不把外部调用放入资金事务。
  订单支付状态按方式置位:凭证退款与退回原钱包在企微通过时置已退款,原路须渠道明确成功。
- 按官方契约实现微信直连 v3、微信 v2(双向证书)、富友(/commonRefund 与 /refundQuery)、
  支付宝四类原路退款;能力只由服务商类型与退款必需凭证完整性决定,无人工开关。
  渠道请求号在提交时冻结到尝试记录,并以 channel_submitted_at 条件认领保证资金动作至多
  提交一次(重复投递只查询不二次提交);不向任何渠道传递退款结果通知地址。
- 新增 refund:channel:recovery 恢复任务只查询回填;本地查询窗口超期(富友 72 小时、
  微信 v2 7 天)转原路退款失败、渠道状态已失败、分类超时未知并置异常转人工,不放行自动
  重提以避免重复退款。
- 同步退款 DTO/导出/审计资源与审计查询关联、商户凭证文档,并修正 fuiou 集成契约文档。

迁移 000218(退款尝试与渠道退款事实)、000219(微信 v2 客户端证书凭证)成对提供,
未修改既有迁移;测试库 junhong_cmp_test 完成 up/down/up 与行为核对,未调用真实渠道。
2026-09-14 11:55:16 +08:00

1579 lines
65 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 提供退款申请的业务逻辑服务
// 包含退款申请的创建、审批、拒绝、退回、重新提交等完整生命周期管理
// 审批通过后异步执行佣金回扣和退款后资产处理
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
}
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]
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]
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, "当前状态不允许重新提交退款申请")
}
// deductAllCommission 幂等回扣该订单所有已入账佣金。
// 每条佣金在独立事务中锁定并失效;全部完成后才设置退款单完成标记。
func (s *Service) deductAllCommission(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
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDRefundCommissionPostProcessing,
ActorName: "退款佣金自动回扣任务", Source: constants.AuditSourceWorker, CorrelationID: refund.RefundNo,
})
if refund.CommissionDeducted {
return
}
// 先确认订单佣金已计算完成;否则可能与异步佣金计算竞态:
// “无已入账记录”会被误判为“无需回扣”并提前置 commission_deducted随后佣金才入账且不再回溯。
var order model.Order
if err := s.db.Select("commission_status").First(&order, refund.OrderID).Error; err != nil {
logger.Error("佣金回扣:查询订单佣金状态失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err))
return
}
if order.CommissionStatus == model.CommissionStatusPending {
logger.Info("佣金回扣:订单佣金尚未计算完成,等待重试", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID))
return
}
// 查询该订单所有已入账佣金记录
var commissions []model.CommissionRecord
if err := s.db.Where("order_id = ? AND status = ?", refund.OrderID, constants.CommissionStatusReleased).Find(&commissions).Error; err != nil {
logger.Error("佣金回扣:查询佣金记录失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err))
return
}
if len(commissions) == 0 {
logger.Info("佣金回扣:无已入账佣金记录", zap.Uint("refund_id", refundID))
// 无佣金需要回扣,直接标记完成
s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("commission_deducted", true)
return
}
allSucceeded := true
// 对每条佣金记录执行扣减
for _, commission := range commissions {
if err := s.deductSingleCommission(ctx, &refund, &commission); err != nil {
allSucceeded = false
s.recordCommissionFailure(ctx, &refund, &commission, err)
logger.Error("佣金回扣:单条佣金扣减失败",
zap.Uint("refund_id", refundID),
zap.Uint("commission_id", commission.ID),
zap.Uint("shop_id", commission.ShopID),
zap.Int64("amount", commission.Amount),
zap.Error(err),
)
// 继续处理下一条,不中断
}
}
if !allSucceeded {
return
}
// 全部完成后标记退款单佣金已回扣
if err := s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("commission_deducted", true).Error; err != nil {
logger.Error("佣金回扣:更新回扣标记失败", zap.Uint("refund_id", refundID), zap.Error(err))
}
}
// deductSingleCommission 回扣单条佣金记录:先拒绝该店铺待审核提现释放冻结余额,
// 再扣减佣金钱包(允许余额为负),最后失效佣金记录并创建流水。
func (s *Service) deductSingleCommission(ctx context.Context, refund *model.RefundRequest, commission *model.CommissionRecord) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current model.CommissionRecord
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", commission.ID).First(&current).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款佣金记录失败")
}
if current.Status == constants.CommissionStatusInvalid {
return nil
}
if current.Status != constants.CommissionStatusReleased {
return errors.New(errors.CodeInvalidStatus, "退款佣金状态不允许回扣")
}
var existingCount int64
if err := tx.WithContext(ctx).Model(&model.AgentWalletTransaction{}).
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
constants.ReferenceTypeCommission, current.ID, constants.AgentTransactionTypeCommissionDeduct, constants.TransactionStatusSuccess).
Count(&existingCount).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复核退款佣金回扣流水失败")
}
if existingCount > 0 {
if err := tx.WithContext(ctx).Model(&model.CommissionRecord{}).Where("id = ?", current.ID).
Update("status", constants.CommissionStatusInvalid).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "复核后失效退款佣金记录失败")
}
return nil
}
// 全库统一加锁顺序:申请行 → 尝试行 → 钱包行(钱包永远最后)。
// 待审提现的加锁与释放额计算必须发生在钱包加锁之前,否则与企微终态消费者
// (申请 → 尝试 → 钱包)形成 A→B、B→A 的死锁环。
rejects, err := s.collectPendingWithdrawalRejects(ctx, tx, current.ShopID)
if err != nil {
return err
}
var wallet model.AgentWallet
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", current.ShopID, constants.AgentWalletTypeCommission).
First(&wallet).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款佣金钱包失败")
}
if err := s.applyPendingWithdrawalRejects(ctx, tx, &wallet, refund, rejects); err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]any{"balance": gorm.Expr("balance - ?", current.Amount), "version": gorm.Expr("version + 1")})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "扣减退款佣金钱包失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款佣金钱包版本已变化")
}
refType, refID := constants.ReferenceTypeCommission, current.ID
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID, ShopID: current.ShopID, UserID: refund.Creator,
TransactionType: constants.AgentTransactionTypeCommissionDeduct, Amount: -current.Amount,
BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance - current.Amount,
Status: constants.TransactionStatusSuccess, ReferenceType: &refType, ReferenceID: &refID,
Creator: refund.Creator, ShopIDTag: current.ShopID,
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款佣金回扣流水失败")
}
updated := tx.WithContext(ctx).Model(&model.CommissionRecord{}).
Where("id = ? AND status = ?", current.ID, constants.CommissionStatusReleased).
Update("status", constants.CommissionStatusInvalid)
if updated.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "失效退款佣金记录失败")
}
if updated.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "退款佣金状态已变化")
}
current.Status = constants.CommissionStatusInvalid
return s.appendCommissionAudit(ctx, tx, refund, &current, &wallet, transaction)
})
}
// 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
}
}