Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18df7e4d6b |
@@ -2,7 +2,6 @@ package invite
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"slices"
|
|
||||||
|
|
||||||
modellog "github.com/perfect-panel/server/internal/model/log"
|
modellog "github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"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 {
|
for userId := range inviteeAndInviterSet {
|
||||||
inviteeAndInviterIds = append(inviteeAndInviterIds, userId)
|
inviteeAndInviterIds = append(inviteeAndInviterIds, userId)
|
||||||
}
|
}
|
||||||
slices.Sort(inviteeAndInviterIds)
|
|
||||||
|
|
||||||
if err := fillCommissionBenefits(ctx, db, result, orderToInvitee, inviteeToInviter, orderNos, inviterIds); err != nil {
|
if err := fillCommissionBenefits(ctx, db, result, orderToInvitee, inviteeToInviter, orderNos, inviterIds); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func TestQueryBenefitsCountsFamilyOwnerGiftAsInviteeGift(t *testing.T) {
|
|||||||
WithArgs(33, int64(100), "family-order", 331, 332).
|
WithArgs(33, int64(100), "family-order", 331, 332).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
||||||
mock.ExpectQuery("object_id IN").
|
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"}).
|
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
|
||||||
AddRow(900, `{"type":341,"order_no":"family-order","amount":7,"balance":7,"remark":"邀请赠送"}`))
|
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).
|
WithArgs(33, int64(100), "direct-order", 331, 332).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}))
|
||||||
mock.ExpectQuery("object_id IN").
|
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"}).
|
WillReturnRows(sqlmock.NewRows([]string{"object_id", "content"}).
|
||||||
AddRow(200, `{"type":341,"order_no":"direct-order","amount":5,"balance":5,"remark":"邀请赠送"}`))
|
AddRow(200, `{"type":341,"order_no":"direct-order","amount":5,"balance":5,"remark":"邀请赠送"}`))
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hibiken/asynq"
|
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/pkg/payment/epay"
|
|
||||||
"github.com/perfect-panel/server/pkg/payment/stripe"
|
"github.com/perfect-panel/server/pkg/payment/stripe"
|
||||||
queueTypes "github.com/perfect-panel/server/queue/types"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/model/order"
|
"github.com/perfect-panel/server/internal/model/order"
|
||||||
@@ -21,52 +18,19 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/payment/alipay"
|
"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 {
|
type CloseOrderLogic struct {
|
||||||
logger.Logger
|
logger.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
svcCtx *svc.ServiceContext
|
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
|
// NewCloseOrderLogic Close order
|
||||||
func NewCloseOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CloseOrderLogic {
|
func NewCloseOrderLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CloseOrderLogic {
|
||||||
l := &CloseOrderLogic{
|
return &CloseOrderLogic{
|
||||||
Logger: logger.WithContext(ctx),
|
Logger: logger.WithContext(ctx),
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
svcCtx: svcCtx,
|
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 {
|
func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
|
||||||
@@ -88,31 +52,6 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
|
|||||||
return nil
|
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)
|
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, orderInfo.SubscribeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[CloseOrder] Find subscribe info failed",
|
l.Errorw("[CloseOrder] Find subscribe info failed",
|
||||||
@@ -227,117 +166,42 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// recoverPaidOrder is the "gateway said paid" branch: flip the order to
|
// confirmationPayment Determine whether the payment is successful
|
||||||
// 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
|
//nolint:unused
|
||||||
// reuse ForthwithActivateOrder so the rest of the activation pipeline
|
func (l *CloseOrderLogic) confirmationPayment(order *order.Order) bool {
|
||||||
// (idempotency, claim/release, commission, etc.) stays untouched.
|
paymentConfig, err := l.svcCtx.PaymentModel.FindOne(l.ctx, order.PaymentId)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[CloseOrder] marshal activation payload failed",
|
l.Errorw("[CloseOrder] Find payment config failed", logger.Field("error", err.Error()), logger.Field("paymentMark", order.Method))
|
||||||
logger.Field("error", err.Error()),
|
return false
|
||||||
logger.Field("orderNo", orderInfo.OrderNo),
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
taskID, err := l.enqueueActivate(l.ctx, bytes)
|
switch order.Method {
|
||||||
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 {
|
|
||||||
case AlipayF2f:
|
case AlipayF2f:
|
||||||
return l.queryAlipay(orderInfo)
|
if l.queryAlipay(paymentConfig, order.TradeNo) {
|
||||||
case StripeAlipay, StripeWeChatPay:
|
return true
|
||||||
return l.queryStripe(orderInfo)
|
}
|
||||||
case Epay:
|
case StripeAlipay:
|
||||||
return l.queryEpay(orderInfo)
|
if l.queryStripe(paymentConfig, order.TradeNo) {
|
||||||
case Balance:
|
return true
|
||||||
// Balance is settled in-process; no external gateway to ask. If status=1
|
}
|
||||||
// here, the balance deduction simply never completed — safe to close.
|
case StripeWeChatPay:
|
||||||
return GatewayStatusUnpaid
|
if l.queryStripe(paymentConfig, order.TradeNo) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
l.Infow("[CloseOrder] no gateway query for method — falling back to close",
|
l.Infow("[CloseOrder] Unsupported payment method", logger.Field("paymentMethod", order.Method))
|
||||||
logger.Field("method", orderInfo.Method),
|
|
||||||
logger.Field("orderNo", orderInfo.OrderNo),
|
|
||||||
)
|
|
||||||
return GatewayStatusUnpaid
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryAlipay queries Alipay F2F and maps the response to a tri-state.
|
// queryAlipay Query Alipay payment status
|
||||||
// "trade not exist" on Alipay's side is treated as Unpaid (the user never
|
//
|
||||||
// completed the QR-code scan), not Unknown.
|
//nolint:unused
|
||||||
func (l *CloseOrderLogic) queryAlipay(orderInfo *order.Order) GatewayPaymentStatus {
|
func (l *CloseOrderLogic) queryAlipay(paymentConfig *payment.Payment, TradeNo string) bool {
|
||||||
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
|
|
||||||
}
|
|
||||||
config := payment.AlipayF2FConfig{}
|
config := payment.AlipayF2FConfig{}
|
||||||
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
|
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
|
||||||
l.Errorw("[CloseOrder] Unmarshal Alipay config failed",
|
l.Errorw("[CloseOrder] Unmarshal payment config failed", logger.Field("error", err.Error()), logger.Field("config", paymentConfig.Config))
|
||||||
logger.Field("error", err.Error()),
|
return false
|
||||||
logger.Field("orderNo", orderInfo.OrderNo),
|
|
||||||
)
|
|
||||||
return GatewayStatusUnknown
|
|
||||||
}
|
}
|
||||||
client := alipay.NewClient(alipay.Config{
|
client := alipay.NewClient(alipay.Config{
|
||||||
AppId: config.AppId,
|
AppId: config.AppId,
|
||||||
@@ -345,112 +209,35 @@ func (l *CloseOrderLogic) queryAlipay(orderInfo *order.Order) GatewayPaymentStat
|
|||||||
PublicKey: config.PublicKey,
|
PublicKey: config.PublicKey,
|
||||||
InvoiceName: config.InvoiceName,
|
InvoiceName: config.InvoiceName,
|
||||||
})
|
})
|
||||||
if client == nil {
|
status, err := client.QueryTrade(l.ctx, TradeNo)
|
||||||
return GatewayStatusUnknown
|
|
||||||
}
|
|
||||||
status, err := client.QueryTrade(l.ctx, orderInfo.TradeNo)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[CloseOrder] Alipay QueryTrade failed",
|
l.Errorw("[CloseOrder] Query trade failed", logger.Field("error", err.Error()), logger.Field("TradeNo", TradeNo))
|
||||||
logger.Field("error", err.Error()),
|
return false
|
||||||
logger.Field("orderNo", orderInfo.OrderNo),
|
|
||||||
logger.Field("tradeNo", orderInfo.TradeNo),
|
|
||||||
)
|
|
||||||
return GatewayStatusUnknown
|
|
||||||
}
|
}
|
||||||
switch status {
|
if status == alipay.Success || status == alipay.Finished {
|
||||||
case alipay.Success, alipay.Finished:
|
return true
|
||||||
return GatewayStatusPaid
|
|
||||||
case alipay.Pending, alipay.Closed:
|
|
||||||
return GatewayStatusUnpaid
|
|
||||||
default:
|
|
||||||
// Unknown alipay status — be conservative.
|
|
||||||
return GatewayStatusUnknown
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryStripe queries Stripe PaymentIntent and maps to a tri-state.
|
// queryStripe Query Stripe payment status
|
||||||
// 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
|
//nolint:unused
|
||||||
// downgrade query failures to "unpaid".
|
func (l *CloseOrderLogic) queryStripe(paymentConfig *payment.Payment, TradeNo string) bool {
|
||||||
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
|
|
||||||
}
|
|
||||||
config := payment.StripeConfig{}
|
config := payment.StripeConfig{}
|
||||||
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
|
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
|
||||||
l.Errorw("[CloseOrder] Unmarshal Stripe config failed",
|
l.Errorw("[CloseOrder] Unmarshal payment config failed", logger.Field("error", err.Error()), logger.Field("config", paymentConfig.Config))
|
||||||
logger.Field("error", err.Error()),
|
return false
|
||||||
logger.Field("orderNo", orderInfo.OrderNo),
|
|
||||||
)
|
|
||||||
return GatewayStatusUnknown
|
|
||||||
}
|
}
|
||||||
client := stripe.NewClient(stripe.Config{
|
client := stripe.NewClient(stripe.Config{
|
||||||
PublicKey: config.PublicKey,
|
PublicKey: config.PublicKey,
|
||||||
SecretKey: config.SecretKey,
|
SecretKey: config.SecretKey,
|
||||||
WebhookSecret: config.WebhookSecret,
|
WebhookSecret: config.WebhookSecret,
|
||||||
})
|
})
|
||||||
paid, err := client.QueryOrderStatus(orderInfo.TradeNo)
|
status, err := client.QueryOrderStatus(TradeNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[CloseOrder] Stripe QueryOrderStatus failed",
|
l.Errorw("[CloseOrder] Query order status failed", logger.Field("error", err.Error()), logger.Field("TradeNo", TradeNo))
|
||||||
logger.Field("error", err.Error()),
|
return false
|
||||||
logger.Field("orderNo", orderInfo.OrderNo),
|
|
||||||
logger.Field("tradeNo", orderInfo.TradeNo),
|
|
||||||
)
|
|
||||||
return GatewayStatusUnknown
|
|
||||||
}
|
}
|
||||||
if paid {
|
return status
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"context"
|
||||||
|
|
||||||
logicCommon "github.com/perfect-panel/server/internal/logic/common"
|
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/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"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)
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "cancel withdrawal failed: %v", txErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commission was NOT deducted at application time under the HIF-22
|
if txErr = l.svcCtx.UserModel.UpdateCommission(l.ctx, withdrawal.UserId, withdrawal.Amount, tx); txErr != nil {
|
||||||
// approval flow, so cancellation requires no refund — only a status
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "refund commission failed: %v", txErr)
|
||||||
// update. Refunding here would mint phantom commission and pollute
|
}
|
||||||
// reconciliation (mirrors rejectWithdrawal in admin/user/withdrawalCommon.go).
|
|
||||||
|
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
|
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,
|
|
||||||
// 严禁触发 UpdateCommission(commission 列读 / 写)或写 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
|
|
||||||
// 改成 1(FOR 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/perfect-panel/server/pkg/xerr"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CommissionWithdrawLogic struct {
|
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) {
|
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 {
|
if !ok {
|
||||||
logger.Error("current user is not found in context")
|
logger.Error("current user is not found in context")
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
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.
|
// HIF-139: read commission and pending total directly from DB inside a
|
||||||
// Available = commission - pendingTotal; commission is only deducted on approval.
|
// transaction, FOR UPDATE on the user row. The ctxUser snapshot may be
|
||||||
var pendingTotal int64
|
// served from cache and can be stale (the original bug allowed a user
|
||||||
if err = l.svcCtx.DB.WithContext(l.ctx).
|
// with cached commission=996900 to submit a withdrawal while DB said 0,
|
||||||
Model(&user.Withdrawal{}).
|
// which then failed admin approval with 20010). Approve already locks
|
||||||
Where("user_id = ? AND status = ?", u.Id, user.WithdrawalStatusPending).
|
// the user row this way; aligning submission closes the gap.
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
var w user.Withdrawal
|
var w user.Withdrawal
|
||||||
err = l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
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{
|
w = user.Withdrawal{
|
||||||
UserId: u.Id,
|
UserId: ctxUser.Id,
|
||||||
Amount: req.Amount,
|
Amount: req.Amount,
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
Status: user.WithdrawalStatusPending,
|
Status: user.WithdrawalStatusPending,
|
||||||
@@ -81,16 +92,19 @@ func (l *CommissionWithdrawLogic) CommissionWithdraw(req *types.CommissionWithdr
|
|||||||
Account: req.Account,
|
Account: req.Account,
|
||||||
QrCodeUrl: req.QrCodeUrl,
|
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 {
|
if err != nil {
|
||||||
l.Errorf("Failed to create withdrawal for user %d: %v", u.Id, err)
|
return nil, err
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "Failed to create withdrawal for user %d: %v", u.Id, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &types.WithdrawalLog{
|
return &types.WithdrawalLog{
|
||||||
Id: w.Id,
|
Id: w.Id,
|
||||||
UserId: u.Id,
|
UserId: ctxUser.Id,
|
||||||
Amount: req.Amount,
|
Amount: req.Amount,
|
||||||
Content: req.Content,
|
Content: req.Content,
|
||||||
Status: user.WithdrawalStatusPending,
|
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
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package epay
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -89,42 +88,28 @@ func (c *Client) VerifySign(params map[string]string) bool {
|
|||||||
return c.createSign(params) == params["sign"]
|
return c.createSign(params) == params["sign"]
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryOrderStatus returns (paid, err). A non-nil err means the query itself
|
func (c *Client) QueryOrderStatus(orderNo string) bool {
|
||||||
// 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) {
|
|
||||||
client := http.Client{
|
client := http.Client{
|
||||||
Timeout: 5 * time.Second,
|
Timeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
resp, err := client.Get(c.Url + "/api.php" + "?act=order" + "&pid=" + c.Pid + "&key=" + c.Key + "&out_trade_no=" + orderNo)
|
resp, err := client.Get(c.Url + "/api.php" + "?act=order" + "&pid=" + c.Pid + "&key=" + c.Key + "&out_trade_no=" + orderNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("[Epay] QueryOrderStatus error", logger.Field("orderNo", orderNo), logger.Field("error", err.Error()))
|
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()
|
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)
|
value, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("[Epay] QueryOrderStatus error", logger.Field("orderNo", orderNo), logger.Field("error", err.Error()))
|
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
|
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()))
|
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).
|
return response.Status == 1
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StructToMap converts a struct to map[string]string
|
// StructToMap converts a struct to map[string]string
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ func init() {
|
|||||||
TelegramNotBound: "Telegram not bound ",
|
TelegramNotBound: "Telegram not bound ",
|
||||||
UserNotBindOauth: "User not bind oauth method",
|
UserNotBindOauth: "User not bind oauth method",
|
||||||
InviteCodeError: "Invite code error",
|
InviteCodeError: "Invite code error",
|
||||||
UserCommissionNotEnough: "佣金余额不足",
|
|
||||||
RegisterIPLimit: "Too many registrations",
|
RegisterIPLimit: "Too many registrations",
|
||||||
EmailBindError: "Email already bound",
|
EmailBindError: "Email already bound",
|
||||||
UserBindInviteCodeExist: "Invite code already bound",
|
UserBindInviteCodeExist: "Invite code already bound",
|
||||||
@@ -83,8 +82,6 @@ func init() {
|
|||||||
// System error
|
// System error
|
||||||
DebugModeError: "Debug mode is enabled",
|
DebugModeError: "Debug mode is enabled",
|
||||||
|
|
||||||
SendSmsError: "短信发送失败",
|
|
||||||
|
|
||||||
GetAuthenticatorError: "Unsupported login method",
|
GetAuthenticatorError: "Unsupported login method",
|
||||||
AuthenticatorNotSupportedError: "The authenticator does not support this method",
|
AuthenticatorNotSupportedError: "The authenticator does not support this method",
|
||||||
|
|
||||||
@@ -97,18 +94,15 @@ func init() {
|
|||||||
TelephoneExist: "Telephone already exists",
|
TelephoneExist: "Telephone already exists",
|
||||||
DeviceExist: "device exists",
|
DeviceExist: "device exists",
|
||||||
PasswordIsEmpty: "password is empty",
|
PasswordIsEmpty: "password is empty",
|
||||||
AreaCodeIsEmpty: "国家区号不能为空",
|
|
||||||
TelephoneError: "telephone number error",
|
TelephoneError: "telephone number error",
|
||||||
DeviceNotExist: "Device does not exist",
|
DeviceNotExist: "Device does not exist",
|
||||||
UseridNotMatch: "Userid not match",
|
UseridNotMatch: "Userid not match",
|
||||||
DeviceBindLimitExceeded: "设备绑定数量已达上限",
|
|
||||||
|
|
||||||
// Order error
|
// Order error
|
||||||
OrderNotExist: "Order does not exist",
|
OrderNotExist: "Order does not exist",
|
||||||
PaymentMethodNotFound: "Payment method not found",
|
PaymentMethodNotFound: "Payment method not found",
|
||||||
OrderStatusError: "Order status error",
|
OrderStatusError: "Order status error",
|
||||||
InsufficientOfPeriod: "Insufficient number of period",
|
InsufficientOfPeriod: "Insufficient number of period",
|
||||||
ExistAvailableTraffic: "存在可用流量",
|
|
||||||
OrderAlreadyRefunded: "Order already refunded",
|
OrderAlreadyRefunded: "Order already refunded",
|
||||||
OrderRefundNoSubscription: "Refund target subscription not found",
|
OrderRefundNoSubscription: "Refund target subscription not found",
|
||||||
OrderRefundCommissionMismatch: "Refund commission source not found",
|
OrderRefundCommissionMismatch: "Refund commission source not found",
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user