Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/gzev/client.go
T
lingniu d6b957dfc8 feat: sync vehicle monitoring and accumulated platform updates
Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates.

Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
2026-10-26 18:05:59 +08:00

338 lines
10 KiB
Go

// Package gzev collects Guangzhou platform daily mileage without browser automation.
package gzev
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
const Origin = "https://gzev.d.lnoneos.com"
const MileagePath = "/evdata-web/api/operation/queryHyVehicleMileageStatistics"
var ErrAuth = errors.New("GZEV session expired; replace session file after interactive login")
var decimalPattern = regexp.MustCompile(`^[0-9]{1,11}(\.[0-9]{1,3})?$`)
// Decimal preserves source decimal precision and distinguishes missing values from zero.
type Decimal struct {
Value string
Valid bool
}
func (d *Decimal) UnmarshalJSON(b []byte) error {
*d = Decimal{}
if string(b) == "null" {
return nil
}
s := string(b)
if len(s) > 0 && s[0] == '"' {
if err := json.Unmarshal(b, &s); err != nil {
return err
}
}
if !decimalPattern.MatchString(s) {
return errors.New("invalid nonnegative mileage decimal")
}
d.Value = s
d.Valid = true
return nil
}
func (d Decimal) MarshalJSON() ([]byte, error) {
if !d.Valid {
return []byte("null"), nil
}
return json.Marshal(d.Value)
}
func (d Decimal) SQLValue() any {
if !d.Valid {
return nil
}
return d.Value
}
type Row struct {
VIN string `json:"vin"`
Plate string `json:"plateNumber"`
VehicleSeq string `json:"vehicleSeq"`
ProductionCompany string `json:"productionCompany"`
OperationCompany string `json:"operationCompany"`
Brand string `json:"brandName"`
Model string `json:"brandModel"`
DateRange string `json:"dateRange"`
Total Decimal `json:"runMileage"`
Hydrogen Decimal `json:"hyRunMileage"`
Charge Decimal `json:"chargeRunMileage"`
Raw json.RawMessage `json:"-"`
}
type Day struct {
Date string `json:"date"`
Rows []Row `json:"rows"`
Pages []json.RawMessage `json:"pages"`
FetchedAt time.Time `json:"fetched_at"`
}
type SessionCookie struct {
Name string `json:"name"`
Value string `json:"value"`
}
type Session struct {
Authorization string `json:"authorization"`
Cookies []SessionCookie `json:"cookies"`
}
func ReadSession(path string) (Session, error) {
var s Session
info, err := os.Stat(path)
if err != nil {
return s, errors.New("session file unavailable")
}
if info.Mode().Perm()&0077 != 0 {
return s, errors.New("session file must have permissions 0600")
}
b, err := os.ReadFile(path)
if err != nil {
return s, errors.New("cannot read session file")
}
if json.Unmarshal(b, &s) != nil {
return s, errors.New("invalid session JSON")
}
return s, ValidateSession(s)
}
func ValidateSession(s Session) error {
if !strings.HasPrefix(s.Authorization, "Bearer ") || len(s.Authorization) <= 7 || strings.ContainsAny(s.Authorization, "\r\n") {
return errors.New("invalid session authorization")
}
seenCookies := map[string]bool{}
for _, cookie := range s.Cookies {
if seenCookies[cookie.Name] {
return errors.New("duplicate session cookie")
}
seenCookies[cookie.Name] = true
if cookie.Name == "token" && "Bearer "+cookie.Value != s.Authorization {
return errors.New("token cookie and bearer mismatch")
}
if cookie.Name != "token" && cookie.Name != "bigs" {
return errors.New("session contains unsupported cookie")
}
if strings.ContainsAny(cookie.Value, "\r\n;") {
return errors.New("invalid session cookie")
}
}
if !seenCookies["token"] {
return errors.New("token cookie is required")
}
return nil
}
func ShanghaiToday(now time.Time) time.Time {
loc := time.FixedZone("Asia/Shanghai", 8*3600)
n := now.In(loc)
return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc)
}
func ParseDate(s string) (time.Time, error) {
return time.ParseInLocation("2006-01-02", s, time.FixedZone("Asia/Shanghai", 8*3600))
}
func ValidateDate(s string, now time.Time) error {
d, e := ParseDate(s)
if e != nil || !d.Before(ShanghaiToday(now)) {
return errors.New("date must be a completed Asia/Shanghai calendar day")
}
return nil
}
type Client struct {
HTTP *http.Client
Session Session
baseURL string // Only tests override this; production credentials stay on the fixed HTTPS origin.
PageSize int
Delay time.Duration
}
func NewClient(s Session) *Client {
return &Client{HTTP: &http.Client{Timeout: 45 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}, Session: s, baseURL: Origin, PageSize: 500, Delay: 500 * time.Millisecond}
}
func wait(ctx context.Context, d time.Duration) error {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
func (c *Client) page(ctx context.Context, date string, page int) (json.RawMessage, error) {
q := url.Values{"productionCompany": {""}, "operationCompany": {""}, "vin": {""}, "plateNumber": {""}, "brandModel": {""}, "queryBeginDate": {date}, "queryEndDate": {date}, "dateRange[0]": {date}, "dateRange[1]": {date}, "queryType": {"2"}, "showMonthlyColumn": {"false"}, "pageNum": {fmt.Sprint(page)}, "pageSize": {fmt.Sprint(c.PageSize)}}
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
if err := wait(ctx, time.Duration(attempt*attempt)*time.Second); err != nil {
return nil, err
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+MileagePath+"?"+q.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", c.Session.Authorization)
req.Header.Set("Accept", "application/json")
for _, cookie := range c.Session.Cookies {
req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value})
}
resp, err := c.HTTP.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if attempt < 2 {
continue
}
return nil, errors.New("GZEV network request failed")
}
b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
resp.Body.Close()
if resp.StatusCode == 401 || resp.StatusCode >= 300 && resp.StatusCode < 400 {
return nil, ErrAuth
}
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
if attempt < 2 {
continue
}
return nil, fmt.Errorf("GZEV temporary HTTP failure: %d", resp.StatusCode)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("GZEV HTTP failure: %d", resp.StatusCode)
}
if readErr != nil {
return nil, errors.New("GZEV response read failed")
}
var envelope struct {
Code json.RawMessage `json:"code"`
}
if json.Unmarshal(b, &envelope) != nil {
return nil, errors.New("GZEV non-JSON response; login or upstream failure")
}
code := strings.Trim(string(envelope.Code), `"`)
if code == "401" {
return nil, ErrAuth
}
if code != "0" {
if code == "00000004" || code == "403" {
return nil, errors.New("GZEV account lacks endpoint permission")
}
return nil, errors.New("GZEV non-success business response")
}
return b, nil
}
return nil, errors.New("GZEV retries exhausted")
}
func (c *Client) FetchDay(ctx context.Context, date string) (Day, error) {
day := Day{Date: date, FetchedAt: time.Now().UTC()}
if err := wait(ctx, c.Delay); err != nil {
return day, err
}
if err := ValidateDate(date, time.Now()); err != nil {
return day, err
}
if c.PageSize < 1 || c.PageSize > 500 {
return day, errors.New("page size must be 1..500")
}
seen := map[string]bool{}
expected := -1
for page := 1; page <= 10000; page++ {
if page > 1 {
if err := wait(ctx, c.Delay); err != nil {
return day, err
}
}
b, err := c.page(ctx, date, page)
if err != nil {
return day, err
}
var env struct {
Data *struct {
PageNum int `json:"pageNum"`
Total *int `json:"total"`
List []json.RawMessage `json:"list"`
} `json:"data"`
}
if json.Unmarshal(b, &env) != nil || env.Data == nil || env.Data.Total == nil || env.Data.PageNum != page {
return day, errors.New("invalid pagination schema")
}
p := env.Data
if expected < 0 {
expected = *p.Total
}
if expected < 0 || expected > 1000000 || *p.Total != expected {
return day, errors.New("source total changed or is invalid; retry day")
}
for _, raw := range p.List {
var row Row
if err := json.Unmarshal(raw, &row); err != nil {
return day, fmt.Errorf("invalid mileage row: %w", err)
}
if len(row.VIN) != 17 || seen[row.VIN] || row.DateRange != date+" - "+date {
return day, errors.New("invalid VIN, duplicate VIN, or non-daily date range")
}
row.Raw = raw
seen[row.VIN] = true
day.Rows = append(day.Rows, row)
}
day.Pages = append(day.Pages, b)
if len(day.Rows) == expected {
return day, nil
}
if len(day.Rows) > expected || len(p.List) == 0 {
return day, errors.New("incomplete or inconsistent pagination")
}
}
return day, errors.New("pagination safety limit exceeded")
}
// VerifyAccount prevents an operator importing another account's valid session into this dataset.
func (c *Client) VerifyAccount(ctx context.Context, account string) error {
q := url.Values{"token": {strings.TrimPrefix(c.Session.Authorization, "Bearer ")}}
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/evdata-sso/api/base/getUserInfo?"+q.Encode(), nil)
if err != nil {
return errors.New("cannot create identity request")
}
req.Header.Set("Authorization", c.Session.Authorization)
for _, cookie := range c.Session.Cookies {
req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value})
}
resp, err := c.HTTP.Do(req)
if err != nil {
return errors.New("GZEV identity request failed")
}
defer resp.Body.Close()
if resp.StatusCode == 401 || resp.StatusCode >= 300 && resp.StatusCode < 400 {
return ErrAuth
}
if resp.StatusCode != 200 {
return errors.New("GZEV identity HTTP failure")
}
var body struct {
Code json.RawMessage `json:"code"`
Data struct {
LoginName string `json:"loginname"`
} `json:"data"`
}
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body) != nil {
return errors.New("invalid identity response")
}
code := strings.Trim(string(body.Code), `"`)
if code == "401" {
return ErrAuth
}
if code != "0" || body.Data.LoginName != account {
return errors.New("GZEV session account does not match configured account, or identity check denied")
}
return nil
}