后端: - 新增 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、回调地址 - 菜单归入「审计与设置 → 支付配置」
288 lines
8.7 KiB
Go
288 lines
8.7 KiB
Go
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() }
|