fix: 修复微信/富友支付回调配置加载错误
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m5s

- WechatPayCallback 移除对永远为 nil 的单例 wechatPayment 的依赖,
  改为动态按 payment_config_id 加载创建订单时所用的精确配置
- PaymentV2Service 新增 VerifyCallback()(v2 XML 验签)和 PeekOrderNo()(不验签预解析)
- FuiouPayCallback 由 GetActiveConfig 改为 GetConfigForCallback,
  通过 ParseNotify 预解析订单号后精确加载配置,防止切换配置后旧订单验签失败
- wechat_config.Service 注入 assetRechargeStore/agentRechargeStore,
  新增 GetConfigForCallback():按订单号前缀查 payment_config_id,
  GetByIDUnscoped 加载配置(含已停用/软删除记录),找不到则回退激活配置
- 修复微信 v2 回调响应格式:从 JSON 改为 XML,避免微信持续重试
This commit is contained in:
2026-03-31 19:17:45 +08:00
parent 69ee754c19
commit 045aa4fa3a
5 changed files with 283 additions and 49 deletions

View File

@@ -108,7 +108,7 @@ func initServices(s *stores, deps *Dependencies) *services {
iotCard.SetPollingCallback(polling.NewAPICallback(deps.Redis, deps.Logger)) iotCard.SetPollingCallback(polling.NewAPICallback(deps.Redis, deps.Logger))
// 创建支付配置服务Order 和 Recharge 依赖) // 创建支付配置服务Order 和 Recharge 依赖)
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, accountAudit, deps.Redis, deps.Logger) wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, s.AssetRecharge, s.AgentRecharge, accountAudit, deps.Redis, deps.Logger)
packageActivation := packageSvc.NewActivationService( packageActivation := packageSvc.NewActivationService(
deps.DB, deps.DB,

View File

@@ -16,7 +16,6 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/fuiou" "github.com/break/junhong_cmp_fiber/pkg/fuiou"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/wechat" "github.com/break/junhong_cmp_fiber/pkg/wechat"
) )
@@ -28,6 +27,7 @@ type AgentRechargeServiceInterface interface {
// WechatConfigServiceInterface 支付配置服务接口 // WechatConfigServiceInterface 支付配置服务接口
type WechatConfigServiceInterface interface { type WechatConfigServiceInterface interface {
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error) GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
GetConfigForCallback(ctx context.Context, orderNo string) (*model.WechatConfig, error)
} }
type PaymentHandler struct { type PaymentHandler struct {
@@ -60,43 +60,84 @@ func NewPaymentHandler(
// WechatPayCallback 微信支付回调(带签名验证) // WechatPayCallback 微信支付回调(带签名验证)
// POST /api/callback/wechat-pay // POST /api/callback/wechat-pay
func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error { func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
if h.wechatPayment == nil { body := c.Body()
ctx := context.Background()
// 预解析订单号(不验签),用于按 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, "微信支付服务未配置") return errors.New(errors.CodeWechatCallbackInvalid, "微信支付服务未配置")
} }
var httpReq http.Request switch cfg.ProviderType {
fasthttpadaptor.ConvertRequest(c.Context(), &httpReq, true) case model.ProviderTypeWechatV2:
paymentSvc := wechat.NewPaymentV2Service(cfg.OaAppID, cfg.WxMchID, cfg.WxAPIV2Key, cfg.WxNotifyURL, h.logger)
ctx := context.Background() result, err := paymentSvc.VerifyCallback(body)
_, err := h.wechatPayment.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error { if err != nil {
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "微信 v2 回调验签失败")
}
if result.TradeState != "SUCCESS" { if result.TradeState != "SUCCESS" {
return nil return h.wechatV2SuccessResponse(c)
}
if err := h.dispatchWechatCallback(ctx, result.OutTradeNo, result.TransactionID); err != nil {
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "处理微信支付回调失败")
} }
// TODO: 按 payment_config_id 加载配置验签(当前留桩,仍用 wechatPayment 单例验签) case model.ProviderTypeWechat:
if h.wechatPayment == nil {
// 按订单号前缀分发 return errors.New(errors.CodeWechatCallbackInvalid, "微信 v3 支付未初始化")
outTradeNo := result.OutTradeNo }
switch { var httpReq http.Request
case strings.HasPrefix(outTradeNo, "ORD"): fasthttpadaptor.ConvertRequest(c.Context(), &httpReq, true)
return h.orderService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat) _, err := h.wechatPayment.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error {
case strings.HasPrefix(outTradeNo, constants.AssetRechargeOrderPrefix): if result.TradeState != "SUCCESS" {
return h.rechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, result.TransactionID) return nil
case strings.HasPrefix(outTradeNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
return h.agentRechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, result.TransactionID)
} }
return fmt.Errorf("代理充值服务未配置,无法处理订单: %s", outTradeNo) return h.dispatchWechatCallback(ctx, result.OutTradeNo, result.TransactionID)
default: })
return fmt.Errorf("未知订单号前缀: %s", outTradeNo) if err != nil {
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "处理微信支付回调失败")
} }
})
if err != nil { default:
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "处理微信支付回调失败") return errors.New(errors.CodeWechatCallbackInvalid, "不支持的支付渠道类型")
} }
return response.Success(c, map[string]string{"return_code": "SUCCESS"}) return h.wechatV2SuccessResponse(c)
}
// dispatchWechatCallback 按订单号前缀将支付结果分发到对应业务
func (h *PaymentHandler) dispatchWechatCallback(ctx context.Context, outTradeNo, transactionID string) error {
switch {
case strings.HasPrefix(outTradeNo, "ORD"):
return h.orderService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat)
case strings.HasPrefix(outTradeNo, constants.AssetRechargeOrderPrefix):
return h.rechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, transactionID)
case strings.HasPrefix(outTradeNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
return h.agentRechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, transactionID)
}
return fmt.Errorf("代理充值服务未配置,无法处理订单: %s", outTradeNo)
default:
return fmt.Errorf("未知订单号前缀: %s", outTradeNo)
}
}
// 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>`)
} }
type AlipayCallbackRequest struct { type AlipayCallbackRequest struct {
@@ -151,20 +192,30 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
ctx := c.UserContext() ctx := c.UserContext()
c.Set("Content-Type", "text/xml; charset=gbk") c.Set("Content-Type", "text/xml; charset=gbk")
activeConfig, err := h.wechatConfigService.GetActiveConfig(ctx) // 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置
if err != nil || activeConfig == nil { preNotify, err := fuiou.ParseNotify(body)
h.logger.Error("富友回调:加载支付配置失败", zap.Error(err)) if err != nil {
h.logger.Error("富友回调:预解析失败", zap.Error(err))
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")) return c.Send(fuiou.BuildNotifyFailResponse("payment config unavailable"))
} }
client, err := fuiou.NewClient( client, err := fuiou.NewClient(
activeConfig.FyInsCd, cfg.FyInsCd,
activeConfig.FyMchntCd, cfg.FyMchntCd,
activeConfig.FyTermID, cfg.FyTermID,
activeConfig.FyAPIURL, cfg.FyAPIURL,
activeConfig.FyNotifyURL, cfg.FyNotifyURL,
activeConfig.FyPrivateKey, cfg.FyPrivateKey,
activeConfig.FyPublicKey, cfg.FyPublicKey,
h.logger, h.logger,
) )
if err != nil { if err != nil {

View File

@@ -31,21 +31,33 @@ type AuditServiceInterface interface {
// Service 微信参数配置业务服务 // Service 微信参数配置业务服务
type Service struct { type Service struct {
store *postgres.WechatConfigStore store *postgres.WechatConfigStore
orderStore *postgres.OrderStore orderStore *postgres.OrderStore
auditService AuditServiceInterface assetRechargeStore *postgres.AssetRechargeStore
redis *redis.Client agentRechargeStore *postgres.AgentRechargeStore
logger *zap.Logger auditService AuditServiceInterface
redis *redis.Client
logger *zap.Logger
} }
// New 创建微信参数配置服务实例 // New 创建微信参数配置服务实例
func New(store *postgres.WechatConfigStore, orderStore *postgres.OrderStore, auditService AuditServiceInterface, rdb *redis.Client, logger *zap.Logger) *Service { func New(
store *postgres.WechatConfigStore,
orderStore *postgres.OrderStore,
assetRechargeStore *postgres.AssetRechargeStore,
agentRechargeStore *postgres.AgentRechargeStore,
auditService AuditServiceInterface,
rdb *redis.Client,
logger *zap.Logger,
) *Service {
return &Service{ return &Service{
store: store, store: store,
orderStore: orderStore, orderStore: orderStore,
auditService: auditService, assetRechargeStore: assetRechargeStore,
redis: rdb, agentRechargeStore: agentRechargeStore,
logger: logger, auditService: auditService,
redis: rdb,
logger: logger,
} }
} }
@@ -471,3 +483,58 @@ func (s *Service) mergeSensitiveField(target *string, newVal *string) {
*target = *newVal *target = *newVal
} }
} }
// GetConfigForCallback 按订单号查找创建该订单时所用的支付配置(含已软删除/停用的记录)
// 回调验签必须使用创建订单时的密钥,否则签名不匹配。
// 若找不到 payment_config_id旧订单或数据缺失回退到当前激活配置。
func (s *Service) GetConfigForCallback(ctx context.Context, orderNo string) (*model.WechatConfig, error) {
configID, err := s.resolvePaymentConfigID(ctx, orderNo)
if err != nil {
s.logger.Warn("回调:查询订单 payment_config_id 失败,回退激活配置",
zap.String("order_no", orderNo),
zap.Error(err),
)
}
if configID != nil {
cfg, err := s.store.GetByIDUnscoped(ctx, *configID)
if err == nil {
return cfg, nil
}
s.logger.Warn("回调:按 config_id 加载配置失败,回退激活配置",
zap.Uint("config_id", *configID),
zap.Error(err),
)
}
return s.GetActiveConfig(ctx)
}
// resolvePaymentConfigID 按订单号前缀从对应表中取 payment_config_id
func (s *Service) resolvePaymentConfigID(ctx context.Context, orderNo string) (*uint, error) {
switch {
case len(orderNo) >= 3 && orderNo[:3] == "ORD":
order, err := s.orderStore.GetByOrderNo(ctx, orderNo)
if err != nil {
return nil, err
}
return order.PaymentConfigID, nil
case len(orderNo) >= 4 && orderNo[:4] == constants.AssetRechargeOrderPrefix:
record, err := s.assetRechargeStore.GetByRechargeNo(ctx, orderNo)
if err != nil {
return nil, err
}
return record.PaymentConfigID, nil
case len(orderNo) >= 4 && orderNo[:4] == constants.AgentRechargeOrderPrefix:
record, err := s.agentRechargeStore.GetByRechargeNo(ctx, orderNo)
if err != nil {
return nil, err
}
return record.PaymentConfigID, nil
default:
return nil, fmt.Errorf("未知订单号前缀: %s", orderNo)
}
}

View File

@@ -7,6 +7,29 @@ import (
"strings" "strings"
) )
// ParseNotify 解析回调通知(不验签)
// 用于在验签前提取订单号以确定使用哪套支付配置
func ParseNotify(rawBody []byte) (*NotifyRequest, error) {
content := string(rawBody)
if strings.HasPrefix(content, "req=") {
content = content[4:]
}
decoded, err := url.QueryUnescape(content)
if err != nil {
return nil, fmt.Errorf("URL 解码回调数据失败: %w", err)
}
utf8Bytes, err := GBKToUTF8([]byte(decoded))
if err != nil {
return nil, fmt.Errorf("GBK 转 UTF-8 失败: %w", err)
}
utf8Str := strings.Replace(string(utf8Bytes), `encoding="GBK"`, `encoding="UTF-8"`, 1)
var notify NotifyRequest
if err := xml.Unmarshal([]byte(utf8Str), &notify); err != nil {
return nil, fmt.Errorf("XML 解析回调数据失败: %w", err)
}
return &notify, nil
}
// VerifyNotify 验证回调通知并解析数据 // VerifyNotify 验证回调通知并解析数据
// rawBody: 原始请求 body可能包含 req= 前缀的 URL 编码 GBK XML // rawBody: 原始请求 body可能包含 req= 前缀的 URL 编码 GBK XML
func (c *Client) VerifyNotify(rawBody []byte) (*NotifyRequest, error) { func (c *Client) VerifyNotify(rawBody []byte) (*NotifyRequest, error) {

View File

@@ -201,3 +201,96 @@ func v2SignMD5(params map[string]string, apiKey string) string {
func v2GenerateNonceStr() string { func v2GenerateNonceStr() string {
return fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))) return fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))))
} }
// v2CallbackNotify v2 支付回调通知 XML 结构
// 所有字段均为 string避免 CDATA 整数字段的类型转换问题
type v2CallbackNotify struct {
XMLName xml.Name `xml:"xml"`
AppID string `xml:"appid"`
BankType string `xml:"bank_type"`
CashFee string `xml:"cash_fee"`
FeeType string `xml:"fee_type"`
IsSubscribe string `xml:"is_subscribe"`
MchID string `xml:"mch_id"`
NonceStr string `xml:"nonce_str"`
OpenID string `xml:"openid"`
OutTradeNo string `xml:"out_trade_no"`
ResultCode string `xml:"result_code"`
ReturnCode string `xml:"return_code"`
Sign string `xml:"sign"`
TimeEnd string `xml:"time_end"`
TotalFee string `xml:"total_fee"`
TradeType string `xml:"trade_type"`
TransactionID string `xml:"transaction_id"`
}
// PeekOrderNo 从 v2 回调 XML 中解析订单号(不验签)
// 用于在验签前确定使用哪套支付配置
func PeekOrderNo(body []byte) (string, error) {
var notify v2CallbackNotify
if err := xml.Unmarshal(body, &notify); err != nil {
return "", fmt.Errorf("解析回调 XML 失败: %w", err)
}
return notify.OutTradeNo, nil
}
// V2CallbackResult v2 回调解析结果
type V2CallbackResult struct {
OutTradeNo string
TransactionID string
// TradeState 为 "SUCCESS" 表示支付成功return_code 和 result_code 均为 SUCCESS
TradeState string
TotalFee string
OpenID string
}
// VerifyCallback 解析并验证 v2 回调 XML返回解析结果
func (s *PaymentV2Service) VerifyCallback(body []byte) (*V2CallbackResult, error) {
var notify v2CallbackNotify
if err := xml.Unmarshal(body, &notify); err != nil {
return nil, fmt.Errorf("解析回调 XML 失败: %w", err)
}
// 构建待签名参数(排除 sign 字段v2SignMD5 内部自动跳过空值)
params := map[string]string{
"appid": notify.AppID,
"bank_type": notify.BankType,
"cash_fee": notify.CashFee,
"fee_type": notify.FeeType,
"is_subscribe": notify.IsSubscribe,
"mch_id": notify.MchID,
"nonce_str": notify.NonceStr,
"openid": notify.OpenID,
"out_trade_no": notify.OutTradeNo,
"result_code": notify.ResultCode,
"return_code": notify.ReturnCode,
"time_end": notify.TimeEnd,
"total_fee": notify.TotalFee,
"trade_type": notify.TradeType,
"transaction_id": notify.TransactionID,
}
expected := v2SignMD5(params, s.apiKey)
if !strings.EqualFold(notify.Sign, expected) {
s.logger.Error("v2 回调签名验证失败",
zap.String("received", notify.Sign),
zap.String("expected", expected),
zap.String("mch_id", notify.MchID),
)
return nil, fmt.Errorf("签名验证失败")
}
// return_code 和 result_code 均为 SUCCESS 才算支付成功
tradeState := ""
if notify.ReturnCode == "SUCCESS" && notify.ResultCode == "SUCCESS" {
tradeState = "SUCCESS"
}
return &V2CallbackResult{
OutTradeNo: notify.OutTradeNo,
TransactionID: notify.TransactionID,
TradeState: tradeState,
TotalFee: notify.TotalFee,
OpenID: notify.OpenID,
}, nil
}