Files
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

173 lines
5.5 KiB
Go
Raw Permalink 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/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
}