后端: - 新增 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、回调地址 - 菜单归入「审计与设置 → 支付配置」
284 lines
8.6 KiB
Go
284 lines
8.6 KiB
Go
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
|
||
}
|