按 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 与行为核对,未调用真实渠道。
1579 lines
65 KiB
Go
1579 lines
65 KiB
Go
// 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(¤t).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, ¤t, &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
|
||
}
|
||
}
|