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 ";平台证书应答验签通过" }