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