223 lines
7.7 KiB
Go
223 lines
7.7 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"quyun/v2/app/errorx"
|
|
"quyun/v2/database/models"
|
|
"quyun/v2/pkg/consts"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// LedgerApplyResult is the result of a single ledger application, including the created ledger record
|
|
// and the updated tenant user balance snapshot.
|
|
type LedgerApplyResult struct {
|
|
// Ledger is the created ledger record (or existing one if idempotent hit).
|
|
Ledger *models.TenantLedger
|
|
// TenantUser is the updated tenant user record reflecting the post-apply balances.
|
|
TenantUser *models.TenantUser
|
|
}
|
|
|
|
// ledger provides tenant balance ledger operations (freeze/unfreeze/etc.) with idempotency and row-locking.
|
|
//
|
|
// @provider
|
|
type ledger struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// Freeze moves funds from available balance to frozen balance and records a tenant ledger entry.
|
|
func (s *ledger) Freeze(ctx context.Context, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
|
return s.apply(ctx, s.db, tenantID, userID, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now)
|
|
}
|
|
|
|
// Unfreeze moves funds from frozen balance back to available balance and records a tenant ledger entry.
|
|
func (s *ledger) Unfreeze(ctx context.Context, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
|
return s.apply(ctx, s.db, tenantID, userID, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now)
|
|
}
|
|
|
|
// FreezeTx is the transaction-scoped variant of Freeze.
|
|
func (s *ledger) FreezeTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
|
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeFreeze, amount, -amount, amount, idempotencyKey, remark, now)
|
|
}
|
|
|
|
// UnfreezeTx is the transaction-scoped variant of Unfreeze.
|
|
func (s *ledger) UnfreezeTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
|
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeUnfreeze, amount, amount, -amount, idempotencyKey, remark, now)
|
|
}
|
|
|
|
// DebitPurchaseTx turns frozen funds into a finalized debit (reduces frozen balance) and records a ledger entry.
|
|
func (s *ledger) DebitPurchaseTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
|
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeDebitPurchase, amount, 0, -amount, idempotencyKey, remark, now)
|
|
}
|
|
|
|
// CreditRefundTx credits funds back to available balance and records a ledger entry.
|
|
func (s *ledger) CreditRefundTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
|
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeCreditRefund, amount, amount, 0, idempotencyKey, remark, now)
|
|
}
|
|
|
|
// CreditTopupTx credits funds to available balance and records a ledger entry.
|
|
func (s *ledger) CreditTopupTx(ctx context.Context, tx *gorm.DB, tenantID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
|
|
return s.apply(ctx, tx, tenantID, userID, orderID, consts.TenantLedgerTypeCreditTopup, amount, amount, 0, idempotencyKey, remark, now)
|
|
}
|
|
|
|
func (s *ledger) apply(
|
|
ctx context.Context,
|
|
tx *gorm.DB,
|
|
tenantID, userID, orderID int64,
|
|
ledgerType consts.TenantLedgerType,
|
|
amount, deltaBalance, deltaFrozen int64,
|
|
idempotencyKey, remark string,
|
|
now time.Time,
|
|
) (*LedgerApplyResult, error) {
|
|
if amount <= 0 {
|
|
return nil, errorx.ErrInvalidParameter.WithMsg("amount must be > 0")
|
|
}
|
|
if now.IsZero() {
|
|
now = time.Now()
|
|
}
|
|
|
|
logrus.WithFields(logrus.Fields{
|
|
"tenant_id": tenantID,
|
|
"user_id": userID,
|
|
"order_id": orderID,
|
|
"type": ledgerType,
|
|
"amount": amount,
|
|
"idempotency_key": idempotencyKey,
|
|
"delta_balance": deltaBalance,
|
|
"delta_frozen": deltaFrozen,
|
|
"remark_non_empty": remark != "",
|
|
}).Info("services.ledger.apply")
|
|
|
|
var out LedgerApplyResult
|
|
|
|
err := tx.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if idempotencyKey != "" {
|
|
var existing models.TenantLedger
|
|
if err := tx.
|
|
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey).
|
|
First(&existing).Error; err == nil {
|
|
var current models.TenantUser
|
|
if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(¤t).Error; err != nil {
|
|
return err
|
|
}
|
|
out.Ledger = &existing
|
|
out.TenantUser = ¤t
|
|
return nil
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
}
|
|
|
|
var tu models.TenantUser
|
|
if err := tx.
|
|
Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
Where("tenant_id = ? AND user_id = ?", tenantID, userID).
|
|
First(&tu).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return errorx.ErrRecordNotFound.WithMsg("tenant user not found")
|
|
}
|
|
return err
|
|
}
|
|
|
|
if idempotencyKey != "" {
|
|
var existing models.TenantLedger
|
|
if err := tx.
|
|
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey).
|
|
First(&existing).Error; err == nil {
|
|
out.Ledger = &existing
|
|
out.TenantUser = &tu
|
|
return nil
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
}
|
|
|
|
balanceBefore := tu.Balance
|
|
frozenBefore := tu.BalanceFrozen
|
|
balanceAfter := balanceBefore + deltaBalance
|
|
frozenAfter := frozenBefore + deltaFrozen
|
|
|
|
if balanceAfter < 0 {
|
|
return errorx.ErrPreconditionFailed.WithMsg("余额不足")
|
|
}
|
|
if frozenAfter < 0 {
|
|
return errorx.ErrPreconditionFailed.WithMsg("冻结余额不足")
|
|
}
|
|
|
|
if err := tx.Model(&models.TenantUser{}).
|
|
Where("id = ?", tu.ID).
|
|
Updates(map[string]any{
|
|
"balance": balanceAfter,
|
|
"balance_frozen": frozenAfter,
|
|
"updated_at": now,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
ledger := &models.TenantLedger{
|
|
TenantID: tenantID,
|
|
UserID: userID,
|
|
OrderID: orderID,
|
|
Type: ledgerType,
|
|
Amount: amount,
|
|
BalanceBefore: balanceBefore,
|
|
BalanceAfter: balanceAfter,
|
|
FrozenBefore: frozenBefore,
|
|
FrozenAfter: frozenAfter,
|
|
IdempotencyKey: idempotencyKey,
|
|
Remark: remark,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if err := tx.Create(ledger).Error; err != nil {
|
|
if idempotencyKey != "" {
|
|
var existing models.TenantLedger
|
|
if e2 := tx.
|
|
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey).
|
|
First(&existing).Error; e2 == nil {
|
|
out.Ledger = &existing
|
|
out.TenantUser = &tu
|
|
return nil
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
tu.Balance = balanceAfter
|
|
tu.BalanceFrozen = frozenAfter
|
|
tu.UpdatedAt = now
|
|
|
|
out.Ledger = ledger
|
|
out.TenantUser = &tu
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
logrus.WithFields(logrus.Fields{
|
|
"tenant_id": tenantID,
|
|
"user_id": userID,
|
|
"order_id": orderID,
|
|
"type": ledgerType,
|
|
"idempotency_key": idempotencyKey,
|
|
}).WithError(err).Warn("services.ledger.apply.failed")
|
|
return nil, err
|
|
}
|
|
|
|
logrus.WithFields(logrus.Fields{
|
|
"tenant_id": tenantID,
|
|
"user_id": userID,
|
|
"order_id": orderID,
|
|
"type": ledgerType,
|
|
"ledger_id": out.Ledger.ID,
|
|
"idempotency_key": idempotencyKey,
|
|
"balance_after": out.TenantUser.Balance,
|
|
"frozen_after": out.TenantUser.BalanceFrozen,
|
|
}).Info("services.ledger.apply.ok")
|
|
|
|
return &out, nil
|
|
}
|