Files
quyun-v2/backend/app/services/ledger.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(&current).Error; err != nil {
return err
}
out.Ledger = &existing
out.TenantUser = &current
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
}