package app import ( "context" "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/pem" "errors" "fmt" "math" "net" "net/url" "os" "path/filepath" "strconv" "strings" "time" "git.5iot.com/iotlcm-public/iotlcm-app/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-app/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-app/internal/model/entity" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "github.com/wechatpay-apiv3/wechatpay-go/core" "github.com/wechatpay-apiv3/wechatpay-go/core/auth" "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" "github.com/wechatpay-apiv3/wechatpay-go/core/notify" "github.com/wechatpay-apiv3/wechatpay-go/core/option" "github.com/wechatpay-apiv3/wechatpay-go/services/payments" "github.com/wechatpay-apiv3/wechatpay-go/services/payments/h5" "github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi" "github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic" ) const ( appWechatPayMethodH5 = "wechat_h5" appWechatPayMethodMini = "wechat_mini" appWechatNotifyPath = "/api/v1/payment/wechat/notify" appWechatRefundPath = "/api/v1/payment/wechat/refund-notify" ) type appWechatConfig struct { Enabled bool `json:"enabled"` EnvFile string `json:"envFile"` ApiBase string `json:"apiBase"` MchId string `json:"mchId"` MchSerialNo string `json:"mchSerialNo"` ApiV3Key string `json:"apiV3Key"` PrivateKeyPath string `json:"privateKeyPath"` PrivateKey string `json:"privateKey"` NotifyUrl string `json:"notifyUrl"` NotifyBaseUrl string `json:"notifyBaseUrl"` RefundNotifyUrl string `json:"refundNotifyUrl"` TimeoutMinutes int `json:"timeoutMinutes"` PlatformPublicKeyId string `json:"platformPublicKeyId"` PlatformPublicKeyPath string `json:"platformPublicKeyPath"` PlatformPublicKey string `json:"platformPublicKey"` H5 appWechatH5Config `json:"h5"` Mini appWechatMiniConfig `json:"mini"` } type appWechatH5Config struct { Enabled bool `json:"enabled"` AppId string `json:"appId"` AppName string `json:"appName"` AppUrl string `json:"appUrl"` PayerClientIp string `json:"payerClientIp"` } type appWechatMiniConfig struct { Enabled bool `json:"enabled"` AppId string `json:"appId"` AppSecret string `json:"appSecret"` } type appWechatPayRequest struct { PayUrl string PayMethod string PayParams map[string]string } func appWechatPayUrl(payRequest *appWechatPayRequest) string { if payRequest == nil { return "" } return payRequest.PayUrl } func appWechatPayMethod(payRequest *appWechatPayRequest) string { if payRequest == nil { return "" } return payRequest.PayMethod } func appWechatPayParams(payRequest *appWechatPayRequest) map[string]string { if payRequest == nil || len(payRequest.PayParams) == 0 { return nil } copied := make(map[string]string, len(payRequest.PayParams)) for key, value := range payRequest.PayParams { copied[key] = value } return copied } func appLoadWechatConfig(ctx context.Context) (*appWechatConfig, error) { cfg := &appWechatConfig{} if err := g.Cfg().MustGet(ctx, "payment.wechat").Scan(cfg); err != nil { return nil, gerror.Wrap(err, "读取微信支付配置失败") } envDir, err := appLoadWechatEnvFile(ctx, cfg.EnvFile) if err != nil { return nil, err } appApplyWechatEnvOverrides(cfg, envDir) appNormalizeWechatConfig(cfg) return cfg, nil } func appLoadWechatEnvFile(ctx context.Context, configured string) (string, error) { envFile := strings.TrimSpace(os.Getenv("WECHAT_PAY_ENV_FILE")) if envFile == "" { envFile = strings.TrimSpace(configured) } if envFile == "" { return "", nil } absPath, err := filepath.Abs(filepath.Clean(envFile)) if err != nil { return "", gerror.Wrap(err, "解析微信支付环境配置路径失败") } data, err := os.ReadFile(absPath) if errors.Is(err, os.ErrNotExist) { g.Log().Warningf(ctx, "微信支付环境配置文件不存在,将继续读取系统环境变量: %s", absPath) return filepath.Dir(absPath), nil } if err != nil { return filepath.Dir(absPath), gerror.Wrap(err, "读取微信支付环境配置失败") } values := appParseDotenv(string(data)) for key, value := range values { if _, exists := os.LookupEnv(key); exists { continue } if err = os.Setenv(key, value); err != nil { return filepath.Dir(absPath), gerror.Wrapf(err, "写入微信支付环境变量失败: %s", key) } } g.Log().Debugf(ctx, "微信支付环境配置已加载: %s", absPath) return filepath.Dir(absPath), nil } func appParseDotenv(content string) map[string]string { result := make(map[string]string) content = strings.TrimPrefix(content, "\ufeff") for _, line := range strings.Split(content, "\n") { line = strings.TrimSpace(strings.TrimRight(line, "\r")) if line == "" || strings.HasPrefix(line, "#") { continue } index := strings.Index(line, "=") if index <= 0 { continue } key := strings.TrimSpace(line[:index]) value := strings.TrimSpace(line[index+1:]) value = strings.Trim(value, `"'`) if key != "" { result[key] = value } } return result } func appApplyWechatEnvOverrides(cfg *appWechatConfig, envDir string) { cfg.Enabled = appEnvBool("WECHAT_PAY_ENABLED", cfg.Enabled) cfg.H5.Enabled = appEnvBool("WECHAT_PAY_H5_ENABLED", cfg.H5.Enabled) cfg.Mini.Enabled = appEnvBool("WECHAT_PAY_MINI_ENABLED", cfg.Mini.Enabled) cfg.MchId = appEnvValue("WECHAT_PAY_MCH_ID", cfg.MchId) cfg.MchSerialNo = appEnvValue("WECHAT_PAY_MCH_SERIAL_NO", cfg.MchSerialNo) cfg.ApiV3Key = appEnvValue("WECHAT_PAY_API_V3_KEY", cfg.ApiV3Key) cfg.PrivateKey = appEnvValue("WECHAT_PAY_MCH_PRIVATE_KEY", cfg.PrivateKey) cfg.PrivateKeyPath = appEnvPath("WECHAT_PAY_MCH_PRIVATE_KEY_PATH", cfg.PrivateKeyPath, envDir) cfg.PlatformPublicKey = appEnvValue("WECHAT_PAY_PUBLIC_KEY", cfg.PlatformPublicKey) cfg.PlatformPublicKeyId = appEnvValue("WECHAT_PAY_PUBLIC_KEY_ID", cfg.PlatformPublicKeyId) cfg.PlatformPublicKeyPath = appEnvPath("WECHAT_PAY_PUBLIC_KEY_PATH", cfg.PlatformPublicKeyPath, envDir) cfg.NotifyUrl = appEnvValue("WECHAT_PAY_NOTIFY_URL", cfg.NotifyUrl) cfg.NotifyBaseUrl = appEnvValue("WECHAT_PAY_NOTIFY_BASE_URL", cfg.NotifyBaseUrl) cfg.RefundNotifyUrl = appEnvValue("WECHAT_PAY_REFUND_NOTIFY_URL", cfg.RefundNotifyUrl) if timeout := strings.TrimSpace(os.Getenv("WECHAT_PAY_TIMEOUT_MINUTES")); timeout != "" { if value, err := strconv.Atoi(timeout); err == nil { cfg.TimeoutMinutes = value } } // AppID 优先使用本项目配置,避免模板 .env 中的旧 AppID 覆盖当前小程序配置。 if value := appEnvFirst("APP_WECHAT_H5_APP_ID", "WECHAT_PAY_H5_APP_ID"); value != "" { cfg.H5.AppId = value } else if cfg.H5.AppId == "" { cfg.H5.AppId = appEnvFirst("WECHAT_PAY_SERVICE_APP_ID", "WECHAT_PAY_APP_ID") } cfg.H5.AppName = appEnvValue("WECHAT_PAY_H5_APP_NAME", cfg.H5.AppName) cfg.H5.AppUrl = appEnvValue("WECHAT_PAY_H5_APP_URL", cfg.H5.AppUrl) cfg.H5.PayerClientIp = appEnvValue("WECHAT_PAY_H5_PAYER_CLIENT_IP", cfg.H5.PayerClientIp) if value := appEnvFirst("APP_WECHAT_MINI_APP_ID", "WECHAT_PAY_MINI_APP_ID"); value != "" { cfg.Mini.AppId = value } else if cfg.Mini.AppId == "" { cfg.Mini.AppId = strings.TrimSpace(os.Getenv("WECHAT_PAY_MINI_PROGRAM_APP_ID")) } if value := appEnvFirst("APP_WECHAT_MINI_APP_SECRET", "WECHAT_MINI_APP_SECRET", "WECHAT_PAY_MINI_APP_SECRET", "WECHAT_PAY_MINI_PROGRAM_APP_SECRET"); value != "" { cfg.Mini.AppSecret = value } } func appNormalizeWechatConfig(cfg *appWechatConfig) { cfg.EnvFile = strings.TrimSpace(cfg.EnvFile) cfg.ApiBase = strings.TrimRight(strings.TrimSpace(cfg.ApiBase), "/") cfg.MchId = strings.TrimSpace(cfg.MchId) cfg.MchSerialNo = strings.TrimSpace(cfg.MchSerialNo) cfg.ApiV3Key = strings.TrimSpace(cfg.ApiV3Key) cfg.PrivateKeyPath = strings.TrimSpace(cfg.PrivateKeyPath) cfg.PrivateKey = strings.TrimSpace(cfg.PrivateKey) cfg.NotifyUrl = strings.TrimSpace(cfg.NotifyUrl) cfg.NotifyBaseUrl = strings.TrimSpace(cfg.NotifyBaseUrl) cfg.RefundNotifyUrl = strings.TrimSpace(cfg.RefundNotifyUrl) cfg.PlatformPublicKeyId = strings.TrimSpace(cfg.PlatformPublicKeyId) cfg.PlatformPublicKeyPath = strings.TrimSpace(cfg.PlatformPublicKeyPath) cfg.PlatformPublicKey = strings.TrimSpace(cfg.PlatformPublicKey) cfg.H5.AppId = strings.TrimSpace(cfg.H5.AppId) cfg.H5.AppName = strings.TrimSpace(cfg.H5.AppName) cfg.H5.AppUrl = strings.TrimSpace(cfg.H5.AppUrl) cfg.H5.PayerClientIp = strings.TrimSpace(cfg.H5.PayerClientIp) cfg.Mini.AppId = strings.TrimSpace(cfg.Mini.AppId) cfg.Mini.AppSecret = strings.TrimSpace(cfg.Mini.AppSecret) if cfg.ApiBase == "" { cfg.ApiBase = "https://api.mch.weixin.qq.com" } if cfg.TimeoutMinutes <= 0 { cfg.TimeoutMinutes = 5 } if cfg.NotifyBaseUrl != "" { cfg.NotifyUrl = appWechatBuildNotifyUrl(cfg.NotifyBaseUrl) if cfg.RefundNotifyUrl == "" { cfg.RefundNotifyUrl = appWechatBuildUrl(cfg.NotifyBaseUrl, appWechatRefundPath) } } else { cfg.NotifyUrl = appWechatNormalizeNotifyUrl(cfg.NotifyUrl) } if cfg.RefundNotifyUrl != "" { cfg.RefundNotifyUrl = appWechatNormalizePathUrl(cfg.RefundNotifyUrl, appWechatRefundPath) } } func appEnvValue(key string, current string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return current } func appEnvFirst(keys ...string) string { for _, key := range keys { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } } return "" } func appEnvBool(key string, current bool) bool { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return current } switch strings.ToLower(value) { case "1", "true", "yes", "y", "on", "enabled": return true case "0", "false", "no", "n", "off", "disabled": return false default: return current } } func appEnvPath(key string, current string, envDir string) string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return current } if filepath.IsAbs(value) || envDir == "" { return value } return filepath.Join(envDir, value) } func appWechatBuildNotifyUrl(base string) string { return appWechatBuildUrl(base, appWechatNotifyPath) } func appWechatBuildUrl(base string, path string) string { base = strings.TrimSpace(base) if base == "" { return "" } parsed, err := url.Parse(base) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return strings.TrimRight(base, "/") + path } parsed.Path = path parsed.RawPath = "" parsed.RawQuery = "" parsed.Fragment = "" return parsed.String() } func appWechatNormalizeNotifyUrl(raw string) string { return appWechatNormalizePathUrl(raw, appWechatNotifyPath) } func appWechatNormalizePathUrl(raw string, path string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } parsed, err := url.Parse(raw) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return raw } if strings.TrimSpace(parsed.Path) == "" || strings.TrimRight(parsed.Path, "/") == strings.TrimRight(path, "/") { parsed.Path = path parsed.RawPath = "" parsed.RawQuery = "" parsed.Fragment = "" return parsed.String() } return raw } func appWechatEnabled(ctx context.Context) bool { cfg, err := appLoadWechatConfig(ctx) return err == nil && cfg.Enabled } func appWechatPaymentTimeoutDuration(ctx context.Context) time.Duration { minutes := g.Cfg().MustGet(ctx, "payment.wechat.timeoutMinutes", 5).Int() if timeout := strings.TrimSpace(os.Getenv("WECHAT_PAY_TIMEOUT_MINUTES")); timeout != "" { if value, err := strconv.Atoi(timeout); err == nil { minutes = value } } if minutes <= 0 { minutes = 5 } return time.Duration(minutes) * time.Minute } func appBalanceRechargePaymentTiming(ctx context.Context, order *entity.TOrder) (*gtime.Time, int64, bool) { if order == nil || order.OrderType != consts.OrderTypeBalanceRecharge || order.PayStatus != consts.PayStatusUnpaid { return nil, 0, false } if order.CreatedAt == nil || order.CreatedAt.IsZero() { return nil, 0, false } expireAt := order.CreatedAt.Add(appWechatPaymentTimeoutDuration(ctx)) remaining := expireAt.Sub(gtime.Now()) if remaining <= 0 { return expireAt, 0, true } return expireAt, int64(math.Ceil(remaining.Seconds())), false } func appWechatPrepayExpireTime(ctx context.Context, cfg *appWechatConfig, order *entity.TOrder) time.Time { timeout := appWechatPaymentTimeoutDuration(ctx) if cfg != nil && cfg.TimeoutMinutes > 0 { timeout = time.Duration(cfg.TimeoutMinutes) * time.Minute } if order != nil && order.CreatedAt != nil && !order.CreatedAt.IsZero() { return time.Unix(order.CreatedAt.Add(timeout).Timestamp(), 0) } return time.Now().Add(timeout) } func appMockPaymentEnabled(ctx context.Context) bool { return g.Cfg().MustGet(ctx, "payment.mock.enabled", false).Bool() } func appBuildWechatPayRequest(ctx context.Context, order *entity.TOrder, payScene string, user *entity.SysUser) (*appWechatPayRequest, error) { if order == nil || strings.TrimSpace(order.OrderSn) == "" { return nil, gerror.New("微信支付下单订单信息不完整") } cfg, err := appLoadWechatConfig(ctx) if err != nil { return nil, err } if !cfg.Enabled { return nil, nil } if err = appValidateWechatCommonConfig(cfg); err != nil { return nil, err } if _, _, expired := appBalanceRechargePaymentTiming(ctx, order); expired { return nil, gerror.New("支付订单已过期,请重新提交充值") } switch appNormalizeWechatPayScene(payScene) { case "mini": return appBuildWechatMiniPayRequest(ctx, cfg, order, user) default: return appBuildWechatH5PayRequest(ctx, cfg, order) } } func appValidateWechatCommonConfig(cfg *appWechatConfig) error { if cfg.MchId == "" { return gerror.New("微信支付商户号未配置") } if cfg.MchSerialNo == "" { return gerror.New("微信支付商户证书序列号未配置") } if cfg.ApiV3Key == "" { return gerror.New("微信支付 API v3 密钥未配置") } if len([]byte(cfg.ApiV3Key)) != 32 { return gerror.New("微信支付 API v3 密钥长度必须为 32 字节") } if cfg.NotifyUrl == "" { return gerror.New("微信支付回调地址未配置") } if cfg.PrivateKey == "" && cfg.PrivateKeyPath == "" { return gerror.New("微信支付商户私钥未配置") } if cfg.PlatformPublicKey == "" && cfg.PlatformPublicKeyPath == "" { return gerror.New("微信支付平台公钥未配置") } if cfg.PlatformPublicKeyId == "" { return gerror.New("微信支付平台公钥 ID 未配置") } return nil } func appBuildWechatH5PayRequest(ctx context.Context, cfg *appWechatConfig, order *entity.TOrder) (*appWechatPayRequest, error) { if !cfg.H5.Enabled { return nil, gerror.New("微信 H5 支付未启用") } appId := cfg.H5.AppId if appId == "" { appId = cfg.Mini.AppId } if appId == "" { return nil, gerror.New("微信 H5 支付 AppID 未配置") } clientIp := appWechatClientIp(ctx, cfg) if clientIp == "" { return nil, gerror.New("微信 H5 支付终端 IP 未获取到") } expireAt := appWechatPrepayExpireTime(ctx, cfg, order) prepayReq := h5.PrepayRequest{ Appid: core.String(appId), Mchid: core.String(cfg.MchId), Description: core.String(appWechatOrderDescription(order)), OutTradeNo: core.String(order.OrderSn), TimeExpire: &expireAt, NotifyUrl: core.String(cfg.NotifyUrl), Amount: &h5.Amount{ Total: core.Int64(appWechatAmountFen(order.TotalAmount)), Currency: core.String("CNY"), }, SceneInfo: &h5.SceneInfo{ PayerClientIp: core.String(clientIp), H5Info: &h5.H5Info{ Type: core.String("Wap"), AppName: appStringPtr(appWechatH5AppName(cfg)), AppUrl: appStringPtr(cfg.H5.AppUrl), }, }, } client, err := appWechatClient(ctx, cfg) if err != nil { return nil, err } rsp, _, err := (&h5.H5ApiService{Client: client}).Prepay(ctx, prepayReq) if err != nil { return nil, gerror.Wrap(err, "微信 H5 支付预下单失败") } if rsp == nil || rsp.H5Url == nil || strings.TrimSpace(*rsp.H5Url) == "" { return nil, gerror.New("微信 H5 支付未返回 h5_url") } return &appWechatPayRequest{ PayUrl: appWechatH5PayUrl(strings.TrimSpace(*rsp.H5Url), cfg, order), PayMethod: appWechatPayMethodH5, }, nil } func appWechatH5PayUrl(raw string, cfg *appWechatConfig, order *entity.TOrder) string { raw = strings.TrimSpace(raw) if raw == "" || cfg == nil || order == nil || strings.TrimSpace(order.OrderSn) == "" { return raw } returnUrl := appWechatH5ReturnUrl(cfg, order.OrderSn) if returnUrl == "" { return raw } parsed, err := url.Parse(raw) if err != nil { return raw } query := parsed.Query() if strings.TrimSpace(query.Get("redirect_url")) == "" { query.Set("redirect_url", returnUrl) parsed.RawQuery = query.Encode() } return parsed.String() } func appWechatH5ReturnUrl(cfg *appWechatConfig, orderSn string) string { base := strings.TrimRight(strings.TrimSpace(cfg.H5.AppUrl), "/") if base == "" { base = strings.TrimRight(strings.TrimSpace(cfg.NotifyBaseUrl), "/") } if base == "" { return "" } return fmt.Sprintf("%s/#/pages/business/views/balanceRecharge/index?orderSn=%s", base, url.QueryEscape(strings.TrimSpace(orderSn))) } func appBuildWechatMiniPayRequest(ctx context.Context, cfg *appWechatConfig, order *entity.TOrder, user *entity.SysUser) (*appWechatPayRequest, error) { if !cfg.Mini.Enabled { return nil, gerror.New("微信小程序支付未启用") } if cfg.Mini.AppId == "" { return nil, gerror.New("微信小程序支付 AppID 未配置") } openId, err := appCurrentWechatOpenId(ctx, user) if err != nil { return nil, err } expireAt := appWechatPrepayExpireTime(ctx, cfg, order) prepayReq := jsapi.PrepayRequest{ Appid: core.String(cfg.Mini.AppId), Mchid: core.String(cfg.MchId), Description: core.String(appWechatOrderDescription(order)), OutTradeNo: core.String(order.OrderSn), TimeExpire: &expireAt, NotifyUrl: core.String(cfg.NotifyUrl), Amount: &jsapi.Amount{ Total: core.Int64(appWechatAmountFen(order.TotalAmount)), Currency: core.String("CNY"), }, Payer: &jsapi.Payer{ Openid: core.String(openId), }, } client, err := appWechatClient(ctx, cfg) if err != nil { return nil, err } rsp, _, err := (&jsapi.JsapiApiService{Client: client}).PrepayWithRequestPayment(ctx, prepayReq) if err != nil { return nil, gerror.Wrap(err, "微信小程序支付预下单失败") } if rsp == nil || rsp.Package == nil || strings.TrimSpace(*rsp.Package) == "" { return nil, gerror.New("微信小程序支付未返回调起支付参数") } return &appWechatPayRequest{ PayMethod: appWechatPayMethodMini, PayParams: map[string]string{ "appId": appStringValue(rsp.Appid), "timeStamp": appStringValue(rsp.TimeStamp), "nonceStr": appStringValue(rsp.NonceStr), "package": appStringValue(rsp.Package), "signType": appStringValue(rsp.SignType), "paySign": appStringValue(rsp.PaySign), }, }, nil } func appWechatClient(ctx context.Context, cfg *appWechatConfig) (*core.Client, error) { privateKey, err := appWechatPrivateKey(cfg) if err != nil { return nil, err } verifier, err := appWechatVerifier(cfg) if err != nil { return nil, err } client, err := core.NewClient(ctx, option.WithMerchantCredential(cfg.MchId, cfg.MchSerialNo, privateKey), option.WithVerifier(verifier), ) if err != nil { return nil, gerror.Wrap(err, "初始化微信支付 SDK 客户端失败") } return client, nil } func appWechatVerifier(cfg *appWechatConfig) (auth.Verifier, error) { publicKey, err := appWechatPlatformPublicKey(cfg) if err != nil { return nil, err } return verifiers.NewSHA256WithRSAPubkeyVerifier(cfg.PlatformPublicKeyId, *publicKey), nil } func appCurrentWechatOpenId(ctx context.Context, user *entity.SysUser) (string, error) { if user == nil || user.UserId <= 0 { return "", gerror.New("当前用户信息不完整,无法发起微信小程序支付") } var social *entity.SysSocial if err := dao.SysSocial.Ctx(ctx). Where(dao.SysSocial.Columns().TenantId, user.TenantId). Where(dao.SysSocial.Columns().UserId, user.UserId). Where(dao.SysSocial.Columns().Source, consts.SourceWechatMp). OrderDesc(dao.SysSocial.Columns().Id). Limit(1). Scan(&social); err != nil { return "", gerror.Wrap(err, "查询微信 openid 失败") } if social == nil || strings.TrimSpace(social.OpenId) == "" { return "", gerror.New("当前账号未绑定微信 openid,无法发起小程序支付") } return strings.TrimSpace(social.OpenId), nil } func (s *sAppCard) BalanceRechargeWechatNotify(ctx context.Context) error { r := g.RequestFromCtx(ctx) if r == nil || r.Request == nil { return gerror.New("微信支付通知请求不存在") } cfg, err := appLoadWechatConfig(ctx) if err != nil { return err } if !cfg.Enabled { return gerror.New("微信支付未启用") } if err = appValidateWechatCommonConfig(cfg); err != nil { return err } verifier, err := appWechatVerifier(cfg) if err != nil { return err } handler := notify.NewNotifyHandler(cfg.ApiV3Key, verifier) transaction := new(payments.Transaction) notifyReq, err := handler.ParseNotifyRequest(ctx, r.Request, transaction) if err != nil { return gerror.Wrap(err, "解析微信支付通知失败") } tradeState := appStringValue(transaction.TradeState) outTradeNo := appStringValue(transaction.OutTradeNo) transactionId := appStringValue(transaction.TransactionId) eventType := "" if notifyReq != nil { eventType = notifyReq.EventType } if notifyReq == nil || eventType != "TRANSACTION.SUCCESS" || tradeState != "SUCCESS" { g.Log().Infof(ctx, "微信支付余额充值通知忽略非成功状态: order=%s event=%s status=%s", outTradeNo, eventType, tradeState) return nil } if outTradeNo == "" { return gerror.New("微信支付通知订单号为空") } g.Log().Infof(ctx, "微信支付余额充值通知开始处理: order=%s event=%s status=%s tradeNo=%s", outTradeNo, eventType, tradeState, transactionId) paidAt := appParseWechatPayTime(appStringValue(transaction.SuccessTime)) if paidAt == nil { paidAt = gtime.Now() } amountFen := appWechatTransactionAmountFen(transaction) if amountFen <= 0 { return gerror.New("微信支付通知金额不正确") } _, err = s.completeAppBalanceRechargePayment( ctx, outTradeNo, nil, transactionId, "wechat", consts.BalanceTransactionChannelWechat, float64(amountFen)/100, paidAt, ) if err != nil { return err } g.Log().Infof(ctx, "微信支付余额充值通知处理成功: order=%s tradeNo=%s amountFen=%d", outTradeNo, transactionId, amountFen) return nil } func (s *sAppCard) BalanceRechargeWechatRefundNotify(ctx context.Context) error { r := g.RequestFromCtx(ctx) if r == nil || r.Request == nil { return gerror.New("微信退款通知请求不存在") } cfg, err := appLoadWechatConfig(ctx) if err != nil { return err } if !cfg.Enabled { return gerror.New("微信支付未启用") } if err = appValidateWechatCommonConfig(cfg); err != nil { return err } verifier, err := appWechatVerifier(cfg) if err != nil { return err } handler := notify.NewNotifyHandler(cfg.ApiV3Key, verifier) refund := new(refunddomestic.Refund) notifyReq, err := handler.ParseNotifyRequest(ctx, r.Request, refund) if err != nil { return gerror.Wrap(err, "解析微信退款通知失败") } eventType := "" summary := "" if notifyReq != nil { eventType = notifyReq.EventType summary = notifyReq.Summary } refundStatus := "" refundStatus = appWechatRefundStatus(refund) refundAmountFen := appWechatRefundAmountFen(refund) g.Log().Infof( ctx, "微信退款通知已验签: event=%s status=%s order=%s refundNo=%s refundId=%s amountFen=%d summary=%s", eventType, refundStatus, appStringValue(refund.OutTradeNo), appStringValue(refund.OutRefundNo), appStringValue(refund.RefundId), refundAmountFen, summary, ) return s.applyAppBalanceRechargeRefundNotify(ctx, refund) } func appWechatTransactionAmountFen(transaction *payments.Transaction) int64 { if transaction == nil || transaction.Amount == nil { return 0 } if transaction.Amount.PayerTotal != nil && *transaction.Amount.PayerTotal > 0 { return *transaction.Amount.PayerTotal } if transaction.Amount.Total != nil && *transaction.Amount.Total > 0 { return *transaction.Amount.Total } return 0 } func appWechatPrivateKey(cfg *appWechatConfig) (*rsa.PrivateKey, error) { raw, err := appWechatKeyContent(cfg.PrivateKey, cfg.PrivateKeyPath) if err != nil { return nil, gerror.Wrap(err, "读取微信支付商户私钥失败") } der, err := appWechatDecodeKey(raw) if err != nil { return nil, gerror.Wrap(err, "解析微信支付商户私钥失败") } if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { if privateKey, ok := key.(*rsa.PrivateKey); ok { return privateKey, nil } } if privateKey, err := x509.ParsePKCS1PrivateKey(der); err == nil { return privateKey, nil } return nil, gerror.New("微信支付商户私钥不是有效 RSA 私钥") } func appWechatPlatformPublicKey(cfg *appWechatConfig) (*rsa.PublicKey, error) { raw, err := appWechatKeyContent(cfg.PlatformPublicKey, cfg.PlatformPublicKeyPath) if err != nil { return nil, gerror.Wrap(err, "读取微信支付平台公钥失败") } der, err := appWechatDecodeKey(raw) if err != nil { return nil, gerror.Wrap(err, "解析微信支付平台公钥失败") } if key, err := x509.ParsePKIXPublicKey(der); err == nil { if publicKey, ok := key.(*rsa.PublicKey); ok { return publicKey, nil } } if publicKey, err := x509.ParsePKCS1PublicKey(der); err == nil { return publicKey, nil } return nil, gerror.New("微信支付平台公钥不是有效 RSA 公钥") } func appWechatKeyContent(raw string, path string) (string, error) { if strings.TrimSpace(raw) != "" { return strings.TrimSpace(raw), nil } if strings.TrimSpace(path) == "" { return "", gerror.New("密钥路径未配置") } content, err := os.ReadFile(path) if err != nil { return "", err } return string(content), nil } func appWechatDecodeKey(raw string) ([]byte, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, gerror.New("密钥内容为空") } if block, _ := pem.Decode([]byte(raw)); block != nil { return block.Bytes, nil } compact := strings.Join(strings.Fields(raw), "") der, err := base64.StdEncoding.DecodeString(compact) if err != nil { return nil, err } return der, nil } func appNormalizeWechatPayScene(payScene string) string { switch strings.ToLower(strings.TrimSpace(payScene)) { case "mini", "mp", "mp-weixin", "wechat_mini", "miniprogram": return "mini" default: return "h5" } } func appWechatAmountFen(amount float64) int64 { return int64(math.Round(appRoundAmount(amount) * 100)) } func appWechatClientIp(ctx context.Context, cfg *appWechatConfig) string { clientIp := strings.TrimSpace(cfg.H5.PayerClientIp) if clientIp == "" { if r := g.RequestFromCtx(ctx); r != nil { clientIp = strings.TrimSpace(r.GetClientIp()) } } if parsed := net.ParseIP(clientIp); parsed != nil { return parsed.String() } return clientIp } func appWechatOrderDescription(order *entity.TOrder) string { amount := appFormatMoney(order.TotalAmount) return fmt.Sprintf("余额充值 %s", amount) } func appWechatH5AppName(cfg *appWechatConfig) string { if cfg.H5.AppName != "" { return cfg.H5.AppName } return "示例企业" } func appParseWechatPayTime(value string) *gtime.Time { value = strings.TrimSpace(value) if value == "" { return nil } parsed, err := time.Parse(time.RFC3339, value) if err != nil { return nil } return gtime.NewFromTime(parsed) } func appStringPtr(value string) *string { value = strings.TrimSpace(value) if value == "" { return nil } return core.String(value) } func appStringValue(value *string) string { if value == nil { return "" } return strings.TrimSpace(*value) }