Files
hi-server/internal/logic/public/user/commissionWithdrawLogic.go
T
架构师 18df7e4d6b 修复(#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>
2026-06-01 22:29:10 -07:00

119 lines
4.2 KiB
Go

package user
import (
"context"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type CommissionWithdrawLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Commission Withdraw
func NewCommissionWithdrawLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CommissionWithdrawLogic {
return &CommissionWithdrawLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdrawRequest) (resp *types.WithdrawalLog, err error) {
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")
}
// Validate payment method fields
switch req.Method {
case user.WithdrawalMethodAlipay, user.WithdrawalMethodWechat:
if req.QrCodeUrl == "" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "qr_code_url is required for method %d", req.Method)
}
case user.WithdrawalMethodBank:
if req.Account == "" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account is required for bank transfer")
}
default: // WithdrawalMethodOther
if req.Account == "" {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "account is required for other methods")
}
}
// 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: ctxUser.Id,
Amount: req.Amount,
Content: req.Content,
Status: user.WithdrawalStatusPending,
Reason: "",
Method: req.Method,
Account: req.Account,
QrCodeUrl: req.QrCodeUrl,
}
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 {
return nil, err
}
return &types.WithdrawalLog{
Id: w.Id,
UserId: ctxUser.Id,
Amount: req.Amount,
Content: req.Content,
Status: user.WithdrawalStatusPending,
Reason: "",
Method: req.Method,
Account: req.Account,
QrCodeUrl: req.QrCodeUrl,
CreatedAt: w.CreatedAt.UnixMilli(),
UpdatedAt: w.UpdatedAt.UnixMilli(),
}, nil
}