Files
opsx-licence/backend/internal/service/payment/config_files.go
T
Logan.Li 7d53c6ed3c 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、回调地址
- 菜单归入「审计与设置 → 支付配置」
2026-11-01 05:59:51 +08:00

288 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() }