有效天数问题,充值单回调问题
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
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/service/packageprice"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
@@ -560,10 +561,7 @@ func buildClientPackageItem(
|
|||||||
return dto.ClientPackageItem{}, false
|
return dto.ClientPackageItem{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
validityDays := pkg.DurationDays
|
validityDays := packagepkg.CalculateValidityDays(pkg.CalendarType, time.Now(), pkg.DurationMonths, pkg.DurationDays)
|
||||||
if validityDays <= 0 && pkg.DurationMonths > 0 {
|
|
||||||
validityDays = pkg.DurationMonths * 30
|
|
||||||
}
|
|
||||||
|
|
||||||
dataAllowance := pkg.RealDataMB
|
dataAllowance := pkg.RealDataMB
|
||||||
if dataAllowance <= 0 {
|
if dataAllowance <= 0 {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
"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/response"
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/wechat"
|
"github.com/break/junhong_cmp_fiber/pkg/wechat"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
@@ -300,12 +302,13 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
|
|||||||
rechargeNo := generateClientRechargeNo()
|
rechargeNo := generateClientRechargeNo()
|
||||||
paymentNo := generateClientPaymentNo()
|
paymentNo := generateClientPaymentNo()
|
||||||
|
|
||||||
// 先初始化微信支付实例并创建预支付订单,确认支付通道可用
|
// 先初始化生效支付通道并创建预支付订单,确认支付通道可用
|
||||||
// 避免先写入充值记录后支付初始化失败,导致产生孤儿记录
|
// 避免先写入充值记录后支付初始化失败,导致产生孤儿记录
|
||||||
payResult, err := h.createJSAPIPayOrder(
|
payConfig, err := h.createClientRechargePayConfig(
|
||||||
resolved.SkipPermissionCtx,
|
resolved.SkipPermissionCtx,
|
||||||
config,
|
config,
|
||||||
appID,
|
appID,
|
||||||
|
req.AppType,
|
||||||
paymentNo,
|
paymentNo,
|
||||||
"资产钱包充值",
|
"资产钱包充值",
|
||||||
openID,
|
openID,
|
||||||
@@ -347,7 +350,6 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
|
|||||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
payConfig := buildClientRechargePayConfig(appID, payResult)
|
|
||||||
resp := &dto.ClientRechargeResponse{
|
resp := &dto.ClientRechargeResponse{
|
||||||
Recharge: dto.ClientRechargeResult{
|
Recharge: dto.ClientRechargeResult{
|
||||||
RechargeID: rechargeOrder.ID,
|
RechargeID: rechargeOrder.ID,
|
||||||
@@ -607,33 +609,107 @@ func pickAppIDByType(config *model.WechatConfig, appType string) (string, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createJSAPIPayOrder 根据支付配置的提供者类型路由到对应支付通道,发起 JSAPI 预下单。
|
// createClientRechargePayConfig 根据当前生效支付配置创建充值预支付订单。
|
||||||
// 与 client_order.Service.newPaymentProvider 保持相同的路由逻辑,支持 wechat_v2 和 wechat(v3)。
|
// provider_type 必须显式匹配,避免富友等渠道误落到微信直连。
|
||||||
func (h *ClientWalletHandler) createJSAPIPayOrder(
|
func (h *ClientWalletHandler) createClientRechargePayConfig(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
config *model.WechatConfig,
|
config *model.WechatConfig,
|
||||||
appID, orderNo, description, openID string,
|
appID, appType, orderNo, description, openID string,
|
||||||
amount int,
|
amount int,
|
||||||
) (*wechat.JSAPIPayResult, error) {
|
) (dto.ClientRechargePayConfig, error) {
|
||||||
switch config.ProviderType {
|
switch config.ProviderType {
|
||||||
case model.ProviderTypeWechatV2:
|
case model.ProviderTypeWechatV2:
|
||||||
// 微信 v2:仅需 APIv2Key,无需证书序列号
|
// 微信 v2:仅需 APIv2Key,无需证书序列号
|
||||||
svc, err := wechat.NewPaymentV2ServiceFromConfig(config, appID, h.logger)
|
svc, err := wechat.NewPaymentV2ServiceFromConfig(config, appID, h.logger)
|
||||||
if err != nil {
|
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)
|
result, err := svc.CreateJSAPIOrder(ctx, orderNo, description, openID, amount)
|
||||||
default:
|
if err != nil {
|
||||||
|
return dto.ClientRechargePayConfig{}, err
|
||||||
|
}
|
||||||
|
return buildClientRechargePayConfig(appID, result), nil
|
||||||
|
case model.ProviderTypeWechat:
|
||||||
// 微信 v3(PowerWeChat):需要证书序列号和 API v3 Key
|
// 微信 v3(PowerWeChat):需要证书序列号和 API v3 Key
|
||||||
cache := wechat.NewRedisCache(h.redis)
|
cache := wechat.NewRedisCache(h.redis)
|
||||||
paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, h.logger)
|
paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, h.logger)
|
||||||
if err != nil {
|
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 {
|
func generateClientRechargeNo() string {
|
||||||
timestamp := time.Now().Format("20060102150405")
|
timestamp := time.Now().Format("20060102150405")
|
||||||
randomNum := rand.Intn(1000000)
|
randomNum := rand.Intn(1000000)
|
||||||
|
|||||||
@@ -260,18 +260,25 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
|
|||||||
// FuiouPayCallback 富友支付回调(带签名验证)
|
// FuiouPayCallback 富友支付回调(带签名验证)
|
||||||
// POST /api/callback/fuiou-pay
|
// POST /api/callback/fuiou-pay
|
||||||
func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
|
func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
|
||||||
body := c.Body()
|
body, bodySource := fuiouCallbackPayload(c)
|
||||||
if len(body) == 0 {
|
if strings.TrimSpace(string(body)) == "" {
|
||||||
return errors.New(errors.CodeFuiouCallbackInvalid, "回调请求体为空")
|
return errors.New(errors.CodeFuiouCallbackInvalid, "回调请求体为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := c.UserContext()
|
ctx := c.UserContext()
|
||||||
c.Set("Content-Type", "text/xml; charset=gbk")
|
c.Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
|
||||||
// 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置
|
// 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置
|
||||||
preNotify, err := fuiou.ParseNotify(body)
|
preNotify, err := fuiou.ParseNotify(body)
|
||||||
if err != nil {
|
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"))
|
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"))
|
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(
|
client, err := fuiou.NewClient(
|
||||||
cfg.FyInsCd,
|
cfg.FyInsCd,
|
||||||
@@ -307,7 +329,16 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
|
|||||||
zap.String("result_msg", notify.ResultMsg))
|
zap.String("result_msg", notify.ResultMsg))
|
||||||
return c.Send(fuiou.BuildNotifySuccessResponse())
|
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()))
|
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,3 +376,14 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return c.Send(fuiou.BuildNotifySuccessResponse())
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ type ClientPackageItem struct {
|
|||||||
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:加油包)"`
|
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:加油包)"`
|
||||||
RetailPrice int64 `json:"retail_price" description:"生效零售价(分)"`
|
RetailPrice int64 `json:"retail_price" description:"生效零售价(分)"`
|
||||||
CostPrice int64 `json:"cost_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:"是否加油包"`
|
IsAddon bool `json:"is_addon" description:"是否加油包"`
|
||||||
DataAllowance int64 `json:"data_allowance" description:"流量额度"`
|
DataAllowance int64 `json:"data_allowance" description:"流量额度"`
|
||||||
DataUnit string `json:"data_unit" description:"流量单位"`
|
DataUnit string `json:"data_unit" description:"流量单位"`
|
||||||
|
|||||||
@@ -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{
|
if err = tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
|
||||||
"generation": card.Generation + 1,
|
"generation": card.Generation + 1,
|
||||||
"asset_status": 1,
|
"asset_status": 1,
|
||||||
"accumulated_recharge": 0,
|
|
||||||
"first_commission_paid": false,
|
|
||||||
"accumulated_recharge_by_series": "{}",
|
"accumulated_recharge_by_series": "{}",
|
||||||
"first_recharge_triggered_by_series": "{}",
|
"first_recharge_triggered_by_series": "{}",
|
||||||
"updater": middleware.GetUserIDFromContext(ctx),
|
"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{
|
if err = tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{
|
||||||
"generation": device.Generation + 1,
|
"generation": device.Generation + 1,
|
||||||
"asset_status": 1,
|
"asset_status": 1,
|
||||||
"accumulated_recharge": 0,
|
|
||||||
"first_commission_paid": false,
|
|
||||||
"accumulated_recharge_by_series": "{}",
|
"accumulated_recharge_by_series": "{}",
|
||||||
"first_recharge_triggered_by_series": "{}",
|
"first_recharge_triggered_by_series": "{}",
|
||||||
"updater": middleware.GetUserIDFromContext(ctx),
|
"updater": middleware.GetUserIDFromContext(ctx),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
// calendarType: 套餐周期类型(natural_month=自然月,by_day=按天)
|
// calendarType: 套餐周期类型(natural_month=自然月,by_day=按天)
|
||||||
// activatedAt: 激活时间
|
// activatedAt: 激活时间
|
||||||
// durationMonths: 套餐时长(月数,calendar_type=natural_month 时使用)
|
// durationMonths: 套餐时长(月数,calendar_type=natural_month 时使用)
|
||||||
// durationDays: 套餐天数(calendar_type=by_day 时使用)
|
// durationDays: 套餐天数(calendar_type=by_day 时使用,兼容每月折算天数)
|
||||||
// 返回:过期时间(当天 23:59:59)
|
// 返回:过期时间(当天 23:59:59)
|
||||||
func CalculateExpiryTime(calendarType string, activatedAt time.Time, durationMonths, durationDays int) time.Time {
|
func CalculateExpiryTime(calendarType string, activatedAt time.Time, durationMonths, durationDays int) time.Time {
|
||||||
var expiryDate 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())
|
expiryDate = time.Date(targetYear, targetMonth+1, 0, 23, 59, 59, 0, activatedAt.Location())
|
||||||
} else {
|
} else {
|
||||||
// 按天套餐:activated_at + N 天,23:59:59
|
// 按天套餐: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())
|
expiryDate = time.Date(expiryDate.Year(), expiryDate.Month(), expiryDate.Day(), 23, 59, 59, 0, expiryDate.Location())
|
||||||
}
|
}
|
||||||
|
|
||||||
return expiryDate
|
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 计算下次流量重置时间
|
// CalculateNextResetTime 计算下次流量重置时间
|
||||||
// dataResetCycle: 流量重置周期(daily/monthly/yearly/none)
|
// dataResetCycle: 流量重置周期(daily/monthly/yearly/none)
|
||||||
// calendarType: 套餐周期类型(natural_month/by_day),影响月重置逻辑
|
// calendarType: 套餐周期类型(natural_month/by_day),影响月重置逻辑
|
||||||
|
|||||||
@@ -193,6 +193,3 @@ func (s *Service) checkForceRechargeRequirement(ctx context.Context, resourceTyp
|
|||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateAccumulatedRechargeInTx 更新累计充值(事务内使用)
|
|
||||||
// 同时更新旧的 accumulated_recharge 字段和新的 accumulated_recharge_by_series JSON 字段
|
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ func (s *Service) updateAccumulatedRechargeInTx(ctx context.Context, tx *gorm.DB
|
|||||||
result := tx.Model(&model.IotCard{}).
|
result := tx.Model(&model.IotCard{}).
|
||||||
Where("id = ?", resourceID).
|
Where("id = ?", resourceID).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"accumulated_recharge": gorm.Expr("accumulated_recharge + ?", amount),
|
|
||||||
"accumulated_recharge_by_series": card.AccumulatedRechargeBySeriesJSON,
|
"accumulated_recharge_by_series": card.AccumulatedRechargeBySeriesJSON,
|
||||||
})
|
})
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
@@ -225,7 +224,6 @@ func (s *Service) updateAccumulatedRechargeInTx(ctx context.Context, tx *gorm.DB
|
|||||||
result := tx.Model(&model.Device{}).
|
result := tx.Model(&model.Device{}).
|
||||||
Where("id = ?", resourceID).
|
Where("id = ?", resourceID).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"accumulated_recharge": gorm.Expr("accumulated_recharge + ?", amount),
|
|
||||||
"accumulated_recharge_by_series": device.AccumulatedRechargeBySeriesJSON,
|
"accumulated_recharge_by_series": device.AccumulatedRechargeBySeriesJSON,
|
||||||
})
|
})
|
||||||
if result.Error != nil {
|
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).
|
if err := tx.Model(&model.IotCard{}).Where("id = ?", rechargeOrder.ResourceID).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"first_commission_paid": true,
|
|
||||||
"first_recharge_triggered_by_series": card.FirstRechargeTriggeredBySeriesJSON,
|
"first_recharge_triggered_by_series": card.FirstRechargeTriggeredBySeriesJSON,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡佣金发放状态失败")
|
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).
|
if err := tx.Model(&model.Device{}).Where("id = ?", rechargeOrder.ResourceID).
|
||||||
Updates(map[string]any{
|
Updates(map[string]any{
|
||||||
"first_commission_paid": true,
|
|
||||||
"first_recharge_triggered_by_series": device.FirstRechargeTriggeredBySeriesJSON,
|
"first_recharge_triggered_by_series": device.FirstRechargeTriggeredBySeriesJSON,
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "更新设备佣金发放状态失败")
|
return errors.Wrap(errors.CodeDatabaseError, err, "更新设备佣金发放状态失败")
|
||||||
|
|||||||
@@ -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{
|
if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
|
||||||
"generation": card.Generation + 1,
|
"generation": card.Generation + 1,
|
||||||
"asset_status": 1,
|
"asset_status": 1,
|
||||||
"accumulated_recharge": 0,
|
|
||||||
"first_commission_paid": false,
|
|
||||||
"accumulated_recharge_by_series": "{}",
|
"accumulated_recharge_by_series": "{}",
|
||||||
"first_recharge_triggered_by_series": "{}",
|
"first_recharge_triggered_by_series": "{}",
|
||||||
"updated_at": now,
|
"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{
|
if err := tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{
|
||||||
"generation": device.Generation + 1,
|
"generation": device.Generation + 1,
|
||||||
"asset_status": 1,
|
"asset_status": 1,
|
||||||
"accumulated_recharge": 0,
|
|
||||||
"first_commission_paid": false,
|
|
||||||
"accumulated_recharge_by_series": "{}",
|
"accumulated_recharge_by_series": "{}",
|
||||||
"first_recharge_triggered_by_series": "{}",
|
"first_recharge_triggered_by_series": "{}",
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -50,6 +52,9 @@ func NewClient(insCd, mchntCd, termId, apiURL, notifyURL, privateKeyBase64, publ
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("解析富友公钥失败: %w", err)
|
return nil, fmt.Errorf("解析富友公钥失败: %w", err)
|
||||||
}
|
}
|
||||||
|
if rsaPublicKeysEqual(pubKey, &privKey.PublicKey) {
|
||||||
|
return nil, fmt.Errorf("富友公钥配置错误:当前 fy_public_key 与商户私钥匹配,请配置富友平台提供的验签公钥")
|
||||||
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
InsCd: insCd,
|
InsCd: insCd,
|
||||||
@@ -91,6 +96,13 @@ func (c *Client) Sign(data interface{}) (string, error) {
|
|||||||
// Verify 验证签名
|
// Verify 验证签名
|
||||||
func (c *Client) Verify(data interface{}, sign string) error {
|
func (c *Client) Verify(data interface{}, sign string) error {
|
||||||
signStr := buildSignString(data)
|
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))
|
gbkBytes, err := utf8ToGBK([]byte(signStr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -104,7 +116,17 @@ func (c *Client) Verify(data interface{}, sign string) error {
|
|||||||
return fmt.Errorf("Base64 解码签名失败: %w", err)
|
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 请求到富友
|
// DoRequest 发送 HTTP 请求到富友
|
||||||
@@ -175,10 +197,13 @@ func (c *Client) DoRequest(path string, req interface{}, resp interface{}) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildSignString 构建签名原文
|
// buildSignString 构建签名原文
|
||||||
// 提取非空字段(排除 sign 和 reserved_ 开头字段),按字典序拼接为 key=value&key=value
|
// 提取字段(排除 sign 和 reserved 开头字段),按字典序拼接为 key=value&key=value。
|
||||||
func buildSignString(data interface{}) string {
|
func buildSignString(data interface{}) string {
|
||||||
fields := structToMap(data)
|
return buildSignStringFromMap(structToMap(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSignStringFromMap 按富友规则将字段集合拼接为签名原文。
|
||||||
|
func buildSignStringFromMap(fields map[string]string) string {
|
||||||
// 按 key 字典序排列
|
// 按 key 字典序排列
|
||||||
keys := make([]string, 0, len(fields))
|
keys := make([]string, 0, len(fields))
|
||||||
for k := range fields {
|
for k := range fields {
|
||||||
@@ -195,8 +220,8 @@ func buildSignString(data interface{}) string {
|
|||||||
return strings.Join(pairs, "&")
|
return strings.Join(pairs, "&")
|
||||||
}
|
}
|
||||||
|
|
||||||
// structToMap 通过反射提取结构体的 xml tag 和值
|
// structToMap 通过反射提取结构体的 xml tag 和值。
|
||||||
// 排除 sign 字段、reserved_ 开头字段、空值字段
|
// 排除 sign 字段和 reserved 开头字段,保留空值字段参与签名。
|
||||||
func structToMap(data interface{}) map[string]string {
|
func structToMap(data interface{}) map[string]string {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
|
|
||||||
@@ -226,8 +251,8 @@ func structToMap(data interface{}) map[string]string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排除 reserved_ 开头字段
|
// 排除 reserved 开头字段
|
||||||
if strings.HasPrefix(tag, "reserved_") {
|
if strings.HasPrefix(tag, "reserved") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,3 +367,20 @@ func parseDERPublicKey(derBytes []byte) (*rsa.PublicKey, error) {
|
|||||||
|
|
||||||
return rsaPub, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package fuiou
|
|||||||
import (
|
import (
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -10,19 +11,10 @@ import (
|
|||||||
// ParseNotify 解析回调通知(不验签)
|
// ParseNotify 解析回调通知(不验签)
|
||||||
// 用于在验签前提取订单号以确定使用哪套支付配置
|
// 用于在验签前提取订单号以确定使用哪套支付配置
|
||||||
func ParseNotify(rawBody []byte) (*NotifyRequest, error) {
|
func ParseNotify(rawBody []byte) (*NotifyRequest, error) {
|
||||||
content := string(rawBody)
|
utf8Str, err := decodeNotifyXML(rawBody)
|
||||||
if strings.HasPrefix(content, "req=") {
|
|
||||||
content = content[4:]
|
|
||||||
}
|
|
||||||
decoded, err := url.QueryUnescape(content)
|
|
||||||
if err != nil {
|
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
|
var notify NotifyRequest
|
||||||
if err := xml.Unmarshal([]byte(utf8Str), ¬ify); err != nil {
|
if err := xml.Unmarshal([]byte(utf8Str), ¬ify); err != nil {
|
||||||
return nil, fmt.Errorf("XML 解析回调数据失败: %w", err)
|
return nil, fmt.Errorf("XML 解析回调数据失败: %w", err)
|
||||||
@@ -33,36 +25,22 @@ func ParseNotify(rawBody []byte) (*NotifyRequest, error) {
|
|||||||
// 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) {
|
||||||
content := string(rawBody)
|
utf8Str, err := decodeNotifyXML(rawBody)
|
||||||
|
|
||||||
// 处理 req= 前缀(富友回调可能以 form 格式发送)
|
|
||||||
if strings.HasPrefix(content, "req=") {
|
|
||||||
content = content[4:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL 解码
|
|
||||||
decoded, err := url.QueryUnescape(content)
|
|
||||||
if err != nil {
|
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
|
var notify NotifyRequest
|
||||||
if err := xml.Unmarshal([]byte(utf8Str), ¬ify); err != nil {
|
if err := xml.Unmarshal([]byte(utf8Str), ¬ify); err != nil {
|
||||||
return nil, fmt.Errorf("XML 解析回调数据失败: %w", err)
|
return nil, fmt.Errorf("XML 解析回调数据失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证签名
|
signStr, err := buildNotifySignString(utf8Str)
|
||||||
if err := c.Verify(¬ify, notify.Sign); err != nil {
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 富友回调字段会随支付通道扩展,验签必须以 XML 实际出现的字段为准。
|
||||||
|
if err := c.verifySignString(signStr, notify.Sign); err != nil {
|
||||||
return nil, fmt.Errorf("回调签名验证失败: %w", err)
|
return nil, fmt.Errorf("回调签名验证失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,14 +52,162 @@ func (c *Client) VerifyNotify(rawBody []byte) (*NotifyRequest, error) {
|
|||||||
return ¬ify, nil
|
return ¬ify, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildNotifySuccessResponse 构建成功响应(GBK 编码的 XML)
|
// buildNotifySignString 从回调 XML 实际字段构建验签原文。
|
||||||
func BuildNotifySuccessResponse() []byte {
|
func buildNotifySignString(utf8XML string) (string, error) {
|
||||||
return buildNotifyResponse("000000", "success")
|
fields, err := extractNotifySignFields(utf8XML)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return buildSignStringFromMap(fields), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildNotifyFailResponse 构建失败响应(GBK 编码的 XML)
|
// extractNotifySignFields 提取 XML 根节点下参与验签的字段。
|
||||||
func BuildNotifyFailResponse(msg string) []byte {
|
func extractNotifySignFields(utf8XML string) (map[string]string, error) {
|
||||||
return buildNotifyResponse("999999", msg)
|
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 编码)
|
// buildNotifyResponse 构建回调响应 XML(GBK 编码)
|
||||||
|
|||||||
@@ -57,7 +57,13 @@ type NotifyRequest struct {
|
|||||||
InsCd string `xml:"ins_cd"` // 机构号
|
InsCd string `xml:"ins_cd"` // 机构号
|
||||||
MchntOrderNo string `xml:"mchnt_order_no"` // 商户订单号
|
MchntOrderNo string `xml:"mchnt_order_no"` // 商户订单号
|
||||||
OrderAmt string `xml:"order_amt"` // 订单金额(分)
|
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"` // 交易流水号
|
TransactionId string `xml:"transaction_id"` // 交易流水号
|
||||||
|
TxnFinTs string `xml:"txn_fin_ts"` // 交易完成时间
|
||||||
|
UserId string `xml:"user_id"` // 用户标识
|
||||||
ResultCode string `xml:"result_code"` // 结果码
|
ResultCode string `xml:"result_code"` // 结果码
|
||||||
ResultMsg string `xml:"result_msg"` // 结果消息
|
ResultMsg string `xml:"result_msg"` // 结果消息
|
||||||
Sign string `xml:"sign"` // 签名
|
Sign string `xml:"sign"` // 签名
|
||||||
|
|||||||
Reference in New Issue
Block a user