feat(platform): harden telemetry pipeline and unify Semi UI workspaces
This commit is contained in:
211
go/vehicle-gateway/internal/feichibridge/client.go
Normal file
211
go/vehicle-gateway/internal/feichibridge/client.go
Normal file
@@ -0,0 +1,211 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user