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 }