From 045aa4fa3a585d87d3a44539a4f9cb2e8f16dd7e Mon Sep 17 00:00:00 2001 From: huang Date: Tue, 31 Mar 2026 19:17:45 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=BE=AE=E4=BF=A1/?= =?UTF-8?q?=E5=AF=8C=E5=8F=8B=E6=94=AF=E4=BB=98=E5=9B=9E=E8=B0=83=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=8A=A0=E8=BD=BD=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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,避免微信持续重试 --- internal/bootstrap/services.go | 2 +- internal/handler/callback/payment.go | 125 +++++++++++++++------- internal/service/wechat_config/service.go | 89 +++++++++++++-- pkg/fuiou/notify.go | 23 ++++ pkg/wechat/payment_v2.go | 93 ++++++++++++++++ 5 files changed, 283 insertions(+), 49 deletions(-) diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index cf9070b..5ee0dd6 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -108,7 +108,7 @@ func initServices(s *stores, deps *Dependencies) *services { iotCard.SetPollingCallback(polling.NewAPICallback(deps.Redis, deps.Logger)) // 创建支付配置服务(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( deps.DB, diff --git a/internal/handler/callback/payment.go b/internal/handler/callback/payment.go index 76158c4..84896e8 100644 --- a/internal/handler/callback/payment.go +++ b/internal/handler/callback/payment.go @@ -16,7 +16,6 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/fuiou" - "github.com/break/junhong_cmp_fiber/pkg/response" "github.com/break/junhong_cmp_fiber/pkg/wechat" ) @@ -28,6 +27,7 @@ type AgentRechargeServiceInterface interface { // WechatConfigServiceInterface 支付配置服务接口 type WechatConfigServiceInterface interface { GetActiveConfig(ctx context.Context) (*model.WechatConfig, error) + GetConfigForCallback(ctx context.Context, orderNo string) (*model.WechatConfig, error) } type PaymentHandler struct { @@ -60,43 +60,84 @@ func NewPaymentHandler( // WechatPayCallback 微信支付回调(带签名验证) // POST /api/callback/wechat-pay 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, "微信支付服务未配置") } - var httpReq http.Request - fasthttpadaptor.ConvertRequest(c.Context(), &httpReq, true) - - ctx := context.Background() - _, err := h.wechatPayment.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error { + 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" { - 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 单例验签) - - // 按订单号前缀分发 - outTradeNo := result.OutTradeNo - 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, result.TransactionID) - case strings.HasPrefix(outTradeNo, constants.AgentRechargeOrderPrefix): - if h.agentRechargeService != nil { - return h.agentRechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, result.TransactionID) + 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 nil } - return fmt.Errorf("代理充值服务未配置,无法处理订单: %s", outTradeNo) - default: - return fmt.Errorf("未知订单号前缀: %s", outTradeNo) + return h.dispatchWechatCallback(ctx, result.OutTradeNo, result.TransactionID) + }) + if err != nil { + return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "处理微信支付回调失败") } - }) - if err != nil { - return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "处理微信支付回调失败") + default: + 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(``) } type AlipayCallbackRequest struct { @@ -151,20 +192,30 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error { ctx := c.UserContext() c.Set("Content-Type", "text/xml; charset=gbk") - activeConfig, err := h.wechatConfigService.GetActiveConfig(ctx) - if err != nil || activeConfig == nil { - h.logger.Error("富友回调:加载支付配置失败", zap.Error(err)) + // 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置 + preNotify, err := fuiou.ParseNotify(body) + 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")) } client, err := fuiou.NewClient( - activeConfig.FyInsCd, - activeConfig.FyMchntCd, - activeConfig.FyTermID, - activeConfig.FyAPIURL, - activeConfig.FyNotifyURL, - activeConfig.FyPrivateKey, - activeConfig.FyPublicKey, + cfg.FyInsCd, + cfg.FyMchntCd, + cfg.FyTermID, + cfg.FyAPIURL, + cfg.FyNotifyURL, + cfg.FyPrivateKey, + cfg.FyPublicKey, h.logger, ) if err != nil { diff --git a/internal/service/wechat_config/service.go b/internal/service/wechat_config/service.go index d1d00ed..2d9b580 100644 --- a/internal/service/wechat_config/service.go +++ b/internal/service/wechat_config/service.go @@ -31,21 +31,33 @@ type AuditServiceInterface interface { // Service 微信参数配置业务服务 type Service struct { - store *postgres.WechatConfigStore - orderStore *postgres.OrderStore - auditService AuditServiceInterface - redis *redis.Client - logger *zap.Logger + store *postgres.WechatConfigStore + orderStore *postgres.OrderStore + assetRechargeStore *postgres.AssetRechargeStore + agentRechargeStore *postgres.AgentRechargeStore + auditService AuditServiceInterface + redis *redis.Client + logger *zap.Logger } // 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{ - store: store, - orderStore: orderStore, - auditService: auditService, - redis: rdb, - logger: logger, + store: store, + orderStore: orderStore, + assetRechargeStore: assetRechargeStore, + agentRechargeStore: agentRechargeStore, + auditService: auditService, + redis: rdb, + logger: logger, } } @@ -471,3 +483,58 @@ func (s *Service) mergeSensitiveField(target *string, newVal *string) { *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) + } +} diff --git a/pkg/fuiou/notify.go b/pkg/fuiou/notify.go index 9824898..c5fef18 100644 --- a/pkg/fuiou/notify.go +++ b/pkg/fuiou/notify.go @@ -7,6 +7,29 @@ import ( "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), ¬ify); err != nil { + return nil, fmt.Errorf("XML 解析回调数据失败: %w", err) + } + return ¬ify, nil +} + // VerifyNotify 验证回调通知并解析数据 // rawBody: 原始请求 body(可能包含 req= 前缀的 URL 编码 GBK XML) func (c *Client) VerifyNotify(rawBody []byte) (*NotifyRequest, error) { diff --git a/pkg/wechat/payment_v2.go b/pkg/wechat/payment_v2.go index 9ccfa4c..23fc3fe 100644 --- a/pkg/wechat/payment_v2.go +++ b/pkg/wechat/payment_v2.go @@ -201,3 +201,96 @@ func v2SignMD5(params map[string]string, apiKey string) string { func v2GenerateNonceStr() string { 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, ¬ify); 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, ¬ify); 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 +}