package feichibridge import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" ) var ErrUnauthorized = errors.New("feichi API session unauthorized") type APIClient struct { baseURL *url.URL headers http.Header client *http.Client login *loginSession } func NewAPIClient(baseURL string, headers map[string]string, timeout time.Duration) (*APIClient, error) { parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/") if err != nil { return nil, fmt.Errorf("parse FEICHI_BASE_URL: %w", err) } if parsed.Scheme != "http" && parsed.Scheme != "https" { return nil, fmt.Errorf("FEICHI_BASE_URL must use http or https") } if timeout <= 0 { timeout = 15 * time.Second } out := &APIClient{ baseURL: parsed, headers: make(http.Header), client: &http.Client{Timeout: timeout}, } for name, value := range headers { if strings.TrimSpace(name) != "" && strings.TrimSpace(value) != "" { out.headers.Set(name, value) } } return out, nil } func NewAuthenticatedAPIClient(baseURL string, credentials LoginCredentials, solver CaptchaSolver, timeout time.Duration) (*APIClient, error) { client, err := NewAPIClient(baseURL, nil, timeout) if err != nil { return nil, err } login, err := newLoginSession(credentials, solver) if err != nil { return nil, err } client.login = login return client, nil } func (c *APIClient) Vehicles(ctx context.Context) ([]Vehicle, error) { payload := map[string]any{ "conditions": []map[string]string{ {"name": "iccid", "value": ""}, {"name": "powerMode", "value": ""}, }, "sort": []map[string]string{{"name": "updateTime", "order": "desc"}}, "start": 0, "limit": 100, } var envelope collectionEnvelope[Vehicle] if err := c.doJSON(ctx, http.MethodPost, "api/v1/sys/vehicleRealStatuss", payload, &envelope); err != nil { return nil, err } if !apiCodeOK(envelope.Code) { return nil, apiCodeError(envelope.Code) } return envelope.Data, nil } func (c *APIClient) Snapshot(ctx context.Context, vehicleID string) (Snapshot, error) { path := "api/v1/sys/vehicleRealStatussByVId/" + url.PathEscape(vehicleID) + "/-1" var envelope objectEnvelope[Snapshot] if err := c.doJSON(ctx, http.MethodGet, path, nil, &envelope); err != nil { return Snapshot{}, err } if !apiCodeOK(envelope.Code) { return Snapshot{}, apiCodeError(envelope.Code) } return envelope.Data, nil } func (c *APIClient) History(ctx context.Context, vin string, begin, end time.Time) ([]Record, error) { payload := map[string]any{ "conditions": []map[string]string{ {"name": "queryType", "value": "0"}, {"name": "queryContent", "value": vin}, {"name": "beginTime", "value": begin.In(shanghai).Format("2006-01-02 15:04:05")}, {"name": "endTime", "value": end.In(shanghai).Format("2006-01-02 15:04:05")}, {"name": "hisdataType", "value": "0"}, }, "start": 0, "limit": 100, } var envelope collectionEnvelope[historySegment] if err := c.doJSON(ctx, http.MethodPost, "api/v1/sys/hisdataQuerys", payload, &envelope); err != nil { return nil, err } if !apiCodeOK(envelope.Code) { return nil, apiCodeError(envelope.Code) } var records []Record for _, segment := range envelope.Data { records = append(records, segment.Subdata...) } return records, nil } func (c *APIClient) doJSON(ctx context.Context, method, path string, body any, output any) error { if c.login != nil { if err := c.login.ensure(ctx, c); err != nil { return err } } for attempt := 0; attempt < 2; attempt++ { _, err := c.rawJSON(ctx, method, path, body, output) if !errors.Is(err, ErrUnauthorized) || c.login == nil || attempt == 1 { return err } c.login.invalidate() if err := c.login.ensure(ctx, c); err != nil { return err } } return ErrUnauthorized } func (c *APIClient) rawJSON(ctx context.Context, method, path string, body any, output any) (http.Header, error) { endpoint, err := c.baseURL.Parse(path) if err != nil { return nil, err } var reader io.Reader if body != nil { encoded, marshalErr := json.Marshal(body) if marshalErr != nil { return nil, marshalErr } reader = bytes.NewReader(encoded) } request, err := http.NewRequestWithContext(ctx, method, endpoint.String(), reader) if err != nil { return nil, err } request.Header.Set("Accept", "application/json") if body != nil { request.Header.Set("Content-Type", "application/json") } for name, values := range c.headers { for _, value := range values { request.Header.Add(name, value) } } if c.login != nil { if cookie := c.login.cookieHeader(); cookie != "" { request.Header.Set("Cookie", cookie) } if csrf := c.login.csrfHeader(); csrf != "" { request.Header.Set("x-api-csrf", csrf) } } response, err := c.client.Do(request) if err != nil { return nil, fmt.Errorf("feichi %s %s: %w", method, path, err) } defer response.Body.Close() if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { return response.Header, ErrUnauthorized } if response.StatusCode < 200 || response.StatusCode >= 300 { snippet, _ := io.ReadAll(io.LimitReader(response.Body, 1024)) return response.Header, fmt.Errorf("feichi %s %s: HTTP %d: %s", method, path, response.StatusCode, strings.TrimSpace(string(snippet))) } encoded, err := io.ReadAll(io.LimitReader(response.Body, 16<<20)) if err != nil { return response.Header, fmt.Errorf("read feichi %s: %w", path, err) } var status struct { Code any `json:"code"` } if err := json.Unmarshal(encoded, &status); err != nil { return response.Header, fmt.Errorf("decode feichi %s: %w", path, err) } if fmt.Sprint(status.Code) == "401" || fmt.Sprint(status.Code) == "403" { return response.Header, ErrUnauthorized } if output != nil { if err := json.Unmarshal(encoded, output); err != nil { return response.Header, fmt.Errorf("decode feichi %s: %w", path, err) } } return response.Header, nil } func apiCodeError(code any) error { if fmt.Sprint(code) == "401" || fmt.Sprint(code) == "403" { return ErrUnauthorized } return fmt.Errorf("feichi API returned code %v", code) }