All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m41s
968 lines
35 KiB
Go
968 lines
35 KiB
Go
// Package refund 提供退款申请的业务逻辑服务
|
||
// 包含退款申请的创建、审批、拒绝、退回、重新提交等完整生命周期管理
|
||
// 审批通过后异步执行佣金回扣和退款后资产处理
|
||
package refund
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"math/rand"
|
||
"strings"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
|
||
"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/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
|
||
logger *zap.Logger
|
||
}
|
||
|
||
// 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,
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 检查是否存在活跃退款申请(待审批、已通过、已退回状态)
|
||
existing, err := s.refundStore.FindActiveByOrderID(ctx, req.OrderID)
|
||
if err == nil && existing != nil {
|
||
return nil, errors.New(errors.CodeConflict, "该订单已存在退款申请")
|
||
}
|
||
|
||
// 从订单获取 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 err := s.refundStore.Create(ctx, refund); err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建退款申请失败")
|
||
}
|
||
|
||
return buildRefundResponse(refund), 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,
|
||
}
|
||
|
||
requests, total, err := s.refundStore.List(ctx, opts, filters)
|
||
if err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询退款申请列表失败")
|
||
}
|
||
|
||
items := make([]dto.RefundResponse, 0, len(requests))
|
||
for _, r := range requests {
|
||
items = append(items, *buildRefundResponse(r))
|
||
}
|
||
|
||
return &dto.RefundListResponse{
|
||
Items: items,
|
||
Total: total,
|
||
Page: opts.Page,
|
||
Size: opts.PageSize,
|
||
}, nil
|
||
}
|
||
|
||
// GetByID 根据 ID 查询退款申请详情
|
||
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, "退款申请不存在")
|
||
}
|
||
return buildRefundResponse(refund), nil
|
||
}
|
||
|
||
// Approve 审批通过退款申请
|
||
// 条件更新 WHERE status=1,设置审批信息
|
||
// 事务提交成功后异步执行佣金回扣和退款后资产处理
|
||
func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRequest) error {
|
||
userID := middleware.GetUserIDFromContext(ctx)
|
||
if userID == 0 {
|
||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||
}
|
||
if err := ensureRefundProcessor(ctx); err != nil {
|
||
return err
|
||
}
|
||
|
||
refund, err := s.refundStore.GetByID(ctx, id)
|
||
if err != nil {
|
||
return errors.New(errors.CodeNotFound, "退款申请不存在")
|
||
}
|
||
if refund.Status != model.RefundStatusPending {
|
||
return errors.New(errors.CodeInvalidStatus, "仅待审批状态可审批通过")
|
||
}
|
||
|
||
now := time.Now()
|
||
approvedAmount := refund.RequestedRefundAmount
|
||
if req.ApprovedRefundAmount != nil {
|
||
approvedAmount = *req.ApprovedRefundAmount
|
||
}
|
||
order, err := s.orderStore.GetByID(ctx, refund.OrderID)
|
||
if err != nil {
|
||
return errors.New(errors.CodeNotFound, "订单不存在")
|
||
}
|
||
if err := validateApprovedRefundAmount(approvedAmount, refund.RequestedRefundAmount, order); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 事务内同步更新退款状态、订单支付状态和钱包回款,避免订单已退款但资金未退回。
|
||
if 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.refundWalletPayment(ctx, tx, refund, order, approvedAmount, userID); err != nil {
|
||
return err
|
||
}
|
||
|
||
return nil
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 事务提交成功后,异步执行佣金回扣和退款后资产处理(失败不影响审批结果)
|
||
go func() {
|
||
asyncCtx := context.Background()
|
||
s.deductAllCommission(asyncCtx, id)
|
||
}()
|
||
|
||
go func() {
|
||
asyncCtx := context.Background()
|
||
s.handleRefundAssetProcessing(asyncCtx, id)
|
||
}()
|
||
|
||
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 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 {
|
||
wallet, relatedShopID, subtype, err := s.resolveAgentRefundWallet(tx, order)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
balanceBefore := wallet.Balance
|
||
if err := s.agentWalletStore.AddBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "退回代理预充值钱包失败")
|
||
}
|
||
|
||
refType := constants.ReferenceTypeRefund
|
||
refID := refund.ID
|
||
remark := fmt.Sprintf("订单%s退款退回预充值钱包", order.OrderNo)
|
||
transaction := &model.AgentWalletTransaction{
|
||
AgentWalletID: wallet.ID,
|
||
ShopID: wallet.ShopID,
|
||
UserID: operatorID,
|
||
TransactionType: constants.AgentTransactionTypeRefund,
|
||
TransactionSubtype: subtype,
|
||
Amount: amount,
|
||
BalanceBefore: balanceBefore,
|
||
BalanceAfter: balanceBefore + amount,
|
||
Status: constants.TransactionStatusSuccess,
|
||
ReferenceType: &refType,
|
||
ReferenceID: &refID,
|
||
RelatedShopID: relatedShopID,
|
||
Remark: &remark,
|
||
Creator: operatorID,
|
||
ShopIDTag: wallet.ShopIDTag,
|
||
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||
}
|
||
if err := s.agentWalletTransactionStore.CreateWithTx(ctx, tx, transaction); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建代理钱包退款流水失败")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// resolveAgentRefundWallet 优先按原扣款流水定位回款钱包,兼容历史缺失扣款流水的订单。
|
||
func (s *Service) resolveAgentRefundWallet(tx *gorm.DB, order *model.Order) (*model.AgentWallet, *uint, *string, error) {
|
||
var deductTx model.AgentWalletTransaction
|
||
err := tx.Where("reference_type = ? AND reference_id = ? AND transaction_type = ?",
|
||
constants.ReferenceTypeOrder, order.ID, constants.AgentTransactionTypeDeduct).
|
||
Order("id ASC").
|
||
First(&deductTx).Error
|
||
if err == nil {
|
||
wallet, err := lockAgentMainWalletByID(tx, deductTx.AgentWalletID)
|
||
if err != nil {
|
||
return nil, nil, nil, err
|
||
}
|
||
return wallet, deductTx.RelatedShopID, deductTx.TransactionSubtype, nil
|
||
}
|
||
if err != gorm.ErrRecordNotFound {
|
||
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原代理钱包扣款流水失败")
|
||
}
|
||
|
||
payerShopID, relatedShopID, err := resolveAgentWalletRefundShopID(order)
|
||
if err != nil {
|
||
return nil, nil, nil, err
|
||
}
|
||
wallet, err := lockAgentMainWalletByShopID(tx, payerShopID)
|
||
if err != nil {
|
||
return nil, nil, nil, err
|
||
}
|
||
return wallet, relatedShopID, nil, nil
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// lockAgentMainWalletByID 锁定代理主钱包,保证余额快照与后续入账一致。
|
||
func lockAgentMainWalletByID(tx *gorm.DB, walletID uint) (*model.AgentWallet, error) {
|
||
var wallet model.AgentWallet
|
||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Where("id = ? AND wallet_type = ?", walletID, constants.AgentWalletTypeMain).
|
||
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
|
||
}
|
||
|
||
// lockAgentMainWalletByShopID 按店铺锁定代理主钱包。
|
||
func lockAgentMainWalletByShopID(tx *gorm.DB, shopID uint) (*model.AgentWallet, error) {
|
||
var wallet model.AgentWallet
|
||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).
|
||
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
|
||
}
|
||
|
||
// refundAssetWalletPayment 将个人资产钱包支付的订单退款退回原资产钱包。
|
||
func (s *Service) refundAssetWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error {
|
||
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 {
|
||
userID := middleware.GetUserIDFromContext(ctx)
|
||
if userID == 0 {
|
||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||
}
|
||
if err := ensureRefundProcessor(ctx); err != nil {
|
||
return err
|
||
}
|
||
|
||
now := time.Now()
|
||
result := s.db.WithContext(ctx).
|
||
Model(&model.RefundRequest{}).
|
||
Where("id = ? AND status = ?", 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 nil
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
now := time.Now()
|
||
result := s.db.WithContext(ctx).
|
||
Model(&model.RefundRequest{}).
|
||
Where("id = ? AND status = ?", 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 nil
|
||
}
|
||
|
||
// 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.GetByID(ctx, id)
|
||
if err != nil {
|
||
return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
|
||
}
|
||
if refund.Status != model.RefundStatusReturned {
|
||
return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交")
|
||
}
|
||
|
||
requestedRefundAmount := refund.RequestedRefundAmount
|
||
if req.RequestedRefundAmount != nil {
|
||
requestedRefundAmount = *req.RequestedRefundAmount
|
||
}
|
||
refundVoucherKey := refund.RefundVoucherKey
|
||
if req.RefundVoucherKey != nil {
|
||
refundVoucherKey = *req.RefundVoucherKey
|
||
}
|
||
refundVoucherKey, err = normalizeRefundVoucherKey(refundVoucherKey)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
order, err := s.orderStore.GetByID(ctx, refund.OrderID)
|
||
if err != nil {
|
||
return errors.New(errors.CodeNotFound, "订单不存在")
|
||
}
|
||
if err := validateRequestedRefundAmountByOrder(requestedRefundAmount, order); err != nil {
|
||
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
|
||
}
|
||
|
||
result := s.db.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 nil
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 查询该订单所有已入账佣金记录
|
||
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
|
||
}
|
||
|
||
// 对每条佣金记录执行扣减
|
||
for _, commission := range commissions {
|
||
if err := s.deductSingleCommission(ctx, &refund, &commission); err != nil {
|
||
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 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 {
|
||
// 获取对应代理的佣金钱包
|
||
wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, commission.ShopID)
|
||
if err != nil {
|
||
return fmt.Errorf("获取佣金钱包失败: shop_id=%d, %w", commission.ShopID, err)
|
||
}
|
||
|
||
// 乐观锁扣减余额(允许负数)
|
||
result := s.db.Model(&model.AgentWallet{}).
|
||
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
|
||
Updates(map[string]any{
|
||
"balance": gorm.Expr("balance - ?", commission.Amount),
|
||
"version": gorm.Expr("version + 1"),
|
||
})
|
||
if result.Error != nil {
|
||
return fmt.Errorf("扣减钱包余额失败: %w", result.Error)
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return fmt.Errorf("乐观锁冲突: wallet_id=%d, version=%d", wallet.ID, wallet.Version)
|
||
}
|
||
|
||
// 创建交易流水
|
||
refType := constants.ReferenceTypeRefund
|
||
refID := refund.ID
|
||
transaction := &model.AgentWalletTransaction{
|
||
AgentWalletID: wallet.ID,
|
||
ShopID: commission.ShopID,
|
||
UserID: refund.Creator,
|
||
TransactionType: constants.AgentTransactionTypeCommissionDeduct,
|
||
Amount: -commission.Amount,
|
||
BalanceBefore: wallet.Balance,
|
||
BalanceAfter: wallet.Balance - commission.Amount,
|
||
Status: constants.TransactionStatusSuccess,
|
||
ReferenceType: &refType,
|
||
ReferenceID: &refID,
|
||
Creator: refund.Creator,
|
||
ShopIDTag: commission.ShopID,
|
||
}
|
||
|
||
if err := s.agentWalletTransactionStore.CreateWithTx(ctx, s.db, transaction); err != nil {
|
||
return fmt.Errorf("创建交易流水失败: %w", 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
|
||
}
|
||
|
||
// 查询关联订单
|
||
var order model.Order
|
||
if err := s.db.Where("id = ?", refund.OrderID).First(&order).Error; err != nil {
|
||
logger.Error("退款资产处理:查询订单失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err))
|
||
return
|
||
}
|
||
|
||
// 确定资产类型和 ID
|
||
var assetType string
|
||
var assetID uint
|
||
switch order.OrderType {
|
||
case model.OrderTypeSingleCard:
|
||
if order.IotCardID == nil {
|
||
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 {
|
||
logger.Error("退款资产处理:设备订单缺少 device_id", zap.Uint("order_id", order.ID))
|
||
return
|
||
}
|
||
assetType = "device"
|
||
assetID = *order.DeviceID
|
||
default:
|
||
logger.Error("退款资产处理:未知订单类型", zap.String("order_type", order.OrderType))
|
||
return
|
||
}
|
||
|
||
// 1. 按退款单精准失效套餐(仅处理本次退款订单关联套餐)
|
||
if s.packageActivationService == nil {
|
||
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 {
|
||
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 {
|
||
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 {
|
||
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. 无可用主套餐时才停机;退款不再重置世代或重建钱包。
|
||
s.stopAsset(ctx, assetType, assetID)
|
||
}
|
||
|
||
// 4. 标记退款后资产处理已完成
|
||
if err := s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("asset_reset", true).Error; err != nil {
|
||
logger.Error("退款资产处理:更新处理标记失败", zap.Uint("refund_id", refundID), zap.Error(err))
|
||
}
|
||
}
|
||
|
||
// stopAsset 根据资产类型执行停机操作
|
||
func (s *Service) stopAsset(ctx context.Context, assetType string, assetID uint) {
|
||
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
|
||
}
|
||
if s.stopResumeService != nil {
|
||
if err := s.stopResumeService.ManualStopCard(ctx, card.ICCID); err != nil {
|
||
logger.Error("退款资产处理:单卡停机失败", zap.String("iccid", card.ICCID), zap.Error(err))
|
||
}
|
||
}
|
||
case "device":
|
||
// 设备停机
|
||
if s.deviceService != nil {
|
||
if _, err := s.deviceService.StopDevice(ctx, assetID); err != nil {
|
||
logger.Error("退款资产处理:设备停机失败", zap.Uint("device_id", assetID), zap.Error(err))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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(voucherKey string) (string, error) {
|
||
normalized := strings.TrimSpace(voucherKey)
|
||
if normalized == "" {
|
||
return "", errors.New(errors.CodeInvalidParam, "退款申请必须上传退款凭证")
|
||
}
|
||
if len(normalized) > 500 {
|
||
return "", errors.New(errors.CodeInvalidParam, "退款凭证长度不能超过500")
|
||
}
|
||
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: 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,
|
||
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
|
||
}
|