package fadada import ( "bytes" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) // HTTPClient 法大大 OpenAPI HTTP 客户端(不依赖官方 SDK) type HTTPClient struct { config *Config client *http.Client } func NewHTTPClient(config *Config) *HTTPClient { return &HTTPClient{ config: config, client: &http.Client{Timeout: 30 * time.Second}, } } // apiResponse 通用响应外壳 type apiResponse struct { Code string `json:"code"` Msg string `json:"msg"` Data json.RawMessage `json:"data"` RequestID string `json:"requestId,omitempty"` } // PostBiz 发送业务 API:POST form bizContent=,带 AccessToken 签名 func (h *HTTPClient) PostBiz(path, accessToken string, biz any) (*apiResponse, error) { bizJSON, err := marshalBizContent(biz) if err != nil { return nil, err } headers := h.buildBizHeaders(accessToken, bizJSON) return h.doPost(path, headers, bizJSON) } // PostToken 获取 accessToken:无 AccessToken,带 Grant-Type func (h *HTTPClient) PostToken(path string) (*apiResponse, error) { headers := h.buildTokenHeaders() return h.doPost(path, headers, "") } func (h *HTTPClient) doPost(path string, headers map[string]string, bizJSON string) (*apiResponse, error) { fullURL := joinURL(h.config.ServerURL, path) form := url.Values{} form.Set("bizContent", bizJSON) body := form.Encode() req, err := http.NewRequest(http.MethodPost, fullURL, strings.NewReader(body)) if err != nil { return nil, fmt.Errorf("创建法大大请求失败: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") for k, v := range headers { req.Header.Set(k, v) } resp, err := h.client.Do(req) if err != nil { // 网络错误也记录一次,便于排查连接问题 writeDebugLog(http.MethodPost, fullURL, headers, bizJSON, -1, fmt.Sprintf("HTTP ERROR: %v", err), "") return nil, fmt.Errorf("调用法大大接口失败 %s: %w", path, err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { writeDebugLog(http.MethodPost, fullURL, headers, bizJSON, resp.StatusCode, fmt.Sprintf("READ BODY ERROR: %v", err), "") return nil, fmt.Errorf("读取法大大响应失败 %s: %w", path, err) } requestID := "" if vals := resp.Header.Values("X-FASC-Request-Id"); len(vals) > 0 { requestID = vals[0] } // 落盘 cURL + 响应(含 X-FASC-* 头与 bizContent),便于排查免验证签等问题 writeDebugLog(http.MethodPost, fullURL, headers, bizJSON, resp.StatusCode, string(respBody), requestID) var out apiResponse if err := json.Unmarshal(respBody, &out); err != nil { return nil, fmt.Errorf("解析法大大响应失败 %s: %w body=%s", path, err, truncate(string(respBody), 512)) } if out.RequestID == "" { out.RequestID = requestID } return &out, nil } func (h *HTTPClient) buildTokenHeaders() map[string]string { timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) nonce := randomNonce() headMap := map[string]string{ "X-FASC-App-Id": h.config.AppID, "X-FASC-Sign-Type": signTypeHMACSHA256, "X-FASC-Timestamp": timestamp, "X-FASC-Nonce": nonce, "X-FASC-Grant-Type": "client_credential", "X-FASC-Api-SubVersion": apiSubVersion, } headMap["X-FASC-Sign"] = SignByMap(headMap, timestamp, h.config.AppSecret) return headMap } func (h *HTTPClient) buildBizHeaders(accessToken, bizJSON string) map[string]string { timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) nonce := randomNonce() headMap := map[string]string{ "X-FASC-App-Id": h.config.AppID, "X-FASC-Sign-Type": signTypeHMACSHA256, "X-FASC-Timestamp": timestamp, "X-FASC-Nonce": nonce, "X-FASC-AccessToken": accessToken, "X-FASC-Api-SubVersion": apiSubVersion, "bizContent": bizJSON, } sign := SignByMap(headMap, timestamp, h.config.AppSecret) delete(headMap, "bizContent") headMap["X-FASC-Sign"] = sign return headMap } func marshalBizContent(biz any) (string, error) { if biz == nil { return "", nil } switch v := biz.(type) { case string: return v, nil case []byte: return string(v), nil default: b, err := json.Marshal(biz) if err != nil { return "", fmt.Errorf("序列化 bizContent 失败: %w", err) } // 紧凑 JSON,避免多余空格影响签名 var buf bytes.Buffer if err := json.Compact(&buf, b); err == nil { return buf.String(), nil } return string(b), nil } } func joinURL(base, path string) string { base = strings.TrimRight(base, "/") if !strings.HasPrefix(path, "/") { path = "/" + path } return base + path } func randomNonce() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return strconv.FormatInt(time.Now().UnixNano(), 16) } return hex.EncodeToString(b) } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." } // decodeData 将 apiResponse.Data 反序列化到目标结构 func decodeData(raw json.RawMessage, dest any) error { if len(raw) == 0 || string(raw) == "null" { return nil } return json.Unmarshal(raw, dest) }