修复(#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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user