Files
junhong_cmp_fiber/internal/handler/callback/payment.go
邹进 909f4d0ec2
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s
feat: 实名即生效套餐沉默期、延期办理与资产出库时间
- 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录
- 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描
- 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理
- 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付
- 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:32:58 +09:00

720 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
"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)
GetPaymentServiceForCallback(config *model.WechatConfig) (wechat.PaymentServiceInterface, 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
silence SilencePaymentConfirmer
}
// SilencePaymentConfirmer 确认沉默期延期订单支付(order_type = silence_extension)。
type SilencePaymentConfirmer interface {
ConfirmPayment(ctx context.Context, command packagesilenceApp.PaymentConfirmCommand) error
}
// SetSilencePaymentConfirmer 注入沉默期延期支付确认用例。
func (h *PaymentHandler) SetSilencePaymentConfirmer(confirmer SilencePaymentConfirmer) {
h.silence = confirmer
}
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:
paymentSvc, err := h.wechatConfigService.GetPaymentServiceForCallback(cfg)
if err != nil {
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "构建微信 v3 回调验签实例失败")
}
var httpReq http.Request
fasthttpadaptor.ConvertRequest(c.Context(), &httpReq, true)
_, err = paymentSvc.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)
case constants.PaymentOrderTypeSilenceExtension:
// 沉默期延期:回调按支付单冻结商户已验签,金额由用例按订单冻结金额核对;总开关关闭也照常处理。
if h.silence == nil {
processErr = errors.New(errors.CodeInternalError, "沉默期延期支付确认未配置")
} else {
processErr = h.silence.ConfirmPayment(auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorExternalSystem, ActorID: callback.Provider, ActorName: "支付渠道回调",
Source: constants.AuditSourceCallback, CorrelationID: callback.PaymentNo,
}), packagesilenceApp.PaymentConfirmCommand{
PaymentNo: callback.PaymentNo, ThirdPartyTradeNo: callback.TransactionID,
Amount: callback.Amount, PaidAt: callback.PaidAt,
})
}
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)
// 富友本质是微信支付上游通道,回调渠道归一化为业务方式 wechat 后再进入确认用例。
paymentMethod := callback.PaymentMethod
if paymentMethod == model.ProviderTypeFuiou {
paymentMethod = constants.RechargeMethodWechat
}
result, confirmErr := h.agentPaymentConfirm.Execute(ctx, agentrechargeApp.ConfirmOnlinePaymentCommand{
PaymentNo: callback.PaymentNo, PaymentMethod: 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, "支付通道校验失败")
}
// 商户支付单已由 GetConfigForCallback 按 merchant_id 绑定当前商户凭证;
// payment_config_id 只属于历史双读路径,不能用于否定新商户回调。
if payment.MerchantID == nil && 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.MerchantID == nil && *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 || payment.OrderType == constants.PaymentOrderTypeSilenceExtension {
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"
}