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 }