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,283 @@
package payment
import (
"backend/internal/dto"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
)
// 支付渠道连通性测试(只做最小校验,不下单、不写业务数据)。
// 无真实凭据时返回 status=skipped,绝不以 500 结束。 // Logan.Li
// 微信支付 APIv3 基础地址 // Logan.Li
const wechatAPIBase = "https://api.mch.weixin.qq.com"
// Test 执行连通性测试(channel: wechat / alipay / all) // Logan.Li
func (s *ConfigService) Test(channel string) *dto.PaymentTestResponse {
ch := strings.ToLower(strings.TrimSpace(channel))
resp := &dto.PaymentTestResponse{Results: []dto.PaymentTestResult{}}
if ch == "" || ch == "all" {
resp.Results = append(resp.Results, s.TestWechat(), s.TestAlipay())
return resp
}
switch ch {
case "wechat":
resp.Results = append(resp.Results, s.TestWechat())
case "alipay":
resp.Results = append(resp.Results, s.TestAlipay())
default:
resp.Results = append(resp.Results, dto.PaymentTestResult{
Channel: ch, Status: "fail", OK: false,
Message: "未知渠道:" + ch + "(仅支持 wechat / alipay / all)",
})
}
return resp
}
// ============ 支付宝 ============
// TestAlipay 调用支付宝网关做一次签名校验请求 // Logan.Li
func (s *ConfigService) TestAlipay() dto.PaymentTestResult {
res := dto.PaymentTestResult{Channel: "alipay"}
row, err := s.loadRow()
if err != nil {
res.Status, res.Message = "fail", err.Error()
return res
}
appID := strings.TrimSpace(row.AlipayAppID)
privPEM, _ := s.alipayPrivateKey(row)
var missing []string
if appID == "" {
missing = append(missing, "app_id")
}
if strings.TrimSpace(privPEM) == "" {
missing = append(missing, "应用私钥")
}
if len(missing) > 0 {
res.Status = "skipped"
res.Message = "未配置:" + strings.Join(missing, "、") + "(跳过连通性校验)"
return res
}
key, err := ParseRSAPrivateKey([]byte(privPEM))
if err != nil {
res.Status, res.Message = "fail", "应用私钥不可用:"+err.Error()
return res
}
gateway := strings.TrimSpace(row.AlipayGateway)
if gateway == "" {
gateway = DefaultAlipayGateway
}
params := map[string]string{
"app_id": appID,
"method": "alipay.trade.query",
"format": "JSON",
"charset": "utf-8",
"sign_type": "RSA2",
"timestamp": time.Now().Format("2006-01-02 15:04:05"),
"version": "1.0",
"notify_url": AlipayNotifyURL,
"biz_content": `{"out_trade_no":"OPSX-CONFIG-CHECK"}`,
}
sign, err := alipaySign(params, key)
if err != nil {
res.Status, res.Message = "fail", "生成签名失败:"+err.Error()
return res
}
form := make([]string, 0, len(params)+1)
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
form = append(form, k+"="+urlEncode(params[k]))
}
form = append(form, "sign="+urlEncode(sign))
start := time.Now()
status, body, err := httpPostForm(gateway, strings.Join(form, "&"), 12*time.Second)
res.LatencyMS = time.Since(start).Milliseconds()
if err != nil {
res.Status = "fail"
res.Message = "网关不可达:" + err.Error()
return res
}
res.HTTPStatus = status
var wrapper struct {
Resp json.RawMessage `json:"alipay_trade_query_response"`
Sign string `json:"sign"`
}
if err := json.Unmarshal(body, &wrapper); err != nil || len(wrapper.Resp) == 0 {
res.Status = "fail"
res.Message = "网关返回非预期内容(HTTP " + itoa(status) + ")"
res.Detail = truncateText(string(body), 300)
return res
}
var biz struct {
Code string `json:"code"`
Msg string `json:"msg"`
SubCode string `json:"sub_code"`
SubMsg string `json:"sub_msg"`
}
_ = json.Unmarshal(wrapper.Resp, &biz)
res.Detail = fmt.Sprintf("code=%s sub_code=%s sub_msg=%s", biz.Code, biz.SubCode, biz.SubMsg)
// 支付宝公钥验签(若已配置,能顺带验证公钥是否正确) // Logan.Li
pubNote := ""
if pubPEM, _ := s.alipayPublicKey(row); strings.TrimSpace(pubPEM) != "" {
if pub, perr := ParseRSAPublicKey([]byte(pubPEM)); perr == nil && wrapper.Sign != "" {
if verr := alipayVerify(wrapper.Resp, wrapper.Sign, pub); verr == nil {
pubNote = ";支付宝公钥验签通过"
} else {
pubNote = ";注意:响应验签失败,支付宝公钥可能与应用不匹配"
}
}
}
switch {
case biz.Code == "10000":
res.Status, res.OK = "ok", true
res.Message = "签名校验通过:支付宝网关正常应答(交易查询成功)" + pubNote
case strings.EqualFold(biz.SubCode, "ACQ.TRADE_NOT_EXIST"):
res.Status, res.OK = "ok", true
res.Message = "签名校验通过:网关已受理请求(测试单号不存在,属预期)" + pubNote
case isAlipayAuthError(biz.Code, biz.SubCode):
res.Status = "fail"
res.Message = "签名/应用校验失败:" + firstNonEmptyStr(biz.SubMsg, biz.Msg) + "(请核对 app_id、应用私钥与密钥格式)"
default:
res.Status = "fail"
res.Message = "网关返回业务错误:" + firstNonEmptyStr(biz.SubMsg, biz.Msg, biz.Code) + pubNote
}
return res
}
// isAlipayAuthError 判断是否属于签名/应用身份类错误 // Logan.Li
func isAlipayAuthError(code, subCode string) bool {
sc := strings.ToLower(strings.TrimSpace(subCode))
c := strings.TrimSpace(code)
for _, k := range []string{"isv.invalid-signature", "isv.missing-signature", "isv.invalid-app-id", "isv.invalid-encrypt-type", "isv.insufficient-isv-permissions"} {
if sc == k {
return true
}
}
return c == "40002" || c == "40006"
}
// alipaySign 按支付宝规则(去 sign、字典序、k=v&…)做 SHA256withRSA 签名 // Logan.Li
func alipaySign(params map[string]string, key *rsa.PrivateKey) (string, error) {
keys := make([]string, 0, len(params))
for k, v := range params {
if k == "sign" || strings.TrimSpace(v) == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+params[k])
}
digest := sha256.Sum256([]byte(strings.Join(parts, "&")))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(sig), nil
}
// alipayVerify 用支付宝公钥校验响应签名 // Logan.Li
func alipayVerify(content []byte, signB64 string, pub *rsa.PublicKey) error {
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(signB64))
if err != nil {
return err
}
digest := sha256.Sum256(content)
return rsa.VerifyPKCS1v15(pub, crypto.SHA256, digest[:], sig)
}
// ============ 通用 HTTP 辅助 ============
// httpPostForm 以 form-urlencoded 提交 // Logan.Li
func httpPostForm(url, body string, timeout time.Duration) (int, []byte, error) {
client := &http.Client{Timeout: timeout}
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
if err != nil {
return 0, nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
req.Header.Set("User-Agent", "opsx-licence/1.0")
resp, err := client.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
return resp.StatusCode, data, nil
}
// urlEncode 表单值转义(空格用 %20,符合支付宝要求) // Logan.Li
func urlEncode(s string) string {
var b strings.Builder
for _, c := range []byte(s) {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
continue
}
b.WriteString("%")
b.WriteString(strings.ToUpper(hex2(c)))
}
return b.String()
}
// hex2 单字节转两位十六进制 // Logan.Li
func hex2(c byte) string {
const digits = "0123456789abcdef"
return string([]byte{digits[c>>4], digits[c&0x0f]})
}
// truncateText 按字符截断(waffo_api.go 的 truncate 按字节,中文会切坏) // Logan.Li
func truncateText(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= n {
return string(r)
}
return string(r[:n]) + "…"
}
// firstNonEmptyStr 返回第一个非空字符串 // Logan.Li
func firstNonEmptyStr(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
// randomNonce 生成随机串(微信 nonce_str) // Logan.Li
func randomNonce(n int) (string, error) {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
for i := range buf {
buf[i] = charset[int(buf[i])%len(charset)]
}
return string(buf), nil
}