修复(#136): EPay notify 静默失败硬化 + 回写 trade_no
Build docker and publish / build (20.15.1) (push) Failing after 8m55s
Build docker and publish / build (20.15.1) (pull_request) Failing after 22m22s

P01 签名校验失败由 return nil 改为返回 xerr.SignatureInvalid,handler 回 400,EPay 网关重试;Debug 旁路保留
P02 订单不存在维持 error 返回,仅 status=5 Finished 保留 nil 作幂等短路
P03 支付完成路径写入 order.trade_no = req.TradeNo,再 UpdateOrderStatus 刷缓存
附加:TradeStatus != TRADE_SUCCESS 降为 INFO 日志 + metric epay_notify_trade_not_success
抽离纯决策函数 evaluateEPayNotify,新增单测覆盖 4 个核心分支 + 签名优先级回归 + SQL 写路径 + URL 解析(9/9 PASS,-race 干净)

仅 internal/logic/notify/ePayNotifyLogic.go 与对应单测,无其他文件变更。

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
架构师
2026-06-01 03:08:31 -07:00
parent aa11588c8f
commit 750b7be424
2 changed files with 271 additions and 7 deletions
+89 -7
View File
@@ -13,6 +13,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/hibiken/asynq"
"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"
@@ -22,6 +23,47 @@ import (
queueType "github.com/perfect-panel/server/queue/types"
)
// epayNotifyTradeNotSuccess identifies the "buyer paid but trade not in TRADE_SUCCESS
// terminal state" branch in metrics / log search. EPay is told 200 here so it will
// not retry; we still want it visible for monitoring (HIF-135 / HIF-136).
const epayNotifyTradeNotSuccess = "epay_notify_trade_not_success"
// epayNotifyDecision encodes the post-parse decision so the branching logic can be
// unit-tested without spinning up a real OrderModel / Redis / asynq stack.
type epayNotifyDecision int
const (
// epayNotifyDecisionRejectSign: signature is invalid and debug bypass is off.
// Caller MUST return an error so the handler responds non-200 and EPay retries.
epayNotifyDecisionRejectSign epayNotifyDecision = iota
// epayNotifyDecisionAckTradeNotSuccess: trade_status != TRADE_SUCCESS
// (user cancelled / failed at gateway). Acknowledge with 200 "success" and emit
// a metric-named log for monitoring.
epayNotifyDecisionAckTradeNotSuccess
// epayNotifyDecisionAckIdempotent: order already at Finished (status=5).
// Repeat callback — ack with 200 "success", do not re-enqueue activation.
epayNotifyDecisionAckIdempotent
// epayNotifyDecisionProcess: happy path. Write trade_no, flip status to Paid,
// enqueue activation task.
epayNotifyDecisionProcess
)
// evaluateEPayNotify is a pure decision function so each branch can be unit-tested
// without DB/Redis. Caller has already located the order; orderStatus is the
// current status from the DB row.
func evaluateEPayNotify(signValid, debugBypass bool, tradeStatus string, orderStatus uint8) epayNotifyDecision {
if !signValid && !debugBypass {
return epayNotifyDecisionRejectSign
}
if tradeStatus != "TRADE_SUCCESS" {
return epayNotifyDecisionAckTradeNotSuccess
}
if orderStatus == 5 {
return epayNotifyDecisionAckIdempotent
}
return epayNotifyDecisionProcess
}
type EPayNotifyLogic struct {
logger.Logger
ctx *gin.Context
@@ -47,6 +89,8 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
}
orderInfo, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OutTradeNo)
if err != nil {
// HIF-136 P02: order missing must propagate as error so EPay retries instead
// of being silently lost (was previously masked by a `return nil` further down).
l.Logger.Error("[EPayNotify] Find order failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OutTradeNo))
return errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist: %v", req.OutTradeNo)
}
@@ -65,17 +109,54 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
}
// Verify sign
client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type)
if !client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery)) && !l.svcCtx.Config.Debug {
l.Logger.Error("[EPayNotify] Verify sign failed")
signValid := client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery))
decision := evaluateEPayNotify(signValid, l.svcCtx.Config.Debug, req.TradeStatus, orderInfo.Status)
switch decision {
case epayNotifyDecisionRejectSign:
// HIF-136 P01: previously `return nil` here let EPay see 200 and stop retrying;
// caller still left order at status=1, which DeferCloseOrder then closed.
// Return error so handler responds non-200 and EPay retries.
l.Logger.Error("[EPayNotify] Verify sign failed",
logger.Field("order_no", req.OutTradeNo),
logger.Field("trade_no", req.TradeNo),
logger.Field("raw_query", l.ctx.Request.URL.RawQuery),
)
return errors.Wrapf(xerr.NewErrCode(xerr.SignatureInvalid), "verify sign failed: %v", req.OutTradeNo)
case epayNotifyDecisionAckTradeNotSuccess:
// User cancelled / gateway-side failure: ack 200 to stop retries, but keep
// the path observable under metric name `epay_notify_trade_not_success`.
l.Logger.Info("[EPayNotify] Trade status not success",
logger.Field("order_no", req.OutTradeNo),
logger.Field("trade_status", req.TradeStatus),
logger.Field("metric", epayNotifyTradeNotSuccess),
)
return nil
}
if req.TradeStatus != "TRADE_SUCCESS" {
l.Logger.Error("[EPayNotify] Trade status is not success", logger.Field("orderNo", req.OutTradeNo), logger.Field("tradeStatus", req.TradeStatus))
case epayNotifyDecisionAckIdempotent:
// Order already Finished — repeat callback is expected, ack with 200.
return nil
case epayNotifyDecisionProcess:
// fall through
}
if orderInfo.Status == 5 {
return nil
// HIF-136 P03: persist gateway trade_no BEFORE flipping status so post-mortems can
// reverse-lookup EPay flows to ppanel orders. Raw gorm write is acceptable here —
// the subsequent UpdateOrderStatus will invalidate the cache key (keys are by
// order_no / id, both stable across this field write).
if req.TradeNo != "" {
if err := l.svcCtx.DB.WithContext(l.ctx).
Model(&order.Order{}).
Where("order_no = ?", req.OutTradeNo).
Update("trade_no", req.TradeNo).Error; err != nil {
l.Logger.Error("[EPayNotify] Update trade_no failed",
logger.Field("error", err.Error()),
logger.Field("order_no", req.OutTradeNo),
logger.Field("trade_no", req.TradeNo),
)
return errors.Wrapf(err, "update trade_no failed: %v", req.OutTradeNo)
}
}
// Update order status
err = l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, req.OutTradeNo, 2)
if err != nil {
@@ -86,6 +167,7 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
"[SubscriptionFlow] epay notify marked order as paid",
append(commonLogic.OrderTraceFields(orderInfo),
logger.Field("payment_platform", data.Platform),
logger.Field("trade_no", req.TradeNo),
)...,
)
// Create activate order task