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

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