修复(#137): DeferCloseOrder 关单前反查支付网关

启用 confirmationPayment 作为关单兜底,对 status=1 订单反查 Alipay/Stripe/EPay
网关,三态分支处理:
  - 网关已收钱:不关单,订单进入 status=2 + 投递 ForthwithActivateOrder
  - 网关明确未支付/已取消:维持原 close 行为,置 status=3
  - 网关反查失败(超时/5xx/网络错):保留 status=1,等下一次 DeferCloseOrder 重试

EPay 客户端 QueryOrderStatus 改为返回 (bool, error),避免"未支付"与"查询失败"
混淆。Balance 等无网关方法保持 Unpaid 兜底,"用户主动取消"路径回归通过。

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
架构师
2026-06-01 03:08:46 -07:00
parent aa11588c8f
commit 1367c4fefd
3 changed files with 513 additions and 53 deletions
+259 -46
View File
@@ -5,9 +5,12 @@ 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"
@@ -18,19 +21,52 @@ 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 {
return &CloseOrderLogic{
l := &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 {
@@ -52,6 +88,31 @@ 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",
@@ -166,42 +227,117 @@ func (l *CloseOrderLogic) CloseOrder(req *types.CloseOrderRequest) error {
return nil
}
// 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)
// 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)
if err != nil {
l.Errorw("[CloseOrder] Find payment config failed", logger.Field("error", err.Error()), logger.Field("paymentMark", order.Method))
return false
l.Errorw("[CloseOrder] marshal activation payload failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return nil
}
switch order.Method {
case AlipayF2f:
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] Unsupported payment method", logger.Field("paymentMethod", order.Method))
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
}
return false
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
}
// queryAlipay Query Alipay payment status
//
//nolint:unused
func (l *CloseOrderLogic) queryAlipay(paymentConfig *payment.Payment, TradeNo string) bool {
// 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:
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
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
}
}
// 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
}
config := payment.AlipayF2FConfig{}
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
l.Errorw("[CloseOrder] Unmarshal payment config failed", logger.Field("error", err.Error()), logger.Field("config", paymentConfig.Config))
return false
l.Errorw("[CloseOrder] Unmarshal Alipay config failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return GatewayStatusUnknown
}
client := alipay.NewClient(alipay.Config{
AppId: config.AppId,
@@ -209,35 +345,112 @@ func (l *CloseOrderLogic) queryAlipay(paymentConfig *payment.Payment, TradeNo st
PublicKey: config.PublicKey,
InvoiceName: config.InvoiceName,
})
status, err := client.QueryTrade(l.ctx, TradeNo)
if client == nil {
return GatewayStatusUnknown
}
status, err := client.QueryTrade(l.ctx, orderInfo.TradeNo)
if err != nil {
l.Errorw("[CloseOrder] Query trade failed", logger.Field("error", err.Error()), logger.Field("TradeNo", TradeNo))
return false
l.Errorw("[CloseOrder] Alipay QueryTrade failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
logger.Field("tradeNo", orderInfo.TradeNo),
)
return GatewayStatusUnknown
}
if status == alipay.Success || status == alipay.Finished {
return true
switch status {
case alipay.Success, alipay.Finished:
return GatewayStatusPaid
case alipay.Pending, alipay.Closed:
return GatewayStatusUnpaid
default:
// Unknown alipay status — be conservative.
return GatewayStatusUnknown
}
return false
}
// queryStripe Query Stripe payment status
//
//nolint:unused
func (l *CloseOrderLogic) queryStripe(paymentConfig *payment.Payment, TradeNo string) bool {
// 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
}
config := payment.StripeConfig{}
if err := json.Unmarshal([]byte(paymentConfig.Config), &config); err != nil {
l.Errorw("[CloseOrder] Unmarshal payment config failed", logger.Field("error", err.Error()), logger.Field("config", paymentConfig.Config))
return false
l.Errorw("[CloseOrder] Unmarshal Stripe config failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
)
return GatewayStatusUnknown
}
client := stripe.NewClient(stripe.Config{
PublicKey: config.PublicKey,
SecretKey: config.SecretKey,
WebhookSecret: config.WebhookSecret,
})
status, err := client.QueryOrderStatus(TradeNo)
paid, err := client.QueryOrderStatus(orderInfo.TradeNo)
if err != nil {
l.Errorw("[CloseOrder] Query order status failed", logger.Field("error", err.Error()), logger.Field("TradeNo", TradeNo))
return false
l.Errorw("[CloseOrder] Stripe QueryOrderStatus failed",
logger.Field("error", err.Error()),
logger.Field("orderNo", orderInfo.OrderNo),
logger.Field("tradeNo", orderInfo.TradeNo),
)
return GatewayStatusUnknown
}
return status
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
}