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
11 changed files with 311 additions and 822 deletions
-2
View File
@@ -2,7 +2,6 @@ package invite
import (
"context"
"slices"
modellog "github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/pkg/xerr"
@@ -114,7 +113,6 @@ func QueryBenefits(ctx context.Context, db *gorm.DB, relations []InviteRelation)
for userId := range inviteeAndInviterSet {
inviteeAndInviterIds = append(inviteeAndInviterIds, userId)
}
slices.Sort(inviteeAndInviterIds)
if err := fillCommissionBenefits(ctx, db, result, orderToInvitee, inviteeToInviter, orderNos, inviterIds); err != nil {
return nil, err
+2 -2
View File
@@ -25,7 +25,7 @@ func TestQueryBenefitsCountsFamilyOwnerGiftAsInviteeGift(t *testing.T) {
WithArgs(33, int64(100), "family-order", 331, 332).
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
mock.ExpectQuery("object_id IN").
WithArgs(34, int64(100), int64(200), int64(900), "family-order").
WithArgs(34, int64(900), int64(200), int64(100), "family-order").
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
AddRow(900, `{"type":341,"order_no":"family-order","amount":7,"balance":7,"remark":"邀请赠送"}`))
@@ -58,7 +58,7 @@ func TestQueryBenefitsKeepsDirectInviteeGift(t *testing.T) {
WithArgs(33, int64(100), "direct-order", 331, 332).
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
mock.ExpectQuery("object_id IN").
WithArgs(34, int64(100), int64(200), "direct-order").
WithArgs(34, int64(200), int64(100), "direct-order").
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
AddRow(200, `{"type":341,"order_no":"direct-order","amount":5,"balance":5,"remark":"邀请赠送"}`))
+44 -257
View File
@@ -5,12 +5,9 @@ import (
"encoding/json"
"time"
"github.com/hibiken/asynq"
"github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/pkg/payment/epay"
"github.com/perfect-panel/server/pkg/payment/stripe"
queueTypes "github.com/perfect-panel/server/queue/types"
"gorm.io/gorm"
"github.com/perfect-panel/server/internal/model/order"
@@ -21,52 +18,19 @@ import (
"github.com/perfect-panel/server/pkg/payment/alipay"
)
// GatewayPaymentStatus is the tri-state result of an external payment-gateway
// query. The third state (Unknown) is the whole point of HIF-137: when the
// gateway is unreachable or returns an inconclusive answer we must NOT collapse
// it to "unpaid" — that's exactly the bug that closes already-paid orders.
type GatewayPaymentStatus int
const (
// GatewayStatusUnknown — the gateway query itself failed (timeout, 5xx,
// network error, decode error) or the payment method has no gateway we can
// query. The caller must treat this as "do not close, retry later".
GatewayStatusUnknown GatewayPaymentStatus = iota
// GatewayStatusPaid — the gateway confirms the user has paid.
GatewayStatusPaid
// GatewayStatusUnpaid — the gateway confirms the order is unpaid /
// cancelled / closed on its side. Safe to close locally.
GatewayStatusUnpaid
)
type CloseOrderLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
// Test seams (injected via overrides on the returned struct). Production
// code never touches these — NewCloseOrderLogic wires the real impls.
gatewayQuery func(*order.Order) GatewayPaymentStatus
enqueueActivate func(context.Context, []byte) (string, error)
}
// NewCloseOrderLogic Close order
func NewCloseOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CloseOrderLogic {
l := &CloseOrderLogic{
return &CloseOrderLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
l.gatewayQuery = l.queryGatewayPaymentStatus
l.enqueueActivate = func(c context.Context, payload []byte) (string, error) {
task := asynq.NewTask(queueTypes.ForthwithActivateOrder, payload, asynq.MaxRetry(5))
info, err := svcCtx.Queue.EnqueueContext(c, task)
if err != nil {
return "", err
}
return info.ID, nil
}
return l
}
func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
@@ -88,31 +52,6 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
return nil
}
// HIF-137: before closing a pending order, ask the gateway whether it was
// actually paid. Silent callback failures must not cause us to close a
// paid order. Three branches:
// - Paid → recover to status=2 + enqueue activation (do NOT close)
// - Unpaid → fall through to the normal close flow
// - Unknown → keep status=1 and let DeferCloseOrder retry on the next tick
switch l.gatewayQuery(orderInfo) {
case GatewayStatusPaid:
return l.recoverPaidOrder(orderInfo)
case GatewayStatusUnknown:
l.Errorw("[CloseOrder] gateway query inconclusive — keeping order open (metric=close_gateway_error_kept_open)",
logger.Field("metric", "close_gateway_error_kept_open"),
logger.Field("orderNo", orderInfo.OrderNo),
logger.Field("method", orderInfo.Method),
logger.Field("trade_no", orderInfo.TradeNo),
)
return nil
}
l.Infow("[CloseOrder] gateway confirmed unpaid — proceeding with normal close (metric=close_normal)",
logger.Field("metric", "close_normal"),
logger.Field("orderNo", orderInfo.OrderNo),
logger.Field("method", orderInfo.Method),
)
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, orderInfo.SubscribeId)
if err != nil {
l.Errorw("[CloseOrder] Find subscribe info failed",
@@ -227,117 +166,42 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
return nil
}
// recoverPaidOrder is the "gateway said paid" branch: flip the order to
// status=2 (paid), persist the trade_no if we have one, and enqueue the same
// activation task the notify handlers would have enqueued. We deliberately
// reuse ForthwithActivateOrder so the rest of the activation pipeline
// (idempotency, claim/release, commission, etc.) stays untouched.
func (l *CloseOrderLogic) recoverPaidOrder(orderInfo *order.Order) error {
updates := map[string]any{"status": 2}
if orderInfo.TradeNo != "" {
updates["trade_no"] = orderInfo.TradeNo
}
// Race-safe: only flip 1→2. If another worker already moved the order
// forward (e.g. a late notify finally landed), do nothing.
result := l.svcCtx.DB.WithContext(l.ctx).
Model(&order.Order{}).
Where("order_no = ? AND status = ?", orderInfo.OrderNo, 1).
Updates(updates)
if result.Error != nil {
l.Errorw("[CloseOrder] gateway-paid recovery update failed — keeping order open",
logger.Field("metric", "close_gateway_error_kept_open"),
logger.Field("error", result.Error.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return nil
}
if result.RowsAffected == 0 {
l.Infow("[CloseOrder] gateway-paid recovery skipped — order already moved past status=1",
logger.Field("orderNo", orderInfo.OrderNo),
)
return nil
}
payload := queueTypes.ForthwithActivateOrderPayload{OrderNo: orderInfo.OrderNo}
bytes, err := json.Marshal(&payload)
// confirmationPayment Determine whether the payment is successful
//
//nolint:unused
func (l *CloseOrderLogic) confirmationPayment(order *order.Order) bool {
paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, order.PaymentId)
if err != nil {
l.Errorw("[CloseOrder] marshal activation payload failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return nil
l.Errorw("[CloseOrder] Find payment config failed", logger.Field("error", err.Error()), logger.Field("paymentMark", order.Method))
return false
}
taskID, err := l.enqueueActivate(l.ctx, bytes)
if err != nil {
// Order is already at status=2; if enqueue fails the stuck-order
// recovery sweeper will pick it up. Log loudly but don't error out.
l.Errorw("[CloseOrder] enqueue activation task failed after gateway-paid recovery",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return nil
}
l.Infow("[CloseOrder] gateway-paid recovery succeeded — order promoted to paid + activation enqueued (metric=close_with_gateway_paid_recovered)",
logger.Field("metric", "close_with_gateway_paid_recovered"),
logger.Field("orderNo", orderInfo.OrderNo),
logger.Field("method", orderInfo.Method),
logger.Field("trade_no", orderInfo.TradeNo),
logger.Field("queue_task_id", taskID),
)
return nil
}
// queryGatewayPaymentStatus dispatches to the right gateway client for the
// order's payment method and returns a tri-state result. Methods without an
// external gateway (Balance, unknown) return Unpaid — the historical close
// path is preserved for them.
func (l *CloseOrderLogic) queryGatewayPaymentStatus(orderInfo *order.Order) GatewayPaymentStatus {
switch orderInfo.Method {
switch order.Method {
case AlipayF2f:
return l.queryAlipay(orderInfo)
case StripeAlipay, StripeWeChatPay:
return l.queryStripe(orderInfo)
case Epay:
return l.queryEpay(orderInfo)
case Balance:
// Balance is settled in-process; no external gateway to ask. If status=1
// here, the balance deduction simply never completed — safe to close.
return GatewayStatusUnpaid
if l.queryAlipay(paymentConfig, order.TradeNo) {
return true
}
case StripeAlipay:
if l.queryStripe(paymentConfig, order.TradeNo) {
return true
}
case StripeWeChatPay:
if l.queryStripe(paymentConfig, order.TradeNo) {
return true
}
default:
l.Infow("[CloseOrder] no gateway query for method — falling back to close",
logger.Field("method", orderInfo.Method),
logger.Field("orderNo", orderInfo.OrderNo),
)
return GatewayStatusUnpaid
l.Infow("[CloseOrder] Unsupported payment method", logger.Field("paymentMethod", order.Method))
}
return false
}
// queryAlipay queries Alipay F2F and maps the response to a tri-state.
// "trade not exist" on Alipay's side is treated as Unpaid (the user never
// completed the QR-code scan), not Unknown.
func (l *CloseOrderLogic) queryAlipay(orderInfo *order.Order) GatewayPaymentStatus {
if orderInfo.TradeNo == "" {
// Alipay F2F creates the trade lazily — no trade_no means the user
// never scanned. Treat as definitively unpaid.
return GatewayStatusUnpaid
}
paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId)
if err != nil {
l.Errorw("[CloseOrder] Find payment config failed",
logger.Field("error", err.Error()),
logger.Field("paymentMark", orderInfo.Method),
)
return GatewayStatusUnknown
}
// queryAlipay Query Alipay payment status
//
//nolint:unused
func (l *CloseOrderLogic) queryAlipay(paymentConfig *payment.Payment, TradeNo string) bool {
config := payment.AlipayF2FConfig{}
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
l.Errorw("[CloseOrder] Unmarshal Alipay config failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return GatewayStatusUnknown
l.Errorw("[CloseOrder] Unmarshal payment config failed", logger.Field("error", err.Error()), logger.Field("config", paymentConfig.Config))
return false
}
client := alipay.NewClient(alipay.Config{
AppId: config.AppId,
@@ -345,112 +209,35 @@ func (l *CloseOrderLogic) queryAlipay(orderInfo *order.Order) GatewayPaymentStat
PublicKey: config.PublicKey,
InvoiceName: config.InvoiceName,
})
if client == nil {
return GatewayStatusUnknown
}
status, err := client.QueryTrade(l.ctx, orderInfo.TradeNo)
status, err := client.QueryTrade(l.ctx, TradeNo)
if err != nil {
l.Errorw("[CloseOrder] Alipay QueryTrade failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
logger.Field("tradeNo", orderInfo.TradeNo),
)
return GatewayStatusUnknown
l.Errorw("[CloseOrder] Query trade failed", logger.Field("error", err.Error()), logger.Field("TradeNo", TradeNo))
return false
}
switch status {
case alipay.Success, alipay.Finished:
return GatewayStatusPaid
case alipay.Pending, alipay.Closed:
return GatewayStatusUnpaid
default:
// Unknown alipay status — be conservative.
return GatewayStatusUnknown
if status == alipay.Success || status == alipay.Finished {
return true
}
return false
}
// queryStripe queries Stripe PaymentIntent and maps to a tri-state.
// Any Stripe-side error (network, 5xx, decode) is Unknown — Stripe is the
// gateway most prone to silent webhook drops in this codebase, so we must not
// downgrade query failures to "unpaid".
func (l *CloseOrderLogic) queryStripe(orderInfo *order.Order) GatewayPaymentStatus {
if orderInfo.TradeNo == "" {
// Stripe's PaymentIntent ID is written into trade_no at create time.
// Missing it means the intent was never persisted — treat as unpaid.
return GatewayStatusUnpaid
}
paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId)
if err != nil {
l.Errorw("[CloseOrder] Find payment config failed",
logger.Field("error", err.Error()),
logger.Field("paymentMark", orderInfo.Method),
)
return GatewayStatusUnknown
}
// queryStripe Query Stripe payment status
//
//nolint:unused
func (l *CloseOrderLogic) queryStripe(paymentConfig *payment.Payment, TradeNo string) bool {
config := payment.StripeConfig{}
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
l.Errorw("[CloseOrder] Unmarshal Stripe config failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return GatewayStatusUnknown
l.Errorw("[CloseOrder] Unmarshal payment config failed", logger.Field("error", err.Error()), logger.Field("config", paymentConfig.Config))
return false
}
client := stripe.NewClient(stripe.Config{
PublicKey: config.PublicKey,
SecretKey: config.SecretKey,
WebhookSecret: config.WebhookSecret,
})
paid, err := client.QueryOrderStatus(orderInfo.TradeNo)
status, err := client.QueryOrderStatus(TradeNo)
if err != nil {
l.Errorw("[CloseOrder] Stripe QueryOrderStatus failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
logger.Field("tradeNo", orderInfo.TradeNo),
)
return GatewayStatusUnknown
l.Errorw("[CloseOrder] Query order status failed", logger.Field("error", err.Error()), logger.Field("TradeNo", TradeNo))
return false
}
if paid {
return GatewayStatusPaid
}
return GatewayStatusUnpaid
}
// queryEpay queries EPay using out_trade_no (EPay's order endpoint accepts
// out_trade_no even when we never recorded its internal trade_no, which is the
// common case for orders that lost their notify callback).
func (l *CloseOrderLogic) queryEpay(orderInfo *order.Order) GatewayPaymentStatus {
paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, orderInfo.PaymentId)
if err != nil {
l.Errorw("[CloseOrder] Find payment config failed",
logger.Field("error", err.Error()),
logger.Field("paymentMark", orderInfo.Method),
)
return GatewayStatusUnknown
}
config := payment.EPayConfig{}
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
l.Errorw("[CloseOrder] Unmarshal EPay config failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return GatewayStatusUnknown
}
if config.Url == "" || config.Pid == "" || config.Key == "" {
l.Errorw("[CloseOrder] EPay config incomplete",
logger.Field("orderNo", orderInfo.OrderNo),
)
return GatewayStatusUnknown
}
client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type)
paid, err := client.QueryOrderStatus(orderInfo.OrderNo)
if err != nil {
l.Errorw("[CloseOrder] EPay QueryOrderStatus failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return GatewayStatusUnknown
}
if paid {
return GatewayStatusPaid
}
return GatewayStatusUnpaid
return status
}
@@ -1,232 +0,0 @@
package order
import (
"context"
"fmt"
"strings"
"sync/atomic"
"testing"
"github.com/DATA-DOG/go-sqlmock"
modelorder "github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/logger"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// newCloseOrderTestDB wires gorm to go-sqlmock with a substring SQL matcher,
// matching the convention used elsewhere in this package.
func newCloseOrderTestDB(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 newCloseOrderLogicForTest(db *gorm.DB) *CloseOrderLogic {
ctx := context.Background()
return &CloseOrderLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: &svc.ServiceContext{DB: db},
}
}
// TestRecoverPaidOrder_HappyPath covers HIF-137 P01 branch "gateway 已支付":
// gateway said the order was paid → we must flip status 1→2, persist
// trade_no, and enqueue exactly one ForthwithActivateOrder task. This is the
// most important regression to keep — silently dropping the enqueue here would
// re-create the bug we are fixing.
func TestRecoverPaidOrder_HappyPath(t *testing.T) {
db, mock, cleanup := newCloseOrderTestDB(t)
defer cleanup()
const (
orderNo = "ORD-PAID-1"
tradeNo = "ALIPAY-TRADE-9999"
)
mock.ExpectBegin()
mock.ExpectExec("UPDATE `order`").
WithArgs(2, tradeNo, sqlmock.AnyArg(), orderNo, 1).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
var (
enqueueCalls int32
capturedPayload []byte
)
logic := newCloseOrderLogicForTest(db)
logic.enqueueActivate = func(_ context.Context, payload []byte) (string, error) {
atomic.AddInt32(&enqueueCalls, 1)
capturedPayload = append([]byte(nil), payload...)
return "task-123", nil
}
err := logic.recoverPaidOrder(&modelorder.Order{
OrderNo: orderNo,
Method: AlipayF2f,
TradeNo: tradeNo,
Status: 1,
})
if err != nil {
t.Fatalf("recoverPaidOrder error: %v", err)
}
if got := atomic.LoadInt32(&enqueueCalls); got != 1 {
t.Fatalf("expected exactly one enqueue, got %d", got)
}
if !strings.Contains(string(capturedPayload), orderNo) {
t.Fatalf("activation payload missing order_no: %q", capturedPayload)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestRecoverPaidOrder_AlreadyAdvanced covers the race: the late-arriving
// gateway-notify already flipped the order past status=1 by the time the
// deferred close ran. UPDATE returns 0 rows; we must NOT double-enqueue.
func TestRecoverPaidOrder_AlreadyAdvanced(t *testing.T) {
db, mock, cleanup := newCloseOrderTestDB(t)
defer cleanup()
const orderNo = "ORD-RACE"
mock.ExpectBegin()
mock.ExpectExec("UPDATE `order`").
WithArgs(2, sqlmock.AnyArg(), orderNo, 1).
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectCommit()
var enqueueCalls int32
logic := newCloseOrderLogicForTest(db)
logic.enqueueActivate = func(_ context.Context, _ []byte) (string, error) {
atomic.AddInt32(&enqueueCalls, 1)
return "should-not-fire", nil
}
err := logic.recoverPaidOrder(&modelorder.Order{
OrderNo: orderNo,
Method: Epay,
Status: 1,
})
if err != nil {
t.Fatalf("recoverPaidOrder error: %v", err)
}
if got := atomic.LoadInt32(&enqueueCalls); got != 0 {
t.Fatalf("expected no enqueue when 0 rows updated, got %d", got)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestRecoverPaidOrder_EnqueueFailureIsNotFatal covers the case where the DB
// move succeeded but the activation enqueue failed (Redis blip, etc). The
// order is already at status=2, so the stuck-order sweeper will pick it up —
// recoverPaidOrder must not return an error or revert the status.
func TestRecoverPaidOrder_EnqueueFailureIsNotFatal(t *testing.T) {
db, mock, cleanup := newCloseOrderTestDB(t)
defer cleanup()
const orderNo = "ORD-ENQ-FAIL"
mock.ExpectBegin()
mock.ExpectExec("UPDATE `order`").
WithArgs(2, sqlmock.AnyArg(), orderNo, 1).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
logic := newCloseOrderLogicForTest(db)
logic.enqueueActivate = func(_ context.Context, _ []byte) (string, error) {
return "", fmt.Errorf("simulated redis outage")
}
if err := logic.recoverPaidOrder(&modelorder.Order{OrderNo: orderNo, Method: Epay}); err != nil {
t.Fatalf("recoverPaidOrder must swallow enqueue errors, got: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestQueryGatewayPaymentStatus_NonGatewayMethods checks the "fall-through"
// branches: methods that don't talk to an external gateway (Balance, empty
// string, anything unrecognised) must report Unpaid so the legacy close path
// is preserved. This is the regression hook for the issue's acceptance
// criterion #5 ("user-initiated cancellation can still close normally").
func TestQueryGatewayPaymentStatus_NonGatewayMethods(t *testing.T) {
logic := newCloseOrderLogicForTest(nil) // no DB needed for these branches
cases := []struct {
method string
want GatewayPaymentStatus
}{
{Balance, GatewayStatusUnpaid},
{"", GatewayStatusUnpaid},
{"future-method-we-dont-know", GatewayStatusUnpaid},
}
for _, tc := range cases {
got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: tc.method})
if got != tc.want {
t.Fatalf("method=%q: got %v want %v", tc.method, got, tc.want)
}
}
}
// TestQueryGatewayPaymentStatus_AlipayNoTradeNoIsUnpaid: Alipay F2F creates
// the trade lazily — if we never recorded a trade_no, the user never scanned
// the QR code. We must report Unpaid (not Unknown) so the close proceeds.
// Without this short-circuit, every legitimately abandoned QR-code order
// would be "kept open" forever by the inconclusive-query branch.
func TestQueryGatewayPaymentStatus_AlipayNoTradeNoIsUnpaid(t *testing.T) {
logic := newCloseOrderLogicForTest(nil)
got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: AlipayF2f, TradeNo: ""})
if got != GatewayStatusUnpaid {
t.Fatalf("Alipay F2F with empty trade_no should be Unpaid, got %v", got)
}
}
// TestQueryGatewayPaymentStatus_StripeNoTradeNoIsUnpaid: same reasoning as
// the Alipay case — Stripe's PaymentIntent ID lives in trade_no; if it's
// missing the intent was never persisted.
func TestQueryGatewayPaymentStatus_StripeNoTradeNoIsUnpaid(t *testing.T) {
logic := newCloseOrderLogicForTest(nil)
for _, method := range []string{StripeAlipay, StripeWeChatPay} {
got := logic.queryGatewayPaymentStatus(&modelorder.Order{Method: method, TradeNo: ""})
if got != GatewayStatusUnpaid {
t.Fatalf("%s with empty trade_no should be Unpaid, got %v", method, got)
}
}
}
// TestCloseOrderGatewayQueryDispatch_DefaultProductionWiring asserts that the
// default gatewayQuery is wired to the real implementation (not nil) by
// NewCloseOrderLogic. Without this guard a future refactor could silently
// drop the wiring and re-create HIF-135.
func TestCloseOrderGatewayQueryDispatch_DefaultProductionWiring(t *testing.T) {
svcCtx := &svc.ServiceContext{}
l := NewCloseOrderLogic(context.Background(), svcCtx)
if l.gatewayQuery == nil {
t.Fatal("NewCloseOrderLogic must wire gatewayQuery; got nil")
}
if l.enqueueActivate == nil {
t.Fatal("NewCloseOrderLogic must wire enqueueActivate; got nil")
}
}
@@ -4,6 +4,7 @@ import (
"context"
logicCommon "github.com/perfect-panel/server/internal/logic/common"
"github.com/perfect-panel/server/internal/model/log"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
@@ -56,10 +57,13 @@ func (l *CancelWithdrawalLogic) CancelWithdrawal(req *types.CancelWithdrawalRequ
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "cancel withdrawal failed: %v", txErr)
}
// Commission was NOT deducted at application time under the HIF-22
// approval flow, so cancellation requires no refund — only a status
// update. Refunding here would mint phantom commission and pollute
// reconciliation (mirrors rejectWithdrawal in admin/user/withdrawalCommon.go).
if txErr = l.svcCtx.UserModel.UpdateCommission(l.ctx, withdrawal.UserId, withdrawal.Amount, tx); txErr != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "refund commission failed: %v", txErr)
}
if txErr = logicCommon.WriteCommissionLog(tx, withdrawal.UserId, log.CommissionTypeWithdrawCancel, withdrawal.Amount, ""); txErr != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "write commission log failed: %v", txErr)
}
return nil
})
@@ -1,228 +0,0 @@
package user
import (
"context"
"fmt"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
usermodel "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"
)
// TestCancelWithdrawal_DoesNotRefundCommission 验证 HIF-140 修复:
// 用户撤销 pending 提现的事务体必须只更新 withdrawal.status
// 严禁触发 UpdateCommissioncommission 列读 / 写)或写 333/338 commission 日志。
//
// sqlmock 严格匹配期望 SQL:只允许出现 BEGIN / SELECT withdrawal FOR UPDATE /
// UPDATE withdrawal SET status / COMMIT,不允许出现 SELECT/UPDATE `user` 或
// INSERT system_logs。
func TestCancelWithdrawal_DoesNotRefundCommission(t *testing.T) {
const (
withdrawalID = int64(987654)
userID = int64(42)
amount = int64(2500)
)
db, mock, cleanup := newCancelWithdrawalTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `withdrawals`").
WithArgs(withdrawalID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}).
AddRow(withdrawalID, userID, amount, usermodel.WithdrawalStatusPending))
mock.ExpectExec("UPDATE `withdrawals` SET").
WithArgs(usermodel.WithdrawalStatusCancelled, sqlmock.AnyArg(), withdrawalID, usermodel.WithdrawalStatusPending).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
logic := newTestCancelWithdrawalLogic(t, db, userID)
resp, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID})
if err != nil {
t.Fatalf("CancelWithdrawal unexpected error: %v", err)
}
if resp == nil || resp.Id != withdrawalID {
t.Fatalf("CancelWithdrawal response = %+v, want id=%d", resp, withdrawalID)
}
if resp.Status != usermodel.WithdrawalStatusCancelled {
t.Fatalf("CancelWithdrawal status = %d, want %d", resp.Status, usermodel.WithdrawalStatusCancelled)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestCancelWithdrawal_RejectsNonPending 覆盖:状态非 pending(已批准/拒绝/已撤销)
// 时撤销必须短路返回 WithdrawalStatusInvalid,且不得写任何状态或佣金。
func TestCancelWithdrawal_RejectsNonPending(t *testing.T) {
const (
withdrawalID = int64(55555)
userID = int64(42)
)
cases := []struct {
name string
status uint8
}{
{"already approved", usermodel.WithdrawalStatusApproved},
{"already rejected", usermodel.WithdrawalStatusRejected},
{"already cancelled", usermodel.WithdrawalStatusCancelled},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db, mock, cleanup := newCancelWithdrawalTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `withdrawals`").
WithArgs(withdrawalID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}).
AddRow(withdrawalID, userID, int64(2500), tc.status))
mock.ExpectRollback()
logic := newTestCancelWithdrawalLogic(t, db, userID)
_, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID})
if !isCancelErrCode(err, xerr.WithdrawalStatusInvalid) {
t.Fatalf("CancelWithdrawal err = %v, want WithdrawalStatusInvalid", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
})
}
}
// TestCancelWithdrawal_RejectsOtherUserWithdrawal 覆盖:当前登录用户尝试撤销
// 不属于自己的 pending 提现,必须返回 PermissionDenied 且不得写库。
func TestCancelWithdrawal_RejectsOtherUserWithdrawal(t *testing.T) {
const (
withdrawalID = int64(33333)
ownerUserID = int64(99)
attackerID = int64(42)
)
db, mock, cleanup := newCancelWithdrawalTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `withdrawals`").
WithArgs(withdrawalID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}).
AddRow(withdrawalID, ownerUserID, int64(2500), usermodel.WithdrawalStatusPending))
mock.ExpectRollback()
logic := newTestCancelWithdrawalLogic(t, db, attackerID)
_, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID})
if !isCancelErrCode(err, xerr.PermissionDenied) {
t.Fatalf("CancelWithdrawal err = %v, want PermissionDenied", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestCancelWithdrawal_ConcurrentRaceLosesViaFORUPDATE 模拟撤销与审批并发的场景:
// 第二个 cancel 在 LoadPendingWithdrawalForUpdate 取到行时,状态已被先到的 approve
// 改成 1FOR UPDATE 行锁让出后看到的最新状态),cancel 应当短路返回错误,
// 严禁继续往下写 status 或动 commission。
func TestCancelWithdrawal_ConcurrentRaceLosesViaFORUPDATE(t *testing.T) {
const (
withdrawalID = int64(77777)
userID = int64(42)
)
db, mock, cleanup := newCancelWithdrawalTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectQuery("FROM `withdrawals`").
WithArgs(withdrawalID, 1).
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "amount", "status"}).
AddRow(withdrawalID, userID, int64(2500), usermodel.WithdrawalStatusApproved))
mock.ExpectRollback()
logic := newTestCancelWithdrawalLogic(t, db, userID)
_, err := logic.CancelWithdrawal(&types.CancelWithdrawalRequest{WithdrawalId: withdrawalID})
if !isCancelErrCode(err, xerr.WithdrawalStatusInvalid) {
t.Fatalf("CancelWithdrawal err = %v, want WithdrawalStatusInvalid (loser of race)", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
func newCancelWithdrawalTestDB(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 newTestCancelWithdrawalLogic(t *testing.T, db *gorm.DB, userID int64) *CancelWithdrawalLogic {
t.Helper()
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, &usermodel.User{Id: userID})
return &CancelWithdrawalLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: &svc.ServiceContext{
DB: db,
UserModel: stubUserModelForCancel{},
},
}
}
// stubUserModelForCancel 是 user.Model 的零依赖替身,仅覆盖 CancelWithdrawal 必须
// 调用的 ClearUserCache。其它方法被调用会导致 nil-interface panic,正好可以暴露
// 测试边界外的意外依赖。
type stubUserModelForCancel struct {
usermodel.Model
}
func (stubUserModelForCancel) ClearUserCache(_ context.Context, _ ...*usermodel.User) error {
return nil
}
func cancelErrCodeOf(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 isCancelErrCode(err error, code uint32) bool {
return cancelErrCodeOf(err) == code
}
@@ -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,
@@ -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
}
+7 -22
View File
@@ -2,7 +2,6 @@ package epay
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
@@ -89,42 +88,28 @@ func (c *Client) VerifySign(params map[string]string) bool {
return c.createSign(params) == params["sign"]
}
// QueryOrderStatus returns (paid, err). A non-nil err means the query itself
// failed (network / 5xx / decode error) and the result is inconclusive — callers
// MUST NOT treat that as "unpaid". A nil err with paid=false means the gateway
// answered and reported the order is not paid.
func (c *Client) QueryOrderStatus(orderNo string) (bool, error) {
func (c *Client) QueryOrderStatus(orderNo string) bool {
client := http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get(c.Url + "/api.php" + "?act=order" + "&pid=" + c.Pid + "&key=" + c.Key + "&out_trade_no=" + orderNo)
if err != nil {
logger.Error("[Epay] QueryOrderStatus error", logger.Field("orderNo", orderNo), logger.Field("error", err.Error()))
return false, fmt.Errorf("epay query request failed: %w", err)
return false
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
err := fmt.Errorf("epay query upstream status %d", resp.StatusCode)
logger.Error("[Epay] QueryOrderStatus upstream 5xx", logger.Field("orderNo", orderNo), logger.Field("status", resp.StatusCode))
return false, err
}
value, err := io.ReadAll(resp.Body)
if err != nil {
logger.Error("[Epay] QueryOrderStatus error", logger.Field("orderNo", orderNo), logger.Field("error", err.Error()))
return false, fmt.Errorf("epay query read body failed: %w", err)
return false
}
var response queryOrderStatusResponse
if err = json.Unmarshal(value, &response); err != nil {
err = json.Unmarshal(value, &response)
if err != nil {
logger.Error("[Epay] QueryOrderStatus error", logger.Field("orderNo", orderNo), logger.Field("error", err.Error()))
return false, fmt.Errorf("epay query decode failed: %w", err)
return false
}
// EPay API contract: code != 1 means the API itself errored (e.g. wrong key).
// Treat it as a query failure, not a definitive "unpaid", to avoid wrongly
// closing a paid order under a transient upstream config error.
if response.Code != 1 {
return false, fmt.Errorf("epay query api code=%d msg=%q", response.Code, response.Msg)
}
return response.Status == 1, nil
return response.Status == 1
}
// StructToMap converts a struct to map[string]string
-6
View File
@@ -37,7 +37,6 @@ func init() {
TelegramNotBound: "Telegram not bound ",
UserNotBindOauth: "User not bind oauth method",
InviteCodeError: "Invite code error",
UserCommissionNotEnough: "佣金余额不足",
RegisterIPLimit: "Too many registrations",
EmailBindError: "Email already bound",
UserBindInviteCodeExist: "Invite code already bound",
@@ -83,8 +82,6 @@ func init() {
// System error
DebugModeError: "Debug mode is enabled",
SendSmsError: "短信发送失败",
GetAuthenticatorError: "Unsupported login method",
AuthenticatorNotSupportedError: "The authenticator does not support this method",
@@ -97,18 +94,15 @@ func init() {
TelephoneExist: "Telephone already exists",
DeviceExist: "device exists",
PasswordIsEmpty: "password is empty",
AreaCodeIsEmpty: "国家区号不能为空",
TelephoneError: "telephone number error",
DeviceNotExist: "Device does not exist",
UseridNotMatch: "Userid not match",
DeviceBindLimitExceeded: "设备绑定数量已达上限",
// Order error
OrderNotExist: "Order does not exist",
PaymentMethodNotFound: "Payment method not found",
OrderStatusError: "Order status error",
InsufficientOfPeriod: "Insufficient number of period",
ExistAvailableTraffic: "存在可用流量",
OrderAlreadyRefunded: "Order already refunded",
OrderRefundNoSubscription: "Refund target subscription not found",
OrderRefundCommissionMismatch: "Refund commission source not found",
-45
View File
@@ -1,45 +0,0 @@
package xerr
import "testing"
// TestMapErrMsg_MissingCodesAreFilled 覆盖 HIF-138 中补齐的 5 个错误码:
// 这些码之前在 message 表中缺失,导致 MapErrMsg 回退到 "Internal Server Error"。
func TestMapErrMsg_MissingCodesAreFilled(t *testing.T) {
cases := []struct {
name string
code uint32
want string
}{
{"UserCommissionNotEnough", UserCommissionNotEnough, "佣金余额不足"},
{"SendSmsError", SendSmsError, "短信发送失败"},
{"AreaCodeIsEmpty", AreaCodeIsEmpty, "国家区号不能为空"},
{"DeviceBindLimitExceeded", DeviceBindLimitExceeded, "设备绑定数量已达上限"},
{"ExistAvailableTraffic", ExistAvailableTraffic, "存在可用流量"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := MapErrMsg(tc.code)
if got == "Internal Server Error" {
t.Fatalf("MapErrMsg(%d) fell back to Internal Server Error; expected %q", tc.code, tc.want)
}
if got != tc.want {
t.Errorf("MapErrMsg(%d) = %q, want %q", tc.code, got, tc.want)
}
if !IsCodeErr(tc.code) {
t.Errorf("IsCodeErr(%d) = false, want true", tc.code)
}
})
}
}
// TestMapErrMsg_UnknownCodeFallsBack 验证未定义码仍然安全回退,不 panic。
func TestMapErrMsg_UnknownCodeFallsBack(t *testing.T) {
const unknown uint32 = 99999
if got := MapErrMsg(unknown); got != "Internal Server Error" {
t.Errorf("MapErrMsg(%d) = %q, want %q", unknown, got, "Internal Server Error")
}
if IsCodeErr(unknown) {
t.Errorf("IsCodeErr(%d) = true, want false", unknown)
}
}