Files
junhong_cmp_fiber/internal/application/wallet/posting.go
2026-07-24 16:07:18 +08:00

183 lines
7.9 KiB
Go

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, "查询代理主钱包入账流水失败")
}