Files
junhong_cmp_fiber/internal/service/refund/service.go
break 575d056f54
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(代理分销提现): 落地扫码注册、提现资料资格与企微终审提现
AUG26-008。

- 迁移 000214–000217:tb_shop 全局唯一且不可修改的随机分销码(含存量回填)、
  tb_agent_distribution_registration 待审批注册记录、tb_withdrawal_qualification 资料版本、
  tb_commission_withdrawal_request_attempt 审批尝试记录,以及提现申请的 latest_*/异常标记列;
  不修改既有迁移,down 在存在本 Change 业务事实或新类型场景行时拒绝破坏性回滚。
- 公开接口 POST /api/c/v1/agent-distribution-registrations:无认证,复用既有短信验证码校验、
  消费与限流;无效分销码、停用上级、验证码无效或已消费统一返回「分销码不可用」且不落库,
  审批通过前不创建店铺、账号或钱包。
- 审批通过才在同一事务内建启用店铺、代理主账号、钱包、上级层级与业务员快照,驳回不建实体,
  重复回调不重复建实体,提交后清理上级下级缓存。
- 提现资料资格按不可变版本保存,替换合同或法人身份证即新增版本并同事务失效旧有效版本;
  超管作废原因必填;代理停用与店铺删除联动失效。
- 提现每次提交或重提新增不可变审批尝试记录并冻结金额;企业微信通过仅一次从冻结扣减、
  保持状态 2 并写 paid_at(不使用状态 4),驳回/cancelled/deleted 仅一次释放,
  通过后撤销不回滚、不重新冻结、只写正交异常标记;加锁顺序统一为申请→尝试→钱包。
- 本地人工终审对已关联审批实例的申请返回状态冲突,approval_instance_id 为空的存量申请保持既有行为,
  不新增任何配置开关。
- 补齐审批业务类型注册点全集:业务类型与场景字段常量、场景 DTO 两处枚举与中文描述、
  场景字段白名单/合法类型/中文名、数据库 CHECK、Worker 决策消费者与装配、审批审计资源映射,
  以及三个新审计资源与 13 个审计动作;失败/拒绝审计改为必达。
- 新增后台路由与 OpenAPI:资格提交/查询/作废、提现申请/重提/详情、店铺详情返回只读分销码。
- 归档本 Change:主 Spec 新增 agent-distribution-withdrawal 能力(5 个 Requirement)。

验证(junhong_cmp_test + Redis DB 6,显式 DB_*,未重置整库):
- 迁移 up → version 217 且 dirty=false → down 3 → up 回 217,fixture 复核残留为 0。
- 受控状态机脚手架 227 项通过 / 0 项失败,覆盖 18 组场景(幂等与乱序回调、资金冻结/释放/重提、
  退款回扣 × 在途提现并发、负向场景拒绝审计与 14 个动作码审计真实落库)。
- gofmt 空、go build/go vet 通过、gendocs 与工作区逐字节一致、context-health 通过、
  openspec validate --strict 通过、doctor healthy;自动化测试按项目决策为 N/A。

运行期前置(未完成,非代码交付物):由超管经 PUT /api/admin/wecom/scenes/{business_type} 为
agent_distribution_approval、withdrawal_qualification_approval、commission_withdrawal_approval
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
2026-09-14 09:45:13 +08:00

1479 lines
59 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"
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
}
// 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, "仅已支付订单可申请退款")
}
if err := validateRequestedRefundAmountByOrder(req.RequestedRefundAmount, order); err != nil {
return nil, err
}
refundVoucherKey, err := normalizeRefundVoucherKey(req.RefundVoucherKey)
if 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: req.ActualReceivedAmount,
RequestedRefundAmount: req.RequestedRefundAmount,
RefundVoucherKey: refundVoucherKey,
RefundReason: req.RefundReason,
Status: model.RefundStatusPending,
}
refund.Creator = userID
refund.Updater = userID
if s.refundApprovalCreation == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
result, err := s.refundApprovalCreation.Execute(ctx, refundapprovalapp.CreateCommand{
Refund: refund, Order: order, SubmitterAccountID: userID,
})
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
}
items := make([]dto.RefundResponse, 0, len(requests))
for _, r := range requests {
item := buildRefundResponse(r)
item.SubmitterName = submitterNames[r.Creator]
applyApprovalSummary(item, approvalSummaries, r.ApprovalInstanceID)
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.ApprovalInstanceID)
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 {
result := tx.Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", 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 {
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已退回修改部分字段后重新进入待审批状态
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 refund.Status != model.RefundStatusReturned {
businessErr := errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, businessErr)
return businessErr
}
requestedRefundAmount := refund.RequestedRefundAmount
if req.RequestedRefundAmount != nil {
requestedRefundAmount = *req.RequestedRefundAmount
}
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
}
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
}
if err := validateRequestedRefundAmountByOrder(requestedRefundAmount, order); err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
return err
}
now := time.Now()
updates := map[string]any{
"status": model.RefundStatusPending,
"updater": userID,
"updated_at": now,
}
if req.ActualReceivedAmount != nil {
updates["actual_received_amount"] = *req.ActualReceivedAmount
}
if req.RequestedRefundAmount != nil {
updates["requested_refund_amount"] = *req.RequestedRefundAmount
}
if req.RefundVoucherKey != nil {
updates["refund_voucher_key"] = refundVoucherKey
}
if req.RefundReason != nil {
updates["refund_reason"] = *req.RefundReason
}
beforeRefund := refundAuditState(refund)
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ?", id, model.RefundStatusReturned).
Updates(updates)
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.AuditActionRefundResubmitted, "重新提交退款申请", "", beforeRefund, nil, "退款申请已重新提交")
})
if err != nil {
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
}
return err
}
// 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),
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")
}
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
}
func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]approvalSummary, instanceID *uint) {
if response == nil || instanceID == nil {
return
}
summary, exists := summaries[*instanceID]
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
}
}