298 lines
12 KiB
Go
298 lines
12 KiB
Go
package wallet
|
|
|
|
import (
|
|
"context"
|
|
"strconv"
|
|
"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"
|
|
)
|
|
|
|
// ReservationCommand 描述一次具有稳定业务引用的代理主钱包预占操作。
|
|
type ReservationCommand struct {
|
|
ShopID uint
|
|
Amount int64
|
|
ReferenceType string
|
|
ReferenceID uint
|
|
UserID uint
|
|
Creator uint
|
|
Subtype string
|
|
RelatedShopID *uint
|
|
AssetType string
|
|
AssetID uint
|
|
AssetIdentifier string
|
|
Remark string
|
|
RequestID string
|
|
CorrelationID string
|
|
}
|
|
|
|
// ReservationEvent 是代理主钱包预占状态变化事件。
|
|
type ReservationEvent struct {
|
|
EventID string `json:"event_id"`
|
|
ReservationID uint `json:"reservation_id"`
|
|
WalletID uint `json:"wallet_id"`
|
|
ShopID uint `json:"shop_id"`
|
|
Amount int64 `json:"amount"`
|
|
Status int `json:"status"`
|
|
ReferenceType string `json:"reference_type"`
|
|
ReferenceID uint `json:"reference_id"`
|
|
OccurredAt time.Time `json:"occurred_at"`
|
|
RequestID string `json:"request_id,omitempty"`
|
|
CorrelationID string `json:"correlation_id,omitempty"`
|
|
}
|
|
|
|
// ReservationEventWriter 在业务事务内追加预占状态事件。
|
|
type ReservationEventWriter interface {
|
|
Append(ctx context.Context, tx *gorm.DB, event ReservationEvent) error
|
|
}
|
|
|
|
// ReservationService 统一代理主钱包冻结、释放与完成扣除。
|
|
type ReservationService struct {
|
|
reservationEvents ReservationEventWriter
|
|
debitEvents DebitEventWriter
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewReservationService 创建代理主钱包预占服务。
|
|
func NewReservationService(reservationEvents ReservationEventWriter, debitEvents DebitEventWriter, now func() time.Time) *ReservationService {
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
return &ReservationService{reservationEvents: reservationEvents, debitEvents: debitEvents, now: now}
|
|
}
|
|
|
|
// FreezeInTx 在调用方事务内冻结资金并创建唯一预占事实。
|
|
func (s *ReservationService) FreezeInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
|
|
if err := s.validateFreeze(tx, command); err != nil {
|
|
return err
|
|
}
|
|
wallet, aggregate, err := lockMainWallet(ctx, tx, command.ShopID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
existing, err := findReservation(ctx, tx, command.ReferenceType, command.ReferenceID, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing != nil {
|
|
if existing.AgentWalletID == wallet.ID && existing.Amount == command.Amount && existing.Status == constants.AgentWalletReservationStatusFrozen {
|
|
return nil
|
|
}
|
|
return errors.New(errors.CodeConflict, "业务引用已存在不一致的钱包预占")
|
|
}
|
|
if err := aggregate.Freeze(command.Amount); err != nil {
|
|
return err
|
|
}
|
|
now := s.now().UTC()
|
|
if err := updateWalletFunds(ctx, tx, wallet, aggregate, now); err != nil {
|
|
return err
|
|
}
|
|
reservation := &model.AgentWalletReservation{
|
|
AgentWalletID: wallet.ID, ShopID: wallet.ShopID, Amount: command.Amount,
|
|
Status: constants.AgentWalletReservationStatusFrozen,
|
|
ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID, Creator: command.Creator,
|
|
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
|
|
}
|
|
if err := tx.WithContext(ctx).Create(reservation).Error; err != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包预占事实失败")
|
|
}
|
|
return s.appendReservationEvent(ctx, tx, reservation, now, command)
|
|
}
|
|
|
|
// ReleaseInTx 幂等释放处于冻结状态的资金预占。
|
|
func (s *ReservationService) ReleaseInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
|
|
return s.finish(ctx, tx, command, constants.AgentWalletReservationStatusReleased)
|
|
}
|
|
|
|
// CompleteInTx 完成冻结资金扣除并创建真实扣款流水与扣款事件。
|
|
func (s *ReservationService) CompleteInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
|
|
return s.finish(ctx, tx, command, constants.AgentWalletReservationStatusCompleted)
|
|
}
|
|
|
|
func (s *ReservationService) finish(ctx context.Context, tx *gorm.DB, command ReservationCommand, targetStatus int) error {
|
|
if err := s.validateFinish(tx, command); err != nil {
|
|
return err
|
|
}
|
|
reservation, err := findReservation(ctx, tx, command.ReferenceType, command.ReferenceID, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if reservation == nil {
|
|
return errors.New(errors.CodeNotFound, "代理主钱包预占事实不存在")
|
|
}
|
|
if (command.ShopID > 0 && reservation.ShopID != command.ShopID) || (command.Amount > 0 && reservation.Amount != command.Amount) {
|
|
return errors.New(errors.CodeConflict, "钱包预占事实与请求不一致")
|
|
}
|
|
command.ShopID = reservation.ShopID
|
|
command.Amount = reservation.Amount
|
|
if reservation.Status == targetStatus {
|
|
return nil
|
|
}
|
|
if reservation.Status != constants.AgentWalletReservationStatusFrozen {
|
|
return errors.New(errors.CodeInvalidStatus, "钱包预占已经进入其他终态")
|
|
}
|
|
wallet, aggregate, err := lockMainWallet(ctx, tx, command.ShopID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if wallet.ID != reservation.AgentWalletID {
|
|
return errors.New(errors.CodeConflict, "钱包预占归属不一致")
|
|
}
|
|
if targetStatus == constants.AgentWalletReservationStatusReleased {
|
|
err = aggregate.Release(command.Amount)
|
|
} else {
|
|
err = aggregate.CompleteReserved(command.Amount)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := s.now().UTC()
|
|
if err := updateWalletFunds(ctx, tx, wallet, aggregate, now); err != nil {
|
|
return err
|
|
}
|
|
result := tx.WithContext(ctx).Model(&model.AgentWalletReservation{}).
|
|
Where("id = ? AND status = ?", reservation.ID, constants.AgentWalletReservationStatusFrozen).
|
|
Updates(map[string]any{"status": targetStatus, "completed_at": now, "updated_at": now})
|
|
if result.Error != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新代理主钱包预占状态失败")
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return errors.New(errors.CodeConflict, "钱包预占状态已变化")
|
|
}
|
|
reservation.Status = targetStatus
|
|
reservation.CompletedAt = &now
|
|
if targetStatus == constants.AgentWalletReservationStatusCompleted {
|
|
if err := s.createCompletedDebit(ctx, tx, wallet, aggregate, command, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return s.appendReservationEvent(ctx, tx, reservation, now, command)
|
|
}
|
|
|
|
func (s *ReservationService) validateConfigured(tx *gorm.DB) error {
|
|
if s == nil || s.reservationEvents == nil || s.debitEvents == nil || tx == nil {
|
|
return errors.New(errors.CodeInternalError, "代理主钱包预占能力未完整配置")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ReservationService) validateFreeze(tx *gorm.DB, command ReservationCommand) error {
|
|
if err := s.validateConfigured(tx); err != nil {
|
|
return err
|
|
}
|
|
if command.ShopID == 0 || command.ReferenceType != constants.ReferenceTypeOrder || command.ReferenceID == 0 || command.Amount <= 0 {
|
|
return errors.New(errors.CodeInvalidParam, "代理主钱包预占参数无效")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ReservationService) validateFinish(tx *gorm.DB, command ReservationCommand) error {
|
|
if err := s.validateConfigured(tx); err != nil {
|
|
return err
|
|
}
|
|
if command.ReferenceType != constants.ReferenceTypeOrder || command.ReferenceID == 0 || command.Amount < 0 {
|
|
return errors.New(errors.CodeInvalidParam, "代理主钱包预占参数无效")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func lockMainWallet(ctx context.Context, tx *gorm.DB, shopID uint) (model.AgentWallet, *domainwallet.AgentWallet, error) {
|
|
var wallet model.AgentWallet
|
|
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&wallet).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return wallet, nil, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
|
|
}
|
|
return wallet, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
|
|
}
|
|
aggregate := &domainwallet.AgentWallet{
|
|
ID: wallet.ID, ShopID: wallet.ShopID, WalletType: wallet.WalletType,
|
|
Balance: wallet.Balance, FrozenBalance: wallet.FrozenBalance,
|
|
CreditEnabled: wallet.CreditEnabled, CreditLimit: wallet.CreditLimit,
|
|
Status: wallet.Status, Version: wallet.Version,
|
|
}
|
|
return wallet, aggregate, nil
|
|
}
|
|
|
|
func findReservation(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint, lock bool) (*model.AgentWalletReservation, error) {
|
|
var reservation model.AgentWalletReservation
|
|
query := tx.WithContext(ctx)
|
|
if lock {
|
|
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
|
|
}
|
|
err := query.Where("reference_type = ? AND reference_id = ?", referenceType, referenceID).First(&reservation).Error
|
|
if err == nil {
|
|
return &reservation, nil
|
|
}
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, nil
|
|
}
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包预占事实失败")
|
|
}
|
|
|
|
func updateWalletFunds(ctx context.Context, tx *gorm.DB, stored model.AgentWallet, aggregate *domainwallet.AgentWallet, now time.Time) error {
|
|
result := 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, "frozen_balance": aggregate.FrozenBalance,
|
|
"version": gorm.Expr("version + 1"), "updated_at": now,
|
|
})
|
|
if result.Error != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新代理主钱包预占资金失败")
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
return errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ReservationService) createCompletedDebit(ctx context.Context, tx *gorm.DB, stored model.AgentWallet, aggregate *domainwallet.AgentWallet, command ReservationCommand, now time.Time) error {
|
|
referenceType := command.ReferenceType
|
|
transaction := &model.AgentWalletTransaction{
|
|
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
|
|
TransactionType: constants.AgentTransactionTypeDeduct, Amount: -command.Amount,
|
|
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
|
|
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.ReferenceID,
|
|
RelatedShopID: command.RelatedShopID, AssetType: command.AssetType, AssetID: command.AssetID,
|
|
AssetIdentifier: command.AssetIdentifier, Remark: &command.Remark, Creator: command.Creator,
|
|
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
|
|
}
|
|
if command.Subtype != "" {
|
|
transaction.TransactionSubtype = &command.Subtype
|
|
}
|
|
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "创建冻结资金扣款流水失败")
|
|
}
|
|
event := DebitedEvent{
|
|
EventID: "agent-wallet:order:" + strconv.FormatUint(uint64(command.ReferenceID), 10) + ":debited",
|
|
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
|
|
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
|
|
ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID,
|
|
TransactionType: constants.AgentTransactionTypeDeduct, OccurredAt: now,
|
|
RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
|
}
|
|
if err := s.debitEvents.Append(ctx, tx, event); err != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "写入冻结资金扣款事件失败")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *ReservationService) appendReservationEvent(ctx context.Context, tx *gorm.DB, reservation *model.AgentWalletReservation, now time.Time, command ReservationCommand) error {
|
|
event := ReservationEvent{
|
|
EventID: "agent-wallet-reservation:" + strconv.FormatUint(uint64(reservation.ID), 10) + ":" + strconv.Itoa(reservation.Status),
|
|
ReservationID: reservation.ID, WalletID: reservation.AgentWalletID, ShopID: reservation.ShopID,
|
|
Amount: reservation.Amount, Status: reservation.Status,
|
|
ReferenceType: reservation.ReferenceType, ReferenceID: reservation.ReferenceID,
|
|
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
|
}
|
|
if err := s.reservationEvents.Append(ctx, tx, event); err != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包预占事件失败")
|
|
}
|
|
return nil
|
|
}
|