Files
opsx-licence/backend/internal/service/payment/config_test_wechat.go
T
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

196 lines
6.8 KiB
Go
Raw 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"
"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 ";平台证书应答验签通过"
}