750b7be424
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>
209 lines
8.0 KiB
Go
209 lines
8.0 KiB
Go
package notify
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/url"
|
|
|
|
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
|
"github.com/perfect-panel/server/pkg/constant"
|
|
|
|
"github.com/perfect-panel/server/pkg/xerr"
|
|
"github.com/pkg/errors"
|
|
|
|
"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"
|
|
"github.com/perfect-panel/server/pkg/logger"
|
|
"github.com/perfect-panel/server/pkg/payment/epay"
|
|
|
|
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
|
|
svcCtx *svc.ServiceContext
|
|
}
|
|
|
|
// EPay notify
|
|
func NewEPayNotifyLogic(ctx *gin.Context, svcCtx *svc.ServiceContext) *EPayNotifyLogic {
|
|
return &EPayNotifyLogic{
|
|
Logger: logger.WithContext(ctx),
|
|
ctx: ctx,
|
|
svcCtx: svcCtx,
|
|
}
|
|
}
|
|
|
|
func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
|
|
|
|
// Find payment config
|
|
data, ok := l.ctx.Request.Context().Value(constant.CtxKeyPayment).(*payment.Payment)
|
|
if !ok {
|
|
l.Logger.Error("[EPayNotify] Payment not found in context")
|
|
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "payment config not found")
|
|
}
|
|
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)
|
|
}
|
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "payment_notify_received",
|
|
"[SubscriptionFlow] epay notify received",
|
|
append(commonLogic.OrderTraceFields(orderInfo),
|
|
logger.Field("payment_platform", data.Platform),
|
|
logger.Field("trade_status", req.TradeStatus),
|
|
)...,
|
|
)
|
|
|
|
var config payment.EPayConfig
|
|
if err := json.Unmarshal([]byte(data.Config), &config); err != nil {
|
|
l.Logger.Errorw("[EPayNotify] Unmarshal config failed", logger.Field("error", err.Error()))
|
|
return err
|
|
}
|
|
// Verify sign
|
|
client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type)
|
|
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
|
|
case epayNotifyDecisionAckIdempotent:
|
|
// Order already Finished — repeat callback is expected, ack with 200.
|
|
return nil
|
|
case epayNotifyDecisionProcess:
|
|
// fall through
|
|
}
|
|
|
|
// 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 {
|
|
l.Logger.Error("[EPayNotify] Update order status failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OutTradeNo))
|
|
return err
|
|
}
|
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "payment_settled",
|
|
"[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
|
|
payload := queueType.ForthwithActivateOrderPayload{
|
|
OrderNo: req.OutTradeNo,
|
|
}
|
|
bytes, err := json.Marshal(&payload)
|
|
if err != nil {
|
|
l.Logger.Error("[EPayNotify] Marshal payload failed", logger.Field("error", err.Error()))
|
|
return err
|
|
}
|
|
task := asynq.NewTask(queueType.ForthwithActivateOrder, bytes, asynq.MaxRetry(5))
|
|
taskInfo, err := l.svcCtx.Queue.EnqueueContext(l.ctx, task)
|
|
if err != nil {
|
|
l.Logger.Error("[EPayNotify] Enqueue task failed", logger.Field("error", err.Error()))
|
|
return err
|
|
}
|
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "activation_task_enqueued",
|
|
"[SubscriptionFlow] activation task enqueued from epay notify",
|
|
append(commonLogic.OrderTraceFields(orderInfo),
|
|
logger.Field("payment_platform", data.Platform),
|
|
logger.Field("queue_task_id", taskInfo.ID),
|
|
)...,
|
|
)
|
|
l.Logger.Info("[EPayNotify] Enqueue task success", logger.Field("taskInfo", taskInfo))
|
|
return nil
|
|
}
|
|
|
|
func urlParamsToMap(query string) map[string]string {
|
|
params := make(map[string]string)
|
|
values, _ := url.ParseQuery(query)
|
|
for k, v := range values {
|
|
if len(v) > 0 {
|
|
params[k] = v[0]
|
|
}
|
|
}
|
|
return params
|
|
}
|