修复(#139): 申请提现改为事务内 FOR UPDATE 直读 DB
- commissionWithdrawLogic: 不再用 ctx 中可能陈旧的 cache user 做余额校验 - 在事务内 FOR UPDATE user 行 + 求和 pending → 用 DB 真值校验 commission - 与 approveWithdrawal 对齐数据源,消除 cache/DB 不一致导致的漏报 - 新增 4 个 sqlmock 单测:陈旧 cache 拒绝、happy path、pending 吃光、user 丢失 Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/xerr"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type CommissionWithdrawLogic struct {
|
||||
@@ -29,7 +30,7 @@ func NewCommissionWithdrawLogic(ctx context.Context, svcCtx *svc.ServiceContext)
|
||||
}
|
||||
|
||||
func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdrawRequest) (resp *types.WithdrawalLog, err error) {
|
||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
ctxUser, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||
if !ok {
|
||||
logger.Error("current user is not found in context")
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||
@@ -51,28 +52,38 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
||||
}
|
||||
}
|
||||
|
||||
// Sum all pending (status=0) withdrawals to compute available balance.
|
||||
// Available = commission - pendingTotal; commission is only deducted on approval.
|
||||
var pendingTotal int64
|
||||
if err = l.svcCtx.DB.WithContext(l.ctx).
|
||||
Model(&user.Withdrawal{}).
|
||||
Where("user_id = ? AND status = ?", u.Id, user.WithdrawalStatusPending).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&pendingTotal).Error; err != nil {
|
||||
l.Errorf("Failed to query pending withdrawals for user %d: %v", u.Id, err)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to query pending withdrawals for user %d", u.Id)
|
||||
}
|
||||
|
||||
if u.Commission < req.Amount+pendingTotal {
|
||||
logger.Errorf("User %d insufficient available commission: total=%d pending=%d requested=%d",
|
||||
u.Id, u.Commission, pendingTotal, req.Amount)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "User %d has insufficient commission balance", u.Id)
|
||||
}
|
||||
|
||||
// HIF-139: read commission and pending total directly from DB inside a
|
||||
// transaction, FOR UPDATE on the user row. The ctxUser snapshot may be
|
||||
// served from cache and can be stale (the original bug allowed a user
|
||||
// with cached commission=996900 to submit a withdrawal while DB said 0,
|
||||
// which then failed admin approval with 20010). Approve already locks
|
||||
// the user row this way; aligning submission closes the gap.
|
||||
var w user.Withdrawal
|
||||
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var dbUser user.User
|
||||
if txErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", ctxUser.Id).First(&dbUser).Error; txErr != nil {
|
||||
l.Errorf("Failed to lock user %d for withdrawal: %v", ctxUser.Id, txErr)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to lock user %d: %v", ctxUser.Id, txErr)
|
||||
}
|
||||
|
||||
var pendingTotal int64
|
||||
if txErr := tx.Model(&user.Withdrawal{}).
|
||||
Where("user_id = ? AND status = ?", ctxUser.Id, user.WithdrawalStatusPending).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&pendingTotal).Error; txErr != nil {
|
||||
l.Errorf("Failed to query pending withdrawals for user %d: %v", ctxUser.Id, txErr)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Failed to query pending withdrawals for user %d", ctxUser.Id)
|
||||
}
|
||||
|
||||
if dbUser.Commission < req.Amount+pendingTotal {
|
||||
logger.Errorf("User %d insufficient available commission: db_commission=%d pending=%d requested=%d",
|
||||
ctxUser.Id, dbUser.Commission, pendingTotal, req.Amount)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.UserCommissionNotEnough), "User %d has insufficient commission balance", ctxUser.Id)
|
||||
}
|
||||
|
||||
w = user.Withdrawal{
|
||||
UserId: u.Id,
|
||||
UserId: ctxUser.Id,
|
||||
Amount: req.Amount,
|
||||
Content: req.Content,
|
||||
Status: user.WithdrawalStatusPending,
|
||||
@@ -81,16 +92,19 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
||||
Account: req.Account,
|
||||
QrCodeUrl: req.QrCodeUrl,
|
||||
}
|
||||
return tx.Create(&w).Error
|
||||
if txErr := tx.Create(&w).Error; txErr != nil {
|
||||
l.Errorf("Failed to create withdrawal for user %d: %v", ctxUser.Id, txErr)
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal for user %d: %v", ctxUser.Id, txErr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
l.Errorf("Failed to create withdrawal for user %d: %v", u.Id, err)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal for user %d: %v", u.Id, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.WithdrawalLog{
|
||||
Id: w.Id,
|
||||
UserId: u.Id,
|
||||
UserId: ctxUser.Id,
|
||||
Amount: req.Amount,
|
||||
Content: req.Content,
|
||||
Status: user.WithdrawalStatusPending,
|
||||
|
||||
Reference in New Issue
Block a user