暂存一下,防止丢失

This commit is contained in:
2026-07-24 16:07:18 +08:00
parent 5d6e23f1a5
commit a18ed8bc8d
180 changed files with 13597 additions and 1986 deletions

View File

@@ -0,0 +1,58 @@
// Package wallet 提供代理主钱包复杂写用例。
package wallet
import (
"context"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
// ChangeCreditService 调整既有店铺主钱包实际信用额度。
type ChangeCreditService struct {
db *gorm.DB
}
// NewChangeCreditService 创建实际信用额度调整服务。
func NewChangeCreditService(db *gorm.DB) *ChangeCreditService {
return &ChangeCreditService{db: db}
}
// Execute 按主钱包类型和版本条件更新,不修改余额、冻结金额或钱包流水。
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64, version int) (*dto.ShopCreditLimitResponse, error) {
var result *dto.ShopCreditLimitResponse
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var stored model.AgentWallet
if err := tx.Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&stored).Error; err != nil {
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
}
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.ChangeCredit(enabled, limit); err != nil {
return err
}
update := tx.Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND version = ? AND balance::numeric - frozen_balance::numeric + ?::numeric >= 0", stored.ID, constants.AgentWalletTypeMain, version, aggregate.EffectiveCredit()).
Updates(map[string]any{"credit_enabled": enabled, "credit_limit": limit, "version": gorm.Expr("version + 1")})
if update.Error != nil {
return errors.Wrap(errors.CodeInternalError, update.Error, "更新店铺信用额度失败")
}
if update.RowsAffected != 1 {
var current model.AgentWallet
if err := tx.Where("id = ? AND wallet_type = ?", stored.ID, constants.AgentWalletTypeMain).First(&current).Error; err != nil {
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
}
if current.Version != version {
return errors.New(errors.CodeConflict, "钱包版本已变化,请刷新后重试")
}
return errors.New(errors.CodeInsufficientQuota, "当前资金占用无法降低或关闭信用额度")
}
available, _ := aggregate.AvailableBalance()
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: version + 1}
return nil
})
return result, err
}

View File

@@ -0,0 +1,202 @@
// Package wallet 提供代理主钱包复杂写用例。
package wallet
import (
"context"
"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"
)
// DebitCommand 描述一次具有稳定业务引用的代理主钱包扣款。
type DebitCommand struct {
ShopID uint
Amount int64
ReferenceType string
ReferenceID uint
UserID uint
Creator uint
TransactionSubtype string
RelatedShopID *uint
AssetType string
AssetID uint
AssetIdentifier string
Remark string
RequestID string
CorrelationID string
}
// DebitResult 返回统一扣款后的资金快照。
type DebitResult struct {
WalletID uint
BalanceBefore int64
BalanceAfter int64
Version int
AlreadyApplied bool
}
// DebitedEvent 是代理主钱包扣款成功后的可靠领域事实。
type DebitedEvent struct {
EventID string `json:"event_id"`
WalletID uint `json:"wallet_id"`
ShopID uint `json:"shop_id"`
Amount int64 `json:"amount"`
BalanceBefore int64 `json:"balance_before"`
BalanceAfter int64 `json:"balance_after"`
Version int `json:"version"`
ReferenceType string `json:"reference_type"`
ReferenceID uint `json:"reference_id"`
TransactionType string `json:"transaction_type"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// DebitEventWriter 在调用方事务内追加扣款成功事件。
type DebitEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event DebitedEvent) error
}
// DebitService 统一代理主钱包扣款、流水与可靠事件。
type DebitService struct {
eventWriter DebitEventWriter
now func() time.Time
}
// NewDebitService 创建统一代理主钱包扣款服务。
func NewDebitService(eventWriter DebitEventWriter, now func() time.Time) *DebitService {
if now == nil {
now = time.Now
}
return &DebitService{eventWriter: eventWriter, now: now}
}
// DebitInTx 在调用方事务内完成锁定、扣款、唯一流水和 Outbox 事件。
func (s *DebitService) DebitInTx(ctx context.Context, tx *gorm.DB, command DebitCommand) (DebitResult, error) {
if s == nil || s.eventWriter == nil || tx == nil {
return DebitResult{}, errors.New(errors.CodeInternalError, "代理主钱包扣款能力未完整配置")
}
if err := validateDebitCommand(command); err != nil {
return DebitResult{}, err
}
var stored model.AgentWallet
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", command.ShopID, constants.AgentWalletTypeMain).
First(&stored).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return DebitResult{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
}
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
}
existing, err := findExistingDebit(ctx, tx, command.ReferenceType, command.ReferenceID)
if err != nil {
return DebitResult{}, err
}
if existing != nil {
if existing.AgentWalletID != stored.ID || existing.Amount != -command.Amount {
return DebitResult{}, errors.New(errors.CodeConflict, "业务单已存在不一致的钱包扣款流水")
}
return DebitResult{
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.Debit(command.Amount); err != nil {
return DebitResult{}, err
}
updatedAt := s.now().UTC()
update := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where(`id = ? AND wallet_type = ? AND status = ? AND version = ?
AND balance::numeric - frozen_balance::numeric
+ CASE WHEN credit_enabled THEN credit_limit::numeric ELSE 0 END >= ?::numeric`,
stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version, command.Amount).
Updates(map[string]any{
"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": updatedAt,
})
if update.Error != nil {
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "扣减代理主钱包失败")
}
if update.RowsAffected != 1 {
return DebitResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
}
transaction := buildDebitTransaction(stored, aggregate.Balance, command)
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return DebitResult{}, 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: updatedAt,
RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包扣款事件失败")
}
return DebitResult{
WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
Version: stored.Version + 1,
}, nil
}
func validateDebitCommand(command DebitCommand) error {
if command.ShopID == 0 || command.Amount <= 0 || command.ReferenceID == 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包扣款参数无效")
}
if strings.TrimSpace(command.ReferenceType) != constants.ReferenceTypeOrder {
return errors.New(errors.CodeInvalidParam, "当前统一扣款仅支持订单业务")
}
return nil
}
func findExistingDebit(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint) (*model.AgentWalletTransaction, error) {
var transaction model.AgentWalletTransaction
err := tx.WithContext(ctx).Unscoped().
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
referenceType, referenceID, constants.AgentTransactionTypeDeduct, 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 buildDebitTransaction(stored model.AgentWallet, balanceAfter int64, command DebitCommand) *model.AgentWalletTransaction {
referenceType := strings.TrimSpace(command.ReferenceType)
remark := strings.TrimSpace(command.Remark)
var subtype *string
if value := strings.TrimSpace(command.TransactionSubtype); value != "" {
subtype = &value
}
return &model.AgentWalletTransaction{
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
TransactionType: constants.AgentTransactionTypeDeduct, TransactionSubtype: subtype,
Amount: -command.Amount, BalanceBefore: stored.Balance, BalanceAfter: balanceAfter,
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.ReferenceID,
RelatedShopID: command.RelatedShopID, AssetType: command.AssetType, AssetID: command.AssetID,
AssetIdentifier: command.AssetIdentifier, Remark: &remark, Creator: command.Creator,
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
}
}

View File

@@ -0,0 +1,182 @@
package wallet
import (
"context"
"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"
)
// PostingCommand 描述一次具有稳定业务引用的代理主钱包正向入账。
type PostingCommand struct {
ShopID uint
WalletID uint
Amount int64
ReferenceType string
ReferenceID uint
TransactionType string
UserID uint
Creator uint
Remark string
Metadata *string
RequestID string
CorrelationID string
}
// PostingResult 返回统一入账后的资金快照。
type PostingResult struct {
WalletID uint
BalanceBefore int64
BalanceAfter int64
Version int
AlreadyApplied bool
}
// CreditedEvent 是代理主钱包正向入账成功后的可靠领域事实。
type CreditedEvent struct {
EventID string `json:"event_id"`
WalletID uint `json:"wallet_id"`
ShopID uint `json:"shop_id"`
Amount int64 `json:"amount"`
BalanceBefore int64 `json:"balance_before"`
BalanceAfter int64 `json:"balance_after"`
Version int `json:"version"`
ReferenceType string `json:"reference_type"`
ReferenceID uint `json:"reference_id"`
TransactionType string `json:"transaction_type"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// CreditEventWriter 在调用方事务内追加正向入账事件。
type CreditEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event CreditedEvent) error
}
// PostingService 统一代理主钱包充值与人工调整入账。
type PostingService struct {
eventWriter CreditEventWriter
now func() time.Time
}
// NewPostingService 创建统一代理主钱包入账服务。
func NewPostingService(eventWriter CreditEventWriter, now func() time.Time) *PostingService {
if now == nil {
now = time.Now
}
return &PostingService{eventWriter: eventWriter, now: now}
}
// PostInTx 在调用方事务内完成锁定、入账、唯一流水和 Outbox 事件。
func (s *PostingService) PostInTx(ctx context.Context, tx *gorm.DB, command PostingCommand) (PostingResult, error) {
if s == nil || s.eventWriter == nil || tx == nil {
return PostingResult{}, errors.New(errors.CodeInternalError, "代理主钱包入账能力未完整配置")
}
if err := validatePostingCommand(command); err != nil {
return PostingResult{}, err
}
var stored model.AgentWallet
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", command.ShopID, constants.AgentWalletTypeMain).
First(&stored).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return PostingResult{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
}
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
}
if command.WalletID > 0 && command.WalletID != stored.ID {
return PostingResult{}, errors.New(errors.CodeConflict, "入账业务单与代理主钱包归属不一致")
}
existing, err := findExistingPosting(ctx, tx, command.ReferenceType, command.ReferenceID)
if err != nil {
return PostingResult{}, err
}
if existing != nil {
if existing.AgentWalletID != stored.ID || existing.Amount != command.Amount || existing.TransactionType != command.TransactionType {
return PostingResult{}, errors.New(errors.CodeConflict, "业务单已存在不一致的钱包入账流水")
}
return PostingResult{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 PostingResult{}, 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 PostingResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "增加代理主钱包余额失败")
}
if update.RowsAffected != 1 {
return PostingResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
}
referenceType := strings.TrimSpace(command.ReferenceType)
remark := strings.TrimSpace(command.Remark)
transaction := &model.AgentWalletTransaction{
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
TransactionType: command.TransactionType, Amount: command.Amount,
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Status: constants.TransactionStatusSuccess,
ReferenceType: &referenceType, ReferenceID: &command.ReferenceID, Remark: &remark, Metadata: command.Metadata,
Creator: command.Creator, ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包入账流水失败")
}
event := CreditedEvent{
EventID: "agent-wallet:" + referenceType + ":" + strconv.FormatUint(uint64(command.ReferenceID), 10) + ":credited",
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
ReferenceType: referenceType, ReferenceID: command.ReferenceID, TransactionType: command.TransactionType,
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包入账事件失败")
}
return PostingResult{WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1}, nil
}
func validatePostingCommand(command PostingCommand) error {
if command.ShopID == 0 || command.Amount <= 0 || command.ReferenceID == 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包入账参数无效")
}
referenceType := strings.TrimSpace(command.ReferenceType)
validRecharge := referenceType == constants.ReferenceTypeTopup && command.TransactionType == constants.AgentTransactionTypeRecharge
validAdjustment := referenceType == constants.ReferenceTypeManualAdjustment && command.TransactionType == constants.AgentTransactionTypeAdjustment
if !validRecharge && !validAdjustment {
return errors.New(errors.CodeInvalidParam, "代理主钱包入账业务类型无效")
}
return nil
}
func findExistingPosting(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint) (*model.AgentWalletTransaction, error) {
var transaction model.AgentWalletTransaction
err := tx.WithContext(ctx).Unscoped().
Where("reference_type = ? AND reference_id = ? AND transaction_type IN ? AND status = ?",
strings.TrimSpace(referenceType), referenceID, []string{constants.AgentTransactionTypeRecharge, constants.AgentTransactionTypeAdjustment}, 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, "查询代理主钱包入账流水失败")
}

View File

@@ -0,0 +1,289 @@
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)
}

View File

@@ -0,0 +1,297 @@
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
}