diff --git a/internal/logic/public/user/commissionWithdrawLogic.go b/internal/logic/public/user/commissionWithdrawLogic.go index 162284b..848d9b9 100644 --- a/internal/logic/public/user/commissionWithdrawLogic.go +++ b/internal/logic/public/user/commissionWithdrawLogic.go @@ -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, diff --git a/internal/logic/public/user/commissionWithdrawLogic_test.go b/internal/logic/public/user/commissionWithdrawLogic_test.go new file mode 100644 index 0000000..e94d6f4 --- /dev/null +++ b/internal/logic/public/user/commissionWithdrawLogic_test.go @@ -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 +}