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:
Logan.Li committed 2026-11-01 05:59:51 +08:00
1 parent 630f2819cb
commit 7d53c6ed3c
21 files changed
+3090 -6

No files matched your search

+16
View File
@@ -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
+1
View File
@@ -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}})
}
+129
View File
@@ -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"`
}
+39
View File
@@ -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
}
+12
View File
@@ -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)
}
} }
} }
+46
View File
@@ -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,
})
}
+3 -5
View File
@@ -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>
+1 -1
View File
@@ -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' },
}, },
{ {
+342
View File
@@ -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.
+420
View File
@@ -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>
+214
View File
@@ -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>