feat(payment): 后台「支付配置」支持微信支付与支付宝凭据管理
后端: - 新增 payment_configs 单行配置表;APIv3 密钥、应用私钥/公钥以 AES-256-GCM 密文入库(enc:v1 前缀,主密钥取 PAYMENT_SECRET_KEY,缺省回退 JWT_SECRET 派生) - 证书/私钥落 PAYMENT_CERT_DIR(默认 /data/payment-certs),目录 0700 / 文件 0600,不经任何静态路由暴露 - 接口:GET/PUT /api/admin/payment/config、POST /upload(PEM 校验 + 微信证书序列号自动回填)、POST /test(真打官方接口:微信 /v3/certificates 签名校验、支付宝 alipay.trade.query 签名校验)、POST /wechat/platform-certs(APIv3 密钥解密平台证书并落盘) - 未配置凭据时测试返回 skipped 而不是 500;敏感字段在返回体里一律脱敏 前端: - /payments 改为「通用设置 + 微信支付 / 支付宝 / Waffo 海外支付」页签(原 Waffo 页整页迁到 views/payments/Waffo.vue) - 微信:mchid、AppID、APIv3 密钥(密码框 + 掩码回显 + 更换/清除)、证书序列号、支付方式多选、三项证书上传、自动获取平台证书 - 支付宝:app_id、应用私钥(粘贴/上传)、支付宝公钥、网关、RSA2、回调地址 - 菜单归入「审计与设置 → 支付配置」
This commit is contained in:
1 parent
630f2819cb
commit
7d53c6ed3c
21 files changed
+3090
-6
No files matched your search
@@ -107,6 +107,22 @@ SUCCESS_URL=https://l123.91ai.io/api/v1/purchase/success
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Docker Compose
|
# Docker Compose
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 支付配置加密(后台「支付配置」页 · 微信支付 / 支付宝)
|
||||||
|
# ============================================
|
||||||
|
# 敏感字段(微信 APIv3 密钥、支付宝应用私钥/公钥)入库前用 AES-256-GCM 加密,
|
||||||
|
# 主密钥只从环境变量读取,绝不写入数据库或仓库。生成方式二选一:
|
||||||
|
# openssl rand -hex 32 # 64 位 hex
|
||||||
|
# openssl rand -base64 32 # 32 字节 base64
|
||||||
|
# 未设置时系统会退化为用 JWT_SECRET 派生主密钥(可开箱使用,但轮换 JWT_SECRET 会导致
|
||||||
|
# 已保存的支付密钥无法解密,必须重新填写)——生产环境请显式设置本变量。
|
||||||
|
# 注意:主密钥一旦更换,已保存的密文全部失效。
|
||||||
|
# PAYMENT_SECRET_KEY=
|
||||||
|
|
||||||
|
# 支付证书/私钥落地目录(容器内数据卷,位于仓库之外);
|
||||||
|
# 目录权限 0700、文件 0600,不通过任何静态路由对外暴露。默认 /data/payment-certs
|
||||||
|
# PAYMENT_CERT_DIR=/data/payment-certs
|
||||||
# 宿主机映射端口(容器内固定 9001)
|
# 宿主机映射端口(容器内固定 9001)
|
||||||
HOST_PORT=9001
|
HOST_PORT=9001
|
||||||
VERSION=latest
|
VERSION=latest
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func migrateDatabase(db *gorm.DB) (*gorm.DB, error) {
|
|||||||
&model.AuditLog{}, // 操作日志 // Logan.Li
|
&model.AuditLog{}, // 操作日志 // Logan.Li
|
||||||
&model.LicenseTemplate{}, // License 模板 // Logan.Li
|
&model.LicenseTemplate{}, // License 模板 // Logan.Li
|
||||||
&model.PaymentGatewayConfig{}, // 支付网关配置 // Logan.Li
|
&model.PaymentGatewayConfig{}, // 支付网关配置 // Logan.Li
|
||||||
|
&model.PaymentConfig{}, // 支付配置(微信/支付宝,敏感字段 AES-GCM 加密) // Logan.Li
|
||||||
// 核心授权域
|
// 核心授权域
|
||||||
&model.Product{}, // 产品 // Logan.Li
|
&model.Product{}, // 产品 // Logan.Li
|
||||||
&model.Customer{}, // 客户 // Logan.Li
|
&model.Customer{}, // 客户 // Logan.Li
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/config"
|
||||||
|
"backend/internal/dto"
|
||||||
|
paymentservice "backend/internal/service/payment"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PaymentConfigController 支付配置管理控制器(仅管理员,挂在 JWT 保护组下) // Logan.Li
|
||||||
|
type PaymentConfigController struct {
|
||||||
|
svc *paymentservice.ConfigService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPaymentConfigController 创建控制器 // Logan.Li
|
||||||
|
func NewPaymentConfigController(db *gorm.DB, cfg *config.Config) *PaymentConfigController {
|
||||||
|
return &PaymentConfigController{svc: paymentservice.NewConfigService(db, cfg)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig 获取支付配置(敏感字段脱敏) // Logan.Li
|
||||||
|
func (ctrl *PaymentConfigController) GetConfig(c *gin.Context) {
|
||||||
|
data, err := ctrl.svc.Get()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateConfig 保存支付配置 // Logan.Li
|
||||||
|
func (ctrl *PaymentConfigController) UpdateConfig(c *gin.Context) {
|
||||||
|
var req dto.PaymentConfigUpdate
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误:" + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := ctrl.svc.Update(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "支付配置已保存", "data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload 上传证书/私钥(multipart:kind + file) // Logan.Li
|
||||||
|
func (ctrl *PaymentConfigController) Upload(c *gin.Context) {
|
||||||
|
kind := strings.TrimSpace(c.PostForm("kind"))
|
||||||
|
if kind == "" {
|
||||||
|
kind = strings.TrimSpace(c.Query("kind"))
|
||||||
|
}
|
||||||
|
fileHeader, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "未收到文件(字段名应为 file)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "读取上传文件失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
content, err := io.ReadAll(io.LimitReader(f, 256*1024))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "读取上传文件失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := ctrl.svc.Upload(kind, fileHeader.Filename, content)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": data.Message, "data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 用已保存凭据做一次最小连通性校验 // Logan.Li
|
||||||
|
func (ctrl *PaymentConfigController) Test(c *gin.Context) {
|
||||||
|
var req dto.PaymentTestRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
// 允许空 body,默认测试全部渠道 // Logan.Li
|
||||||
|
req = dto.PaymentTestRequest{}
|
||||||
|
}
|
||||||
|
data := ctrl.svc.Test(req.Channel)
|
||||||
|
// 业务失败也返回 200 + code=0,结果在 results[].status 中体现,避免前端弹 500 // Logan.Li
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "测试完成", "data": data})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchWechatPlatformCerts 自动获取微信支付平台证书 // Logan.Li
|
||||||
|
func (ctrl *PaymentConfigController) FetchWechatPlatformCerts(c *gin.Context) {
|
||||||
|
items, msg, err := ctrl.svc.FetchWechatPlatformCerts()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 1, "message": msg, "data": gin.H{"items": []any{}}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": msg, "data": gin.H{"items": items}})
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// ============ 支付配置(微信支付 / 支付宝) ============
|
||||||
|
|
||||||
|
// PaymentFileInfo 已上传证书/密钥文件的可展示元信息(不含任何密钥内容) // Logan.Li
|
||||||
|
type PaymentFileInfo struct {
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
Fingerprint string `json:"fingerprint"` // SHA-256 指纹(前 16 位展示)
|
||||||
|
Subject string `json:"subject"` // 证书 CN / 密钥类型
|
||||||
|
NotBefore string `json:"not_before"` // 证书生效时间(pem 可解析时)
|
||||||
|
NotAfter string `json:"not_after"` // 证书到期时间
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
ParseError string `json:"parse_error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WechatPaymentView 微信支付配置(脱敏视图) // Logan.Li
|
||||||
|
type WechatPaymentView struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
MchID string `json:"mch_id"`
|
||||||
|
AppID string `json:"app_id"`
|
||||||
|
APIv3KeyConfigured bool `json:"api_v3_key_configured"`
|
||||||
|
APIv3KeyMasked string `json:"api_v3_key_masked"` // 仅显示后 4 位
|
||||||
|
APIv3KeyLength int `json:"api_v3_key_length"`
|
||||||
|
SerialNo string `json:"serial_no"`
|
||||||
|
PayMethods []string `json:"pay_methods"` // native / h5 / jsapi
|
||||||
|
NotifyURL string `json:"notify_url"` // 只读
|
||||||
|
CertFile PaymentFileInfo `json:"cert_file"`
|
||||||
|
KeyFile PaymentFileInfo `json:"key_file"`
|
||||||
|
PlatformCertFile PaymentFileInfo `json:"platform_cert_file"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Missing []string `json:"missing"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlipayPaymentView 支付宝配置(脱敏视图) // Logan.Li
|
||||||
|
type AlipayPaymentView struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
AppID string `json:"app_id"`
|
||||||
|
PrivateKeyConfigured bool `json:"private_key_configured"`
|
||||||
|
PrivateKeyMasked string `json:"private_key_masked"` // 指纹脱敏,绝不回显内容
|
||||||
|
PrivateKeySource string `json:"private_key_source"` // paste / upload / ""
|
||||||
|
PublicKeyConfigured bool `json:"public_key_configured"`
|
||||||
|
PublicKeyMasked string `json:"public_key_masked"`
|
||||||
|
Gateway string `json:"gateway"` // 默认 https://openapi.alipay.com/gateway.do
|
||||||
|
SignType string `json:"sign_type"` // 固定 RSA2
|
||||||
|
NotifyURL string `json:"notify_url"`
|
||||||
|
PrivateKeyFile PaymentFileInfo `json:"private_key_file"`
|
||||||
|
PublicKeyFile PaymentFileInfo `json:"public_key_file"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Missing []string `json:"missing"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentConfigView 支付配置整体视图(GET 返回,全部脱敏) // Logan.Li
|
||||||
|
type PaymentConfigView struct {
|
||||||
|
Mode string `json:"mode"` // test / live
|
||||||
|
Wechat WechatPaymentView `json:"wechat"`
|
||||||
|
Alipay AlipayPaymentView `json:"alipay"`
|
||||||
|
MasterKeyOrigin string `json:"master_key_origin"` // env / jwt_secret
|
||||||
|
EncryptionEnabled bool `json:"encryption_enabled"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WechatPaymentUpdate 微信支付保存请求(空字符串 = 保持原值) // Logan.Li
|
||||||
|
type WechatPaymentUpdate struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
MchID *string `json:"mch_id"`
|
||||||
|
AppID *string `json:"app_id"`
|
||||||
|
APIv3Key string `json:"api_v3_key"` // 32 位;留空表示不修改
|
||||||
|
ClearAPIv3Key bool `json:"clear_api_v3_key"`
|
||||||
|
SerialNo *string `json:"serial_no"`
|
||||||
|
PayMethods []string `json:"pay_methods"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlipayPaymentUpdate 支付宝保存请求(空字符串 = 保持原值) // Logan.Li
|
||||||
|
type AlipayPaymentUpdate struct {
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
AppID *string `json:"app_id"`
|
||||||
|
PrivateKey string `json:"private_key"` // 可粘贴 PEM / 裸 base64
|
||||||
|
ClearPrivateKey bool `json:"clear_private_key"`
|
||||||
|
PublicKey string `json:"public_key"`
|
||||||
|
ClearPublicKey bool `json:"clear_public_key"`
|
||||||
|
Gateway *string `json:"gateway"`
|
||||||
|
SignType *string `json:"sign_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentConfigUpdate 支付配置保存请求 // Logan.Li
|
||||||
|
type PaymentConfigUpdate struct {
|
||||||
|
Mode *string `json:"mode"` // test / live
|
||||||
|
Wechat *WechatPaymentUpdate `json:"wechat"`
|
||||||
|
Alipay *AlipayPaymentUpdate `json:"alipay"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentUploadResponse 证书/密钥上传结果 // Logan.Li
|
||||||
|
type PaymentUploadResponse struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
File PaymentFileInfo `json:"file"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentTestRequest 连通性测试请求 // Logan.Li
|
||||||
|
type PaymentTestRequest struct {
|
||||||
|
Channel string `json:"channel"` // wechat / alipay / all(默认)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentTestResult 单渠道连通性测试结果 // Logan.Li
|
||||||
|
type PaymentTestResult struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
Status string `json:"status"` // ok / fail / skipped
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
HTTPStatus int `json:"http_status"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
LatencyMS int64 `json:"latency_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaymentTestResponse 连通性测试整体结果 // Logan.Li
|
||||||
|
type PaymentTestResponse struct {
|
||||||
|
Results []PaymentTestResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WechatPlatformCertItem 微信支付平台证书条目 // Logan.Li
|
||||||
|
type WechatPlatformCertItem struct {
|
||||||
|
SerialNo string `json:"serial_no"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
NotBefore string `json:"not_before"`
|
||||||
|
NotAfter string `json:"not_after"`
|
||||||
|
SavedPath string `json:"-"` // 不回显绝对路径
|
||||||
|
IsSaved bool `json:"is_saved"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// PaymentConfig 支付渠道配置(单行表,固定使用 ID=1)。
|
||||||
|
// 敏感字段(APIv3 密钥、应用私钥)以 AES-GCM 密文入库,绝不存明文。 // Logan.Li
|
||||||
|
type PaymentConfig struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Mode string `json:"mode" gorm:"size:10;default:'test'"` // test / live
|
||||||
|
|
||||||
|
// ============ 微信支付(APIv3) ============
|
||||||
|
WechatEnabled bool `json:"wechat_enabled" gorm:"default:false"`
|
||||||
|
WechatMchID string `json:"wechat_mch_id" gorm:"size:64"`
|
||||||
|
WechatAppID string `json:"wechat_app_id" gorm:"size:64"`
|
||||||
|
WechatAPIv3KeyEnc string `json:"-" gorm:"column:wechat_apiv3_key_enc;type:text"` // AES-GCM 密文
|
||||||
|
WechatSerialNo string `json:"wechat_serial_no" gorm:"size:128"` // 商户 API 证书序列号
|
||||||
|
WechatCertPath string `json:"-" gorm:"column:wechat_cert_path;size:512"` // apiclient_cert.pem 落地路径
|
||||||
|
WechatKeyPath string `json:"-" gorm:"column:wechat_key_path;size:512"` // apiclient_key.pem 落地路径
|
||||||
|
WechatPlatformCertPath string `json:"-" gorm:"column:wechat_platform_cert_path;size:512"`
|
||||||
|
WechatPayMethods string `json:"wechat_pay_methods" gorm:"size:64;default:'native'"` // 逗号分隔 native,h5,jsapi
|
||||||
|
|
||||||
|
// ============ 支付宝(开放平台) ============
|
||||||
|
AlipayEnabled bool `json:"alipay_enabled" gorm:"default:false"`
|
||||||
|
AlipayAppID string `json:"alipay_app_id" gorm:"size:64"`
|
||||||
|
AlipayPrivateKeyEnc string `json:"-" gorm:"column:alipay_private_key_enc;type:text"` // AES-GCM 密文(粘贴来源)
|
||||||
|
AlipayPublicKeyEnc string `json:"-" gorm:"column:alipay_public_key_enc;type:text"` // AES-GCM 密文(粘贴来源)
|
||||||
|
AlipayPrivateKeyPath string `json:"-" gorm:"column:alipay_private_key_path;size:512"`
|
||||||
|
AlipayPublicKeyPath string `json:"-" gorm:"column:alipay_public_key_path;size:512"`
|
||||||
|
AlipayGateway string `json:"alipay_gateway" gorm:"size:255"`
|
||||||
|
AlipaySignType string `json:"alipay_sign_type" gorm:"size:20;default:'RSA2'"`
|
||||||
|
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名 // Logan.Li
|
||||||
|
func (PaymentConfig) TableName() string {
|
||||||
|
return "payment_configs"
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 支付配置敏感字段的对称加密实现(AES-256-GCM)。
|
||||||
|
//
|
||||||
|
// 设计要点:
|
||||||
|
// 1. 主密钥来自环境变量 PAYMENT_SECRET_KEY(32 字节 hex / base64,或任意口令经 SHA-256 派生);
|
||||||
|
// 2. 未设置时退化为用 JWT_SECRET 做域分离派生,保证开箱可用,但轮换 JWT_SECRET 会失去解密能力;
|
||||||
|
// 3. 密文格式 enc:v1:<base64url(nonce||ciphertext)>,前缀便于未来平滑升级算法。 // Logan.Li
|
||||||
|
const (
|
||||||
|
// EnvPaymentMasterKey 加密主密钥环境变量名 // Logan.Li
|
||||||
|
EnvPaymentMasterKey = "PAYMENT_SECRET_KEY"
|
||||||
|
// cipherPrefix 密文前缀 // Logan.Li
|
||||||
|
cipherPrefix = "enc:v1:"
|
||||||
|
// cipherAAD 附加认证数据,避免密文被跨用途搬运 // Logan.Li
|
||||||
|
cipherAAD = "opsx-licence/payment-config/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
masterKeyOnce sync.Once
|
||||||
|
masterKeyBytes []byte
|
||||||
|
masterKeyErr error
|
||||||
|
masterKeyOrigin string
|
||||||
|
)
|
||||||
|
|
||||||
|
// PaymentMasterKeyOrigin 返回主密钥来源(env / jwt_secret),供页面提示 // Logan.Li
|
||||||
|
func PaymentMasterKeyOrigin() string {
|
||||||
|
_, _ = masterKey()
|
||||||
|
return masterKeyOrigin
|
||||||
|
}
|
||||||
|
|
||||||
|
// masterKey 惰性解析主密钥,仅解析一次 // Logan.Li
|
||||||
|
func masterKey() ([]byte, error) {
|
||||||
|
masterKeyOnce.Do(func() {
|
||||||
|
if raw := strings.TrimSpace(os.Getenv(EnvPaymentMasterKey)); raw != "" {
|
||||||
|
key, err := parseKeyMaterial(raw)
|
||||||
|
if err != nil {
|
||||||
|
masterKeyErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
masterKeyBytes = key
|
||||||
|
masterKeyOrigin = "env"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if jwt := strings.TrimSpace(os.Getenv("JWT_SECRET")); jwt != "" {
|
||||||
|
sum := sha256.Sum256([]byte(cipherAAD + "|jwt:" + jwt))
|
||||||
|
masterKeyBytes = sum[:]
|
||||||
|
masterKeyOrigin = "jwt_secret"
|
||||||
|
log.Printf("[支付配置] 未设置 %s,已用 JWT_SECRET 派生加密主密钥;生产环境建议显式设置独立主密钥", EnvPaymentMasterKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
masterKeyOrigin = "none"
|
||||||
|
masterKeyErr = errors.New("未配置加密主密钥:请设置环境变量 " + EnvPaymentMasterKey + "(32 字节 hex/base64,或任意口令)")
|
||||||
|
})
|
||||||
|
return masterKeyBytes, masterKeyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKeyMaterial 把环境变量值解析成 32 字节密钥 // Logan.Li
|
||||||
|
func parseKeyMaterial(raw string) ([]byte, error) {
|
||||||
|
// 1) 64 位 hex
|
||||||
|
if len(raw) == 64 {
|
||||||
|
if b, err := hex.DecodeString(raw); err == nil && len(b) == 32 {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) base64(标准 / URL / 无填充)
|
||||||
|
for _, enc := range []*base64.Encoding{
|
||||||
|
base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding,
|
||||||
|
} {
|
||||||
|
if b, err := enc.DecodeString(raw); err == nil && len(b) == 32 {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3) 任意口令:SHA-256 派生
|
||||||
|
if len(raw) >= 8 {
|
||||||
|
sum := sha256.Sum256([]byte(raw))
|
||||||
|
return sum[:], nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("PAYMENT_SECRET_KEY 过短(至少 8 个字符,推荐 openssl rand -hex 32)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptSecret 加密敏感字段;空串直接返回空串 // Logan.Li
|
||||||
|
func EncryptSecret(plain string) (string, error) {
|
||||||
|
if strings.TrimSpace(plain) == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
key, err := masterKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sealed := gcm.Seal(nonce, nonce, []byte(plain), []byte(cipherAAD))
|
||||||
|
return cipherPrefix + base64.RawURLEncoding.EncodeToString(sealed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptSecret 解密敏感字段;无前缀视为历史明文直接返回 // Logan.Li
|
||||||
|
func DecryptSecret(stored string) (string, error) {
|
||||||
|
raw := strings.TrimSpace(stored)
|
||||||
|
if raw == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(raw, cipherPrefix) {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
key, err := masterKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(raw, cipherPrefix))
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("密文格式错误")
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(payload) < gcm.NonceSize() {
|
||||||
|
return "", errors.New("密文长度异常")
|
||||||
|
}
|
||||||
|
nonce, cipherText := payload[:gcm.NonceSize()], payload[gcm.NonceSize():]
|
||||||
|
plain, err := gcm.Open(nil, nonce, cipherText, []byte(cipherAAD))
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("解密失败:加密主密钥(PAYMENT_SECRET_KEY)与保存时不一致")
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskTail 脱敏展示:仅保留末尾 keep 位,其余用掩码填充到 total 位 // Logan.Li
|
||||||
|
func MaskTail(secret string, keep, total int) string {
|
||||||
|
s := strings.TrimSpace(secret)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if total <= 0 {
|
||||||
|
total = len(s)
|
||||||
|
}
|
||||||
|
if total < keep {
|
||||||
|
total = keep
|
||||||
|
}
|
||||||
|
if len(s) <= keep {
|
||||||
|
return strings.Repeat("•", total-len(s)) + s
|
||||||
|
}
|
||||||
|
return strings.Repeat("•", total-keep) + s[len(s)-keep:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// FingerprintSHA256 计算内容指纹,用于「已配置但不回显」字段 // Logan.Li
|
||||||
|
func FingerprintSHA256(data []byte) string {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortFingerprint 指纹取前 16 位做展示 // Logan.Li
|
||||||
|
func ShortFingerprint(fp string) string {
|
||||||
|
if len(fp) <= 16 {
|
||||||
|
return fp
|
||||||
|
}
|
||||||
|
return fp[:16]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizePEM 把可能是裸 base64 的密钥补全成 PEM 块 // Logan.Li
|
||||||
|
func NormalizePEM(raw, begin, end string) string {
|
||||||
|
s := strings.TrimSpace(raw)
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// 已含 PEM 头则原样(统一换行)
|
||||||
|
if strings.Contains(s, "-----BEGIN") {
|
||||||
|
return strings.ReplaceAll(s, "\r\n", "\n")
|
||||||
|
}
|
||||||
|
body := strings.Join(strings.Fields(s), "")
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(begin)
|
||||||
|
b.WriteString("\n")
|
||||||
|
for i := 0; i < len(body); i += 64 {
|
||||||
|
j := i + 64
|
||||||
|
if j > len(body) {
|
||||||
|
j = len(body)
|
||||||
|
}
|
||||||
|
b.WriteString(body[i:j])
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString(end)
|
||||||
|
b.WriteString("\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMain 固定测试用加密主密钥(masterKey 只会解析一次) // Logan.Li
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
os.Setenv(EnvPaymentMasterKey, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||||
|
os.Setenv("PAYMENT_CERT_DIR", os.TempDir()+"/opsx-licence-test-certs")
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEncryptDecryptRoundTrip 验证 AES-GCM 加解密与密文格式 // Logan.Li
|
||||||
|
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||||
|
plain := "wx-api-v3-key-32-bytes-example!!"
|
||||||
|
enc, err := EncryptSecret(plain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("加密失败: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(enc, cipherPrefix) {
|
||||||
|
t.Fatalf("密文缺少前缀: %s", enc)
|
||||||
|
}
|
||||||
|
if strings.Contains(enc, plain) {
|
||||||
|
t.Fatalf("密文中出现了明文")
|
||||||
|
}
|
||||||
|
got, err := DecryptSecret(enc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解密失败: %v", err)
|
||||||
|
}
|
||||||
|
if got != plain {
|
||||||
|
t.Fatalf("解密结果不一致: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同一明文两次加密应产生不同密文(随机 nonce) // Logan.Li
|
||||||
|
enc2, _ := EncryptSecret(plain)
|
||||||
|
if enc == enc2 {
|
||||||
|
t.Fatalf("两次加密结果相同,nonce 未随机化")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEncryptSecretEmpty 空值不应产生密文 // Logan.Li
|
||||||
|
func TestEncryptSecretEmpty(t *testing.T) {
|
||||||
|
enc, err := EncryptSecret(" ")
|
||||||
|
if err != nil || enc != "" {
|
||||||
|
t.Fatalf("空值加密应返回空串,得到 %q err=%v", enc, err)
|
||||||
|
}
|
||||||
|
if v, err := DecryptSecret(""); err != nil || v != "" {
|
||||||
|
t.Fatalf("空密文应返回空串,得到 %q err=%v", v, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecryptTampered 篡改密文必须解密失败 // Logan.Li
|
||||||
|
func TestDecryptTampered(t *testing.T) {
|
||||||
|
enc, _ := EncryptSecret("secret-value")
|
||||||
|
tampered := enc[:len(enc)-4] + "AAAA"
|
||||||
|
if _, err := DecryptSecret(tampered); err == nil {
|
||||||
|
t.Fatalf("篡改后的密文不应解密成功")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMaskTail 脱敏仅保留末位 // Logan.Li
|
||||||
|
func TestMaskTail(t *testing.T) {
|
||||||
|
masked := MaskTail("0123456789abcdef0123456789abcdef", 4, 32)
|
||||||
|
if !strings.HasSuffix(masked, "cdef") {
|
||||||
|
t.Fatalf("脱敏结果应保留后 4 位: %s", masked)
|
||||||
|
}
|
||||||
|
if len([]rune(masked)) != 32 {
|
||||||
|
t.Fatalf("脱敏长度应为 32: %d", len([]rune(masked)))
|
||||||
|
}
|
||||||
|
if MaskTail("", 4, 32) != "" {
|
||||||
|
t.Fatalf("空值脱敏应为空串")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPayMethods 支付方式校验/去重与展示解析 // Logan.Li
|
||||||
|
func TestPayMethods(t *testing.T) {
|
||||||
|
got, err := normalizePayMethods([]string{"native", " h5 ", "native", "JSAPI"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("合法支付方式不应报错: %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"native", "h5", "jsapi"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("解析结果数量不符: %v", got)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("解析结果不符: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := normalizePayMethods([]string{"alipay"}); err == nil {
|
||||||
|
t.Fatalf("非法支付方式应报错")
|
||||||
|
}
|
||||||
|
if _, err := normalizePayMethods(nil); err == nil {
|
||||||
|
t.Fatalf("空支付方式应报错")
|
||||||
|
}
|
||||||
|
if def := splitPayMethods(""); len(def) != 1 || def[0] != "native" {
|
||||||
|
t.Fatalf("空串展示应回退 native: %v", def)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizePEM 裸 base64 自动补 PEM 头 // Logan.Li
|
||||||
|
func TestNormalizePEM(t *testing.T) {
|
||||||
|
out := NormalizePEM("QUJDREVGRw==", "-----BEGIN PUBLIC KEY-----", "-----END PUBLIC KEY-----")
|
||||||
|
if !strings.HasPrefix(out, "-----BEGIN PUBLIC KEY-----") || !strings.Contains(out, "QUJDREVGRw==") {
|
||||||
|
t.Fatalf("PEM 补全失败: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 证书/密钥落地目录相关逻辑。
|
||||||
|
// 目录必须位于仓库之外(容器内为数据卷 /data/payment-certs),权限 0700,文件 0600,
|
||||||
|
// 不通过任何静态路由对外暴露。 // Logan.Li
|
||||||
|
|
||||||
|
// EnvPaymentCertDir 证书目录覆盖变量 // Logan.Li
|
||||||
|
const EnvPaymentCertDir = "PAYMENT_CERT_DIR"
|
||||||
|
|
||||||
|
// 允许上传的文件类型 // Logan.Li
|
||||||
|
const (
|
||||||
|
UploadKindWechatCert = "wechat_cert" // apiclient_cert.pem
|
||||||
|
UploadKindWechatKey = "wechat_key" // apiclient_key.pem
|
||||||
|
UploadKindWechatPlatformCert = "wechat_platform_cert" // 微信支付平台证书
|
||||||
|
UploadKindAlipayPrivateKey = "alipay_private_key" // 支付宝应用私钥
|
||||||
|
UploadKindAlipayPublicKey = "alipay_public_key" // 支付宝公钥
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixedFileNames 每种类型固定落地文件名,避免用户文件名注入 // Logan.Li
|
||||||
|
var fixedFileNames = map[string]string{
|
||||||
|
UploadKindWechatCert: "wechat_apiclient_cert.pem",
|
||||||
|
UploadKindWechatKey: "wechat_apiclient_key.pem",
|
||||||
|
UploadKindWechatPlatformCert: "wechat_platform_cert.pem",
|
||||||
|
UploadKindAlipayPrivateKey: "alipay_app_private_key.pem",
|
||||||
|
UploadKindAlipayPublicKey: "alipay_public_key.pem",
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidUploadKind 判断上传类型是否受支持 // Logan.Li
|
||||||
|
func IsValidUploadKind(kind string) bool {
|
||||||
|
_, ok := fixedFileNames[kind]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCertKind 该类文件应为 X.509 证书 // Logan.Li
|
||||||
|
func IsCertKind(kind string) bool {
|
||||||
|
return kind == UploadKindWechatCert || kind == UploadKindWechatPlatformCert
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertDir 返回证书目录(默认容器数据卷内,仓库之外) // Logan.Li
|
||||||
|
func CertDir() string {
|
||||||
|
if d := strings.TrimSpace(os.Getenv(EnvPaymentCertDir)); d != "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return "/data/payment-certs"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureCertDir 确保目录存在且权限为 0700,必要时回退到临时目录 // Logan.Li
|
||||||
|
func ensureCertDir() (string, error) {
|
||||||
|
dir := CertDir()
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err == nil {
|
||||||
|
_ = os.Chmod(dir, 0o700)
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
fallback := filepath.Join(os.TempDir(), "opsx-licence-payment-certs")
|
||||||
|
if err := os.MkdirAll(fallback, 0o700); err != nil {
|
||||||
|
return "", errors.New("无法创建证书目录,请检查 PAYMENT_CERT_DIR 配置")
|
||||||
|
}
|
||||||
|
_ = os.Chmod(fallback, 0o700)
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteSecretFile 以 0600 权限原子写入敏感文件 // Logan.Li
|
||||||
|
func WriteSecretFile(kind string, content []byte) (string, error) {
|
||||||
|
name, ok := fixedFileNames[kind]
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("不支持的上传类型")
|
||||||
|
}
|
||||||
|
dir, err := ensureCertDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
target := filepath.Join(dir, name)
|
||||||
|
tmp, err := os.CreateTemp(dir, ".upload-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("写入证书文件失败")
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName)
|
||||||
|
if _, err := tmp.Write(content); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return "", errors.New("写入证书文件失败")
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return "", errors.New("写入证书文件失败")
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmpName, 0o600); err != nil {
|
||||||
|
return "", errors.New("设置证书文件权限失败")
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpName, target); err != nil {
|
||||||
|
return "", errors.New("保存证书文件失败")
|
||||||
|
}
|
||||||
|
_ = os.Chmod(target, 0o600)
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadSecretFile 读取已配置的证书/密钥文件 // Logan.Li
|
||||||
|
func ReadSecretFile(path string) ([]byte, error) {
|
||||||
|
p := strings.TrimSpace(path)
|
||||||
|
if p == "" {
|
||||||
|
return nil, errors.New("未配置文件")
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("读取文件失败(%s)", filepath.Base(p))
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildFileInfo 生成可展示的文件元信息(不含内容) // Logan.Li
|
||||||
|
func BuildFileInfo(path string, wantCert bool) dto.PaymentFileInfo {
|
||||||
|
info := dto.PaymentFileInfo{}
|
||||||
|
p := strings.TrimSpace(path)
|
||||||
|
if p == "" {
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
info.Configured = true
|
||||||
|
info.FileName = filepath.Base(p)
|
||||||
|
b, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
info.ParseError = "文件不可读"
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
info.SizeBytes = int64(len(b))
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
info.Fingerprint = hex.EncodeToString(sum[:])[:16]
|
||||||
|
if wantCert {
|
||||||
|
cert, err := ParseCertificatePEM(b)
|
||||||
|
if err != nil {
|
||||||
|
info.ParseError = "证书解析失败:" + err.Error()
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
info.Subject = cert.Subject.CommonName
|
||||||
|
info.NotBefore = cert.NotBefore.Format("2006-01-02 15:04:05")
|
||||||
|
info.NotAfter = cert.NotAfter.Format("2006-01-02 15:04:05")
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
info.Subject = "私钥"
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseCertificatePEM 解析 X.509 证书 PEM // Logan.Li
|
||||||
|
func ParseCertificatePEM(content []byte) (*x509.Certificate, error) {
|
||||||
|
block, _ := pem.Decode(content)
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("不是合法的 PEM 证书")
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("证书内容不合法")
|
||||||
|
}
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePrivateKeyPEM 解析 RSA/EC 私钥(兼容 PKCS#1 / PKCS#8 / SEC1 / 裸 base64) // Logan.Li
|
||||||
|
func ParsePrivateKeyPEM(content []byte) (any, error) {
|
||||||
|
text := string(content)
|
||||||
|
if block, _ := pem.Decode(content); block != nil {
|
||||||
|
text = string(block.Bytes)
|
||||||
|
} else if !strings.Contains(text, "-----BEGIN") {
|
||||||
|
// 支付宝后台复制的是裸 base64,补 PEM 头再解析 // Logan.Li
|
||||||
|
normalized := NormalizePEM(text, "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----")
|
||||||
|
if block2, _ := pem.Decode([]byte(normalized)); block2 != nil {
|
||||||
|
text = string(block2.Bytes)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("私钥 PEM 解析失败")
|
||||||
|
}
|
||||||
|
raw := []byte(text)
|
||||||
|
|
||||||
|
if key, err := x509.ParsePKCS1PrivateKey(raw); err == nil {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
if keyAny, err := x509.ParsePKCS8PrivateKey(raw); err == nil {
|
||||||
|
switch k := keyAny.(type) {
|
||||||
|
case *rsa.PrivateKey:
|
||||||
|
return k, nil
|
||||||
|
case *ecdsa.PrivateKey:
|
||||||
|
return k, nil
|
||||||
|
default:
|
||||||
|
return nil, errors.New("不支持的私钥类型(需要 RSA)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key, err := x509.ParseECPrivateKey(raw); err == nil {
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("私钥格式无法识别(需 PEM 或裸 base64 的 RSA 私钥)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRSAPrivateKey 强制解析为 RSA 私钥(微信/支付宝签名均需要 RSA) // Logan.Li
|
||||||
|
func ParseRSAPrivateKey(content []byte) (*rsa.PrivateKey, error) {
|
||||||
|
key, err := ParsePrivateKeyPEM(content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("当前仅支持 RSA 私钥")
|
||||||
|
}
|
||||||
|
if err := rsaKey.Validate(); err != nil {
|
||||||
|
return nil, errors.New("RSA 私钥校验失败")
|
||||||
|
}
|
||||||
|
return rsaKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRSAPublicKey 解析支付宝公钥(支持证书 / PKIX / PKCS#1 / 裸 base64) // Logan.Li
|
||||||
|
func ParseRSAPublicKey(content []byte) (*rsa.PublicKey, error) {
|
||||||
|
trimmed := strings.TrimSpace(string(content))
|
||||||
|
if strings.Contains(trimmed, "BEGIN CERTIFICATE") {
|
||||||
|
if cert, err := ParseCertificatePEM(content); err == nil {
|
||||||
|
if pub, ok := cert.PublicKey.(*rsa.PublicKey); ok {
|
||||||
|
return pub, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("证书内公钥不是 RSA")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var der []byte
|
||||||
|
if block, _ := pem.Decode(content); block != nil {
|
||||||
|
der = block.Bytes
|
||||||
|
} else {
|
||||||
|
normalized := NormalizePEM(trimmed, "-----BEGIN PUBLIC KEY-----", "-----END PUBLIC KEY-----")
|
||||||
|
block, _ := pem.Decode([]byte(normalized))
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("公钥格式无法识别")
|
||||||
|
}
|
||||||
|
der = block.Bytes
|
||||||
|
}
|
||||||
|
if pubAny, err := x509.ParsePKIXPublicKey(der); err == nil {
|
||||||
|
if pub, ok := pubAny.(*rsa.PublicKey); ok {
|
||||||
|
return pub, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("公钥不是 RSA")
|
||||||
|
}
|
||||||
|
if pub, err := x509.ParsePKCS1PublicKey(der); err == nil {
|
||||||
|
return pub, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("公钥解析失败(请填写支付宝公钥,非应用公钥)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileModTime 取文件修改时间(页面展示用) // Logan.Li
|
||||||
|
func FileModTime(path string) string {
|
||||||
|
if st, err := os.Stat(path); err == nil {
|
||||||
|
return st.ModTime().Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertSerialHex 取证书序列号(大写十六进制,用于与填写值比对) // Logan.Li
|
||||||
|
func CertSerialHex(cert *x509.Certificate) string {
|
||||||
|
if cert == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToUpper(cert.SerialNumber.Text(16))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sha1Fingerprint 证书 SHA-1 指纹(微信后台展示口径) // Logan.Li
|
||||||
|
func Sha1Fingerprint(cert *x509.Certificate) string {
|
||||||
|
if cert == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha1.Sum(cert.Raw)
|
||||||
|
hexStr := strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||||
|
parts := make([]string, 0, len(hexStr)/2)
|
||||||
|
for i := 0; i < len(hexStr); i += 2 {
|
||||||
|
parts = append(parts, hexStr[i:i+2])
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ":")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NowUnix 当前时间戳(测试用) // Logan.Li
|
||||||
|
func NowUnix() int64 { return time.Now().Unix() }
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/config"
|
||||||
|
"backend/internal/dto"
|
||||||
|
"backend/internal/model"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 支付通道配置(微信支付 APIv3 / 支付宝开放平台)的读写层。
|
||||||
|
//
|
||||||
|
// 安全约定:
|
||||||
|
// 1. 单行表 payment_configs(固定 ID=1),敏感字段只存 AES-GCM 密文(config_crypto.go)
|
||||||
|
// 或 0600 权限的仓库外文件(config_files.go);
|
||||||
|
// 2. 对外只返回 dto 脱敏视图,永不回显明文密钥/私钥;
|
||||||
|
// 3. 证书/私钥文件路径不参与 JSON 输出,只以文件名+指纹的形式展示。 // Logan.Li
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultAlipayGateway 支付宝正式网关 // Logan.Li
|
||||||
|
DefaultAlipayGateway = "https://openapi.alipay.com/gateway.do"
|
||||||
|
// WechatNotifyPath 微信支付回调路径 // Logan.Li
|
||||||
|
WechatNotifyPath = "/api/pay/wechat/notify"
|
||||||
|
// AlipayNotifyPath 支付宝回调路径 // Logan.Li
|
||||||
|
AlipayNotifyPath = "/api/pay/alipay/notify"
|
||||||
|
// EnvPublicBaseURL 对外访问基址(生成回调地址用,缺省用 licence.rlwj.cn) // Logan.Li
|
||||||
|
EnvPublicBaseURL = "PAYMENT_PUBLIC_BASE_URL"
|
||||||
|
// defaultPublicBaseURL 未配置时的默认基址 // Logan.Li
|
||||||
|
defaultPublicBaseURL = "https://licence.rlwj.cn"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlipayNotifyURL 支付宝回调地址(供签名请求携带) // Logan.Li
|
||||||
|
var AlipayNotifyURL = joinURL(publicBaseURL(), AlipayNotifyPath)
|
||||||
|
|
||||||
|
// ConfigService 支付通道配置服务 // Logan.Li
|
||||||
|
type ConfigService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConfigService 创建服务实例(cfg 允许缺省,便于测试) // Logan.Li
|
||||||
|
func NewConfigService(db *gorm.DB, cfg ...*config.Config) *ConfigService {
|
||||||
|
s := &ConfigService{db: db}
|
||||||
|
if len(cfg) > 0 {
|
||||||
|
s.cfg = cfg[0]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadRow 读取单行配置,缺失时按默认值建行 // Logan.Li
|
||||||
|
func (s *ConfigService) loadRow() (*model.PaymentConfig, error) {
|
||||||
|
var row model.PaymentConfig
|
||||||
|
err := s.db.First(&row).Error
|
||||||
|
if err == nil {
|
||||||
|
if strings.TrimSpace(row.Mode) == "" {
|
||||||
|
row.Mode = "test"
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New("读取支付配置失败")
|
||||||
|
}
|
||||||
|
row = model.PaymentConfig{
|
||||||
|
ID: 1,
|
||||||
|
Mode: "test",
|
||||||
|
WechatPayMethods: "native",
|
||||||
|
AlipayGateway: DefaultAlipayGateway,
|
||||||
|
AlipaySignType: "RSA2",
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&row).Error; err != nil {
|
||||||
|
return nil, errors.New("初始化支付配置失败")
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 返回脱敏后的完整配置视图 // Logan.Li
|
||||||
|
func (s *ConfigService) Get() (*dto.PaymentConfigView, error) {
|
||||||
|
row, err := s.loadRow()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
base := publicBaseURL()
|
||||||
|
view := &dto.PaymentConfigView{
|
||||||
|
Mode: normalizeMode(row.Mode),
|
||||||
|
MasterKeyOrigin: PaymentMasterKeyOrigin(),
|
||||||
|
EncryptionEnabled: PaymentMasterKeyOrigin() != "none",
|
||||||
|
UpdatedAt: row.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
view.Wechat = s.buildWechatView(row, base)
|
||||||
|
view.Alipay = s.buildAlipayView(row, base)
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildWechatView 组装微信支付脱敏视图(含就绪度与缺失项) // Logan.Li
|
||||||
|
func (s *ConfigService) buildWechatView(row *model.PaymentConfig, base string) dto.WechatPaymentView {
|
||||||
|
v := dto.WechatPaymentView{
|
||||||
|
Enabled: row.WechatEnabled,
|
||||||
|
MchID: row.WechatMchID,
|
||||||
|
AppID: row.WechatAppID,
|
||||||
|
SerialNo: strings.ToUpper(strings.TrimSpace(row.WechatSerialNo)),
|
||||||
|
PayMethods: splitPayMethods(row.WechatPayMethods),
|
||||||
|
NotifyURL: joinURL(base, WechatNotifyPath),
|
||||||
|
CertFile: BuildFileInfo(row.WechatCertPath, true),
|
||||||
|
KeyFile: BuildFileInfo(row.WechatKeyPath, false),
|
||||||
|
PlatformCertFile: BuildFileInfo(row.WechatPlatformCertPath, true),
|
||||||
|
}
|
||||||
|
if plain, err := DecryptSecret(row.WechatAPIv3KeyEnc); err == nil && plain != "" {
|
||||||
|
v.APIv3KeyConfigured = true
|
||||||
|
v.APIv3KeyLength = len(plain)
|
||||||
|
v.APIv3KeyMasked = MaskTail(plain, 4, 32)
|
||||||
|
}
|
||||||
|
if v.MchID == "" {
|
||||||
|
v.Missing = append(v.Missing, "商户号 mchid")
|
||||||
|
}
|
||||||
|
if v.AppID == "" {
|
||||||
|
v.Missing = append(v.Missing, "AppID(需已在商户平台绑定)")
|
||||||
|
}
|
||||||
|
if !v.APIv3KeyConfigured {
|
||||||
|
v.Missing = append(v.Missing, "APIv3 密钥")
|
||||||
|
} else if v.APIv3KeyLength != 32 {
|
||||||
|
v.Missing = append(v.Missing, "APIv3 密钥应为 32 位(当前 "+strconv.Itoa(v.APIv3KeyLength)+" 位)")
|
||||||
|
}
|
||||||
|
if v.SerialNo == "" {
|
||||||
|
v.Missing = append(v.Missing, "商户 API 证书序列号")
|
||||||
|
}
|
||||||
|
if !v.CertFile.Configured {
|
||||||
|
v.Missing = append(v.Missing, "apiclient_cert.pem 未上传")
|
||||||
|
}
|
||||||
|
if !v.KeyFile.Configured {
|
||||||
|
v.Missing = append(v.Missing, "apiclient_key.pem 未上传")
|
||||||
|
}
|
||||||
|
if v.CertFile.Configured && v.SerialNo != "" {
|
||||||
|
if b, err := ReadSecretFile(row.WechatCertPath); err == nil {
|
||||||
|
if cert, err := ParseCertificatePEM(b); err == nil {
|
||||||
|
if actual := CertSerialHex(cert); actual != "" && !strings.EqualFold(actual, v.SerialNo) {
|
||||||
|
v.Missing = append(v.Missing, "证书序列号与已上传证书不一致(证书实际为 "+actual+")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.Ready = len(v.Missing) == 0
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAlipayView 组装支付宝脱敏视图 // Logan.Li
|
||||||
|
func (s *ConfigService) buildAlipayView(row *model.PaymentConfig, base string) dto.AlipayPaymentView {
|
||||||
|
v := dto.AlipayPaymentView{
|
||||||
|
Enabled: row.AlipayEnabled,
|
||||||
|
AppID: row.AlipayAppID,
|
||||||
|
Gateway: firstNonEmptyStr(row.AlipayGateway, DefaultAlipayGateway),
|
||||||
|
SignType: firstNonEmptyStr(row.AlipaySignType, "RSA2"),
|
||||||
|
NotifyURL: joinURL(base, AlipayNotifyPath),
|
||||||
|
PrivateKeyFile: BuildFileInfo(row.AlipayPrivateKeyPath, false),
|
||||||
|
PublicKeyFile: BuildFileInfo(row.AlipayPublicKeyPath, false),
|
||||||
|
}
|
||||||
|
if plain, err := DecryptSecret(row.AlipayPrivateKeyEnc); err == nil && plain != "" {
|
||||||
|
v.PrivateKeyConfigured = true
|
||||||
|
v.PrivateKeySource = "paste"
|
||||||
|
v.PrivateKeyMasked = "指纹 " + ShortFingerprint(FingerprintSHA256([]byte(plain)))
|
||||||
|
}
|
||||||
|
if v.PrivateKeyFile.Configured {
|
||||||
|
v.PrivateKeyConfigured = true
|
||||||
|
v.PrivateKeySource = "upload"
|
||||||
|
v.PrivateKeyMasked = "指纹 " + v.PrivateKeyFile.Fingerprint
|
||||||
|
}
|
||||||
|
if plain, err := DecryptSecret(row.AlipayPublicKeyEnc); err == nil && plain != "" {
|
||||||
|
v.PublicKeyConfigured = true
|
||||||
|
v.PublicKeyMasked = "指纹 " + ShortFingerprint(FingerprintSHA256([]byte(plain)))
|
||||||
|
} else if v.PublicKeyFile.Configured {
|
||||||
|
v.PublicKeyConfigured = true
|
||||||
|
v.PublicKeyMasked = "指纹 " + v.PublicKeyFile.Fingerprint
|
||||||
|
}
|
||||||
|
if v.AppID == "" {
|
||||||
|
v.Missing = append(v.Missing, "app_id")
|
||||||
|
}
|
||||||
|
if !v.PrivateKeyConfigured {
|
||||||
|
v.Missing = append(v.Missing, "应用私钥")
|
||||||
|
}
|
||||||
|
if !v.PublicKeyConfigured {
|
||||||
|
v.Missing = append(v.Missing, "支付宝公钥(非应用公钥)")
|
||||||
|
}
|
||||||
|
v.Ready = len(v.Missing) == 0
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 保存配置(指针为 nil 的字段表示不改动) // Logan.Li
|
||||||
|
func (s *ConfigService) Update(req dto.PaymentConfigUpdate) (*dto.PaymentConfigView, error) {
|
||||||
|
row, err := s.loadRow()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Mode != nil {
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(*req.Mode))
|
||||||
|
if mode != "test" && mode != "live" {
|
||||||
|
return nil, errors.New("运行模式只能是 test 或 live")
|
||||||
|
}
|
||||||
|
row.Mode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
if in := req.Wechat; in != nil {
|
||||||
|
if in.Enabled != nil {
|
||||||
|
row.WechatEnabled = *in.Enabled
|
||||||
|
}
|
||||||
|
if in.MchID != nil {
|
||||||
|
row.WechatMchID = strings.TrimSpace(*in.MchID)
|
||||||
|
}
|
||||||
|
if in.AppID != nil {
|
||||||
|
row.WechatAppID = strings.TrimSpace(*in.AppID)
|
||||||
|
}
|
||||||
|
if in.SerialNo != nil {
|
||||||
|
row.WechatSerialNo = strings.ToUpper(strings.TrimSpace(*in.SerialNo))
|
||||||
|
}
|
||||||
|
if len(in.PayMethods) > 0 {
|
||||||
|
methods, merr := normalizePayMethods(in.PayMethods)
|
||||||
|
if merr != nil {
|
||||||
|
return nil, merr
|
||||||
|
}
|
||||||
|
row.WechatPayMethods = strings.Join(methods, ",")
|
||||||
|
}
|
||||||
|
if in.ClearAPIv3Key {
|
||||||
|
row.WechatAPIv3KeyEnc = ""
|
||||||
|
} else if key := strings.TrimSpace(in.APIv3Key); key != "" {
|
||||||
|
if len(key) != 32 {
|
||||||
|
return nil, fmt.Errorf("APIv3 密钥必须为 32 位字符(当前 %d 位)", len(key))
|
||||||
|
}
|
||||||
|
enc, eerr := EncryptSecret(key)
|
||||||
|
if eerr != nil {
|
||||||
|
return nil, eerr
|
||||||
|
}
|
||||||
|
row.WechatAPIv3KeyEnc = enc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if in := req.Alipay; in != nil {
|
||||||
|
if in.Enabled != nil {
|
||||||
|
row.AlipayEnabled = *in.Enabled
|
||||||
|
}
|
||||||
|
if in.AppID != nil {
|
||||||
|
row.AlipayAppID = strings.TrimSpace(*in.AppID)
|
||||||
|
}
|
||||||
|
if in.Gateway != nil {
|
||||||
|
gw := strings.TrimSpace(*in.Gateway)
|
||||||
|
if gw == "" {
|
||||||
|
gw = DefaultAlipayGateway
|
||||||
|
}
|
||||||
|
if u, uerr := url.Parse(gw); uerr != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
|
||||||
|
return nil, errors.New("支付宝网关地址不合法(需完整的 http/https 地址)")
|
||||||
|
}
|
||||||
|
row.AlipayGateway = gw
|
||||||
|
}
|
||||||
|
if in.SignType != nil {
|
||||||
|
st := strings.ToUpper(strings.TrimSpace(*in.SignType))
|
||||||
|
if st == "" {
|
||||||
|
st = "RSA2"
|
||||||
|
}
|
||||||
|
if st != "RSA2" && st != "RSA" {
|
||||||
|
return nil, errors.New("签名类型仅支持 RSA2(推荐)或 RSA")
|
||||||
|
}
|
||||||
|
row.AlipaySignType = st
|
||||||
|
}
|
||||||
|
if in.ClearPrivateKey {
|
||||||
|
row.AlipayPrivateKeyEnc = ""
|
||||||
|
row.AlipayPrivateKeyPath = ""
|
||||||
|
} else if pk := strings.TrimSpace(in.PrivateKey); pk != "" {
|
||||||
|
normalized := NormalizePEM(pk, "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----")
|
||||||
|
if _, perr := ParseRSAPrivateKey([]byte(normalized)); perr != nil {
|
||||||
|
return nil, errors.New("应用私钥无法解析:" + perr.Error())
|
||||||
|
}
|
||||||
|
enc, eerr := EncryptSecret(normalized)
|
||||||
|
if eerr != nil {
|
||||||
|
return nil, eerr
|
||||||
|
}
|
||||||
|
row.AlipayPrivateKeyEnc = enc
|
||||||
|
row.AlipayPrivateKeyPath = "" // 粘贴优先,清掉旧文件避免来源歧义 // Logan.Li
|
||||||
|
}
|
||||||
|
if in.ClearPublicKey {
|
||||||
|
row.AlipayPublicKeyEnc = ""
|
||||||
|
row.AlipayPublicKeyPath = ""
|
||||||
|
} else if pub := strings.TrimSpace(in.PublicKey); pub != "" {
|
||||||
|
normalized := NormalizePEM(pub, "-----BEGIN PUBLIC KEY-----", "-----END PUBLIC KEY-----")
|
||||||
|
if _, perr := ParseRSAPublicKey([]byte(normalized)); perr != nil {
|
||||||
|
return nil, errors.New("支付宝公钥无法解析(注意是支付宝公钥,不是应用公钥):" + perr.Error())
|
||||||
|
}
|
||||||
|
enc, eerr := EncryptSecret(normalized)
|
||||||
|
if eerr != nil {
|
||||||
|
return nil, eerr
|
||||||
|
}
|
||||||
|
row.AlipayPublicKeyEnc = enc
|
||||||
|
row.AlipayPublicKeyPath = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Save(row).Error; err != nil {
|
||||||
|
return nil, errors.New("保存支付配置失败")
|
||||||
|
}
|
||||||
|
return s.Get()
|
||||||
|
}
|
||||||
|
|
||||||
|
// alipayPrivateKey 取支付宝应用私钥(粘贴值优先,其次已上传文件) // Logan.Li
|
||||||
|
func (s *ConfigService) alipayPrivateKey(row *model.PaymentConfig) (string, error) {
|
||||||
|
if plain, err := DecryptSecret(row.AlipayPrivateKeyEnc); err == nil && strings.TrimSpace(plain) != "" {
|
||||||
|
return plain, nil
|
||||||
|
}
|
||||||
|
if b, err := ReadSecretFile(row.AlipayPrivateKeyPath); err == nil && len(b) > 0 {
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// alipayPublicKey 取支付宝公钥(粘贴值优先,其次已上传文件) // Logan.Li
|
||||||
|
func (s *ConfigService) alipayPublicKey(row *model.PaymentConfig) (string, error) {
|
||||||
|
if plain, err := DecryptSecret(row.AlipayPublicKeyEnc); err == nil && strings.TrimSpace(plain) != "" {
|
||||||
|
return plain, nil
|
||||||
|
}
|
||||||
|
if b, err := ReadSecretFile(row.AlipayPublicKeyPath); err == nil && len(b) > 0 {
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 通用工具 ============
|
||||||
|
|
||||||
|
// publicBaseURL 对外访问基址(用于生成回调地址) // Logan.Li
|
||||||
|
func publicBaseURL() string {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(EnvPublicBaseURL)); v != "" {
|
||||||
|
return strings.TrimRight(v, "/")
|
||||||
|
}
|
||||||
|
return defaultPublicBaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinURL 拼接基址与路径 // Logan.Li
|
||||||
|
func joinURL(base, path string) string {
|
||||||
|
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
||||||
|
if base == "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return base + path
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeMode 归一化运行模式 // Logan.Li
|
||||||
|
func normalizeMode(mode string) string {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(mode), "live") {
|
||||||
|
return "live"
|
||||||
|
}
|
||||||
|
return "test"
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizePayMethods 校验并去重支付方式 // Logan.Li
|
||||||
|
func normalizePayMethods(in []string) ([]string, error) {
|
||||||
|
allowed := map[string]bool{"native": true, "h5": true, "jsapi": true}
|
||||||
|
out := make([]string, 0, len(in))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range in {
|
||||||
|
k := strings.ToLower(strings.TrimSpace(m))
|
||||||
|
if k == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !allowed[k] {
|
||||||
|
return nil, errors.New("支付方式只能是 native(扫码)/ h5 / jsapi")
|
||||||
|
}
|
||||||
|
if seen[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[k] = true
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, errors.New("至少选择一种支付方式")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitPayMethods 逗号分隔串 → 切片 // Logan.Li
|
||||||
|
func splitPayMethods(raw string) []string {
|
||||||
|
out := make([]string, 0, 3)
|
||||||
|
for _, p := range strings.Split(raw, ",") {
|
||||||
|
if v := strings.ToLower(strings.TrimSpace(p)); v != "" {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return []string{"native"}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// itoa 整数转字符串(避免各文件重复引入 strconv 别名) // Logan.Li
|
||||||
|
func itoa(n int) string {
|
||||||
|
return strconv.Itoa(n)
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 支付渠道连通性测试(只做最小校验,不下单、不写业务数据)。
|
||||||
|
// 无真实凭据时返回 status=skipped,绝不以 500 结束。 // Logan.Li
|
||||||
|
|
||||||
|
// 微信支付 APIv3 基础地址 // Logan.Li
|
||||||
|
const wechatAPIBase = "https://api.mch.weixin.qq.com"
|
||||||
|
|
||||||
|
// Test 执行连通性测试(channel: wechat / alipay / all) // Logan.Li
|
||||||
|
func (s *ConfigService) Test(channel string) *dto.PaymentTestResponse {
|
||||||
|
ch := strings.ToLower(strings.TrimSpace(channel))
|
||||||
|
resp := &dto.PaymentTestResponse{Results: []dto.PaymentTestResult{}}
|
||||||
|
if ch == "" || ch == "all" {
|
||||||
|
resp.Results = append(resp.Results, s.TestWechat(), s.TestAlipay())
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
switch ch {
|
||||||
|
case "wechat":
|
||||||
|
resp.Results = append(resp.Results, s.TestWechat())
|
||||||
|
case "alipay":
|
||||||
|
resp.Results = append(resp.Results, s.TestAlipay())
|
||||||
|
default:
|
||||||
|
resp.Results = append(resp.Results, dto.PaymentTestResult{
|
||||||
|
Channel: ch, Status: "fail", OK: false,
|
||||||
|
Message: "未知渠道:" + ch + "(仅支持 wechat / alipay / all)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 支付宝 ============
|
||||||
|
|
||||||
|
// TestAlipay 调用支付宝网关做一次签名校验请求 // Logan.Li
|
||||||
|
func (s *ConfigService) TestAlipay() dto.PaymentTestResult {
|
||||||
|
res := dto.PaymentTestResult{Channel: "alipay"}
|
||||||
|
row, err := s.loadRow()
|
||||||
|
if err != nil {
|
||||||
|
res.Status, res.Message = "fail", err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
appID := strings.TrimSpace(row.AlipayAppID)
|
||||||
|
privPEM, _ := s.alipayPrivateKey(row)
|
||||||
|
var missing []string
|
||||||
|
if appID == "" {
|
||||||
|
missing = append(missing, "app_id")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(privPEM) == "" {
|
||||||
|
missing = append(missing, "应用私钥")
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
res.Status = "skipped"
|
||||||
|
res.Message = "未配置:" + strings.Join(missing, "、") + "(跳过连通性校验)"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := ParseRSAPrivateKey([]byte(privPEM))
|
||||||
|
if err != nil {
|
||||||
|
res.Status, res.Message = "fail", "应用私钥不可用:"+err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
gateway := strings.TrimSpace(row.AlipayGateway)
|
||||||
|
if gateway == "" {
|
||||||
|
gateway = DefaultAlipayGateway
|
||||||
|
}
|
||||||
|
|
||||||
|
params := map[string]string{
|
||||||
|
"app_id": appID,
|
||||||
|
"method": "alipay.trade.query",
|
||||||
|
"format": "JSON",
|
||||||
|
"charset": "utf-8",
|
||||||
|
"sign_type": "RSA2",
|
||||||
|
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
"version": "1.0",
|
||||||
|
"notify_url": AlipayNotifyURL,
|
||||||
|
"biz_content": `{"out_trade_no":"OPSX-CONFIG-CHECK"}`,
|
||||||
|
}
|
||||||
|
sign, err := alipaySign(params, key)
|
||||||
|
if err != nil {
|
||||||
|
res.Status, res.Message = "fail", "生成签名失败:"+err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
form := make([]string, 0, len(params)+1)
|
||||||
|
keys := make([]string, 0, len(params))
|
||||||
|
for k := range params {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
form = append(form, k+"="+urlEncode(params[k]))
|
||||||
|
}
|
||||||
|
form = append(form, "sign="+urlEncode(sign))
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
status, body, err := httpPostForm(gateway, strings.Join(form, "&"), 12*time.Second)
|
||||||
|
res.LatencyMS = time.Since(start).Milliseconds()
|
||||||
|
if err != nil {
|
||||||
|
res.Status = "fail"
|
||||||
|
res.Message = "网关不可达:" + err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
res.HTTPStatus = status
|
||||||
|
|
||||||
|
var wrapper struct {
|
||||||
|
Resp json.RawMessage `json:"alipay_trade_query_response"`
|
||||||
|
Sign string `json:"sign"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrapper); err != nil || len(wrapper.Resp) == 0 {
|
||||||
|
res.Status = "fail"
|
||||||
|
res.Message = "网关返回非预期内容(HTTP " + itoa(status) + ")"
|
||||||
|
res.Detail = truncateText(string(body), 300)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
var biz struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
SubCode string `json:"sub_code"`
|
||||||
|
SubMsg string `json:"sub_msg"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(wrapper.Resp, &biz)
|
||||||
|
res.Detail = fmt.Sprintf("code=%s sub_code=%s sub_msg=%s", biz.Code, biz.SubCode, biz.SubMsg)
|
||||||
|
|
||||||
|
// 支付宝公钥验签(若已配置,能顺带验证公钥是否正确) // Logan.Li
|
||||||
|
pubNote := ""
|
||||||
|
if pubPEM, _ := s.alipayPublicKey(row); strings.TrimSpace(pubPEM) != "" {
|
||||||
|
if pub, perr := ParseRSAPublicKey([]byte(pubPEM)); perr == nil && wrapper.Sign != "" {
|
||||||
|
if verr := alipayVerify(wrapper.Resp, wrapper.Sign, pub); verr == nil {
|
||||||
|
pubNote = ";支付宝公钥验签通过"
|
||||||
|
} else {
|
||||||
|
pubNote = ";注意:响应验签失败,支付宝公钥可能与应用不匹配"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case biz.Code == "10000":
|
||||||
|
res.Status, res.OK = "ok", true
|
||||||
|
res.Message = "签名校验通过:支付宝网关正常应答(交易查询成功)" + pubNote
|
||||||
|
case strings.EqualFold(biz.SubCode, "ACQ.TRADE_NOT_EXIST"):
|
||||||
|
res.Status, res.OK = "ok", true
|
||||||
|
res.Message = "签名校验通过:网关已受理请求(测试单号不存在,属预期)" + pubNote
|
||||||
|
case isAlipayAuthError(biz.Code, biz.SubCode):
|
||||||
|
res.Status = "fail"
|
||||||
|
res.Message = "签名/应用校验失败:" + firstNonEmptyStr(biz.SubMsg, biz.Msg) + "(请核对 app_id、应用私钥与密钥格式)"
|
||||||
|
default:
|
||||||
|
res.Status = "fail"
|
||||||
|
res.Message = "网关返回业务错误:" + firstNonEmptyStr(biz.SubMsg, biz.Msg, biz.Code) + pubNote
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAlipayAuthError 判断是否属于签名/应用身份类错误 // Logan.Li
|
||||||
|
func isAlipayAuthError(code, subCode string) bool {
|
||||||
|
sc := strings.ToLower(strings.TrimSpace(subCode))
|
||||||
|
c := strings.TrimSpace(code)
|
||||||
|
for _, k := range []string{"isv.invalid-signature", "isv.missing-signature", "isv.invalid-app-id", "isv.invalid-encrypt-type", "isv.insufficient-isv-permissions"} {
|
||||||
|
if sc == k {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c == "40002" || c == "40006"
|
||||||
|
}
|
||||||
|
|
||||||
|
// alipaySign 按支付宝规则(去 sign、字典序、k=v&…)做 SHA256withRSA 签名 // Logan.Li
|
||||||
|
func alipaySign(params map[string]string, key *rsa.PrivateKey) (string, error) {
|
||||||
|
keys := make([]string, 0, len(params))
|
||||||
|
for k, v := range params {
|
||||||
|
if k == "sign" || strings.TrimSpace(v) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
parts = append(parts, k+"="+params[k])
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256([]byte(strings.Join(parts, "&")))
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(sig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// alipayVerify 用支付宝公钥校验响应签名 // Logan.Li
|
||||||
|
func alipayVerify(content []byte, signB64 string, pub *rsa.PublicKey) error {
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(signB64))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(content)
|
||||||
|
return rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 通用 HTTP 辅助 ============
|
||||||
|
|
||||||
|
// httpPostForm 以 form-urlencoded 提交 // Logan.Li
|
||||||
|
func httpPostForm(url, body string, timeout time.Duration) (int, []byte, error) {
|
||||||
|
client := &http.Client{Timeout: timeout}
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
|
||||||
|
req.Header.Set("User-Agent", "opsx-licence/1.0")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
return resp.StatusCode, data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlEncode 表单值转义(空格用 %20,符合支付宝要求) // Logan.Li
|
||||||
|
func urlEncode(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, c := range []byte(s) {
|
||||||
|
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||||
|
c == '-' || c == '_' || c == '.' || c == '~' {
|
||||||
|
b.WriteByte(c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("%")
|
||||||
|
b.WriteString(strings.ToUpper(hex2(c)))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hex2 单字节转两位十六进制 // Logan.Li
|
||||||
|
func hex2(c byte) string {
|
||||||
|
const digits = "0123456789abcdef"
|
||||||
|
return string([]byte{digits[c>>4], digits[c&0x0f]})
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateText 按字符截断(waffo_api.go 的 truncate 按字节,中文会切坏) // Logan.Li
|
||||||
|
func truncateText(s string, n int) string {
|
||||||
|
r := []rune(strings.TrimSpace(s))
|
||||||
|
if len(r) <= n {
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
return string(r[:n]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstNonEmptyStr 返回第一个非空字符串 // Logan.Li
|
||||||
|
func firstNonEmptyStr(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomNonce 生成随机串(微信 nonce_str) // Logan.Li
|
||||||
|
func randomNonce(n int) (string, error) {
|
||||||
|
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for i := range buf {
|
||||||
|
buf[i] = charset[int(buf[i])%len(charset)]
|
||||||
|
}
|
||||||
|
return string(buf), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 微信支付 APIv3 连通性校验:用 mchid + 证书序列号 + 商户私钥请求 /v3/certificates。
|
||||||
|
// 200 = 三者匹配且签名通过;401 = 签名/身份被拒;无凭据则 skipped。 // Logan.Li
|
||||||
|
|
||||||
|
// TestWechat 校验微信支付凭据是否可用 // Logan.Li
|
||||||
|
func (s *ConfigService) TestWechat() dto.PaymentTestResult {
|
||||||
|
res := dto.PaymentTestResult{Channel: "wechat"}
|
||||||
|
row, err := s.loadRow()
|
||||||
|
if err != nil {
|
||||||
|
res.Status, res.Message = "fail", err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing []string
|
||||||
|
if strings.TrimSpace(row.WechatMchID) == "" {
|
||||||
|
missing = append(missing, "商户号 mchid")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(row.WechatSerialNo) == "" {
|
||||||
|
missing = append(missing, "证书序列号 serial_no")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(row.WechatKeyPath) == "" {
|
||||||
|
missing = append(missing, "apiclient_key.pem")
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
res.Status = "skipped"
|
||||||
|
res.Message = "未配置:" + strings.Join(missing, "、") + "(跳过连通性校验)"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
keyBytes, err := ReadSecretFile(row.WechatKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
res.Status, res.Message = "fail", "商户 API 私钥不可用:"+err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
key, err := ParseRSAPrivateKey(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
res.Status, res.Message = "fail", "商户 API 私钥解析失败:"+err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传了证书时先做本地一致性检查,避免把「序列号填错」误报成签名错误 // Logan.Li
|
||||||
|
if strings.TrimSpace(row.WechatCertPath) != "" {
|
||||||
|
if certBytes, cerr := ReadSecretFile(row.WechatCertPath); cerr == nil {
|
||||||
|
if cert, perr := ParseCertificatePEM(certBytes); perr == nil {
|
||||||
|
actual := CertSerialHex(cert)
|
||||||
|
if !strings.EqualFold(actual, strings.TrimSpace(row.WechatSerialNo)) {
|
||||||
|
res.Status = "fail"
|
||||||
|
res.Message = "证书序列号与 apiclient_cert.pem 不匹配:证书实际序列号为 " + actual
|
||||||
|
res.Detail = "请在微信商户平台「API 安全」核对证书序列号,或点上传后自动回填"
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
status, body, headers, err := wechatAPIGet("/v3/certificates", row.WechatMchID, row.WechatSerialNo, key)
|
||||||
|
res.LatencyMS = time.Since(start).Milliseconds()
|
||||||
|
if err != nil {
|
||||||
|
res.Status = "fail"
|
||||||
|
res.Message = "微信支付接口不可达:" + err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
res.HTTPStatus = status
|
||||||
|
|
||||||
|
var errBody struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(body, &errBody)
|
||||||
|
|
||||||
|
// 平台证书响应验签(若已上传平台证书,可顺带验证其有效性) // Logan.Li
|
||||||
|
verifyNote := ""
|
||||||
|
if strings.TrimSpace(row.WechatPlatformCertPath) != "" && status == 200 {
|
||||||
|
verifyNote = verifyWechatResponse(row.WechatPlatformCertPath, headers, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case status == 200:
|
||||||
|
res.Status, res.OK = "ok", true
|
||||||
|
res.Message = "签名校验通过:mchid / 证书序列号 / 商户私钥匹配,平台证书接口可访问" + verifyNote
|
||||||
|
var certResp struct {
|
||||||
|
Data []struct {
|
||||||
|
SerialNo string `json:"serial_no"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(body, &certResp) == nil {
|
||||||
|
res.Detail = fmt.Sprintf("平台证书数量=%d", len(certResp.Data))
|
||||||
|
}
|
||||||
|
case status == 401:
|
||||||
|
res.Status = "fail"
|
||||||
|
res.Message = "认证失败(HTTP 401):请核对商户号、证书序列号与 apiclient_key.pem 是否为同一套(" +
|
||||||
|
firstNonEmptyStr(errBody.Code, "SIGN_ERROR") + ")"
|
||||||
|
res.Detail = truncateText(firstNonEmptyStr(errBody.Message, string(body)), 300)
|
||||||
|
case status == 403:
|
||||||
|
res.Status, res.OK = "ok", true
|
||||||
|
res.Message = "签名校验通过,但该商户/证书无「查询平台证书」权限(HTTP 403),实际下单前请确认权限集"
|
||||||
|
res.Detail = truncateText(firstNonEmptyStr(errBody.Message, string(body)), 300)
|
||||||
|
default:
|
||||||
|
res.Status = "fail"
|
||||||
|
res.Message = fmt.Sprintf("微信支付返回异常状态 HTTP %d(%s)", status, firstNonEmptyStr(errBody.Code, "-"))
|
||||||
|
res.Detail = truncateText(firstNonEmptyStr(errBody.Message, string(body)), 300)
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// wechatAPIGet 发起一次 APIv3 GET 请求并返回状态码/响应体/响应头 // Logan.Li
|
||||||
|
func wechatAPIGet(path, mchID, serialNo string, key *rsa.PrivateKey) (int, []byte, http.Header, error) {
|
||||||
|
nonce, err := randomNonce(32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, nil, err
|
||||||
|
}
|
||||||
|
ts := fmt.Sprintf("%d", time.Now().Unix())
|
||||||
|
sig, err := signWechatV3("GET", path, ts, nonce, "", key)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, nil, err
|
||||||
|
}
|
||||||
|
auth := fmt.Sprintf(`WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"`,
|
||||||
|
mchID, nonce, sig, ts, serialNo)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 12 * time.Second}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, wechatAPIBase+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", auth)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "opsx-licence/1.0")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
||||||
|
return resp.StatusCode, body, resp.Header, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// signWechatV3 生成 APIv3 请求签名(SHA256withRSA + 换行拼接的规范串) // Logan.Li
|
||||||
|
func signWechatV3(method, urlPath, timestamp, nonce, body string, key *rsa.PrivateKey) (string, error) {
|
||||||
|
message := method + "\n" + urlPath + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n"
|
||||||
|
digest := sha256.Sum256([]byte(message))
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(sig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyWechatResponse 用平台证书校验应答签名,返回给用户看的一句话说明 // Logan.Li
|
||||||
|
func verifyWechatResponse(platformCertPath string, headers http.Header, body []byte) string {
|
||||||
|
sigB64 := headers.Get("Wechatpay-Signature")
|
||||||
|
ts := headers.Get("Wechatpay-Timestamp")
|
||||||
|
nonce := headers.Get("Wechatpay-Nonce")
|
||||||
|
if sigB64 == "" || ts == "" || nonce == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
certBytes, err := ReadSecretFile(platformCertPath)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cert, err := ParseCertificatePEM(certBytes)
|
||||||
|
if err != nil {
|
||||||
|
return ";平台证书不可解析,已跳过应答验签"
|
||||||
|
}
|
||||||
|
pub, ok := cert.PublicKey.(*rsa.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return ";平台证书公钥非 RSA,已跳过应答验签"
|
||||||
|
}
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(sigB64)
|
||||||
|
if err != nil {
|
||||||
|
return ";平台证书应答验签失败(签名格式异常)"
|
||||||
|
}
|
||||||
|
message := ts + "\n" + nonce + "\n" + string(body) + "\n"
|
||||||
|
digest := sha256.Sum256([]byte(message))
|
||||||
|
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], sig); err != nil {
|
||||||
|
return ";注意:平台证书应答验签失败,平台证书可能已过期或被替换"
|
||||||
|
}
|
||||||
|
return ";平台证书应答验签通过"
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 证书 / 私钥上传处理:校验 PEM 可解析 → 0600 落地到仓库外目录 → 更新配置行。 // Logan.Li
|
||||||
|
|
||||||
|
// maxUploadBytes 单个上传文件大小上限(PEM 文本,64KB 足够) // Logan.Li
|
||||||
|
const maxUploadBytes = 64 * 1024
|
||||||
|
|
||||||
|
// Upload 处理一次证书/密钥上传 // Logan.Li
|
||||||
|
func (s *ConfigService) Upload(kind, filename string, content []byte) (*dto.PaymentUploadResponse, error) {
|
||||||
|
if !IsValidUploadKind(kind) {
|
||||||
|
return nil, errors.New("不支持的上传类型(可选:wechat_cert / wechat_key / wechat_platform_cert / alipay_private_key / alipay_public_key)")
|
||||||
|
}
|
||||||
|
if len(content) == 0 {
|
||||||
|
return nil, errors.New("上传文件为空")
|
||||||
|
}
|
||||||
|
if len(content) > maxUploadBytes {
|
||||||
|
return nil, errors.New("文件过大(上限 64KB)")
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := s.loadRow()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var certSerial, note string
|
||||||
|
// 1) 内容校验(解析失败直接拒绝入库) // Logan.Li
|
||||||
|
switch kind {
|
||||||
|
case UploadKindWechatCert, UploadKindWechatPlatformCert:
|
||||||
|
cert, perr := ParseCertificatePEM(content)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, errors.New("证书文件校验失败:" + perr.Error())
|
||||||
|
}
|
||||||
|
certSerial = CertSerialHex(cert)
|
||||||
|
case UploadKindWechatKey:
|
||||||
|
normalized := NormalizePEM(string(content), "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----")
|
||||||
|
if _, perr := ParseRSAPrivateKey([]byte(normalized)); perr != nil {
|
||||||
|
return nil, errors.New("商户 API 私钥校验失败:" + perr.Error())
|
||||||
|
}
|
||||||
|
content = []byte(normalized)
|
||||||
|
case UploadKindAlipayPrivateKey:
|
||||||
|
normalized := NormalizePEM(string(content), "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----")
|
||||||
|
if _, perr := ParseRSAPrivateKey([]byte(normalized)); perr != nil {
|
||||||
|
return nil, errors.New("应用私钥校验失败:" + perr.Error())
|
||||||
|
}
|
||||||
|
content = []byte(normalized)
|
||||||
|
case UploadKindAlipayPublicKey:
|
||||||
|
normalized := NormalizePEM(string(content), "-----BEGIN PUBLIC KEY-----", "-----END PUBLIC KEY-----")
|
||||||
|
if _, perr := ParseRSAPublicKey([]byte(normalized)); perr != nil {
|
||||||
|
return nil, errors.New("支付宝公钥校验失败:" + perr.Error())
|
||||||
|
}
|
||||||
|
content = []byte(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 落地文件(0600,仓库外目录) // Logan.Li
|
||||||
|
path, err := WriteSecretFile(kind, content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 更新配置行指向新文件 // Logan.Li
|
||||||
|
switch kind {
|
||||||
|
case UploadKindWechatCert:
|
||||||
|
row.WechatCertPath = path
|
||||||
|
if strings.TrimSpace(row.WechatSerialNo) == "" && certSerial != "" {
|
||||||
|
row.WechatSerialNo = certSerial
|
||||||
|
note = "已自动回填证书序列号"
|
||||||
|
} else if certSerial != "" && !strings.EqualFold(strings.TrimSpace(row.WechatSerialNo), certSerial) {
|
||||||
|
note = "注意:填写的序列号与证书实际序列号不一致(证书:" + certSerial + "),请核对"
|
||||||
|
}
|
||||||
|
case UploadKindWechatKey:
|
||||||
|
row.WechatKeyPath = path
|
||||||
|
case UploadKindWechatPlatformCert:
|
||||||
|
row.WechatPlatformCertPath = path
|
||||||
|
case UploadKindAlipayPrivateKey:
|
||||||
|
row.AlipayPrivateKeyPath = path
|
||||||
|
row.AlipayPrivateKeyEnc = "" // 上传后清掉旧的粘贴值,避免来源歧义 // Logan.Li
|
||||||
|
case UploadKindAlipayPublicKey:
|
||||||
|
row.AlipayPublicKeyPath = path
|
||||||
|
row.AlipayPublicKeyEnc = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Save(row).Error; err != nil {
|
||||||
|
return nil, errors.New("保存上传记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := "上传成功:" + filename
|
||||||
|
if note != "" {
|
||||||
|
msg += "(" + note + ")"
|
||||||
|
}
|
||||||
|
return &dto.PaymentUploadResponse{
|
||||||
|
Kind: kind,
|
||||||
|
File: BuildFileInfo(path, IsCertKind(kind)),
|
||||||
|
Message: msg,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package payment
|
||||||
|
|
||||||
|
import (
|
||||||
|
"backend/internal/dto"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 微信支付平台证书自动获取(APIv3 GET /v3/certificates)。
|
||||||
|
// 平台证书用于回调/应答验签,属于公开证书,但仍落在受保护目录。 // Logan.Li
|
||||||
|
|
||||||
|
// wechatCertResponse /v3/certificates 响应结构(仅取所需字段) // Logan.Li
|
||||||
|
type wechatCertResponse struct {
|
||||||
|
Data []struct {
|
||||||
|
SerialNo string `json:"serial_no"`
|
||||||
|
EffectiveTime string `json:"effective_time"`
|
||||||
|
ExpireTime string `json:"expire_time"`
|
||||||
|
EncryptCertificate struct {
|
||||||
|
Algorithm string `json:"algorithm"`
|
||||||
|
Ciphertext string `json:"ciphertext"`
|
||||||
|
AssociatedData string `json:"associated_data"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
} `json:"encrypt_certificate"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchWechatPlatformCerts 拉取并保存平台证书,返回证书清单 // Logan.Li
|
||||||
|
func (s *ConfigService) FetchWechatPlatformCerts() ([]dto.WechatPlatformCertItem, string, error) {
|
||||||
|
row, err := s.loadRow()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
var missing []string
|
||||||
|
if strings.TrimSpace(row.WechatMchID) == "" {
|
||||||
|
missing = append(missing, "商户号 mchid")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(row.WechatSerialNo) == "" {
|
||||||
|
missing = append(missing, "证书序列号 serial_no")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(row.WechatKeyPath) == "" {
|
||||||
|
missing = append(missing, "apiclient_key.pem")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(row.WechatAPIv3KeyEnc) == "" {
|
||||||
|
missing = append(missing, "APIv3 密钥")
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return nil, "未配置:" + strings.Join(missing, "、") + "(跳过自动获取)", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keyBytes, err := ReadSecretFile(row.WechatKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", errors.New("商户 API 私钥不可用:" + err.Error())
|
||||||
|
}
|
||||||
|
priv, err := ParseRSAPrivateKey(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", errors.New("商户 API 私钥解析失败:" + err.Error())
|
||||||
|
}
|
||||||
|
apiV3Key, err := DecryptSecret(row.WechatAPIv3KeyEnc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
status, body, _, err := wechatAPIGet("/v3/certificates", row.WechatMchID, row.WechatSerialNo, priv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", errors.New("微信支付接口不可达:" + err.Error())
|
||||||
|
}
|
||||||
|
if status != 200 {
|
||||||
|
var eb struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(body, &eb)
|
||||||
|
return nil, "获取平台证书失败:HTTP " + itoa(status) + " " + firstNonEmptyStr(eb.Code, "") + " " + truncateText(eb.Message, 120), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp wechatCertResponse
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
return nil, "平台证书响应解析失败", nil
|
||||||
|
}
|
||||||
|
if len(resp.Data) == 0 {
|
||||||
|
return nil, "商户平台未返回任何平台证书", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.WechatPlatformCertItem, 0, len(resp.Data))
|
||||||
|
type candidate struct {
|
||||||
|
item dto.WechatPlatformCertItem
|
||||||
|
pem []byte
|
||||||
|
exp string
|
||||||
|
}
|
||||||
|
cands := make([]candidate, 0, len(resp.Data))
|
||||||
|
|
||||||
|
for _, d := range resp.Data {
|
||||||
|
pemBytes, derr := DecryptWechatResource(apiV3Key, d.EncryptCertificate.AssociatedData,
|
||||||
|
d.EncryptCertificate.Nonce, d.EncryptCertificate.Ciphertext)
|
||||||
|
if derr != nil {
|
||||||
|
items = append(items, dto.WechatPlatformCertItem{SerialNo: d.SerialNo, Subject: "解密失败:" + derr.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := dto.WechatPlatformCertItem{
|
||||||
|
SerialNo: d.SerialNo,
|
||||||
|
NotBefore: formatWechatTime(d.EffectiveTime),
|
||||||
|
NotAfter: formatWechatTime(d.ExpireTime),
|
||||||
|
}
|
||||||
|
if cert, perr := ParseCertificatePEM(pemBytes); perr == nil {
|
||||||
|
item.Subject = cert.Subject.CommonName
|
||||||
|
}
|
||||||
|
cands = append(cands, candidate{item: item, pem: pemBytes, exp: d.ExpireTime})
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cands) == 0 {
|
||||||
|
return items, "平台证书全部解密失败,请核对 APIv3 密钥是否正确", nil
|
||||||
|
}
|
||||||
|
// 取有效期最晚的一张落地(微信通常只返回一张,未来可能轮换为多张) // Logan.Li
|
||||||
|
sort.Slice(cands, func(i, j int) bool { return cands[i].exp > cands[j].exp })
|
||||||
|
saved, err := WriteSecretFile(UploadKindWechatPlatformCert, cands[0].pem)
|
||||||
|
if err != nil {
|
||||||
|
return items, "", err
|
||||||
|
}
|
||||||
|
row.WechatPlatformCertPath = saved
|
||||||
|
if err := s.db.Save(row).Error; err != nil {
|
||||||
|
return items, "", errors.New("保存平台证书记录失败")
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
if items[i].SerialNo == cands[0].item.SerialNo {
|
||||||
|
items[i].IsSaved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items, "已获取并保存平台证书(序列号 " + cands[0].item.SerialNo + ")", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptWechatResource 用 APIv3 密钥解密微信回调/证书资源(AES-256-GCM) // Logan.Li
|
||||||
|
func DecryptWechatResource(apiV3Key, associatedData, nonce, ciphertext string) ([]byte, error) {
|
||||||
|
keyBytes := []byte(apiV3Key)
|
||||||
|
if len(keyBytes) != 32 {
|
||||||
|
return nil, errors.New("APIv3 密钥必须为 32 字节")
|
||||||
|
}
|
||||||
|
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("密文解码失败")
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
plain, err := gcm.Open(nil, []byte(nonce), data, []byte(associatedData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("解密失败(APIv3 密钥不匹配)")
|
||||||
|
}
|
||||||
|
return plain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatWechatTime 微信 RFC3339 时间转本地展示格式 // Logan.Li
|
||||||
|
func formatWechatTime(raw string) string {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||||
|
return t.Local().Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"backend/internal/controller/entitlement"
|
"backend/internal/controller/entitlement"
|
||||||
"backend/internal/controller/license"
|
"backend/internal/controller/license"
|
||||||
"backend/internal/controller/order"
|
"backend/internal/controller/order"
|
||||||
|
paymentctl "backend/internal/controller/payment"
|
||||||
"backend/internal/controller/plan"
|
"backend/internal/controller/plan"
|
||||||
"backend/internal/controller/product"
|
"backend/internal/controller/product"
|
||||||
"backend/internal/controller/setting"
|
"backend/internal/controller/setting"
|
||||||
@@ -106,6 +107,7 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
analyticsController := analytics.NewAnalyticsController(db)
|
analyticsController := analytics.NewAnalyticsController(db)
|
||||||
settingController := setting.NewSettingController(db)
|
settingController := setting.NewSettingController(db)
|
||||||
syncController := synccontroller.NewController(db, cfg)
|
syncController := synccontroller.NewController(db, cfg)
|
||||||
|
paymentConfigController := paymentctl.NewPaymentConfigController(db, cfg)
|
||||||
|
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
{
|
{
|
||||||
@@ -288,6 +290,16 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
|
|||||||
{
|
{
|
||||||
register(paymentGroup, "POST", "/waffo/debug", settingController.DebugWaffo)
|
register(paymentGroup, "POST", "/waffo/debug", settingController.DebugWaffo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 支付配置(微信支付 / 支付宝,仅管理员) // Logan.Li
|
||||||
|
paymentConfig := protected.Group("/admin/payment")
|
||||||
|
{
|
||||||
|
register(paymentConfig, "GET", "/config", paymentConfigController.GetConfig)
|
||||||
|
register(paymentConfig, "PUT", "/config", paymentConfigController.UpdateConfig)
|
||||||
|
register(paymentConfig, "POST", "/upload", paymentConfigController.Upload)
|
||||||
|
register(paymentConfig, "POST", "/test", paymentConfigController.Test)
|
||||||
|
register(paymentConfig, "POST", "/wechat/platform-certs", paymentConfigController.FetchWechatPlatformCerts)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import request from './request'
|
||||||
|
|
||||||
|
// 支付通道配置(微信支付 / 支付宝)API // Logan.Li
|
||||||
|
|
||||||
|
// 读取配置(密钥一律脱敏返回)
|
||||||
|
export function getPaymentConfig() {
|
||||||
|
return request({ url: '/admin/payment/config', method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
export function updatePaymentConfig(data) {
|
||||||
|
return request({ url: '/admin/payment/config', method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传证书 / 私钥(kind: wechat_cert | wechat_key | wechat_platform_cert | alipay_private_key | alipay_public_key)
|
||||||
|
export function uploadPaymentFile(kind, file) {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('kind', kind)
|
||||||
|
form.append('file', file)
|
||||||
|
return request({
|
||||||
|
url: '/admin/payment/upload',
|
||||||
|
method: 'post',
|
||||||
|
data: form,
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 60000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连通性测试(channel: wechat | alipay | all)
|
||||||
|
export function testPaymentConfig(channel = 'all') {
|
||||||
|
return request({
|
||||||
|
url: '/admin/payment/test',
|
||||||
|
method: 'post',
|
||||||
|
data: { channel },
|
||||||
|
timeout: 60000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动获取并保存微信支付平台证书
|
||||||
|
export function fetchWechatPlatformCerts() {
|
||||||
|
return request({
|
||||||
|
url: '/admin/payment/wechat/platform-certs',
|
||||||
|
method: 'post',
|
||||||
|
timeout: 60000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -51,11 +51,6 @@
|
|||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
|
|
||||||
<el-menu-item index="/payments">
|
|
||||||
<el-icon><Wallet /></el-icon>
|
|
||||||
<template #title>支付</template>
|
|
||||||
</el-menu-item>
|
|
||||||
|
|
||||||
<el-menu-item index="/sync">
|
<el-menu-item index="/sync">
|
||||||
<el-icon><Connection /></el-icon>
|
<el-icon><Connection /></el-icon>
|
||||||
<template #title>节点同步</template>
|
<template #title>节点同步</template>
|
||||||
@@ -69,6 +64,9 @@
|
|||||||
<el-menu-item index="/logs">
|
<el-menu-item index="/logs">
|
||||||
<el-icon><Document /></el-icon>操作日志
|
<el-icon><Document /></el-icon>操作日志
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/payments">
|
||||||
|
<el-icon><Wallet /></el-icon>支付配置
|
||||||
|
</el-menu-item>
|
||||||
<el-menu-item index="/settings">
|
<el-menu-item index="/settings">
|
||||||
<el-icon><Setting /></el-icon>系统设置
|
<el-icon><Setting /></el-icon>系统设置
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: 'payments',
|
path: 'payments',
|
||||||
name: 'Payments',
|
name: 'Payments',
|
||||||
component: () => import('@/views/Payments.vue'),
|
component: () => import('@/views/payments/index.vue'),
|
||||||
meta: { title: '支付配置', icon: 'Wallet' },
|
meta: { title: '支付配置', icon: 'Wallet' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="应用私钥与支付宝公钥均可粘贴或上传文件;保存后只显示指纹,不再回显内容。"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-card shadow="never" v-loading="loading">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="head-left">
|
||||||
|
<span class="title">支付宝(开放平台 · RSA2)</span>
|
||||||
|
<el-tag v-if="view.ready" size="small" type="success">已就绪</el-tag>
|
||||||
|
<el-tag v-else size="small" type="warning">待完善 {{ view.missing.length }} 项</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button :loading="testing" @click="runTest">测试连接</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="view.missing.length"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
>
|
||||||
|
<template #title>还缺:{{ view.missing.join('、') }}</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<el-form :model="form" label-width="150px" style="max-width: 900px">
|
||||||
|
<el-form-item label="启用支付宝">
|
||||||
|
<el-switch v-model="form.enabled" />
|
||||||
|
<span class="hint inline">关闭后下单接口不会走支付宝通道</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="app_id">
|
||||||
|
<el-input v-model="form.app_id" placeholder="开放平台 → 应用详情 → APPID" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="应用私钥">
|
||||||
|
<div class="secret-row">
|
||||||
|
<el-tag :type="view.private_key_configured ? 'success' : 'warning'" size="small">
|
||||||
|
{{ view.private_key_configured
|
||||||
|
? `已配置(${view.private_key_source === 'upload' ? '文件上传' : '粘贴'} · ${view.private_key_masked})`
|
||||||
|
: '未配置' }}
|
||||||
|
</el-tag>
|
||||||
|
<el-button size="small" @click="togglePrivate">{{ showPrivate ? '取消' : (view.private_key_configured ? '更换' : '粘贴') }}</el-button>
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:auto-upload="false"
|
||||||
|
accept=".pem,.key,.txt"
|
||||||
|
:on-change="(f) => uploadFile('alipay_private_key', f)"
|
||||||
|
>
|
||||||
|
<el-button size="small" :loading="uploadingKind === 'alipay_private_key'">上传文件</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<el-button
|
||||||
|
v-if="view.private_key_configured"
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
@click="clearPrivate = true"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-if="showPrivate"
|
||||||
|
v-model="privateKeyInput"
|
||||||
|
type="textarea"
|
||||||
|
:rows="5"
|
||||||
|
placeholder="粘贴应用私钥(PEM 或裸 Base64),保存后不再回显"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
/>
|
||||||
|
<div v-if="clearPrivate" class="hint warn">已标记清除,点「保存配置」后生效。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="支付宝公钥">
|
||||||
|
<div class="secret-row">
|
||||||
|
<el-tag :type="view.public_key_configured ? 'success' : 'warning'" size="small">
|
||||||
|
{{ view.public_key_configured ? `已配置(${view.public_key_masked})` : '未配置' }}
|
||||||
|
</el-tag>
|
||||||
|
<el-button size="small" @click="togglePublic">{{ showPublic ? '取消' : (view.public_key_configured ? '更换' : '粘贴') }}</el-button>
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:auto-upload="false"
|
||||||
|
accept=".pem,.pub,.txt"
|
||||||
|
:on-change="(f) => uploadFile('alipay_public_key', f)"
|
||||||
|
>
|
||||||
|
<el-button size="small" :loading="uploadingKind === 'alipay_public_key'">上传文件</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<el-button
|
||||||
|
v-if="view.public_key_configured"
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
@click="clearPublic = true"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-if="showPublic"
|
||||||
|
v-model="publicKeyInput"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="粘贴支付宝公钥(不是应用公钥),保存后不再回显"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
/>
|
||||||
|
<div class="hint">
|
||||||
|
开放平台「开发设置 → 接口加签方式」里的<b>支付宝公钥</b>,填成应用公钥会导致回调验签失败。
|
||||||
|
</div>
|
||||||
|
<div v-if="clearPublic" class="hint warn">已标记清除,点「保存配置」后生效。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="网关地址">
|
||||||
|
<el-input v-model="form.gateway" placeholder="https://openapi.alipay.com/gateway.do" />
|
||||||
|
<div class="hint">沙箱环境请改为沙箱网关,正式环境保持默认值。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="签名类型">
|
||||||
|
<el-input :model-value="form.sign_type" disabled />
|
||||||
|
<div class="hint">固定 RSA2(SHA256withRSA),证书模式与密钥模式均适用。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="回调地址">
|
||||||
|
<el-input :model-value="view.notify_url" readonly>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="copy(view.notify_url)">复制</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<div class="hint">需与开放平台应用里配置的「异步通知地址」一致。</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card v-if="testResults.length" shadow="never" style="margin-top: 16px">
|
||||||
|
<template #header><span class="title">连通性测试结果</span></template>
|
||||||
|
<div v-for="r in testResults" :key="r.channel" class="test-row">
|
||||||
|
<el-tag :type="r.status === 'ok' ? 'success' : (r.status === 'skipped' ? 'info' : 'danger')" size="small">
|
||||||
|
{{ r.channel }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="test-msg">{{ r.message }}</span>
|
||||||
|
<span class="hint">{{ r.http_status ? `HTTP ${r.http_status} · ${r.latency_ms}ms` : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<pre v-for="r in testResults.filter((x) => x.detail)" :key="`d-${r.channel}`" class="debug-box">{{ r.detail }}</pre>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
getPaymentConfig,
|
||||||
|
updatePaymentConfig,
|
||||||
|
uploadPaymentFile,
|
||||||
|
testPaymentConfig,
|
||||||
|
} from '@/api/payment'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const testing = ref(false)
|
||||||
|
const uploadingKind = ref('')
|
||||||
|
const showPrivate = ref(false)
|
||||||
|
const showPublic = ref(false)
|
||||||
|
const privateKeyInput = ref('')
|
||||||
|
const publicKeyInput = ref('')
|
||||||
|
const clearPrivate = ref(false)
|
||||||
|
const clearPublic = ref(false)
|
||||||
|
const testResults = ref([])
|
||||||
|
|
||||||
|
const view = reactive({
|
||||||
|
enabled: false,
|
||||||
|
app_id: '',
|
||||||
|
private_key_configured: false,
|
||||||
|
private_key_masked: '',
|
||||||
|
private_key_source: '',
|
||||||
|
public_key_configured: false,
|
||||||
|
public_key_masked: '',
|
||||||
|
gateway: 'https://openapi.alipay.com/gateway.do',
|
||||||
|
sign_type: 'RSA2',
|
||||||
|
notify_url: '',
|
||||||
|
ready: false,
|
||||||
|
missing: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
enabled: false,
|
||||||
|
app_id: '',
|
||||||
|
gateway: 'https://openapi.alipay.com/gateway.do',
|
||||||
|
sign_type: 'RSA2',
|
||||||
|
})
|
||||||
|
|
||||||
|
function applyView(data) {
|
||||||
|
Object.assign(view, data || {})
|
||||||
|
form.enabled = !!view.enabled
|
||||||
|
form.app_id = view.app_id || ''
|
||||||
|
form.gateway = view.gateway || 'https://openapi.alipay.com/gateway.do'
|
||||||
|
form.sign_type = view.sign_type || 'RSA2'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getPaymentConfig()
|
||||||
|
applyView(res.data?.alipay)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePrivate() {
|
||||||
|
showPrivate.value = !showPrivate.value
|
||||||
|
if (showPrivate.value) clearPrivate.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePublic() {
|
||||||
|
showPublic.value = !showPublic.value
|
||||||
|
if (showPublic.value) clearPublic.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (clearPrivate.value && privateKeyInput.value.trim()) {
|
||||||
|
ElMessage.warning('「清除私钥」与「粘贴新私钥」冲突,请二选一')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (clearPublic.value && publicKeyInput.value.trim()) {
|
||||||
|
ElMessage.warning('「清除公钥」与「粘贴新公钥」冲突,请二选一')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 敏感字段变更需二次确认 // Logan.Li
|
||||||
|
if (clearPrivate.value || privateKeyInput.value.trim() || clearPublic.value || publicKeyInput.value.trim()) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('将修改支付宝密钥类字段(保存后不可回显),确定继续?', '确认修改密钥', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确认修改',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
alipay: {
|
||||||
|
enabled: form.enabled,
|
||||||
|
app_id: form.app_id,
|
||||||
|
gateway: form.gateway,
|
||||||
|
sign_type: form.sign_type,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (clearPrivate.value) payload.alipay.clear_private_key = true
|
||||||
|
if (privateKeyInput.value.trim()) payload.alipay.private_key = privateKeyInput.value.trim()
|
||||||
|
if (clearPublic.value) payload.alipay.clear_public_key = true
|
||||||
|
if (publicKeyInput.value.trim()) payload.alipay.public_key = publicKeyInput.value.trim()
|
||||||
|
|
||||||
|
const res = await updatePaymentConfig(payload)
|
||||||
|
applyView(res.data?.alipay)
|
||||||
|
showPrivate.value = false
|
||||||
|
showPublic.value = false
|
||||||
|
privateKeyInput.value = ''
|
||||||
|
publicKeyInput.value = ''
|
||||||
|
clearPrivate.value = false
|
||||||
|
clearPublic.value = false
|
||||||
|
ElMessage.success('支付宝配置已保存')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(kind, uploadFileRef) {
|
||||||
|
const raw = uploadFileRef?.raw
|
||||||
|
if (!raw) return
|
||||||
|
const label = kind === 'alipay_private_key' ? '应用私钥' : '支付宝公钥'
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`将上传并覆盖服务端当前的「${label}」,确定继续?`, '确认上传', {
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploadingKind.value = kind
|
||||||
|
try {
|
||||||
|
const res = await uploadPaymentFile(kind, raw)
|
||||||
|
ElMessage.success(res.data?.message || '上传成功')
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
uploadingKind.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTest() {
|
||||||
|
testing.value = true
|
||||||
|
try {
|
||||||
|
const res = await testPaymentConfig('alipay')
|
||||||
|
testResults.value = res.data?.results || []
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy(text) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text || '')
|
||||||
|
ElMessage.success('已复制')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败,请手动选中复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.head-left { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.title { font-weight: 600; }
|
||||||
|
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.secret-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.hint { margin-top: 6px; font-size: 12px; color: #909399; line-height: 1.6; }
|
||||||
|
.hint.inline { margin-top: 0; margin-left: 10px; }
|
||||||
|
.hint.warn { color: #e6a23c; }
|
||||||
|
.test-row { display: flex; align-items: center; gap: 10px; font-size: 13px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||||
|
.test-msg { color: #303133; }
|
||||||
|
.debug-box {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File renamed without changes.
@@ -0,0 +1,420 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-alert
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="凭据仅在本机服务端加密保存,页面不再回显明文。证书序列号会在上传 apiclient_cert.pem 后自动回填。"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-card shadow="never" v-loading="loading">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="head-left">
|
||||||
|
<span class="title">微信支付(APIv3)</span>
|
||||||
|
<el-tag v-if="view.ready" size="small" type="success">已就绪</el-tag>
|
||||||
|
<el-tag v-else size="small" type="warning">待完善 {{ view.missing.length }} 项</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button :loading="testing" @click="runTest">测试连接</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="view.missing.length"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
还缺:{{ view.missing.join('、') }}
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<el-form :model="form" label-width="150px" style="max-width: 900px">
|
||||||
|
<el-form-item label="启用微信支付">
|
||||||
|
<el-switch v-model="form.enabled" />
|
||||||
|
<span class="hint inline">关闭后下单接口不会走微信支付通道</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="商户号 mchid">
|
||||||
|
<el-input v-model="form.mch_id" placeholder="商户平台 → 账户中心 → 商户信息" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="AppID">
|
||||||
|
<el-input v-model="form.app_id" placeholder="公众号 / 开放平台网站应用的 AppID" clearable />
|
||||||
|
<div class="hint">
|
||||||
|
必须已在商户平台「产品中心 → AppID 账号管理」绑定,否则下单会报
|
||||||
|
<code>APPID_MCHID_NOT_MATCH</code>。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="APIv3 密钥">
|
||||||
|
<div class="secret-row">
|
||||||
|
<el-tag :type="view.api_v3_key_configured ? 'success' : 'warning'" size="small">
|
||||||
|
{{ view.api_v3_key_configured ? `已配置(${view.api_v3_key_masked})` : '未配置' }}
|
||||||
|
</el-tag>
|
||||||
|
<el-button size="small" @click="showKeyInput = !showKeyInput">
|
||||||
|
{{ showKeyInput ? '取消' : (view.api_v3_key_configured ? '更换' : '填写') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="view.api_v3_key_configured"
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
@click="clearKey = true"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-input
|
||||||
|
v-if="showKeyInput"
|
||||||
|
v-model="keyInput"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
maxlength="32"
|
||||||
|
placeholder="32 位密钥(商户平台 → 账户中心 → API安全 → 设置APIv3密钥)"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
/>
|
||||||
|
<div v-if="clearKey" class="hint warn">已标记清除,点「保存配置」后生效。</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="证书序列号">
|
||||||
|
<el-input v-model="form.serial_no" placeholder="商户 API 证书序列号(上传证书后自动回填)" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="支付方式">
|
||||||
|
<el-checkbox-group v-model="form.pay_methods">
|
||||||
|
<el-checkbox value="native">Native 扫码</el-checkbox>
|
||||||
|
<el-checkbox value="h5">H5 支付</el-checkbox>
|
||||||
|
<el-checkbox value="jsapi">JSAPI(微信内)</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="回调地址">
|
||||||
|
<el-input :model-value="view.notify_url" readonly>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="copy(view.notify_url)">复制</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<div class="hint">下单流程接入后由微信异步通知,务必在商户平台保持可公网访问。</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" style="margin-top: 16px">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="title">证书与私钥</span>
|
||||||
|
<span class="hint inline">文件落在服务端受保护目录(0600),不对外暴露</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="upload-grid">
|
||||||
|
<div v-for="item in uploadItems" :key="item.kind" class="upload-cell">
|
||||||
|
<div class="upload-head">
|
||||||
|
<b>{{ item.title }}</b>
|
||||||
|
<el-tag :type="item.file.configured ? 'success' : 'info'" size="small">
|
||||||
|
{{ item.file.configured ? '已上传' : '未上传' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<p class="upload-desc">{{ item.desc }}</p>
|
||||||
|
<div v-if="item.file.configured" class="file-meta">
|
||||||
|
<div><span>文件</span>{{ item.file.file_name }}</div>
|
||||||
|
<div><span>指纹</span><code>{{ item.file.fingerprint || '-' }}</code></div>
|
||||||
|
<div v-if="item.file.not_after"><span>有效期至</span>{{ item.file.not_after }}</div>
|
||||||
|
<div v-if="item.file.parse_error" class="warn"><span>提示</span>{{ item.file.parse_error }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="upload-actions">
|
||||||
|
<el-upload
|
||||||
|
:show-file-list="false"
|
||||||
|
:auto-upload="false"
|
||||||
|
accept=".pem,.crt,.cer,.key,.txt"
|
||||||
|
:on-change="(f) => onPick(item.kind, f)"
|
||||||
|
>
|
||||||
|
<el-button size="small" :loading="uploadingKind === item.kind">
|
||||||
|
{{ item.file.configured ? '重新上传' : '选择文件上传' }}
|
||||||
|
</el-button>
|
||||||
|
</el-upload>
|
||||||
|
<el-tooltip
|
||||||
|
:content="view.ready ? '调用 APIv3 拉取平台证书并落盘' : `凭据未配置完整(还缺:${view.missing.join('、')}),无法自动获取`"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<span v-if="item.kind === 'wechat_platform_cert'">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:disabled="!view.ready"
|
||||||
|
:loading="fetchingCerts"
|
||||||
|
@click="fetchCerts"
|
||||||
|
>
|
||||||
|
自动获取平台证书
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="platformCerts.length" class="cert-list">
|
||||||
|
<div class="cert-list-title">平台证书(微信返回 {{ platformCerts.length }} 张)</div>
|
||||||
|
<el-table :data="platformCerts" size="small" border>
|
||||||
|
<el-table-column prop="serial_no" label="序列号" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="subject" label="主题" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="not_after" label="到期" width="170" />
|
||||||
|
<el-table-column label="落盘" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.is_saved" size="small" type="success">已保存</el-tag>
|
||||||
|
<span v-else class="hint">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card v-if="testResults.length" shadow="never" style="margin-top: 16px">
|
||||||
|
<template #header><span class="title">连通性测试结果</span></template>
|
||||||
|
<div v-for="r in testResults" :key="r.channel" class="test-row">
|
||||||
|
<el-tag :type="r.status === 'ok' ? 'success' : (r.status === 'skipped' ? 'info' : 'danger')" size="small">
|
||||||
|
{{ r.channel }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="test-msg">{{ r.message }}</span>
|
||||||
|
<span class="hint">{{ r.http_status ? `HTTP ${r.http_status} · ${r.latency_ms}ms` : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<pre v-for="r in testResults.filter((x) => x.detail)" :key="`d-${r.channel}`" class="debug-box">{{ r.detail }}</pre>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
getPaymentConfig,
|
||||||
|
updatePaymentConfig,
|
||||||
|
uploadPaymentFile,
|
||||||
|
testPaymentConfig,
|
||||||
|
fetchWechatPlatformCerts,
|
||||||
|
} from '@/api/payment'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const testing = ref(false)
|
||||||
|
const fetchingCerts = ref(false)
|
||||||
|
const uploadingKind = ref('')
|
||||||
|
const showKeyInput = ref(false)
|
||||||
|
const keyInput = ref('')
|
||||||
|
const clearKey = ref(false)
|
||||||
|
const testResults = ref([])
|
||||||
|
const platformCerts = ref([])
|
||||||
|
|
||||||
|
const view = reactive({
|
||||||
|
enabled: false,
|
||||||
|
mch_id: '',
|
||||||
|
app_id: '',
|
||||||
|
api_v3_key_configured: false,
|
||||||
|
api_v3_key_masked: '',
|
||||||
|
serial_no: '',
|
||||||
|
pay_methods: ['native'],
|
||||||
|
notify_url: '',
|
||||||
|
cert_file: {},
|
||||||
|
key_file: {},
|
||||||
|
platform_cert_file: {},
|
||||||
|
ready: false,
|
||||||
|
missing: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
enabled: false,
|
||||||
|
mch_id: '',
|
||||||
|
app_id: '',
|
||||||
|
serial_no: '',
|
||||||
|
pay_methods: ['native'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploadItems = computed(() => [
|
||||||
|
{
|
||||||
|
kind: 'wechat_cert',
|
||||||
|
title: 'apiclient_cert.pem',
|
||||||
|
desc: '商户 API 证书(公开证书),用于标识商户身份。',
|
||||||
|
file: view.cert_file || {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'wechat_key',
|
||||||
|
title: 'apiclient_key.pem',
|
||||||
|
desc: '商户 API 私钥,用于请求签名,上传后只存不显。',
|
||||||
|
file: view.key_file || {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'wechat_platform_cert',
|
||||||
|
title: '平台证书',
|
||||||
|
desc: '微信支付平台证书,用于回调验签,可用上面的凭据自动获取。',
|
||||||
|
file: view.platform_cert_file || {},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
// 把接口返回的脱敏视图同步到本地 // Logan.Li
|
||||||
|
function applyView(data) {
|
||||||
|
Object.assign(view, data || {})
|
||||||
|
form.enabled = !!view.enabled
|
||||||
|
form.mch_id = view.mch_id || ''
|
||||||
|
form.app_id = view.app_id || ''
|
||||||
|
form.serial_no = view.serial_no || ''
|
||||||
|
form.pay_methods = Array.isArray(view.pay_methods) && view.pay_methods.length ? view.pay_methods : ['native']
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getPaymentConfig()
|
||||||
|
applyView(res.data?.wechat)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (clearKey.value && keyInput.value) {
|
||||||
|
ElMessage.warning('「清除密钥」与「填写新密钥」冲突,请二选一')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (keyInput.value && keyInput.value.length !== 32) {
|
||||||
|
ElMessage.warning('APIv3 密钥必须为 32 位')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 敏感字段变更需二次确认 // Logan.Li
|
||||||
|
if (clearKey.value || keyInput.value) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
clearKey.value
|
||||||
|
? '将清除已保存的 APIv3 密钥,清除后微信支付不可用,确定继续?'
|
||||||
|
: '即将覆盖已保存的 APIv3 密钥(保存后仅显示后 4 位),确定继续?',
|
||||||
|
'确认修改密钥',
|
||||||
|
{ type: 'warning', confirmButtonText: '确认修改', cancelButtonText: '取消' }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
wechat: {
|
||||||
|
enabled: form.enabled,
|
||||||
|
mch_id: form.mch_id,
|
||||||
|
app_id: form.app_id,
|
||||||
|
serial_no: form.serial_no,
|
||||||
|
pay_methods: form.pay_methods,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (clearKey.value) payload.wechat.clear_api_v3_key = true
|
||||||
|
if (keyInput.value) payload.wechat.api_v3_key = keyInput.value
|
||||||
|
|
||||||
|
const res = await updatePaymentConfig(payload)
|
||||||
|
applyView(res.data?.wechat)
|
||||||
|
showKeyInput.value = false
|
||||||
|
keyInput.value = ''
|
||||||
|
clearKey.value = false
|
||||||
|
ElMessage.success('微信支付配置已保存')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传证书/私钥(先确认再传,避免误覆盖) // Logan.Li
|
||||||
|
async function onPick(kind, uploadFile) {
|
||||||
|
const raw = uploadFile?.raw
|
||||||
|
if (!raw) return
|
||||||
|
const label = uploadItems.value.find((i) => i.kind === kind)?.title || kind
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`将上传并覆盖服务端当前的「${label}」,确定继续?`,
|
||||||
|
'确认上传',
|
||||||
|
{ type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploadingKind.value = kind
|
||||||
|
try {
|
||||||
|
const res = await uploadPaymentFile(kind, raw)
|
||||||
|
ElMessage.success(res.data?.message || '上传成功')
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
uploadingKind.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCerts() {
|
||||||
|
fetchingCerts.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetchWechatPlatformCerts()
|
||||||
|
platformCerts.value = res.data?.items || []
|
||||||
|
ElMessage.success(res.message || '平台证书已获取')
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
fetchingCerts.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTest() {
|
||||||
|
testing.value = true
|
||||||
|
try {
|
||||||
|
const res = await testPaymentConfig('wechat')
|
||||||
|
testResults.value = res.data?.results || []
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy(text) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text || '')
|
||||||
|
ElMessage.success('已复制')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败,请手动选中复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.head-left { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.title { font-weight: 600; }
|
||||||
|
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.secret-row { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.hint { margin-top: 6px; font-size: 12px; color: #909399; line-height: 1.6; }
|
||||||
|
.hint.inline { margin-top: 0; margin-left: 10px; }
|
||||||
|
.hint.warn { color: #e6a23c; }
|
||||||
|
.warn { color: #e6a23c; }
|
||||||
|
.upload-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
|
||||||
|
.upload-cell { border: 1px solid #ebeef5; border-radius: 8px; padding: 14px 16px; background: #fafbfc; }
|
||||||
|
.upload-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
.upload-head b { font-size: 13px; color: #303133; }
|
||||||
|
.upload-desc { margin: 8px 0 10px; font-size: 12px; line-height: 1.6; color: #909399; min-height: 38px; }
|
||||||
|
.file-meta { font-size: 12px; color: #606266; line-height: 1.9; }
|
||||||
|
.file-meta span { display: inline-block; width: 60px; color: #a8abb2; }
|
||||||
|
.file-meta code { font-size: 12px; color: #409eff; }
|
||||||
|
.upload-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||||
|
.cert-list { margin-top: 16px; }
|
||||||
|
.cert-list-title { font-size: 13px; color: #606266; margin-bottom: 8px; }
|
||||||
|
.test-row { display: flex; align-items: center; gap: 10px; font-size: 13px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||||
|
.test-msg { color: #303133; }
|
||||||
|
.debug-box {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<template>
|
||||||
|
<div class="payment-page">
|
||||||
|
<!-- 通用设置:测试/正式模式 + 全局保存 + 连通性测试(微信/支付宝) -->
|
||||||
|
<el-card shadow="never" class="common-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="head-left">
|
||||||
|
<span class="title">通用设置</span>
|
||||||
|
<el-tag size="small" :type="mode === 'live' ? 'danger' : 'success'">
|
||||||
|
{{ mode === 'live' ? '正式模式 live' : '测试模式 test' }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-if="modeDirty" size="small" type="warning">有未保存改动</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button :loading="testing" @click="runTestAll">测试连接</el-button>
|
||||||
|
<el-button type="primary" :loading="savingMode" @click="saveMode">保存</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="mode-row">
|
||||||
|
<span :class="{ muted: isLive }">测试模式</span>
|
||||||
|
<el-switch
|
||||||
|
v-model="isLive"
|
||||||
|
inline-prompt
|
||||||
|
active-text="正式"
|
||||||
|
inactive-text="测试"
|
||||||
|
style="margin: 0 12px"
|
||||||
|
/>
|
||||||
|
<span :class="{ muted: !isLive }">正式模式</span>
|
||||||
|
<span class="hint inline">
|
||||||
|
仅为全局标记,供后续下单流程判断是否使用沙箱;页面填写与上传不受影响。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notify-grid">
|
||||||
|
<div>
|
||||||
|
<span class="label">微信支付回调(只读)</span>
|
||||||
|
<code>{{ wechatNotify }}</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="label">支付宝回调(只读)</span>
|
||||||
|
<code>{{ alipayNotify }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="testResults.length" class="test-panel">
|
||||||
|
<div v-for="r in testResults" :key="r.channel" class="test-row">
|
||||||
|
<el-tag
|
||||||
|
:type="r.status === 'ok' ? 'success' : (r.status === 'skipped' ? 'info' : 'danger')"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ r.channel }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="test-msg">{{ r.message }}</span>
|
||||||
|
<span class="hint inline">
|
||||||
|
{{ r.http_status ? `HTTP ${r.http_status} · ${r.latency_ms}ms` : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre
|
||||||
|
v-for="r in testResults.filter((x) => x.detail)"
|
||||||
|
:key="`d-${r.channel}`"
|
||||||
|
class="debug-box"
|
||||||
|
>{{ r.detail }}</pre>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" class="payment-tabs" @tab-change="syncQuery">
|
||||||
|
<el-tab-pane label="微信支付" name="wechat" lazy>
|
||||||
|
<WechatPay />
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="支付宝" name="alipay" lazy>
|
||||||
|
<Alipay />
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="Waffo 海外支付" name="waffo" lazy>
|
||||||
|
<Waffo />
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import WechatPay from './WechatPay.vue'
|
||||||
|
import Alipay from './Alipay.vue'
|
||||||
|
import Waffo from './Waffo.vue'
|
||||||
|
import { getPaymentConfig, updatePaymentConfig, testPaymentConfig } from '@/api/payment'
|
||||||
|
|
||||||
|
// 支付配置总入口:国内(微信 / 支付宝)与海外(Waffo)分页签,避免菜单堆叠。
|
||||||
|
// 顶部「通用设置」承载测试/正式模式与全局连通性测试。
|
||||||
|
// 支持 /payments?tab=wechat 深链,便于从文档或告警里直达。 // Logan.Li
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const TABS = ['wechat', 'alipay', 'waffo']
|
||||||
|
const initial = typeof route.query.tab === 'string' ? route.query.tab : ''
|
||||||
|
const activeTab = ref(TABS.includes(initial) ? initial : 'wechat')
|
||||||
|
|
||||||
|
const mode = ref('test')
|
||||||
|
const savedMode = ref('test')
|
||||||
|
const savingMode = ref(false)
|
||||||
|
const testing = ref(false)
|
||||||
|
const testResults = ref([])
|
||||||
|
const wechatNotify = ref('https://licence.rlwj.cn/api/pay/wechat/notify')
|
||||||
|
const alipayNotify = ref('https://licence.rlwj.cn/api/pay/alipay/notify')
|
||||||
|
|
||||||
|
const isLive = computed({
|
||||||
|
get: () => mode.value === 'live',
|
||||||
|
set: (v) => {
|
||||||
|
mode.value = v ? 'live' : 'test'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const modeDirty = computed(() => mode.value !== savedMode.value)
|
||||||
|
|
||||||
|
// 读取全局配置(模式 + 回调地址) // Logan.Li
|
||||||
|
async function loadCommon() {
|
||||||
|
const res = await getPaymentConfig()
|
||||||
|
const data = res.data || {}
|
||||||
|
mode.value = data.mode === 'live' ? 'live' : 'test'
|
||||||
|
savedMode.value = mode.value
|
||||||
|
if (data.wechat?.notify_url) wechatNotify.value = data.wechat.notify_url
|
||||||
|
if (data.alipay?.notify_url) alipayNotify.value = data.alipay.notify_url
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMode() {
|
||||||
|
if (!modeDirty.value) {
|
||||||
|
ElMessage.info('模式未变更')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
savingMode.value = true
|
||||||
|
try {
|
||||||
|
const res = await updatePaymentConfig({ mode: mode.value })
|
||||||
|
mode.value = res.data?.mode === 'live' ? 'live' : 'test'
|
||||||
|
savedMode.value = mode.value
|
||||||
|
ElMessage.success('模式已保存')
|
||||||
|
} finally {
|
||||||
|
savingMode.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局测试:一次性探测微信与支付宝(未配置的渠道返回 skipped,不报错) // Logan.Li
|
||||||
|
async function runTestAll() {
|
||||||
|
testing.value = true
|
||||||
|
try {
|
||||||
|
const res = await testPaymentConfig('all')
|
||||||
|
testResults.value = res.data?.results || []
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncQuery(name) {
|
||||||
|
const query = { ...route.query, tab: name }
|
||||||
|
router.replace({ path: route.path, query })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query.tab,
|
||||||
|
(tab) => {
|
||||||
|
if (typeof tab === 'string' && TABS.includes(tab) && tab !== activeTab.value) {
|
||||||
|
activeTab.value = tab
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(loadCommon)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.payment-page {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px 16px;
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
.common-card { margin: 8px 0 16px; }
|
||||||
|
.card-header { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.head-left { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.title { font-weight: 600; }
|
||||||
|
.header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.mode-row { display: flex; align-items: center; flex-wrap: wrap; font-size: 13px; color: #303133; }
|
||||||
|
.muted { color: #a8abb2; }
|
||||||
|
.hint { color: #909399; font-size: 12px; }
|
||||||
|
.hint.inline { margin-left: 10px; }
|
||||||
|
.notify-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.notify-grid .label { display: block; font-size: 12px; color: #909399; margin-bottom: 4px; }
|
||||||
|
.notify-grid code { font-size: 12px; color: #409eff; word-break: break-all; }
|
||||||
|
.test-panel { margin-top: 14px; border-top: 1px dashed #ebeef5; padding-top: 12px; }
|
||||||
|
.test-row { display: flex; align-items: center; gap: 10px; font-size: 13px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||||
|
.test-msg { color: #303133; }
|
||||||
|
.debug-box {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
.payment-tabs :deep(.el-tabs__header) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.payment-tabs :deep(.el-tabs__item) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in new issue
Block a user