Files
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

284 lines
8.6 KiB
Go
Raw Permalink 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"
"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
}