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:
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
|
||||
}
|
||||
Reference in new issue
Block a user