fix(wallet): enforce consistent withdrawal accounting
This commit is contained in:
253
backend/api/internal/logic/common/wallet_balance.go
Normal file
253
backend/api/internal/logic/common/wallet_balance.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidWalletAmount = errors.New("invalid wallet amount")
|
||||
ErrWalletUnavailable = errors.New("wallet is unavailable")
|
||||
ErrWalletBalance = errors.New("insufficient wallet balance")
|
||||
ErrWalletOverflow = errors.New("wallet balance overflow")
|
||||
ErrIdempotencyConflict = errors.New("idempotency request conflicts with existing withdrawal")
|
||||
)
|
||||
|
||||
// WalletWithdrawalInput 是统一提现预扣所需的最小业务输入。
|
||||
type WalletWithdrawalInput struct {
|
||||
WalletBasicID uint64
|
||||
WalletBankID uint64
|
||||
RequestNo string
|
||||
CashNo string
|
||||
Amount int64
|
||||
Channel string
|
||||
Remark string
|
||||
OperatorIdentity string
|
||||
OperatorName string
|
||||
}
|
||||
|
||||
// LockWalletForUpdate 锁定钱包事实行,所有资金扣减必须在同一事务内调用。
|
||||
func LockWalletForUpdate(tx *gorm.DB, walletID uint64, requireEnabled bool) (models.WalletBasic, error) {
|
||||
var wallet models.WalletBasic
|
||||
query := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", walletID)
|
||||
if requireEnabled {
|
||||
query = query.Where("status = ?", StatusEnable)
|
||||
}
|
||||
if err := query.First(&wallet).Error; err != nil {
|
||||
return wallet, err
|
||||
}
|
||||
return wallet, nil
|
||||
}
|
||||
|
||||
// SpendWalletBalance 扣减普通消费,并保证可提现余额始终不超过总余额。
|
||||
func SpendWalletBalance(wallet *models.WalletBasic, amount int64) error {
|
||||
if amount <= 0 {
|
||||
return ErrInvalidWalletAmount
|
||||
}
|
||||
if wallet.Status != StatusEnable {
|
||||
return ErrWalletUnavailable
|
||||
}
|
||||
if wallet.Balance < 0 || wallet.WithdrawalBalance < 0 {
|
||||
return ErrWalletBalance
|
||||
}
|
||||
if wallet.Balance < amount {
|
||||
return ErrWalletBalance
|
||||
}
|
||||
wallet.Balance -= amount
|
||||
if wallet.WithdrawalBalance > wallet.Balance {
|
||||
wallet.WithdrawalBalance = wallet.Balance
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReserveWalletWithdrawal 在申请时同时预扣总余额和可提现余额。
|
||||
func ReserveWalletWithdrawal(wallet *models.WalletBasic, amount int64) error {
|
||||
if amount <= 0 {
|
||||
return ErrInvalidWalletAmount
|
||||
}
|
||||
if wallet.Status != StatusEnable {
|
||||
return ErrWalletUnavailable
|
||||
}
|
||||
if wallet.Balance < 0 || wallet.WithdrawalBalance < 0 || wallet.WithdrawalBalance > wallet.Balance {
|
||||
return ErrWalletBalance
|
||||
}
|
||||
if wallet.Balance < amount || wallet.WithdrawalBalance < amount {
|
||||
return ErrWalletBalance
|
||||
}
|
||||
wallet.Balance -= amount
|
||||
wallet.WithdrawalBalance -= amount
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseWalletWithdrawal 在提现驳回时原样返还此前预扣的两类余额。
|
||||
func ReleaseWalletWithdrawal(wallet *models.WalletBasic, amount int64) error {
|
||||
if amount <= 0 {
|
||||
return ErrInvalidWalletAmount
|
||||
}
|
||||
if wallet.Balance < 0 || wallet.WithdrawalBalance < 0 || wallet.WithdrawalBalance > wallet.Balance {
|
||||
return ErrWalletBalance
|
||||
}
|
||||
if wallet.Balance > math.MaxInt64-amount || wallet.WithdrawalBalance > math.MaxInt64-amount {
|
||||
return ErrWalletOverflow
|
||||
}
|
||||
wallet.Balance += amount
|
||||
wallet.WithdrawalBalance += amount
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseLegacyWithdrawalBalance 兼容旧客户端申请只预扣可提现余额的历史记录。
|
||||
func ReleaseLegacyWithdrawalBalance(wallet *models.WalletBasic, amount int64) error {
|
||||
if amount <= 0 {
|
||||
return ErrInvalidWalletAmount
|
||||
}
|
||||
if wallet.WithdrawalBalance > math.MaxInt64-amount {
|
||||
return ErrWalletOverflow
|
||||
}
|
||||
wallet.WithdrawalBalance += amount
|
||||
if wallet.WithdrawalBalance > wallet.Balance {
|
||||
wallet.WithdrawalBalance = wallet.Balance
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseWithdrawalApplication 按新旧申请的实际预扣方式返还余额。
|
||||
func ReleaseWithdrawalApplication(wallet *models.WalletBasic, application models.WalletApplyCash) (bool, error) {
|
||||
if application.BalanceReserved {
|
||||
return true, ReleaseWalletWithdrawal(wallet, application.Amount)
|
||||
}
|
||||
if wallet.OwnerType == "user" || wallet.OwnerType == "staff" {
|
||||
return true, ReleaseLegacyWithdrawalBalance(wallet, application.Amount)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// SettleLegacyWithdrawal 为升级前未完整预扣的申请补扣余额。
|
||||
func SettleLegacyWithdrawal(wallet *models.WalletBasic, application models.WalletApplyCash) (bool, error) {
|
||||
if application.BalanceReserved {
|
||||
return false, nil
|
||||
}
|
||||
if wallet.OwnerType == "user" || wallet.OwnerType == "staff" {
|
||||
return true, SpendWalletBalance(wallet, application.Amount)
|
||||
}
|
||||
return true, ReserveWalletWithdrawal(wallet, application.Amount)
|
||||
}
|
||||
|
||||
// SaveWalletBalances 将内存中已校验的余额快照写回当前事务。
|
||||
func SaveWalletBalances(tx *gorm.DB, wallet models.WalletBasic) error {
|
||||
result := tx.Model(&models.WalletBasic{}).Where("id = ?", wallet.ID).Updates(map[string]any{
|
||||
"balance": wallet.Balance,
|
||||
"withdrawal_balance": wallet.WithdrawalBalance,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrWalletUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateReservedWithdrawal 幂等创建提现申请,并在同一事务中预扣两类余额和写入流水。
|
||||
func CreateReservedWithdrawal(tx *gorm.DB, input WalletWithdrawalInput) (models.WalletApplyCash, bool, error) {
|
||||
wallet, err := LockWalletForUpdate(tx, input.WalletBasicID, false)
|
||||
if err != nil {
|
||||
return models.WalletApplyCash{}, false, err
|
||||
}
|
||||
var existing models.WalletApplyCash
|
||||
err = tx.Where("request_no = ?", input.RequestNo).First(&existing).Error
|
||||
if err == nil {
|
||||
if existing.WalletBasicID != input.WalletBasicID ||
|
||||
existing.WalletBankID != input.WalletBankID ||
|
||||
existing.Amount != input.Amount ||
|
||||
existing.Channel != input.Channel {
|
||||
return existing, false, ErrIdempotencyConflict
|
||||
}
|
||||
return existing, false, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return models.WalletApplyCash{}, false, err
|
||||
}
|
||||
|
||||
if err := ReserveWalletWithdrawal(&wallet, input.Amount); err != nil {
|
||||
return models.WalletApplyCash{}, false, err
|
||||
}
|
||||
if input.CashNo == "" {
|
||||
input.CashNo = models.NewIdentity()
|
||||
}
|
||||
application := models.WalletApplyCash{
|
||||
Entity: NewEntity(StatusEnable),
|
||||
ApplyStatus: StatusPending,
|
||||
WalletBasicID: input.WalletBasicID,
|
||||
WalletBankID: input.WalletBankID,
|
||||
CashNo: input.CashNo,
|
||||
RequestNo: input.RequestNo,
|
||||
Amount: input.Amount,
|
||||
Channel: input.Channel,
|
||||
Remark: input.Remark,
|
||||
BalanceReserved: true,
|
||||
}
|
||||
if err := tx.Create(&application).Error; err != nil {
|
||||
return models.WalletApplyCash{}, false, err
|
||||
}
|
||||
if err := SaveWalletBalances(tx, wallet); err != nil {
|
||||
return models.WalletApplyCash{}, false, err
|
||||
}
|
||||
record := NewWalletBalanceRecord(
|
||||
wallet,
|
||||
"withdrawal-reserve:"+application.Identity,
|
||||
"expense",
|
||||
"withdrawal_reserve",
|
||||
application.Amount,
|
||||
"",
|
||||
application.CashNo,
|
||||
application.Channel,
|
||||
input.OperatorIdentity,
|
||||
input.OperatorName,
|
||||
input.Remark,
|
||||
)
|
||||
if err := tx.Create(&record).Error; err != nil {
|
||||
return models.WalletApplyCash{}, false, err
|
||||
}
|
||||
return application, true, nil
|
||||
}
|
||||
|
||||
// NewWalletBalanceRecord 创建带完整余额快照的不可变资金流水。
|
||||
func NewWalletBalanceRecord(
|
||||
wallet models.WalletBasic,
|
||||
requestNo string,
|
||||
direction string,
|
||||
tradeType string,
|
||||
amount int64,
|
||||
inTradeNo string,
|
||||
outTradeNo string,
|
||||
channel string,
|
||||
operatorIdentity string,
|
||||
operatorName string,
|
||||
remark string,
|
||||
) models.WalletRecord {
|
||||
now := time.Now()
|
||||
return models.WalletRecord{
|
||||
Entity: NewEntity(StatusEnable),
|
||||
WalletBasicID: wallet.ID,
|
||||
RecordNo: models.NewIdentity(),
|
||||
RequestNo: requestNo,
|
||||
Direction: direction,
|
||||
TradeType: tradeType,
|
||||
Amount: amount,
|
||||
BalanceAfter: wallet.Balance,
|
||||
WithdrawalBalanceAfter: wallet.WithdrawalBalance,
|
||||
InTradeNo: inTradeNo,
|
||||
OutTradeNo: outTradeNo,
|
||||
PayChannel: channel,
|
||||
OperatorIdentity: operatorIdentity,
|
||||
OperatorName: operatorName,
|
||||
Ymd: int32(now.Year()*10000 + int(now.Month())*100 + now.Day()),
|
||||
Ym: int32(now.Year()*100 + int(now.Month())),
|
||||
Remark: remark,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user