290 lines
12 KiB
Go
290 lines
12 KiB
Go
package wallet
|
|
|
|
import (
|
|
"context"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
|
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// RefundCommand 描述一次沿订单原扣款回溯的代理主钱包退款。
|
|
type RefundCommand struct {
|
|
OrderID uint
|
|
RefundID uint
|
|
Amount int64
|
|
LegacyPayerShopID uint
|
|
LegacyDeductAmount int64
|
|
LegacyRelatedShopID *uint
|
|
AssetType string
|
|
AssetID uint
|
|
AssetIdentifier string
|
|
UserID uint
|
|
Creator uint
|
|
Remark string
|
|
RequestID string
|
|
CorrelationID string
|
|
}
|
|
|
|
// RefundResult 返回代理主钱包退款后的资金快照。
|
|
type RefundResult struct {
|
|
WalletID uint
|
|
BalanceBefore int64
|
|
BalanceAfter int64
|
|
Version int
|
|
AlreadyApplied bool
|
|
}
|
|
|
|
// RefundedEvent 是代理主钱包订单退款成功后的可靠资金事实。
|
|
type RefundedEvent struct {
|
|
EventID string `json:"event_id"`
|
|
WalletID uint `json:"wallet_id"`
|
|
ShopID uint `json:"shop_id"`
|
|
OrderID uint `json:"order_id"`
|
|
RefundID uint `json:"refund_id"`
|
|
Amount int64 `json:"amount"`
|
|
BalanceBefore int64 `json:"balance_before"`
|
|
BalanceAfter int64 `json:"balance_after"`
|
|
Version int `json:"version"`
|
|
OriginalDebitTransactionID uint `json:"original_debit_transaction_id,omitempty"`
|
|
OriginalDeductAmount int64 `json:"original_deduct_amount"`
|
|
RelatedShopID *uint `json:"related_shop_id,omitempty"`
|
|
TransactionSubtype *string `json:"transaction_subtype,omitempty"`
|
|
AssetType string `json:"asset_type,omitempty"`
|
|
AssetID uint `json:"asset_id,omitempty"`
|
|
AssetIdentifier string `json:"asset_identifier,omitempty"`
|
|
Legacy bool `json:"legacy"`
|
|
OccurredAt time.Time `json:"occurred_at"`
|
|
RequestID string `json:"request_id,omitempty"`
|
|
CorrelationID string `json:"correlation_id,omitempty"`
|
|
}
|
|
|
|
// RefundEventWriter 在调用方事务内追加代理主钱包退款事件。
|
|
type RefundEventWriter interface {
|
|
Append(ctx context.Context, tx *gorm.DB, event RefundedEvent) error
|
|
}
|
|
|
|
// RefundService 统一代理订单退款回充、真实流水和可靠事件。
|
|
type RefundService struct {
|
|
eventWriter RefundEventWriter
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewRefundService 创建统一代理主钱包退款服务。
|
|
func NewRefundService(eventWriter RefundEventWriter, now func() time.Time) *RefundService {
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
return &RefundService{eventWriter: eventWriter, now: now}
|
|
}
|
|
|
|
// RefundInTx 在调用方事务内按原扣款事实完成退款回充、版本递增、唯一流水和 Outbox。
|
|
func (s *RefundService) RefundInTx(ctx context.Context, tx *gorm.DB, command RefundCommand) (RefundResult, error) {
|
|
if s == nil || s.eventWriter == nil || tx == nil {
|
|
return RefundResult{}, errors.New(errors.CodeInternalError, "代理主钱包退款能力未完整配置")
|
|
}
|
|
if err := validateRefundCommand(command); err != nil {
|
|
return RefundResult{}, err
|
|
}
|
|
|
|
origin, err := findOriginalOrderDebit(ctx, tx, command.OrderID)
|
|
if err != nil {
|
|
return RefundResult{}, err
|
|
}
|
|
resolution, err := resolveRefundTarget(command, origin)
|
|
if err != nil {
|
|
return RefundResult{}, err
|
|
}
|
|
stored, err := lockRefundWallet(ctx, tx, resolution)
|
|
if err != nil {
|
|
return RefundResult{}, err
|
|
}
|
|
|
|
existing, err := findExistingRefund(ctx, tx, command.RefundID)
|
|
if err != nil {
|
|
return RefundResult{}, err
|
|
}
|
|
if existing != nil {
|
|
if existing.AgentWalletID != stored.ID || existing.Amount != command.Amount ||
|
|
!sameOptionalUint(existing.RelatedShopID, resolution.relatedShopID) ||
|
|
!sameOptionalString(existing.TransactionSubtype, resolution.subtype) ||
|
|
existing.AssetType != resolution.assetType || existing.AssetID != resolution.assetID ||
|
|
existing.AssetIdentifier != resolution.assetIdentifier {
|
|
return RefundResult{}, errors.New(errors.CodeConflict, "退款单已存在不一致的钱包回充流水")
|
|
}
|
|
return RefundResult{
|
|
WalletID: stored.ID, BalanceBefore: existing.BalanceBefore, BalanceAfter: existing.BalanceAfter,
|
|
Version: stored.Version, AlreadyApplied: true,
|
|
}, nil
|
|
}
|
|
|
|
aggregate := domainwallet.AgentWallet{
|
|
ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType,
|
|
Balance: stored.Balance, FrozenBalance: stored.FrozenBalance,
|
|
CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit,
|
|
Status: stored.Status, Version: stored.Version,
|
|
}
|
|
if err := aggregate.Credit(command.Amount); err != nil {
|
|
return RefundResult{}, err
|
|
}
|
|
now := s.now().UTC()
|
|
update := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
|
Where("id = ? AND wallet_type = ? AND status = ? AND version = ?",
|
|
stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version).
|
|
Updates(map[string]any{"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": now})
|
|
if update.Error != nil {
|
|
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "退回代理主钱包余额失败")
|
|
}
|
|
if update.RowsAffected != 1 {
|
|
return RefundResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
|
|
}
|
|
|
|
transaction := buildRefundTransaction(stored, aggregate.Balance, command, resolution)
|
|
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
|
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包退款流水失败")
|
|
}
|
|
event := RefundedEvent{
|
|
EventID: "agent-wallet:refund:" + strconv.FormatUint(uint64(command.RefundID), 10) + ":refunded",
|
|
WalletID: stored.ID, ShopID: stored.ShopID, OrderID: command.OrderID, RefundID: command.RefundID,
|
|
Amount: command.Amount, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
|
|
Version: stored.Version + 1, OriginalDebitTransactionID: resolution.originalDebitID,
|
|
OriginalDeductAmount: resolution.deductAmount,
|
|
RelatedShopID: resolution.relatedShopID, TransactionSubtype: resolution.subtype, Legacy: resolution.legacy,
|
|
AssetType: resolution.assetType, AssetID: resolution.assetID, AssetIdentifier: resolution.assetIdentifier,
|
|
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
|
}
|
|
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
|
|
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包退款事件失败")
|
|
}
|
|
return RefundResult{
|
|
WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
|
|
Version: stored.Version + 1,
|
|
}, nil
|
|
}
|
|
|
|
type refundResolution struct {
|
|
walletID uint
|
|
shopID uint
|
|
originalDebitID uint
|
|
deductAmount int64
|
|
relatedShopID *uint
|
|
subtype *string
|
|
assetType string
|
|
assetID uint
|
|
assetIdentifier string
|
|
legacy bool
|
|
}
|
|
|
|
func validateRefundCommand(command RefundCommand) error {
|
|
if command.OrderID == 0 || command.RefundID == 0 || command.Amount <= 0 {
|
|
return errors.New(errors.CodeInvalidParam, "代理主钱包退款参数无效")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func findOriginalOrderDebit(ctx context.Context, tx *gorm.DB, orderID uint) (*model.AgentWalletTransaction, error) {
|
|
var transaction model.AgentWalletTransaction
|
|
err := tx.WithContext(ctx).Unscoped().
|
|
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
|
constants.ReferenceTypeOrder, orderID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
|
|
Order("id ASC").First(&transaction).Error
|
|
if err == nil {
|
|
return &transaction, nil
|
|
}
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, nil
|
|
}
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原代理主钱包扣款流水失败")
|
|
}
|
|
|
|
func resolveRefundTarget(command RefundCommand, origin *model.AgentWalletTransaction) (refundResolution, error) {
|
|
if origin != nil {
|
|
if origin.Amount >= 0 || origin.Amount == math.MinInt64 {
|
|
return refundResolution{}, errors.New(errors.CodeInternalError, "原代理主钱包扣款流水金额非法")
|
|
}
|
|
maxAmount := -origin.Amount
|
|
if command.Amount > maxAmount {
|
|
return refundResolution{}, errors.New(errors.CodeInvalidParam, "退款金额不能大于原钱包扣款金额")
|
|
}
|
|
return refundResolution{
|
|
walletID: origin.AgentWalletID, originalDebitID: origin.ID, deductAmount: maxAmount,
|
|
relatedShopID: origin.RelatedShopID, subtype: origin.TransactionSubtype,
|
|
assetType: origin.AssetType, assetID: origin.AssetID, assetIdentifier: origin.AssetIdentifier,
|
|
}, nil
|
|
}
|
|
if command.LegacyPayerShopID == 0 || command.LegacyDeductAmount <= 0 {
|
|
return refundResolution{}, errors.New(errors.CodeInternalError, "历史订单缺少原扣款流水和兼容付款快照")
|
|
}
|
|
if command.Amount > command.LegacyDeductAmount {
|
|
return refundResolution{}, errors.New(errors.CodeInvalidParam, "退款金额不能大于历史订单实付金额")
|
|
}
|
|
return refundResolution{
|
|
shopID: command.LegacyPayerShopID, relatedShopID: command.LegacyRelatedShopID,
|
|
deductAmount: command.LegacyDeductAmount,
|
|
assetType: command.AssetType, assetID: command.AssetID, assetIdentifier: command.AssetIdentifier,
|
|
legacy: true,
|
|
}, nil
|
|
}
|
|
|
|
func lockRefundWallet(ctx context.Context, tx *gorm.DB, resolution refundResolution) (model.AgentWallet, error) {
|
|
var stored model.AgentWallet
|
|
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("wallet_type = ?", constants.AgentWalletTypeMain)
|
|
if resolution.walletID > 0 {
|
|
query = query.Where("id = ?", resolution.walletID)
|
|
} else {
|
|
query = query.Where("shop_id = ?", resolution.shopID)
|
|
}
|
|
if err := query.First(&stored).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return model.AgentWallet{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
|
|
}
|
|
return model.AgentWallet{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
|
|
}
|
|
return stored, nil
|
|
}
|
|
|
|
func findExistingRefund(ctx context.Context, tx *gorm.DB, refundID uint) (*model.AgentWalletTransaction, error) {
|
|
var transaction model.AgentWalletTransaction
|
|
err := tx.WithContext(ctx).Unscoped().
|
|
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
|
|
constants.ReferenceTypeRefund, refundID, constants.AgentTransactionTypeRefund, constants.TransactionStatusSuccess).
|
|
First(&transaction).Error
|
|
if err == nil {
|
|
return &transaction, nil
|
|
}
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, nil
|
|
}
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包退款流水失败")
|
|
}
|
|
|
|
func buildRefundTransaction(stored model.AgentWallet, balanceAfter int64, command RefundCommand, resolution refundResolution) *model.AgentWalletTransaction {
|
|
referenceType := constants.ReferenceTypeRefund
|
|
remark := strings.TrimSpace(command.Remark)
|
|
return &model.AgentWalletTransaction{
|
|
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
|
|
TransactionType: constants.AgentTransactionTypeRefund, TransactionSubtype: resolution.subtype,
|
|
Amount: command.Amount, BalanceBefore: stored.Balance, BalanceAfter: balanceAfter,
|
|
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.RefundID,
|
|
RelatedShopID: resolution.relatedShopID, AssetType: resolution.assetType, AssetID: resolution.assetID,
|
|
AssetIdentifier: resolution.assetIdentifier, Remark: &remark, Creator: command.Creator,
|
|
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
|
|
}
|
|
}
|
|
|
|
func sameOptionalUint(left, right *uint) bool {
|
|
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
|
|
}
|
|
|
|
func sameOptionalString(left, right *string) bool {
|
|
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
|
|
}
|