Compare commits

...

1 Commits

Author SHA1 Message Date
架构师 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
2 changed files with 250 additions and 24 deletions
@@ -11,6 +11,7 @@ import (
"github.com/perfect-panel/server/pkg/xerr" "github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors" "github.com/pkg/errors"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type CommissionWithdrawLogic struct { 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) { 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 { if !ok {
logger.Error("current user is not found in context") logger.Error("current user is not found in context")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access") 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. // HIF-139: read commission and pending total directly from DB inside a
// Available = commission - pendingTotal; commission is only deducted on approval. // transaction, FOR UPDATE on the user row. The ctxUser snapshot may be
var pendingTotal int64 // served from cache and can be stale (the original bug allowed a user
if err = l.svcCtx.DB.WithContext(l.ctx). // with cached commission=996900 to submit a withdrawal while DB said 0,
Model(&user.Withdrawal{}). // which then failed admin approval with 20010). Approve already locks
Where("user_id = ? AND status = ?", u.Id, user.WithdrawalStatusPending). // the user row this way; aligning submission closes the gap.
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)
}
var w user.Withdrawal var w user.Withdrawal
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error { 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{ w = user.Withdrawal{
UserId: u.Id, UserId: ctxUser.Id,
Amount: req.Amount, Amount: req.Amount,
Content: req.Content, Content: req.Content,
Status: user.WithdrawalStatusPending, Status: user.WithdrawalStatusPending,
@@ -81,16 +92,19 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
Account: req.Account, Account: req.Account,
QrCodeUrl: req.QrCodeUrl, 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 { if err != nil {
l.Errorf("Failed to create withdrawal for user %d: %v", u.Id, err) return nil, err
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal for user %d: %v", u.Id, err)
} }
return &types.WithdrawalLog{ return &types.WithdrawalLog{
Id: w.Id, Id: w.Id,
UserId: u.Id, UserId: ctxUser.Id,
Amount: req.Amount, Amount: req.Amount,
Content: req.Content, Content: req.Content,
Status: user.WithdrawalStatusPending, Status: user.WithdrawalStatusPending,
@@ -0,0 +1,212 @@
package user
import (
"context"
"fmt"
"regexp"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
modeluser "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/driver/mysql"
"gorm.io/gorm"
)
// TestCommissionWithdraw_StaleCacheRejected 覆盖 HIF-139 修复:
// ctxUser(来自 auth middleware 的 cache-aside FindOne)即使 Commission=996900
// 只要事务内 FOR UPDATE 读出的 DB 真值 Commission < 申请金额 + pendingTotal
// 申请就必须被拒(UserCommissionNotEnough),且不得 INSERT 任何 withdrawal。
func TestCommissionWithdraw_StaleCacheRejected(t *testing.T) {
const userID = int64(510)
db, mock, cleanup := newWithdrawTestDB(t)
defer cleanup()
mock.ExpectBegin()
// FOR UPDATE 锁 user 行 → DB 真值 commission=0
mock.ExpectQuery("FROM `user`").
WithArgs(userID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(0)))
// pendingTotal=0
mock.ExpectQuery("FROM `withdrawals`").
WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)).
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(0)))
// 校验失败 → ROLLBACK,不得 INSERT
mock.ExpectRollback()
// ctxUser 故意带一个虚高 commission,模拟陈旧 cache
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 996900})
_, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
Amount: 3000,
Method: modeluser.WithdrawalMethodBank,
Account: "222",
})
if err == nil {
t.Fatalf("CommissionWithdraw expected error, got nil")
}
if !isWithdrawErrCode(err, xerr.UserCommissionNotEnough) {
t.Fatalf("CommissionWithdraw error code = %v, want UserCommissionNotEnough; raw=%v", withdrawErrCodeOf(err), err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestCommissionWithdraw_HappyPath 覆盖申请成功路径:DB 真值充足 → INSERT withdrawal。
func TestCommissionWithdraw_HappyPath(t *testing.T) {
const userID = int64(72)
db, mock, cleanup := newWithdrawTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `user`").
WithArgs(userID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(10000)))
mock.ExpectQuery("FROM `withdrawals`").
WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)).
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(2000)))
// 10000 >= 3000 + 2000 → INSERT
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `withdrawals`")).
WillReturnResult(sqlmock.NewResult(99, 1))
mock.ExpectCommit()
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 10000})
resp, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
Amount: 3000,
Method: modeluser.WithdrawalMethodBank,
Account: "acc",
})
if err != nil {
t.Fatalf("CommissionWithdraw unexpected error: %v", err)
}
if resp == nil || resp.Amount != 3000 || resp.Status != modeluser.WithdrawalStatusPending {
t.Fatalf("unexpected response: %+v", resp)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestCommissionWithdraw_PendingTotalExhausts 覆盖 pendingTotal 把可用额度吃光的场景:
// DB commission=5000、pending=4000、申请=2000 → 5000 < 6000 → 20010,不得 INSERT。
func TestCommissionWithdraw_PendingTotalExhausts(t *testing.T) {
const userID = int64(88)
db, mock, cleanup := newWithdrawTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `user`").
WithArgs(userID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}).AddRow(userID, int64(5000)))
mock.ExpectQuery("FROM `withdrawals`").
WithArgs(userID, uint8(modeluser.WithdrawalStatusPending)).
WillReturnRows(sqlmock.NewRows([]string{"coalesce"}).AddRow(int64(4000)))
mock.ExpectRollback()
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 5000})
_, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
Amount: 2000,
Method: modeluser.WithdrawalMethodBank,
Account: "acc",
})
if err == nil {
t.Fatalf("CommissionWithdraw expected error, got nil")
}
if !isWithdrawErrCode(err, xerr.UserCommissionNotEnough) {
t.Fatalf("error code = %v, want UserCommissionNotEnough; raw=%v", withdrawErrCodeOf(err), err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestCommissionWithdraw_LockUserMissing 覆盖事务内 FOR UPDATE 找不到 user 的场景(user 已删/不存在)→ DatabaseQueryError。
func TestCommissionWithdraw_LockUserMissing(t *testing.T) {
const userID = int64(999999)
db, mock, cleanup := newWithdrawTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `user`").
WithArgs(userID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "commission"}))
mock.ExpectRollback()
logic := newTestCommissionWithdrawLogic(t, db, &modeluser.User{Id: userID, Commission: 999})
_, err := logic.CommissionWithdraw(&types.CommissionWithdrawRequest{
Amount: 100,
Method: modeluser.WithdrawalMethodBank,
Account: "acc",
})
if err == nil {
t.Fatalf("CommissionWithdraw expected error, got nil")
}
if !isWithdrawErrCode(err, xerr.DatabaseQueryError) {
t.Fatalf("error code = %v, want DatabaseQueryError; raw=%v", withdrawErrCodeOf(err), err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
func newWithdrawTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
if strings.Contains(actualSQL, expectedSQL) {
return nil
}
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
})))
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() {
_ = sqlDB.Close()
}
}
func newTestCommissionWithdrawLogic(t *testing.T, db *gorm.DB, ctxUser *modeluser.User) *CommissionWithdrawLogic {
t.Helper()
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, ctxUser)
return &CommissionWithdrawLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: &svc.ServiceContext{DB: db},
}
}
func withdrawErrCodeOf(err error) uint32 {
if err == nil {
return 0
}
type coder interface {
GetErrCode() uint32
}
cause := errors.Cause(err)
if c, ok := cause.(coder); ok {
return c.GetErrCode()
}
return 0
}
func isWithdrawErrCode(err error, code uint32) bool {
return withdrawErrCodeOf(err) == code
}