Files
hi-server/internal/logic/public/user/commissionWithdrawLogic_test.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

213 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}