Files
iotlcm/server/internal/logic/app/app_payment_wechat.go
T

907 lines
28 KiB
Go

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)
}