diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go index 2cc92f6..965aa75 100644 --- a/internal/handler/app/client_asset.go +++ b/internal/handler/app/client_asset.go @@ -11,6 +11,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" asset "github.com/break/junhong_cmp_fiber/internal/service/asset" + packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" "github.com/break/junhong_cmp_fiber/internal/service/packageprice" "github.com/break/junhong_cmp_fiber/internal/store" "github.com/break/junhong_cmp_fiber/internal/store/postgres" @@ -560,10 +561,7 @@ func buildClientPackageItem( return dto.ClientPackageItem{}, false } - validityDays := pkg.DurationDays - if validityDays <= 0 && pkg.DurationMonths > 0 { - validityDays = pkg.DurationMonths * 30 - } + validityDays := packagepkg.CalculateValidityDays(pkg.CalendarType, time.Now(), pkg.DurationMonths, pkg.DurationDays) dataAllowance := pkg.RealDataMB if dataAllowance <= 0 { diff --git a/internal/handler/app/client_wallet.go b/internal/handler/app/client_wallet.go index 441c71b..a1a145e 100644 --- a/internal/handler/app/client_wallet.go +++ b/internal/handler/app/client_wallet.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math/rand" + "strconv" "strings" "time" @@ -16,6 +17,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/store/postgres" "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" "github.com/gofiber/fiber/v2" @@ -300,12 +302,13 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error { rechargeNo := generateClientRechargeNo() paymentNo := generateClientPaymentNo() - // 先初始化微信支付实例并创建预支付订单,确认支付通道可用 + // 先初始化生效支付通道并创建预支付订单,确认支付通道可用 // 避免先写入充值记录后支付初始化失败,导致产生孤儿记录 - payResult, err := h.createJSAPIPayOrder( + payConfig, err := h.createClientRechargePayConfig( resolved.SkipPermissionCtx, config, appID, + req.AppType, paymentNo, "资产钱包充值", openID, @@ -347,7 +350,6 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error { return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败") } - payConfig := buildClientRechargePayConfig(appID, payResult) resp := &dto.ClientRechargeResponse{ Recharge: dto.ClientRechargeResult{ RechargeID: rechargeOrder.ID, @@ -607,33 +609,107 @@ func pickAppIDByType(config *model.WechatConfig, appType string) (string, error) } } -// createJSAPIPayOrder 根据支付配置的提供者类型路由到对应支付通道,发起 JSAPI 预下单。 -// 与 client_order.Service.newPaymentProvider 保持相同的路由逻辑,支持 wechat_v2 和 wechat(v3)。 -func (h *ClientWalletHandler) createJSAPIPayOrder( +// createClientRechargePayConfig 根据当前生效支付配置创建充值预支付订单。 +// provider_type 必须显式匹配,避免富友等渠道误落到微信直连。 +func (h *ClientWalletHandler) createClientRechargePayConfig( ctx context.Context, config *model.WechatConfig, - appID, orderNo, description, openID string, + appID, appType, orderNo, description, openID string, amount int, -) (*wechat.JSAPIPayResult, error) { +) (dto.ClientRechargePayConfig, error) { switch config.ProviderType { case model.ProviderTypeWechatV2: // 微信 v2:仅需 APIv2Key,无需证书序列号 svc, err := wechat.NewPaymentV2ServiceFromConfig(config, appID, h.logger) if err != nil { - return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "初始化微信 v2 支付实例失败") + return dto.ClientRechargePayConfig{}, errors.Wrap(errors.CodeWechatPayFailed, err, "初始化微信 v2 支付实例失败") } - return svc.CreateJSAPIOrder(ctx, orderNo, description, openID, amount) - default: + result, err := svc.CreateJSAPIOrder(ctx, orderNo, description, openID, amount) + if err != nil { + return dto.ClientRechargePayConfig{}, err + } + return buildClientRechargePayConfig(appID, result), nil + case model.ProviderTypeWechat: // 微信 v3(PowerWeChat):需要证书序列号和 API v3 Key cache := wechat.NewRedisCache(h.redis) paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, h.logger) if err != nil { - return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "初始化微信支付实例失败") + return dto.ClientRechargePayConfig{}, errors.Wrap(errors.CodeWechatPayFailed, err, "初始化微信支付实例失败") } - return wechat.NewPaymentService(paymentApp, h.logger).CreateJSAPIOrder(ctx, orderNo, description, openID, amount) + result, err := wechat.NewPaymentService(paymentApp, h.logger).CreateJSAPIOrder(ctx, orderNo, description, openID, amount) + if err != nil { + return dto.ClientRechargePayConfig{}, err + } + return buildClientRechargePayConfig(appID, result), nil + case model.ProviderTypeFuiou: + return h.createFuiouRechargePayConfig(ctx, config, appID, appType, orderNo, description, openID, amount) + default: + return dto.ClientRechargePayConfig{}, errors.New(errors.CodeWechatConfigUnavailable, "不支持的支付渠道类型") } } +// createFuiouRechargePayConfig 使用富友支付配置创建充值预支付订单。 +func (h *ClientWalletHandler) createFuiouRechargePayConfig( + ctx context.Context, + config *model.WechatConfig, + appID, appType, orderNo, description, openID string, + amount int, +) (dto.ClientRechargePayConfig, error) { + if strings.TrimSpace(config.FyInsCd) == "" || + strings.TrimSpace(config.FyMchntCd) == "" || + strings.TrimSpace(config.FyTermID) == "" || + strings.TrimSpace(config.FyPrivateKey) == "" || + strings.TrimSpace(config.FyPublicKey) == "" || + strings.TrimSpace(config.FyAPIURL) == "" || + strings.TrimSpace(config.FyNotifyURL) == "" || + strings.TrimSpace(appID) == "" { + return dto.ClientRechargePayConfig{}, errors.New(errors.CodeFuiouPayFailed, "富友支付配置不完整") + } + + client, err := fuiou.NewClient( + config.FyInsCd, + config.FyMchntCd, + config.FyTermID, + config.FyAPIURL, + config.FyNotifyURL, + config.FyPrivateKey, + config.FyPublicKey, + h.logger, + ) + if err != nil { + return dto.ClientRechargePayConfig{}, errors.Wrap(errors.CodeFuiouPayFailed, err, "创建富友支付客户端失败") + } + + tradeType := "JSAPI" + if appType == "miniapp" { + tradeType = "LETPAY" + } + + termIP := fuiou.GetServerIP() + if ip, ok := ctx.Value(constants.ContextKeyIP).(string); ok && strings.TrimSpace(ip) != "" { + termIP = ip + } + + resp, err := client.WxPreCreate(orderNo, strconv.Itoa(amount), description, termIP, tradeType, appID, openID) + if err != nil { + h.logger.Error("富友充值预下单失败", + zap.String("payment_no", orderNo), + zap.String("trade_type", tradeType), + zap.Error(err), + ) + return dto.ClientRechargePayConfig{}, errors.Wrap(errors.CodeFuiouPayFailed, err, "富友预下单失败") + } + + return dto.ClientRechargePayConfig{ + AppID: resp.SdkAppid, + Timestamp: resp.SdkTimestamp, + NonceStr: resp.SdkNoncestr, + PackageVal: resp.SdkPackage, + SignType: resp.SdkSigntype, + PaySign: resp.SdkPaysign, + }, nil +} + func generateClientRechargeNo() string { timestamp := time.Now().Format("20060102150405") randomNum := rand.Intn(1000000) diff --git a/internal/handler/callback/payment.go b/internal/handler/callback/payment.go index b19d105..67eeb03 100644 --- a/internal/handler/callback/payment.go +++ b/internal/handler/callback/payment.go @@ -260,18 +260,25 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error { // FuiouPayCallback 富友支付回调(带签名验证) // POST /api/callback/fuiou-pay func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error { - body := c.Body() - if len(body) == 0 { + body, bodySource := fuiouCallbackPayload(c) + if strings.TrimSpace(string(body)) == "" { return errors.New(errors.CodeFuiouCallbackInvalid, "回调请求体为空") } ctx := c.UserContext() - c.Set("Content-Type", "text/xml; charset=gbk") + 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)) + 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")) } @@ -283,6 +290,21 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error { ) return c.Send(fuiou.BuildNotifyFailResponse("payment config unavailable")) } + 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, @@ -307,7 +329,16 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error { zap.String("result_msg", notify.ResultMsg)) return c.Send(fuiou.BuildNotifySuccessResponse()) } - h.logger.Error("富友回调:验签或解析失败", zap.Error(err)) + 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())) } @@ -345,3 +376,14 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error { return c.Send(fuiou.BuildNotifySuccessResponse()) } + +// 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" +} diff --git a/internal/model/dto/client_asset_dto.go b/internal/model/dto/client_asset_dto.go index c98e8e2..03b282e 100644 --- a/internal/model/dto/client_asset_dto.go +++ b/internal/model/dto/client_asset_dto.go @@ -150,7 +150,7 @@ type ClientPackageItem struct { PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:加油包)"` RetailPrice int64 `json:"retail_price" description:"生效零售价(分)"` CostPrice int64 `json:"cost_price" description:"成本价(分)"` - ValidityDays int `json:"validity_days" description:"有效天数"` + ValidityDays int `json:"validity_days" description:"有效天数(按天套餐为折算总天数,自然月套餐按当前日期推算)"` IsAddon bool `json:"is_addon" description:"是否加油包"` DataAllowance int64 `json:"data_allowance" description:"流量额度"` DataUnit string `json:"data_unit" description:"流量单位"` diff --git a/internal/service/exchange/service.go b/internal/service/exchange/service.go index 04837be..247ac86 100644 --- a/internal/service/exchange/service.go +++ b/internal/service/exchange/service.go @@ -278,8 +278,6 @@ func (s *Service) Renew(ctx context.Context, id uint) error { if err = tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{ "generation": card.Generation + 1, "asset_status": 1, - "accumulated_recharge": 0, - "first_commission_paid": false, "accumulated_recharge_by_series": "{}", "first_recharge_triggered_by_series": "{}", "updater": middleware.GetUserIDFromContext(ctx), @@ -320,8 +318,6 @@ func (s *Service) Renew(ctx context.Context, id uint) error { if err = tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{ "generation": device.Generation + 1, "asset_status": 1, - "accumulated_recharge": 0, - "first_commission_paid": false, "accumulated_recharge_by_series": "{}", "first_recharge_triggered_by_series": "{}", "updater": middleware.GetUserIDFromContext(ctx), diff --git a/internal/service/package/utils.go b/internal/service/package/utils.go index 27797da..d299349 100644 --- a/internal/service/package/utils.go +++ b/internal/service/package/utils.go @@ -10,7 +10,7 @@ import ( // calendarType: 套餐周期类型(natural_month=自然月,by_day=按天) // activatedAt: 激活时间 // durationMonths: 套餐时长(月数,calendar_type=natural_month 时使用) -// durationDays: 套餐天数(calendar_type=by_day 时使用) +// durationDays: 套餐天数(calendar_type=by_day 时使用,兼容每月折算天数) // 返回:过期时间(当天 23:59:59) func CalculateExpiryTime(calendarType string, activatedAt time.Time, durationMonths, durationDays int) time.Time { var expiryDate time.Time @@ -31,13 +31,53 @@ func CalculateExpiryTime(calendarType string, activatedAt time.Time, durationMon expiryDate = time.Date(targetYear, targetMonth+1, 0, 23, 59, 59, 0, activatedAt.Location()) } else { // 按天套餐:activated_at + N 天,23:59:59 - expiryDate = activatedAt.AddDate(0, 0, durationDays) + effectiveDays := calculateByDayValidityDays(durationMonths, durationDays) + expiryDate = activatedAt.AddDate(0, 0, effectiveDays) expiryDate = time.Date(expiryDate.Year(), expiryDate.Month(), expiryDate.Day(), 23, 59, 59, 0, expiryDate.Location()) } return expiryDate } +// CalculateValidityDays 计算套餐展示有效天数。 +// 按天套餐优先返回折算后的总天数;自然月套餐按 baseTime 推算到目标月月末。 +func CalculateValidityDays(calendarType string, baseTime time.Time, durationMonths, durationDays int) int { + if calendarType == constants.PackageCalendarTypeNaturalMonth { + return calculateNaturalMonthValidityDays(baseTime, durationMonths) + } + return calculateByDayValidityDays(durationMonths, durationDays) +} + +func calculateByDayValidityDays(durationMonths, durationDays int) int { + if durationDays <= 0 { + if durationMonths <= 0 { + return 0 + } + return durationMonths * 30 + } + + if durationMonths > 1 && durationDays <= 31 { + return durationMonths * durationDays + } + + return durationDays +} + +func calculateNaturalMonthValidityDays(baseTime time.Time, durationMonths int) int { + if baseTime.IsZero() || durationMonths <= 0 { + return 0 + } + + expiresAt := CalculateExpiryTime(constants.PackageCalendarTypeNaturalMonth, baseTime, durationMonths, 0) + startDate := time.Date(baseTime.Year(), baseTime.Month(), baseTime.Day(), 0, 0, 0, 0, baseTime.Location()) + endDate := time.Date(expiresAt.Year(), expiresAt.Month(), expiresAt.Day(), 0, 0, 0, 0, expiresAt.Location()) + days := int(endDate.Sub(startDate).Hours()/24) + 1 + if days < 0 { + return 0 + } + return days +} + // CalculateNextResetTime 计算下次流量重置时间 // dataResetCycle: 流量重置周期(daily/monthly/yearly/none) // calendarType: 套餐周期类型(natural_month/by_day),影响月重置逻辑 diff --git a/internal/service/recharge/service.go b/internal/service/recharge/service.go index 858188c..01b1411 100644 --- a/internal/service/recharge/service.go +++ b/internal/service/recharge/service.go @@ -193,6 +193,3 @@ func (s *Service) checkForceRechargeRequirement(ctx context.Context, resourceTyp return result, nil } - -// updateAccumulatedRechargeInTx 更新累计充值(事务内使用) -// 同时更新旧的 accumulated_recharge 字段和新的 accumulated_recharge_by_series JSON 字段 diff --git a/internal/service/recharge_order/service.go b/internal/service/recharge_order/service.go index d7b90b0..e57b636 100644 --- a/internal/service/recharge_order/service.go +++ b/internal/service/recharge_order/service.go @@ -204,7 +204,6 @@ func (s *Service) updateAccumulatedRechargeInTx(ctx context.Context, tx *gorm.DB result := tx.Model(&model.IotCard{}). Where("id = ?", resourceID). Updates(map[string]any{ - "accumulated_recharge": gorm.Expr("accumulated_recharge + ?", amount), "accumulated_recharge_by_series": card.AccumulatedRechargeBySeriesJSON, }) if result.Error != nil { @@ -225,7 +224,6 @@ func (s *Service) updateAccumulatedRechargeInTx(ctx context.Context, tx *gorm.DB result := tx.Model(&model.Device{}). Where("id = ?", resourceID). Updates(map[string]any{ - "accumulated_recharge": gorm.Expr("accumulated_recharge + ?", amount), "accumulated_recharge_by_series": device.AccumulatedRechargeBySeriesJSON, }) if result.Error != nil { @@ -400,7 +398,6 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx * } if err := tx.Model(&model.IotCard{}).Where("id = ?", rechargeOrder.ResourceID). Updates(map[string]any{ - "first_commission_paid": true, "first_recharge_triggered_by_series": card.FirstRechargeTriggeredBySeriesJSON, }).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新卡佣金发放状态失败") @@ -415,7 +412,6 @@ func (s *Service) triggerOneTimeCommissionIfNeededInTx(ctx context.Context, tx * } if err := tx.Model(&model.Device{}).Where("id = ?", rechargeOrder.ResourceID). Updates(map[string]any{ - "first_commission_paid": true, "first_recharge_triggered_by_series": device.FirstRechargeTriggeredBySeriesJSON, }).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新设备佣金发放状态失败") diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 458daef..8a937bc 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -593,8 +593,6 @@ func (s *Service) resetAssetGeneration(ctx context.Context, assetType string, as if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{ "generation": card.Generation + 1, "asset_status": 1, - "accumulated_recharge": 0, - "first_commission_paid": false, "accumulated_recharge_by_series": "{}", "first_recharge_triggered_by_series": "{}", "updated_at": now, @@ -644,8 +642,6 @@ func (s *Service) resetAssetGeneration(ctx context.Context, assetType string, as if err := tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{ "generation": device.Generation + 1, "asset_status": 1, - "accumulated_recharge": 0, - "first_commission_paid": false, "accumulated_recharge_by_series": "{}", "first_recharge_triggered_by_series": "{}", "updated_at": now, diff --git a/pkg/fuiou/client.go b/pkg/fuiou/client.go index 05f8de6..b77a491 100644 --- a/pkg/fuiou/client.go +++ b/pkg/fuiou/client.go @@ -5,8 +5,10 @@ import ( "crypto/md5" "crypto/rand" "crypto/rsa" + "crypto/sha256" "crypto/x509" "encoding/base64" + "encoding/hex" "encoding/pem" "encoding/xml" "fmt" @@ -50,6 +52,9 @@ func NewClient(insCd, mchntCd, termId, apiURL, notifyURL, privateKeyBase64, publ if err != nil { return nil, fmt.Errorf("解析富友公钥失败: %w", err) } + if rsaPublicKeysEqual(pubKey, &privKey.PublicKey) { + return nil, fmt.Errorf("富友公钥配置错误:当前 fy_public_key 与商户私钥匹配,请配置富友平台提供的验签公钥") + } return &Client{ InsCd: insCd, @@ -91,6 +96,13 @@ func (c *Client) Sign(data interface{}) (string, error) { // Verify 验证签名 func (c *Client) Verify(data interface{}, sign string) error { signStr := buildSignString(data) + return c.verifySignString(signStr, sign) +} + +// verifySignString 使用指定签名原文验证 RSA 签名。 +func (c *Client) verifySignString(signStr, sign string) error { + c.logger.Debug("富友验签原文", zap.String("sign_str", signStr)) + c.logger.Debug("富友签名", zap.String("sign", sign)) gbkBytes, err := utf8ToGBK([]byte(signStr)) if err != nil { @@ -104,7 +116,17 @@ func (c *Client) Verify(data interface{}, sign string) error { return fmt.Errorf("Base64 解码签名失败: %w", err) } - return rsa.VerifyPKCS1v15(c.publicKey, crypto.MD5, hash[:], sigBytes) + if err := rsa.VerifyPKCS1v15(c.publicKey, crypto.MD5, hash[:], sigBytes); err != nil { + c.logger.Error("富友验签失败诊断", + zap.Error(err), + zap.Int("signature_len", len(sigBytes)), + zap.Int("public_key_bits", c.publicKey.N.BitLen()), + zap.String("public_key_sha256", rsaPublicKeyFingerprint(c.publicKey)), + zap.Bool("public_key_matches_private_key", rsaPublicKeysEqual(c.publicKey, &c.privateKey.PublicKey)), + ) + return err + } + return nil } // DoRequest 发送 HTTP 请求到富友 @@ -175,10 +197,13 @@ func (c *Client) DoRequest(path string, req interface{}, resp interface{}) error } // buildSignString 构建签名原文 -// 提取非空字段(排除 sign 和 reserved_ 开头字段),按字典序拼接为 key=value&key=value +// 提取字段(排除 sign 和 reserved 开头字段),按字典序拼接为 key=value&key=value。 func buildSignString(data interface{}) string { - fields := structToMap(data) + return buildSignStringFromMap(structToMap(data)) +} +// buildSignStringFromMap 按富友规则将字段集合拼接为签名原文。 +func buildSignStringFromMap(fields map[string]string) string { // 按 key 字典序排列 keys := make([]string, 0, len(fields)) for k := range fields { @@ -195,8 +220,8 @@ func buildSignString(data interface{}) string { return strings.Join(pairs, "&") } -// structToMap 通过反射提取结构体的 xml tag 和值 -// 排除 sign 字段、reserved_ 开头字段、空值字段 +// structToMap 通过反射提取结构体的 xml tag 和值。 +// 排除 sign 字段和 reserved 开头字段,保留空值字段参与签名。 func structToMap(data interface{}) map[string]string { result := make(map[string]string) @@ -226,8 +251,8 @@ func structToMap(data interface{}) map[string]string { continue } - // 排除 reserved_ 开头字段 - if strings.HasPrefix(tag, "reserved_") { + // 排除 reserved 开头字段 + if strings.HasPrefix(tag, "reserved") { continue } @@ -342,3 +367,20 @@ func parseDERPublicKey(derBytes []byte) (*rsa.PublicKey, error) { return rsaPub, nil } + +// rsaPublicKeyFingerprint 返回 RSA 公钥的 SHA256 指纹,避免日志输出完整密钥。 +func rsaPublicKeyFingerprint(pub *rsa.PublicKey) string { + derBytes, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return "" + } + sum := sha256.Sum256(derBytes) + return hex.EncodeToString(sum[:]) +} + +func rsaPublicKeysEqual(a, b *rsa.PublicKey) bool { + if a == nil || b == nil { + return false + } + return a.E == b.E && a.N.Cmp(b.N) == 0 +} diff --git a/pkg/fuiou/notify.go b/pkg/fuiou/notify.go index c5fef18..7a39e46 100644 --- a/pkg/fuiou/notify.go +++ b/pkg/fuiou/notify.go @@ -3,6 +3,7 @@ package fuiou import ( "encoding/xml" "fmt" + "io" "net/url" "strings" ) @@ -10,19 +11,10 @@ import ( // ParseNotify 解析回调通知(不验签) // 用于在验签前提取订单号以确定使用哪套支付配置 func ParseNotify(rawBody []byte) (*NotifyRequest, error) { - content := string(rawBody) - if strings.HasPrefix(content, "req=") { - content = content[4:] - } - decoded, err := url.QueryUnescape(content) + utf8Str, err := decodeNotifyXML(rawBody) if err != nil { - return nil, fmt.Errorf("URL 解码回调数据失败: %w", err) + return nil, 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) @@ -33,36 +25,22 @@ func ParseNotify(rawBody []byte) (*NotifyRequest, error) { // VerifyNotify 验证回调通知并解析数据 // rawBody: 原始请求 body(可能包含 req= 前缀的 URL 编码 GBK XML) func (c *Client) VerifyNotify(rawBody []byte) (*NotifyRequest, error) { - content := string(rawBody) - - // 处理 req= 前缀(富友回调可能以 form 格式发送) - if strings.HasPrefix(content, "req=") { - content = content[4:] - } - - // URL 解码 - decoded, err := url.QueryUnescape(content) + utf8Str, err := decodeNotifyXML(rawBody) if err != nil { - return nil, fmt.Errorf("URL 解码回调数据失败: %w", err) + return nil, err } - - // GBK → UTF-8 - 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) - - // XML 解析 var notify NotifyRequest if err := xml.Unmarshal([]byte(utf8Str), ¬ify); err != nil { return nil, fmt.Errorf("XML 解析回调数据失败: %w", err) } - // 验证签名 - if err := c.Verify(¬ify, notify.Sign); err != nil { + signStr, err := buildNotifySignString(utf8Str) + if err != nil { + return nil, err + } + + // 富友回调字段会随支付通道扩展,验签必须以 XML 实际出现的字段为准。 + if err := c.verifySignString(signStr, notify.Sign); err != nil { return nil, fmt.Errorf("回调签名验证失败: %w", err) } @@ -74,14 +52,162 @@ func (c *Client) VerifyNotify(rawBody []byte) (*NotifyRequest, error) { return ¬ify, nil } -// BuildNotifySuccessResponse 构建成功响应(GBK 编码的 XML) -func BuildNotifySuccessResponse() []byte { - return buildNotifyResponse("000000", "success") +// buildNotifySignString 从回调 XML 实际字段构建验签原文。 +func buildNotifySignString(utf8XML string) (string, error) { + fields, err := extractNotifySignFields(utf8XML) + if err != nil { + return "", err + } + return buildSignStringFromMap(fields), nil } -// BuildNotifyFailResponse 构建失败响应(GBK 编码的 XML) -func BuildNotifyFailResponse(msg string) []byte { - return buildNotifyResponse("999999", msg) +// extractNotifySignFields 提取 XML 根节点下参与验签的字段。 +func extractNotifySignFields(utf8XML string) (map[string]string, error) { + fields := make(map[string]string) + decoder := xml.NewDecoder(strings.NewReader(utf8XML)) + depth := 0 + currentField := "" + + for { + token, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("XML 提取验签字段失败: %w", err) + } + + switch t := token.(type) { + case xml.StartElement: + depth++ + if depth == 2 { + currentField = t.Name.Local + fields[currentField] = "" + } + case xml.CharData: + if depth == 2 && currentField != "" { + fields[currentField] += string(t) + } + case xml.EndElement: + if depth == 2 { + currentField = "" + } + depth-- + } + } + + for key := range fields { + if key == "sign" || strings.HasPrefix(key, "reserved") { + delete(fields, key) + } + } + return fields, nil +} + +// decodeNotifyXML 将富友回调报文统一解码为 UTF-8 XML。 +func decodeNotifyXML(rawBody []byte) (string, error) { + content, err := extractNotifyContent(rawBody) + if err != nil { + return "", err + } + + decoded, err := urlDecodeNotifyContent(content) + if err != nil { + return "", err + } + if strings.TrimSpace(decoded) == "" { + return "", fmt.Errorf("回调 XML 内容为空") + } + + return notifyXMLToUTF8(decoded) +} + +// extractNotifyContent 提取 req 字段或原始 XML 内容。 +func extractNotifyContent(rawBody []byte) (string, error) { + content := strings.TrimSpace(string(rawBody)) + if content == "" { + return "", fmt.Errorf("回调数据为空") + } + if strings.HasPrefix(content, "<") { + return content, nil + } + + if values, err := url.ParseQuery(content); err == nil { + if reqValues, ok := values["req"]; ok { + if len(reqValues) == 0 || strings.TrimSpace(reqValues[0]) == "" { + return "", fmt.Errorf("回调 req 内容为空") + } + return reqValues[0], nil + } + } + + if strings.HasPrefix(content, "req=") { + content = strings.TrimPrefix(content, "req=") + } + if strings.TrimSpace(content) == "" { + return "", fmt.Errorf("回调 req 内容为空") + } + return content, nil +} + +// urlDecodeNotifyContent 兼容富友单次或双次 URL 编码的 XML。 +func urlDecodeNotifyContent(content string) (string, error) { + decoded := strings.TrimSpace(content) + for i := 0; i < 2; i++ { + if strings.HasPrefix(decoded, "<") { + return decoded, nil + } + if !strings.ContainsAny(decoded, "%+") { + return decoded, nil + } + + next, err := url.QueryUnescape(decoded) + if err != nil { + return "", fmt.Errorf("URL 解码回调数据失败: %w", err) + } + decoded = strings.TrimSpace(next) + } + return decoded, nil +} + +// notifyXMLToUTF8 将 XML 内容转换为 UTF-8 并修正编码声明。 +func notifyXMLToUTF8(content string) (string, error) { + if hasUTF8XMLDeclaration(content) { + return content, nil + } + + utf8Bytes, err := GBKToUTF8([]byte(content)) + if err != nil { + return "", fmt.Errorf("GBK 转 UTF-8 失败: %w", err) + } + + replacer := strings.NewReplacer( + `encoding="GBK"`, `encoding="UTF-8"`, + `encoding="gbk"`, `encoding="UTF-8"`, + `encoding='GBK'`, `encoding='UTF-8'`, + `encoding='gbk'`, `encoding='UTF-8'`, + ) + return replacer.Replace(string(utf8Bytes)), nil +} + +// hasUTF8XMLDeclaration 判断 XML 声明是否已标明 UTF-8。 +func hasUTF8XMLDeclaration(content string) bool { + head := content + if len(head) > 128 { + head = head[:128] + } + head = strings.ToLower(head) + return strings.Contains(head, `encoding="utf-8"`) || strings.Contains(head, `encoding='utf-8'`) +} + +// BuildNotifySuccessResponse 构建成功响应。 +func BuildNotifySuccessResponse() []byte { + return []byte("1") +} + +// BuildNotifyFailResponse 构建失败响应。 +func BuildNotifyFailResponse(_ string) []byte { + return []byte("0") } // buildNotifyResponse 构建回调响应 XML(GBK 编码) diff --git a/pkg/fuiou/types.go b/pkg/fuiou/types.go index 3fc688f..a73a0e5 100644 --- a/pkg/fuiou/types.go +++ b/pkg/fuiou/types.go @@ -57,7 +57,13 @@ type NotifyRequest struct { InsCd string `xml:"ins_cd"` // 机构号 MchntOrderNo string `xml:"mchnt_order_no"` // 商户订单号 OrderAmt string `xml:"order_amt"` // 订单金额(分) + SettleOrderAmt string `xml:"settle_order_amt"` // 清算订单金额(分) + CurrType string `xml:"curr_type"` // 货币类型 + OrderType string `xml:"order_type"` // 支付类型 + TermId string `xml:"term_id"` // 终端号 TransactionId string `xml:"transaction_id"` // 交易流水号 + TxnFinTs string `xml:"txn_fin_ts"` // 交易完成时间 + UserId string `xml:"user_id"` // 用户标识 ResultCode string `xml:"result_code"` // 结果码 ResultMsg string `xml:"result_msg"` // 结果消息 Sign string `xml:"sign"` // 签名