Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展 Confidence: medium Scope-risk: broad Directive: 后续修改需保持审计事件与业务事务边界一致 Tested: git diff --cached --check Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
687 lines
29 KiB
Go
687 lines
29 KiB
Go
package callback
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gofiber/fiber/v2"
|
||
"github.com/valyala/fasthttp/fasthttpadaptor"
|
||
"go.uber.org/zap"
|
||
|
||
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
|
||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
orderService "github.com/break/junhong_cmp_fiber/internal/service/order"
|
||
rechargeService "github.com/break/junhong_cmp_fiber/internal/service/recharge"
|
||
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
|
||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||
"github.com/break/junhong_cmp_fiber/pkg/alipay"
|
||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
|
||
pkgmiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||
"github.com/break/junhong_cmp_fiber/pkg/wechat"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// AgentRechargeServiceInterface 代理充值服务接口
|
||
type AgentRechargeServiceInterface interface {
|
||
HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string, paidAmount int64) error
|
||
}
|
||
|
||
// WechatConfigServiceInterface 支付配置服务接口
|
||
type WechatConfigServiceInterface interface {
|
||
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
|
||
GetConfigForCallback(ctx context.Context, orderNo string) (*model.WechatConfig, error)
|
||
}
|
||
|
||
type PaymentHandler struct {
|
||
orderService *orderService.Service
|
||
rechargeService *rechargeService.Service
|
||
rechargeOrderService *rechargeOrderSvc.Service
|
||
agentRechargeService AgentRechargeServiceInterface
|
||
wechatPayment wechat.PaymentServiceInterface
|
||
wechatConfigService WechatConfigServiceInterface
|
||
paymentStore *postgres.PaymentStore
|
||
agentPaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService
|
||
integration *integrationlog.Repository
|
||
logger *zap.Logger
|
||
}
|
||
|
||
func NewPaymentHandler(
|
||
orderService *orderService.Service,
|
||
rechargeService *rechargeService.Service,
|
||
rechargeOrderService *rechargeOrderSvc.Service,
|
||
agentRechargeService AgentRechargeServiceInterface,
|
||
wechatPayment wechat.PaymentServiceInterface,
|
||
wechatConfigService WechatConfigServiceInterface,
|
||
paymentStore *postgres.PaymentStore,
|
||
agentPaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService,
|
||
integration *integrationlog.Repository,
|
||
logger *zap.Logger,
|
||
) *PaymentHandler {
|
||
return &PaymentHandler{
|
||
orderService: orderService,
|
||
rechargeService: rechargeService,
|
||
rechargeOrderService: rechargeOrderService,
|
||
agentRechargeService: agentRechargeService,
|
||
wechatPayment: wechatPayment,
|
||
wechatConfigService: wechatConfigService,
|
||
paymentStore: paymentStore,
|
||
agentPaymentConfirm: agentPaymentConfirm,
|
||
integration: integration,
|
||
logger: logger,
|
||
}
|
||
}
|
||
|
||
type verifiedPaymentCallback struct {
|
||
PaymentNo string
|
||
PaymentMethod string
|
||
TransactionID string
|
||
Amount int64
|
||
ConfigID uint
|
||
MerchantIdentity string
|
||
PaidAt time.Time
|
||
Provider string
|
||
RawPayload []byte
|
||
ContentType string
|
||
}
|
||
|
||
// WechatPayCallback 微信支付回调(带签名验证)
|
||
// POST /api/callback/wechat-pay
|
||
func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
|
||
body := c.Body()
|
||
ctx := c.UserContext()
|
||
|
||
// 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置
|
||
orderNo, err := wechat.PeekOrderNo(body)
|
||
if err != nil {
|
||
h.logger.Error("微信回调:预解析订单号失败", zap.Error(err))
|
||
return errors.New(errors.CodeWechatCallbackInvalid, "回调数据格式错误")
|
||
}
|
||
|
||
cfg, err := h.wechatConfigService.GetConfigForCallback(ctx, orderNo)
|
||
if err != nil || cfg == nil {
|
||
h.logger.Error("微信回调:加载支付配置失败",
|
||
zap.String("order_no", orderNo),
|
||
zap.Error(err),
|
||
)
|
||
return errors.New(errors.CodeWechatCallbackInvalid, "微信支付服务未配置")
|
||
}
|
||
ctx = paymentCallbackContext(ctx, constants.IntegrationProviderWechatPay)
|
||
|
||
switch cfg.ProviderType {
|
||
case model.ProviderTypeWechatV2:
|
||
paymentSvc := wechat.NewPaymentV2Service(cfg.OaAppID, cfg.WxMchID, cfg.WxAPIV2Key, cfg.WxNotifyURL, h.logger)
|
||
result, err := paymentSvc.VerifyCallback(body)
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "微信 v2 回调验签失败")
|
||
}
|
||
if result.TradeState != "SUCCESS" {
|
||
if err := h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
|
||
PaymentNo: result.OutTradeNo, TransactionID: result.TransactionID,
|
||
Provider: constants.IntegrationProviderWechatPay, RawPayload: body, ContentType: c.Get("Content-Type"),
|
||
}, result.TradeState); err != nil {
|
||
return err
|
||
}
|
||
return h.wechatV2SuccessResponse(c)
|
||
}
|
||
// TotalFee 为字符串格式的分,解析失败则降级为 0(后续 handlePaymentCallback 会记录日志)
|
||
totalFee, _ := strconv.ParseInt(result.TotalFee, 10, 64)
|
||
paidAt, _ := time.ParseInLocation("20060102150405", result.SuccessTime, time.Local)
|
||
if err := h.dispatchWechatCallback(ctx, verifiedPaymentCallback{
|
||
PaymentNo: result.OutTradeNo, PaymentMethod: model.PaymentMethodWechat,
|
||
TransactionID: result.TransactionID, Amount: totalFee, ConfigID: cfg.ID,
|
||
MerchantIdentity: cfg.WxMchID, PaidAt: paidAt, Provider: constants.IntegrationProviderWechatPay,
|
||
RawPayload: body, ContentType: c.Get("Content-Type"),
|
||
}); err != nil {
|
||
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "处理微信支付回调失败")
|
||
}
|
||
|
||
case model.ProviderTypeWechat:
|
||
if h.wechatPayment == nil {
|
||
return errors.New(errors.CodeWechatCallbackInvalid, "微信 v3 支付未初始化")
|
||
}
|
||
var httpReq http.Request
|
||
fasthttpadaptor.ConvertRequest(c.Context(), &httpReq, true)
|
||
_, err := h.wechatPayment.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error {
|
||
if result.TradeState != "SUCCESS" {
|
||
return h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
|
||
PaymentNo: result.OutTradeNo, TransactionID: result.TransactionID,
|
||
Provider: constants.IntegrationProviderWechatPay, RawPayload: body, ContentType: c.Get("Content-Type"),
|
||
}, result.TradeState)
|
||
}
|
||
paidAt, _ := time.Parse(time.RFC3339, result.SuccessTime)
|
||
return h.dispatchWechatCallback(ctx, verifiedPaymentCallback{
|
||
PaymentNo: result.OutTradeNo, PaymentMethod: model.PaymentMethodWechat,
|
||
TransactionID: result.TransactionID, Amount: result.TotalAmount, ConfigID: cfg.ID,
|
||
MerchantIdentity: cfg.WxMchID, PaidAt: paidAt, Provider: constants.IntegrationProviderWechatPay,
|
||
RawPayload: body, ContentType: c.Get("Content-Type"),
|
||
})
|
||
})
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "处理微信支付回调失败")
|
||
}
|
||
|
||
default:
|
||
return errors.New(errors.CodeWechatCallbackInvalid, "不支持的支付渠道类型")
|
||
}
|
||
|
||
return h.wechatV2SuccessResponse(c)
|
||
}
|
||
|
||
func (h *PaymentHandler) dispatchPaymentRecordCallback(ctx context.Context, callback verifiedPaymentCallback) (bool, error) {
|
||
if h.paymentStore != nil {
|
||
payment, err := h.paymentStore.GetByPaymentNo(ctx, callback.PaymentNo)
|
||
if err == nil {
|
||
log, err := h.recordPaymentCallback(ctx, callback, payment)
|
||
if err != nil {
|
||
return true, err
|
||
}
|
||
var processErr error
|
||
switch payment.OrderType {
|
||
case model.PaymentOrderTypePackage:
|
||
processErr = h.orderService.HandlePaymentRecordCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID, callback.Amount)
|
||
case model.PaymentOrderTypeRecharge:
|
||
if h.rechargeOrderService != nil {
|
||
processErr = h.rechargeOrderService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID)
|
||
} else {
|
||
processErr = errors.New(errors.CodeInternalError, "充值订单服务未配置")
|
||
}
|
||
case model.PaymentOrderTypeAgentRecharge:
|
||
return true, h.confirmAgentRechargePayment(ctx, callback, log)
|
||
default:
|
||
processErr = errors.New(errors.CodeInvalidStatus, "未知支付记录类型")
|
||
}
|
||
completion := integrationlog.Completion{Result: constants.IntegrationResultSuccess, ResponseSummary: map[string]any{"confirmed": true}}
|
||
if processErr != nil {
|
||
completion.Result = constants.IntegrationResultFailed
|
||
completion.SafeProviderMessage = "支付回调业务确认失败"
|
||
completion.ResponseSummary = map[string]any{"confirmed": false}
|
||
} else if current, currentErr := h.paymentStore.GetByPaymentNo(ctx, callback.PaymentNo); currentErr == nil {
|
||
completion.StateChanged = payment.Status != model.PaymentRecordStatusPaid && current.Status == model.PaymentRecordStatusPaid
|
||
}
|
||
h.completePaymentCallbackLog(ctx, log, completion)
|
||
return true, processErr
|
||
}
|
||
if err != gorm.ErrRecordNotFound {
|
||
return true, errors.Wrap(errors.CodeDatabaseError, err, "查询支付记录失败")
|
||
}
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
// dispatchWechatCallback 优先按支付单分发,旧单号继续按前缀兼容处理。
|
||
func (h *PaymentHandler) dispatchWechatCallback(ctx context.Context, callback verifiedPaymentCallback) error {
|
||
handled, err := h.dispatchPaymentRecordCallback(ctx, callback)
|
||
if handled || err != nil {
|
||
return err
|
||
}
|
||
|
||
return h.dispatchLegacyPaymentCallback(ctx, callback)
|
||
}
|
||
|
||
func (h *PaymentHandler) dispatchLegacyPaymentCallback(ctx context.Context, callback verifiedPaymentCallback) error {
|
||
if h.integration == nil {
|
||
return errors.New(errors.CodeInvalidStatus, "支付回调 Integration Log 接缝未配置")
|
||
}
|
||
resourceKey, correlationID := callback.PaymentNo, callback.PaymentNo
|
||
idempotencyKey := callback.TransactionID
|
||
if idempotencyKey == "" {
|
||
idempotencyKey = callback.PaymentNo
|
||
}
|
||
log, _, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
|
||
IdempotencyKey: idempotencyKey, Provider: callback.Provider,
|
||
Operation: constants.IntegrationOperationPaymentCallback, ExternalID: callback.TransactionID,
|
||
ResourceType: constants.IntegrationResourceTypePayment, ResourceKey: &resourceKey,
|
||
RawPayload: callback.RawPayload, ContentType: callback.ContentType,
|
||
RequestID: pkgmiddleware.GetRequestIDFromContext(ctx), CorrelationID: &correlationID,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := h.preparePaymentCallbackRetry(ctx, log); err != nil {
|
||
return err
|
||
}
|
||
var processErr error
|
||
switch {
|
||
case strings.HasPrefix(callback.PaymentNo, "ORD"):
|
||
processErr = h.orderService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.Amount)
|
||
case strings.HasPrefix(callback.PaymentNo, constants.AssetRechargeOrderPrefix):
|
||
if h.rechargeOrderService != nil {
|
||
processErr = h.rechargeOrderService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID)
|
||
} else {
|
||
processErr = errors.New(errors.CodeInternalError, "充值订单服务未配置")
|
||
}
|
||
case strings.HasPrefix(callback.PaymentNo, constants.AgentRechargeOrderPrefix):
|
||
if h.agentRechargeService != nil {
|
||
processErr = h.agentRechargeService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID, callback.Amount)
|
||
} else {
|
||
processErr = errors.New(errors.CodeInternalError, "代理充值服务未配置")
|
||
}
|
||
default:
|
||
processErr = errors.New(errors.CodeInvalidStatus, "未知订单号前缀")
|
||
}
|
||
completion := integrationlog.Completion{Result: constants.IntegrationResultSuccess, ResponseSummary: map[string]any{"confirmed": processErr == nil}}
|
||
if processErr != nil {
|
||
completion.Result = constants.IntegrationResultFailed
|
||
completion.SafeProviderMessage = "旧支付回调业务确认失败"
|
||
}
|
||
h.completePaymentCallbackLog(ctx, log, completion)
|
||
return processErr
|
||
}
|
||
|
||
func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callback verifiedPaymentCallback, log *model.IntegrationLog) error {
|
||
if h.agentPaymentConfirm == nil || h.integration == nil {
|
||
return errors.New(errors.CodeInternalError, "代理充值支付回调能力未配置")
|
||
}
|
||
correlationID := callback.PaymentNo
|
||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: correlationID})
|
||
linkage := auditcontext.From(ctx)
|
||
result, confirmErr := h.agentPaymentConfirm.Execute(ctx, agentrechargeApp.ConfirmOnlinePaymentCommand{
|
||
PaymentNo: callback.PaymentNo, PaymentMethod: callback.PaymentMethod, ConfigID: callback.ConfigID,
|
||
MerchantIdentity: callback.MerchantIdentity, ThirdPartyTradeNo: callback.TransactionID,
|
||
Amount: callback.Amount, PaidAt: callback.PaidAt, RequestID: linkage.RequestID,
|
||
CorrelationID: correlationID, ParentEventID: linkage.ParentEventID,
|
||
})
|
||
if confirmErr != nil {
|
||
h.logger.Error("代理充值支付确认失败",
|
||
zap.String("integration_id", log.IntegrationID),
|
||
zap.String("payment_no", callback.PaymentNo),
|
||
zap.Error(confirmErr),
|
||
)
|
||
h.completePaymentCallbackLog(ctx, log, integrationlog.Completion{
|
||
Result: constants.IntegrationResultFailed, SafeProviderMessage: "代理充值支付确认失败",
|
||
ResponseSummary: map[string]any{"confirmed": false},
|
||
})
|
||
return confirmErr
|
||
}
|
||
if log.Result == constants.IntegrationResultPending {
|
||
resolvedResourceID := strconv.FormatUint(uint64(result.PaymentID), 10)
|
||
_, err = h.integration.Complete(ctx, log.IntegrationID, integrationlog.Completion{
|
||
Result: constants.IntegrationResultSuccess, ProviderCode: "SUCCESS",
|
||
ResponseSummary: map[string]any{"confirmed": true, "already_confirmed": result.AlreadyConfirmed},
|
||
StateChanged: !result.AlreadyConfirmed, ResourceID: &resolvedResourceID,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (h *PaymentHandler) recordPaymentCallback(ctx context.Context, callback verifiedPaymentCallback, payment *model.Payment) (*model.IntegrationLog, error) {
|
||
if h.integration == nil || payment == nil {
|
||
return nil, errors.New(errors.CodeInvalidStatus, "支付回调 Integration Log 接缝未配置")
|
||
}
|
||
resourceID, resourceKey, correlationID := strconv.FormatUint(uint64(payment.ID), 10), payment.PaymentNo, payment.PaymentNo
|
||
idempotencyKey := callback.TransactionID
|
||
if idempotencyKey == "" {
|
||
idempotencyKey = callback.PaymentNo
|
||
}
|
||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: correlationID})
|
||
log, _, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
|
||
IdempotencyKey: idempotencyKey, Provider: callback.Provider,
|
||
Operation: constants.IntegrationOperationPaymentCallback, ExternalID: callback.TransactionID,
|
||
ResourceType: constants.IntegrationResourceTypePayment, ResourceID: &resourceID, ResourceKey: &resourceKey,
|
||
RawPayload: callback.RawPayload, ContentType: callback.ContentType,
|
||
RequestID: pkgmiddleware.GetRequestIDFromContext(ctx), CorrelationID: &correlationID,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := h.preparePaymentCallbackRetry(ctx, log); err != nil {
|
||
return nil, err
|
||
}
|
||
return log, nil
|
||
}
|
||
|
||
func (h *PaymentHandler) preparePaymentCallbackRetry(ctx context.Context, log *model.IntegrationLog) error {
|
||
if log == nil || log.Result != constants.IntegrationResultFailed {
|
||
return nil
|
||
}
|
||
claimed, err := h.integration.ClaimFailedInbound(ctx, log.IntegrationID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if claimed {
|
||
log.Result = constants.IntegrationResultPending
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (h *PaymentHandler) recordIgnoredPaymentCallback(ctx context.Context, callback verifiedPaymentCallback, providerCode string) error {
|
||
if h.integration == nil {
|
||
return errors.New(errors.CodeInvalidStatus, "支付回调 Integration Log 接缝未配置")
|
||
}
|
||
resourceKey, correlationID := callback.PaymentNo, callback.PaymentNo
|
||
idempotencyKey := callback.PaymentNo + ":" + providerCode
|
||
log, _, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
|
||
IdempotencyKey: idempotencyKey, Provider: callback.Provider,
|
||
Operation: constants.IntegrationOperationPaymentCallback, ExternalID: callback.TransactionID,
|
||
ResourceType: constants.IntegrationResourceTypePayment, ResourceKey: &resourceKey,
|
||
RawPayload: callback.RawPayload, ContentType: callback.ContentType,
|
||
RequestID: pkgmiddleware.GetRequestIDFromContext(ctx), CorrelationID: &correlationID,
|
||
})
|
||
if err != nil || log.Result != constants.IntegrationResultPending {
|
||
return err
|
||
}
|
||
_, err = h.integration.Complete(ctx, log.IntegrationID, integrationlog.Completion{
|
||
Result: constants.IntegrationResultIgnored, ProviderCode: providerCode,
|
||
ResponseSummary: map[string]any{"confirmed": false},
|
||
})
|
||
return err
|
||
}
|
||
|
||
func (h *PaymentHandler) completePaymentCallbackLog(ctx context.Context, log *model.IntegrationLog, completion integrationlog.Completion) {
|
||
if log == nil || log.Result != constants.IntegrationResultPending {
|
||
return
|
||
}
|
||
if _, err := h.integration.Complete(ctx, log.IntegrationID, completion); err != nil {
|
||
h.logger.Error("支付回调 Integration Log 终结失败",
|
||
zap.String("integration_id", log.IntegrationID),
|
||
zap.Error(err),
|
||
)
|
||
}
|
||
}
|
||
|
||
// wechatV2SuccessResponse 返回微信 v2 要求的 XML 格式成功响应
|
||
// v2 API 要求 XML 格式的 return_code=SUCCESS,否则微信会持续重试回调
|
||
func (h *PaymentHandler) wechatV2SuccessResponse(c *fiber.Ctx) error {
|
||
c.Set("Content-Type", "text/xml; charset=utf-8")
|
||
return c.SendString(`<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>`)
|
||
}
|
||
|
||
// AlipayCallback 支付宝异步回调(带验签与多维度安全校验)
|
||
// POST /api/callback/alipay
|
||
func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
|
||
ctx := c.UserContext()
|
||
|
||
// 从原始请求体解析 form 参数(x-www-form-urlencoded)
|
||
values, err := url.ParseQuery(string(c.Body()))
|
||
if err != nil {
|
||
h.logger.Error("支付宝回调:解析 form 参数失败", zap.Error(err))
|
||
return errors.New(errors.CodeInvalidParam, "回调数据格式错误")
|
||
}
|
||
|
||
// 未验签前只读取 out_trade_no,用于回溯创建支付单时所用的配置
|
||
outTradeNo := values.Get("out_trade_no")
|
||
if outTradeNo == "" {
|
||
h.logger.Error("支付宝回调:out_trade_no 为空")
|
||
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
|
||
}
|
||
|
||
// 按 payment_config_id 加载创建支付单时所用的配置(支持已停用配置)
|
||
cfg, err := h.wechatConfigService.GetConfigForCallback(ctx, outTradeNo)
|
||
if err != nil || cfg == nil {
|
||
h.logger.Error("支付宝回调:加载支付配置失败",
|
||
zap.String("out_trade_no", outTradeNo),
|
||
zap.Error(err),
|
||
)
|
||
return errors.New(errors.CodeWechatCallbackInvalid, "支付配置不可用")
|
||
}
|
||
ctx = paymentCallbackContext(ctx, constants.IntegrationProviderAlipay)
|
||
|
||
// 使用支付宝公钥验签(DecodeNotification 内部完成签名校验)
|
||
notification, err := alipay.DecodeNotification(ctx, cfg, values)
|
||
if err != nil {
|
||
h.logger.Error("支付宝回调:验签失败",
|
||
zap.String("out_trade_no", outTradeNo),
|
||
zap.Uint("config_id", cfg.ID),
|
||
zap.Error(err),
|
||
)
|
||
return errors.New(errors.CodeWechatCallbackInvalid, "支付宝回调验签失败")
|
||
}
|
||
|
||
// 校验 app_id 与配置一致,防止跨商户混用
|
||
if notification.AppId != cfg.AliAppID {
|
||
h.logger.Error("支付宝回调:app_id 不匹配",
|
||
zap.String("out_trade_no", outTradeNo),
|
||
zap.String("notify_app_id", notification.AppId),
|
||
zap.String("config_app_id", cfg.AliAppID),
|
||
)
|
||
return errors.New(errors.CodeWechatCallbackInvalid, "支付宝 app_id 校验失败")
|
||
}
|
||
|
||
// 非终态交易(如 WAIT_BUYER_PAY、TRADE_CLOSED)直接返回 success,不推进业务
|
||
tradeStatus := string(notification.TradeStatus)
|
||
if tradeStatus != "TRADE_SUCCESS" && tradeStatus != "TRADE_FINISHED" {
|
||
h.logger.Info("支付宝回调:非成功交易状态,忽略",
|
||
zap.String("out_trade_no", outTradeNo),
|
||
zap.String("trade_status", tradeStatus),
|
||
)
|
||
if err := h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
|
||
PaymentNo: outTradeNo, TransactionID: notification.TradeNo,
|
||
Provider: constants.IntegrationProviderAlipay, RawPayload: c.Body(), ContentType: c.Get("Content-Type"),
|
||
}, tradeStatus); err != nil {
|
||
return err
|
||
}
|
||
return c.SendString("success")
|
||
}
|
||
|
||
// 查询支付记录
|
||
payment, err := h.paymentStore.GetByPaymentNo(ctx, outTradeNo)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
h.logger.Error("支付宝回调:支付记录不存在",
|
||
zap.String("out_trade_no", outTradeNo),
|
||
)
|
||
return errors.New(errors.CodeWechatCallbackInvalid, "支付记录不存在")
|
||
}
|
||
h.logger.Error("支付宝回调:查询支付记录失败",
|
||
zap.String("out_trade_no", outTradeNo),
|
||
zap.Error(err),
|
||
)
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询支付记录失败")
|
||
}
|
||
|
||
// 校验支付方式必须为 alipay,防止其他通道的 payment_no 被误用
|
||
if payment.PaymentMethod != model.PaymentByAlipay {
|
||
h.logger.Error("支付宝回调:支付方式不匹配",
|
||
zap.String("out_trade_no", outTradeNo),
|
||
zap.String("payment_method", payment.PaymentMethod),
|
||
)
|
||
return errors.New(errors.CodeWechatCallbackInvalid, "支付通道校验失败")
|
||
}
|
||
|
||
// 校验 payment_config_id 与当前配置一致;旧数据为空时兼容并记录 warn
|
||
if payment.PaymentConfigID == nil {
|
||
h.logger.Warn("支付宝回调:payment_config_id 为空,跳过配置 ID 校验(旧数据兼容)",
|
||
zap.String("out_trade_no", outTradeNo),
|
||
zap.Uint("config_id", cfg.ID),
|
||
)
|
||
} else if *payment.PaymentConfigID != cfg.ID {
|
||
h.logger.Error("支付宝回调:payment_config_id 不匹配",
|
||
zap.String("out_trade_no", outTradeNo),
|
||
zap.Uint("payment_config_id", *payment.PaymentConfigID),
|
||
zap.Uint("callback_config_id", cfg.ID),
|
||
)
|
||
return errors.New(errors.CodeWechatCallbackInvalid, "支付配置 ID 校验失败")
|
||
}
|
||
|
||
// 校验金额:使用 total_amount(原始订单金额)而非 buyer_pay_amount,
|
||
// 避免优惠券/积分场景导致内部订单金额被错误记低
|
||
notifyAmountFen, err := alipay.YuanToFen(notification.TotalAmount)
|
||
if err != nil {
|
||
h.logger.Error("支付宝回调:total_amount 转换失败",
|
||
zap.String("out_trade_no", outTradeNo),
|
||
zap.String("total_amount", notification.TotalAmount),
|
||
zap.Error(err),
|
||
)
|
||
return errors.New(errors.CodeWechatCallbackInvalid, "支付金额格式错误")
|
||
}
|
||
if notifyAmountFen != payment.Amount {
|
||
h.logger.Error("支付宝回调:金额不一致",
|
||
zap.String("out_trade_no", outTradeNo),
|
||
zap.Int64("notify_amount_fen", notifyAmountFen),
|
||
zap.Int64("payment_amount_fen", payment.Amount),
|
||
)
|
||
return errors.New(errors.CodeWechatCallbackInvalid, "支付金额校验失败")
|
||
}
|
||
|
||
callback := verifiedPaymentCallback{
|
||
PaymentNo: outTradeNo, PaymentMethod: model.PaymentByAlipay,
|
||
TransactionID: notification.TradeNo, Amount: notifyAmountFen, ConfigID: cfg.ID,
|
||
MerchantIdentity: notification.AppId, Provider: constants.IntegrationProviderAlipay,
|
||
RawPayload: c.Body(), ContentType: c.Get("Content-Type"),
|
||
}
|
||
if payment.OrderType == model.PaymentOrderTypeAgentRecharge {
|
||
paidAt, parseErr := time.ParseInLocation("2006-01-02 15:04:05", notification.GmtPayment, time.Local)
|
||
if parseErr != nil {
|
||
h.logger.Error("支付宝回调:付款时间格式无效",
|
||
zap.String("out_trade_no", outTradeNo),
|
||
zap.Error(parseErr),
|
||
)
|
||
return errors.New(errors.CodeWechatCallbackInvalid, "支付宝付款时间格式错误")
|
||
}
|
||
callback.PaidAt = paidAt
|
||
}
|
||
if handled, dispatchErr := h.dispatchPaymentRecordCallback(ctx, callback); dispatchErr != nil {
|
||
h.logger.Error("支付宝回调:确认支付失败", zap.String("out_trade_no", outTradeNo), zap.Error(dispatchErr))
|
||
return errors.Wrap(errors.CodeInternalError, dispatchErr, "处理支付宝支付回调失败")
|
||
} else if !handled {
|
||
return errors.New(errors.CodeInternalError, "支付记录分发失败")
|
||
}
|
||
|
||
return c.SendString("success")
|
||
}
|
||
|
||
// FuiouPayCallback 富友支付回调(带签名验证)
|
||
// POST /api/callback/fuiou-pay
|
||
func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
|
||
body, bodySource := fuiouCallbackPayload(c)
|
||
if strings.TrimSpace(string(body)) == "" {
|
||
return errors.New(errors.CodeFuiouCallbackInvalid, "回调请求体为空")
|
||
}
|
||
|
||
ctx := c.UserContext()
|
||
c.Set("Content-Type", "text/plain; charset=utf-8")
|
||
|
||
// 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置
|
||
preNotify, err := fuiou.ParseNotify(body)
|
||
if err != nil {
|
||
h.logger.Error("富友回调:预解析失败",
|
||
zap.Error(err),
|
||
zap.String("payload_source", bodySource),
|
||
zap.Int("raw_body_len", len(c.Body())),
|
||
zap.Int("payload_len", len(body)),
|
||
zap.String("content_type", c.Get("Content-Type")),
|
||
zap.String("query", string(c.Request().URI().QueryString())),
|
||
)
|
||
return c.Send(fuiou.BuildNotifyFailResponse("parse failed"))
|
||
}
|
||
|
||
cfg, err := h.wechatConfigService.GetConfigForCallback(ctx, preNotify.MchntOrderNo)
|
||
if err != nil || cfg == nil {
|
||
h.logger.Error("富友回调:加载支付配置失败",
|
||
zap.String("order_no", preNotify.MchntOrderNo),
|
||
zap.Error(err),
|
||
)
|
||
return c.Send(fuiou.BuildNotifyFailResponse("payment config unavailable"))
|
||
}
|
||
ctx = paymentCallbackContext(ctx, model.ProviderTypeFuiou)
|
||
if cfg.ProviderType != model.ProviderTypeFuiou ||
|
||
strings.TrimSpace(preNotify.InsCd) != strings.TrimSpace(cfg.FyInsCd) ||
|
||
strings.TrimSpace(preNotify.MchntCd) != strings.TrimSpace(cfg.FyMchntCd) {
|
||
h.logger.Error("富友回调:支付配置与回调商户不匹配",
|
||
zap.String("order_no", preNotify.MchntOrderNo),
|
||
zap.Uint("config_id", cfg.ID),
|
||
zap.String("config_name", cfg.Name),
|
||
zap.String("callback_ins_cd", preNotify.InsCd),
|
||
zap.String("config_ins_cd", cfg.FyInsCd),
|
||
zap.String("callback_mchnt_cd", preNotify.MchntCd),
|
||
zap.String("config_mchnt_cd", cfg.FyMchntCd),
|
||
zap.String("provider_type", cfg.ProviderType),
|
||
)
|
||
return c.Send(fuiou.BuildNotifyFailResponse("payment config mismatch"))
|
||
}
|
||
|
||
client, err := fuiou.NewClient(
|
||
cfg.FyInsCd,
|
||
cfg.FyMchntCd,
|
||
cfg.FyTermID,
|
||
cfg.FyAPIURL,
|
||
cfg.FyNotifyURL,
|
||
cfg.FyPrivateKey,
|
||
cfg.FyPublicKey,
|
||
h.logger,
|
||
)
|
||
if err != nil {
|
||
h.logger.Error("富友回调:构造客户端失败", zap.Error(err))
|
||
return c.Send(fuiou.BuildNotifyFailResponse("client init failed"))
|
||
}
|
||
|
||
notify, err := client.VerifyNotify(body)
|
||
if err != nil {
|
||
if notify != nil {
|
||
h.logger.Warn("富友回调:非成功结果",
|
||
zap.String("result_code", notify.ResultCode),
|
||
zap.String("result_msg", notify.ResultMsg))
|
||
if recordErr := h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
|
||
PaymentNo: notify.MchntOrderNo, TransactionID: notify.TransactionId,
|
||
Provider: constants.IntegrationProviderFuiou, RawPayload: body, ContentType: c.Get("Content-Type"),
|
||
}, notify.ResultCode); recordErr != nil {
|
||
return c.Send(fuiou.BuildNotifyFailResponse("integration log failed"))
|
||
}
|
||
return c.Send(fuiou.BuildNotifySuccessResponse())
|
||
}
|
||
h.logger.Error("富友回调:验签或解析失败",
|
||
zap.Error(err),
|
||
zap.String("order_no", preNotify.MchntOrderNo),
|
||
zap.Uint("config_id", cfg.ID),
|
||
zap.String("config_name", cfg.Name),
|
||
zap.String("callback_ins_cd", preNotify.InsCd),
|
||
zap.String("config_ins_cd", cfg.FyInsCd),
|
||
zap.String("callback_mchnt_cd", preNotify.MchntCd),
|
||
zap.String("config_mchnt_cd", cfg.FyMchntCd),
|
||
)
|
||
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
|
||
}
|
||
|
||
orderNo := notify.MchntOrderNo
|
||
// OrderAmt 为字符串格式的分,解析失败则降级为 0
|
||
orderAmt, _ := strconv.ParseInt(notify.OrderAmt, 10, 64)
|
||
paidAt, _ := time.ParseInLocation("20060102150405", notify.TxnFinTs, time.Local)
|
||
if handled, err := h.dispatchPaymentRecordCallback(ctx, verifiedPaymentCallback{
|
||
PaymentNo: orderNo, PaymentMethod: "fuiou", TransactionID: notify.TransactionId, Amount: orderAmt,
|
||
ConfigID: cfg.ID, MerchantIdentity: cfg.FyMchntCd, PaidAt: paidAt, Provider: constants.IntegrationProviderFuiou,
|
||
RawPayload: body, ContentType: c.Get("Content-Type"),
|
||
}); err != nil {
|
||
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
|
||
} else if handled {
|
||
return c.Send(fuiou.BuildNotifySuccessResponse())
|
||
}
|
||
|
||
if err := h.dispatchLegacyPaymentCallback(ctx, verifiedPaymentCallback{
|
||
PaymentNo: orderNo, PaymentMethod: model.ProviderTypeFuiou, TransactionID: notify.TransactionId, Amount: orderAmt,
|
||
Provider: constants.IntegrationProviderFuiou, RawPayload: body, ContentType: c.Get("Content-Type"),
|
||
}); err != nil {
|
||
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
|
||
}
|
||
|
||
return c.Send(fuiou.BuildNotifySuccessResponse())
|
||
}
|
||
|
||
func paymentCallbackContext(ctx context.Context, provider string) context.Context {
|
||
return auditcontext.With(ctx, auditcontext.Context{
|
||
ActorKind: constants.AuditActorExternalSystem, ActorID: provider,
|
||
ActorName: provider, Source: constants.AuditSourceCallback,
|
||
})
|
||
}
|
||
|
||
// fuiouCallbackPayload 提取富友回调载荷,兼容 body、form req 和 query req 三种来源。
|
||
func fuiouCallbackPayload(c *fiber.Ctx) ([]byte, string) {
|
||
if req := strings.TrimSpace(c.FormValue("req")); req != "" {
|
||
return []byte(req), "form:req"
|
||
}
|
||
if req := strings.TrimSpace(c.Query("req")); req != "" {
|
||
return []byte(req), "query:req"
|
||
}
|
||
return c.Body(), "body"
|
||
}
|