后端: - 新增 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、回调地址 - 菜单归入「审计与设置 → 支付配置」
196 lines
6.8 KiB
Go
196 lines
6.8 KiB
Go
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 ";平台证书应答验签通过"
|
||
}
|