chore: 初始开源发布
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 在源仓同一提交上同样失败,属既有缺陷。
This commit is contained in:
@@ -0,0 +1,906 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user