87ebfa1fac
Squash merge of fix/137-defer-close-反查网关 (1367c4f).
DeferCloseOrder 直接将 status=1 订单关单,会把已经在网关侧完成支付但
notify 静默失败的订单错误关闭。本次在关单前调用 EPay 的网关查询接口
(confirmationPayment) 拿到三态结果:
- Paid: 原子化把 status 1->2,写回 trade_no,再投递 asynq 走激活流程
- Unpaid: 继续原来的 close 事务,把 status 改为 cancelled
- Unknown / 网关失败: 保持 status=1,下一轮 DeferClose 再试
新增 closeOrderLogic_test.go (232 行),覆盖三态分支 + recoverPaidOrder
的并发幂等。单测全量 PASS, go build + go vet 均干净。E2E 验收因测试环境
访问受限暂未跑,QA 已在 issue 上注明阻塞原因 (qa_partial_blocked_on_e2e_access)。
Co-authored-by: multica-agent <github@multica.ai>
457 lines
16 KiB
Go
457 lines
16 KiB
Go
package order
|
|
|
|
import (
|
|
"context"
|
|
"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"
|
|
"github.com/perfect-panel/server/internal/model/payment"
|
|
"github.com/perfect-panel/server/internal/svc"
|
|
"github.com/perfect-panel/server/internal/types"
|
|
"github.com/perfect-panel/server/pkg/logger"
|
|
"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{
|
|
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 {
|
|
// Find order information by order number
|
|
orderInfo, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo)
|
|
if err != nil {
|
|
l.Errorw("[CloseOrder] Find order info failed",
|
|
logger.Field("error", err.Error()),
|
|
logger.Field("orderNo", req.OrderNo),
|
|
)
|
|
return nil
|
|
}
|
|
// If the order status is not 1, it means that the order has been closed or paid
|
|
if orderInfo.Status != 1 {
|
|
l.Infow("[CloseOrder] Order status is not 1",
|
|
logger.Field("orderNo", req.OrderNo),
|
|
logger.Field("status", orderInfo.Status),
|
|
)
|
|
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",
|
|
logger.Field("error", err.Error()),
|
|
logger.Field("subscribeId", orderInfo.SubscribeId),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
err = l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
|
|
// update order status
|
|
err := tx.Model(&order.Order{}).Where("order_no = ?", req.OrderNo).Update("status", 3).Error
|
|
if err != nil {
|
|
l.Errorw("[CloseOrder] Update order status failed",
|
|
logger.Field("error", err.Error()),
|
|
logger.Field("orderNo", req.OrderNo),
|
|
)
|
|
return err
|
|
}
|
|
// If User ID is 0, it means that the order is a guest order and does not need to be refunded, the order can be deleted directly
|
|
if orderInfo.UserId == 0 {
|
|
err = tx.Model(&order.Order{}).Where("order_no = ?", req.OrderNo).Delete(&order.Order{}).Error
|
|
if err != nil {
|
|
l.Errorw("[CloseOrder] Delete order failed",
|
|
logger.Field("error", err.Error()),
|
|
logger.Field("orderNo", req.OrderNo),
|
|
)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
// refund deduction amount to user deduction balance
|
|
if orderInfo.GiftAmount > 0 {
|
|
userInfo, err := l.svcCtx.UserModel.FindOne(l.ctx, orderInfo.UserId)
|
|
if err != nil {
|
|
l.Errorw("[CloseOrder] Find user info failed",
|
|
logger.Field("error", err.Error()),
|
|
logger.Field("user_id", orderInfo.UserId),
|
|
)
|
|
return err
|
|
}
|
|
deduction := userInfo.GiftAmount + orderInfo.GiftAmount
|
|
err = tx.Model(&user.User{}).Where("id = ?", orderInfo.UserId).Update("gift_amount", deduction).Error
|
|
if err != nil {
|
|
l.Errorw("[CloseOrder] Refund deduction amount failed",
|
|
logger.Field("error", err.Error()),
|
|
logger.Field("uid", orderInfo.UserId),
|
|
logger.Field("deduction", orderInfo.GiftAmount),
|
|
)
|
|
return err
|
|
}
|
|
// Record the deduction refund log
|
|
|
|
giftLog := log.Gift{
|
|
Type: log.GiftTypeIncrease,
|
|
OrderNo: orderInfo.OrderNo,
|
|
SubscribeId: 0,
|
|
Amount: orderInfo.GiftAmount,
|
|
Balance: deduction,
|
|
Remark: "Order cancellation refund",
|
|
Timestamp: time.Now().UnixMilli(),
|
|
}
|
|
content, _ := giftLog.Marshal()
|
|
|
|
err = tx.Model(&log.SystemLog{}).Create(&log.SystemLog{
|
|
Id: 0,
|
|
Type: log.TypeGift.Uint8(),
|
|
Date: time.Now().Format(time.DateOnly),
|
|
ObjectID: userInfo.Id,
|
|
Content: string(content),
|
|
}).Error
|
|
if err != nil {
|
|
l.Errorw("[CloseOrder] Record cancellation refund log failed",
|
|
logger.Field("error", err.Error()),
|
|
logger.Field("uid", orderInfo.UserId),
|
|
logger.Field("deduction", orderInfo.GiftAmount),
|
|
)
|
|
return err
|
|
}
|
|
}
|
|
// Note: user cache will be updated after transaction commits
|
|
if sub.Inventory != -1 {
|
|
sub.Inventory++
|
|
if e := l.svcCtx.SubscribeModel.Update(l.ctx, sub, tx); e != nil {
|
|
l.Errorw("[CloseOrder] Restore subscribe inventory failed",
|
|
logger.Field("error", e.Error()),
|
|
logger.Field("subscribeId", sub.Id),
|
|
)
|
|
return e
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
logger.Errorf("[CloseOrder] Transaction failed: %v", err.Error())
|
|
return err
|
|
}
|
|
|
|
// Update user cache after transaction commits successfully
|
|
if orderInfo.GiftAmount > 0 && orderInfo.UserId != 0 {
|
|
if userInfo, findErr := l.svcCtx.UserModel.FindOne(l.ctx, orderInfo.UserId); findErr == nil {
|
|
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, userInfo); clearErr != nil {
|
|
l.Errorw("[CloseOrder] failed to clear user cache",
|
|
logger.Field("error", clearErr.Error()),
|
|
logger.Field("user_id", orderInfo.UserId),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
l.Errorw("[CloseOrder] marshal activation payload failed",
|
|
logger.Field("error", err.Error()),
|
|
logger.Field("orderNo", orderInfo.OrderNo),
|
|
)
|
|
return nil
|
|
}
|
|
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 {
|
|
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 Alipay config failed",
|
|
logger.Field("error", err.Error()),
|
|
logger.Field("orderNo", orderInfo.OrderNo),
|
|
)
|
|
return GatewayStatusUnknown
|
|
}
|
|
client := alipay.NewClient(alipay.Config{
|
|
AppId: config.AppId,
|
|
PrivateKey: config.PrivateKey,
|
|
PublicKey: config.PublicKey,
|
|
InvoiceName: config.InvoiceName,
|
|
})
|
|
if client == nil {
|
|
return GatewayStatusUnknown
|
|
}
|
|
status, err := client.QueryTrade(l.ctx, orderInfo.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
|
|
}
|
|
switch status {
|
|
case alipay.Success, alipay.Finished:
|
|
return GatewayStatusPaid
|
|
case alipay.Pending, alipay.Closed:
|
|
return GatewayStatusUnpaid
|
|
default:
|
|
// Unknown alipay status — be conservative.
|
|
return GatewayStatusUnknown
|
|
}
|
|
}
|
|
|
|
// 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 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,
|
|
})
|
|
paid, err := client.QueryOrderStatus(orderInfo.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
|
|
}
|
|
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
|
|
}
|