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