2ed5dfa6a1
iotlcm-app:小程序端 + 用户端服务。 - app/:uni-app 小程序前端(基于 unibest,MIT) - 微信 AppID → YOUR_WX_APPID,uni-app AppID → __UNI__YOURAPPID - 内网后端地址 → admin.iotlcm.example.com - 应用标题与 unibest 模板 README 重写(保留上游致谢与 MIT 声明) - server/:GoFrame 用户端服务(基于 XiuAdmin,Apache-2.0) - module xiuadmin → git.5iot.com/iotlcm-public/iotlcm-app(360 文件) - 支付回调地址 → app.iotlcm.example.com,商户名与应用名泛化 - 真实手机号 → 合法 11 位编造号(保留号段前 3 位 + 后 4 位) - 依赖号码尾部的断言同步更新 - 补 server/LICENSE(Apache-2.0) - 移除内部资产:CI 触发器 - 补根 README,LF 行尾 + .gitattributes 已知:TestSysDept_RefreshDeptAncestors 在源仓同一提交上同样失败,属既有缺陷。
907 lines
28 KiB
Go
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)
|
|
}
|