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 }