feat: 移除“租户管理员为用户充值 / 每租户一套余额”能力:余额统一为全局用户余额

This commit is contained in:
2025-12-23 10:59:59 +08:00
parent dd7bcdfb98
commit a80c9b759b
39 changed files with 566 additions and 1869 deletions

View File

@@ -18,23 +18,25 @@ import (
"gorm.io/gorm/clause"
)
// LedgerApplyResult 表示一次账本写入(含幂等命中)的结果,包含账本记录与租户用户余额快照。
// LedgerApplyResult 表示一次账本写入(含幂等命中)的结果,包含账本记录与用户余额快照。
type LedgerApplyResult struct {
// Ledger 为本次创建的账本记录(若幂等命中则返回已有记录)。
Ledger *models.TenantLedger
// TenantUser 为写入后余额状态(若幂等命中则返回当前快照)。
TenantUser *models.TenantUser
// User 为写入后余额状态(若幂等命中则返回当前快照)。
User *models.User
}
// ledger 提供租户余额账本能力(冻结/解冻/扣减/退款/充值),支持幂等与行锁保证一致性。
// ledger 提供租户账本能力(冻结/解冻/扣减/退款),支持幂等与行锁保证一致性。
// 注意:余额为 users 表的全局余额,用户可在已加入租户间共享消费。
//
// @provider
type ledger struct {
db *gorm.DB
}
// MyBalance 查询当前用户在指定租户下的余额信息(可用/冻结)。
func (s *ledger) MyBalance(ctx context.Context, tenantID, userID int64) (*models.TenantUser, error) {
// MyBalance 查询当前用户的全局余额信息(可用/冻结)。
// 语义:必须先是该租户成员(否则返回 not found但余额数据来源为 users。
func (s *ledger) MyBalance(ctx context.Context, tenantID, userID int64) (*models.User, error) {
if tenantID <= 0 || userID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id/user_id must be > 0")
}
@@ -44,14 +46,23 @@ func (s *ledger) MyBalance(ctx context.Context, tenantID, userID int64) (*models
"user_id": userID,
}).Info("services.ledger.me.balance")
tbl, query := models.TenantUserQuery.QueryContext(ctx)
m, err := query.Where(tbl.TenantID.Eq(tenantID), tbl.UserID.Eq(userID)).First()
if err != nil {
// 必须先是租户成员。
tblTU, queryTU := models.TenantUserQuery.QueryContext(ctx)
if _, err := queryTU.Where(tblTU.TenantID.Eq(tenantID), tblTU.UserID.Eq(userID)).First(); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound.WithMsg("tenant user not found")
}
return nil, err
}
tblU, queryU := models.UserQuery.QueryContext(ctx)
m, err := queryU.Where(tblU.ID.Eq(userID), tblU.DeletedAt.IsNull()).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound.WithMsg("user not found")
}
return nil, err
}
return m, nil
}
@@ -232,11 +243,6 @@ func (s *ledger) CreditRefundTx(ctx context.Context, tx *gorm.DB, tenantID, oper
return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, "order", orderID, consts.TenantLedgerTypeCreditRefund, amount, amount, 0, idempotencyKey, remark, now)
}
// CreditTopupTx 将充值金额记入可用余额,并写入账本记录。
func (s *ledger) CreditTopupTx(ctx context.Context, tx *gorm.DB, tenantID, operatorUserID, userID, orderID, amount int64, idempotencyKey, remark string, now time.Time) (*LedgerApplyResult, error) {
return s.apply(ctx, tx, tenantID, operatorUserID, userID, orderID, "order", orderID, consts.TenantLedgerTypeCreditTopup, amount, amount, 0, idempotencyKey, remark, now)
}
func (s *ledger) apply(
ctx context.Context,
tx *gorm.DB,
@@ -273,18 +279,32 @@ func (s *ledger) apply(
var out LedgerApplyResult
err := tx.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 必须先是租户成员(账本维度仍按 tenant_id 记录)。
var tu models.TenantUser
if err := tx.
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 {
var current models.TenantUser
if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&current).Error; err != nil {
var current models.User
if err := tx.Where("id = ? AND deleted_at IS NULL", userID).First(&current).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("user not found")
}
return err
}
out.Ledger = &existing
out.TenantUser = &current
out.User = &current
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
@@ -297,26 +317,29 @@ func (s *ledger) apply(
if err := tx.
Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType).
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 {
var current models.User
if err := tx.Where("id = ? AND deleted_at IS NULL", userID).First(&current).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("user not found")
}
return err
}
out.Ledger = &existing
out.TenantUser = &current
out.User = &current
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
// 使用行锁锁住 tenant_users确保同一租户下同一用户余额更新的串行一致性。
var tu models.TenantUser
// 使用行锁锁住 users确保同一用户在“跨租户消费”场景下余额更新的串行一致性。
var u models.User
if err := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("tenant_id = ? AND user_id = ?", tenantID, userID).
First(&tu).Error; err != nil {
Where("id = ? AND deleted_at IS NULL", userID).
First(&u).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("tenant user not found")
return errorx.ErrRecordNotFound.WithMsg("user not found")
}
return err
}
@@ -328,7 +351,7 @@ func (s *ledger) apply(
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey).
First(&existing).Error; err == nil {
out.Ledger = &existing
out.TenantUser = &tu
out.User = &u
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
@@ -342,15 +365,15 @@ func (s *ledger) apply(
Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType).
First(&existing).Error; err == nil {
out.Ledger = &existing
out.TenantUser = &tu
out.User = &u
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
balanceBefore := tu.Balance
frozenBefore := tu.BalanceFrozen
balanceBefore := u.Balance
frozenBefore := u.BalanceFrozen
balanceAfter := balanceBefore + deltaBalance
frozenAfter := frozenBefore + deltaFrozen
@@ -363,8 +386,8 @@ func (s *ledger) apply(
}
// 先更新余额,再写账本:任何一步失败都回滚,保证“余额变更”和“账本记录”一致。
if err := tx.Model(&models.TenantUser{}).
Where("id = ?", tu.ID).
if err := tx.Model(&models.User{}).
Where("id = ?", u.ID).
Updates(map[string]any{
"balance": balanceAfter,
"balance_frozen": frozenAfter,
@@ -400,7 +423,7 @@ func (s *ledger) apply(
Where("tenant_id = ? AND user_id = ? AND idempotency_key = ?", tenantID, userID, idempotencyKey).
First(&existing).Error; e2 == nil {
out.Ledger = &existing
out.TenantUser = &tu
out.User = &u
return nil
}
}
@@ -411,19 +434,19 @@ func (s *ledger) apply(
Where("tenant_id = ? AND biz_ref_type = ? AND biz_ref_id = ? AND type = ?", tenantID, bizRefType, bizRefID, ledgerType).
First(&existing).Error; e2 == nil {
out.Ledger = &existing
out.TenantUser = &tu
out.User = &u
return nil
}
}
return err
}
tu.Balance = balanceAfter
tu.BalanceFrozen = frozenAfter
tu.UpdatedAt = now
u.Balance = balanceAfter
u.BalanceFrozen = frozenAfter
u.UpdatedAt = now
out.Ledger = ledger
out.TenantUser = &tu
out.User = &u
return nil
})
if err != nil {
@@ -450,8 +473,8 @@ func (s *ledger) apply(
"type": ledgerType,
"ledger_id": out.Ledger.ID,
"idempotency_key": idempotencyKey,
"balance_after": out.TenantUser.Balance,
"frozen_after": out.TenantUser.BalanceFrozen,
"balance_after": out.User.Balance,
"frozen_after": out.User.BalanceFrozen,
}).Info("services.ledger.apply.ok")
return &out, nil