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,195 @@
package payment
import (
"backend/internal/dto"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// 微信支付 APIv3 连通性校验:用 mchid + 证书序列号 + 商户私钥请求 /v3/certificates。
// 200 = 三者匹配且签名通过;401 = 签名/身份被拒;无凭据则 skipped。 // Logan.Li
// TestWechat 校验微信支付凭据是否可用 // Logan.Li
func (s *ConfigService) TestWechat() dto.PaymentTestResult {
res := dto.PaymentTestResult{Channel: "wechat"}
row, err := s.loadRow()
if err != nil {
res.Status, res.Message = "fail", err.Error()
return res
}
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 len(missing) > 0 {
res.Status = "skipped"
res.Message = "未配置:" + strings.Join(missing, "、") + "(跳过连通性校验)"
return res
}
keyBytes, err := ReadSecretFile(row.WechatKeyPath)
if err != nil {
res.Status, res.Message = "fail", "商户 API 私钥不可用:"+err.Error()
return res
}
key, err := ParseRSAPrivateKey(keyBytes)
if err != nil {
res.Status, res.Message = "fail", "商户 API 私钥解析失败:"+err.Error()
return res
}
// 上传了证书时先做本地一致性检查,避免把「序列号填错」误报成签名错误 // Logan.Li
if strings.TrimSpace(row.WechatCertPath) != "" {
if certBytes, cerr := ReadSecretFile(row.WechatCertPath); cerr == nil {
if cert, perr := ParseCertificatePEM(certBytes); perr == nil {
actual := CertSerialHex(cert)
if !strings.EqualFold(actual, strings.TrimSpace(row.WechatSerialNo)) {
res.Status = "fail"
res.Message = "证书序列号与 apiclient_cert.pem 不匹配:证书实际序列号为 " + actual
res.Detail = "请在微信商户平台「API 安全」核对证书序列号,或点上传后自动回填"
return res
}
}
}
}
start := time.Now()
status, body, headers, err := wechatAPIGet("/v3/certificates", row.WechatMchID, row.WechatSerialNo, key)
res.LatencyMS = time.Since(start).Milliseconds()
if err != nil {
res.Status = "fail"
res.Message = "微信支付接口不可达:" + err.Error()
return res
}
res.HTTPStatus = status
var errBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(body, &errBody)
// 平台证书响应验签(若已上传平台证书,可顺带验证其有效性) // Logan.Li
verifyNote := ""
if strings.TrimSpace(row.WechatPlatformCertPath) != "" && status == 200 {
verifyNote = verifyWechatResponse(row.WechatPlatformCertPath, headers, body)
}
switch {
case status == 200:
res.Status, res.OK = "ok", true
res.Message = "签名校验通过:mchid / 证书序列号 / 商户私钥匹配,平台证书接口可访问" + verifyNote
var certResp struct {
Data []struct {
SerialNo string `json:"serial_no"`
} `json:"data"`
}
if json.Unmarshal(body, &certResp) == nil {
res.Detail = fmt.Sprintf("平台证书数量=%d", len(certResp.Data))
}
case status == 401:
res.Status = "fail"
res.Message = "认证失败(HTTP 401):请核对商户号、证书序列号与 apiclient_key.pem 是否为同一套(" +
firstNonEmptyStr(errBody.Code, "SIGN_ERROR") + ")"
res.Detail = truncateText(firstNonEmptyStr(errBody.Message, string(body)), 300)
case status == 403:
res.Status, res.OK = "ok", true
res.Message = "签名校验通过,但该商户/证书无「查询平台证书」权限(HTTP 403),实际下单前请确认权限集"
res.Detail = truncateText(firstNonEmptyStr(errBody.Message, string(body)), 300)
default:
res.Status = "fail"
res.Message = fmt.Sprintf("微信支付返回异常状态 HTTP %d(%s)", status, firstNonEmptyStr(errBody.Code, "-"))
res.Detail = truncateText(firstNonEmptyStr(errBody.Message, string(body)), 300)
}
return res
}
// wechatAPIGet 发起一次 APIv3 GET 请求并返回状态码/响应体/响应头 // Logan.Li
func wechatAPIGet(path, mchID, serialNo string, key *rsa.PrivateKey) (int, []byte, http.Header, error) {
nonce, err := randomNonce(32)
if err != nil {
return 0, nil, nil, err
}
ts := fmt.Sprintf("%d", time.Now().Unix())
sig, err := signWechatV3("GET", path, ts, nonce, "", key)
if err != nil {
return 0, nil, nil, err
}
auth := fmt.Sprintf(`WECHATPAY2-SHA256-RSA2048 mchid="%s",nonce_str="%s",signature="%s",timestamp="%s",serial_no="%s"`,
mchID, nonce, sig, ts, serialNo)
client := &http.Client{Timeout: 12 * time.Second}
req, err := http.NewRequest(http.MethodGet, wechatAPIBase+path, nil)
if err != nil {
return 0, nil, nil, err
}
req.Header.Set("Authorization", auth)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "opsx-licence/1.0")
resp, err := client.Do(req)
if err != nil {
return 0, nil, nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
return resp.StatusCode, body, resp.Header, nil
}
// signWechatV3 生成 APIv3 请求签名(SHA256withRSA + 换行拼接的规范串) // Logan.Li
func signWechatV3(method, urlPath, timestamp, nonce, body string, key *rsa.PrivateKey) (string, error) {
message := method + "\n" + urlPath + "\n" + timestamp + "\n" + nonce + "\n" + body + "\n"
digest := sha256.Sum256([]byte(message))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(sig), nil
}
// verifyWechatResponse 用平台证书校验应答签名,返回给用户看的一句话说明 // Logan.Li
func verifyWechatResponse(platformCertPath string, headers http.Header, body []byte) string {
sigB64 := headers.Get("Wechatpay-Signature")
ts := headers.Get("Wechatpay-Timestamp")
nonce := headers.Get("Wechatpay-Nonce")
if sigB64 == "" || ts == "" || nonce == "" {
return ""
}
certBytes, err := ReadSecretFile(platformCertPath)
if err != nil {
return ""
}
cert, err := ParseCertificatePEM(certBytes)
if err != nil {
return ";平台证书不可解析,已跳过应答验签"
}
pub, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
return ";平台证书公钥非 RSA,已跳过应答验签"
}
sig, err := base64.StdEncoding.DecodeString(sigB64)
if err != nil {
return ";平台证书应答验签失败(签名格式异常)"
}
message := ts + "\n" + nonce + "\n" + string(body) + "\n"
digest := sha256.Sum256([]byte(message))
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], sig); err != nil {
return ";注意:平台证书应答验签失败,平台证书可能已过期或被替换"
}
return ";平台证书应答验签通过"
}