修复(#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:
架构师
2026-06-01 22:29:10 -07:00
parent 750b7be424
commit 18df7e4d6b
2 changed files with 250 additions and 24 deletions
@@ -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
}