Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/openplatform/operating_data.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

503 lines
18 KiB
Go

package openplatform
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
)
const OperatingDataQueryPath = "/api/v1/vehicles/operating-data/query"
const operatingBudget = 6 * time.Second
const operatingQueryBudget = 4500 * time.Millisecond
const (
operatingRealtime = "REALTIME"
operatingMileage = "DAILY_MILEAGE"
operatingHydrogen = "DAILY_HYDROGEN"
)
type OperatingDataRequest struct {
ResponseMode string `json:"responseMode,omitempty"`
PlateNumbers []string `json:"plateNumbers,omitempty"`
DailyDate string `json:"dailyDate,omitempty"`
DataTypes []string `json:"dataTypes,omitempty"`
ProtocolPriority ProtocolPriority `json:"protocolPriority,omitempty"`
}
func (r *OperatingDataRequest) UnmarshalJSON(data []byte) error {
type plain OperatingDataRequest
var value plain
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return err
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
if fields == nil {
return fmt.Errorf("request must be an object")
}
if _, exists := fields["dailyDate"]; exists && value.DailyDate == "" {
return fmt.Errorf("dailyDate must not be empty")
}
if _, exists := fields["responseMode"]; exists && value.ResponseMode == "" {
return fmt.Errorf("responseMode must not be empty")
}
for key, raw := range fields {
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return fmt.Errorf("%s must not be null", key)
}
}
*r = OperatingDataRequest(value)
return nil
}
// RawMessage retains source number precision while normalizing optional fields
// without duplicating the existing result DTOs or changing their serializers.
type OperatingDataBlock map[string]json.RawMessage
type OperatingVehicleResult struct {
VIN string `json:"vin"`
PlateNumber string `json:"plateNumber"`
Realtime OperatingDataBlock `json:"realtime,omitempty"`
DailyMileage OperatingDataBlock `json:"dailyMileage,omitempty"`
DailyHydrogen OperatingDataBlock `json:"dailyHydrogen,omitempty"`
}
type OperatingDataSummary struct {
RequestedCount int `json:"requestedCount"`
ReturnedCount int `json:"returnedCount"`
SuccessCount int `json:"successCount"`
PartialSuccessCount int `json:"partialSuccessCount"`
FailedCount int `json:"failedCount"`
}
type OperatingDataResponse struct {
Code string `json:"code"`
Message string `json:"message"`
TraceID string `json:"traceId"`
QueryDate *string `json:"queryDate"`
Timezone string `json:"timezone"`
RequestAt string `json:"requestAt"`
GeneratedAt string `json:"generatedAt"`
Data []OperatingVehicleResult `json:"data"`
Summary OperatingDataSummary `json:"summary"`
}
type operatingBatchSizeError struct{}
func (*operatingBatchSizeError) Error() string { return "plateNumbers exceeds 200" }
func (*operatingBatchSizeError) Unwrap() error { return ErrInvalidRequest }
type operatingDateError struct{ code string }
func (e *operatingDateError) Error() string { return e.code }
func (s *Service) validateOperatingData(r OperatingDataRequest, now time.Time) ([]string, map[string]bool, []string, time.Time, error) {
if r.ResponseMode != "" && r.ResponseMode != "FULL" && r.ResponseMode != "SIMPLE" {
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: responseMode must be FULL or SIMPLE", ErrInvalidRequest)
}
if len(r.PlateNumbers) > 200 {
return nil, nil, nil, time.Time{}, &operatingBatchSizeError{}
}
plates, err := normalizePlates(r.PlateNumbers, 200)
if err != nil {
return nil, nil, nil, time.Time{}, err
}
kinds := r.DataTypes
if kinds == nil {
kinds = []string{operatingRealtime, operatingMileage, operatingHydrogen}
}
selected := make(map[string]bool, 3)
for _, kind := range kinds {
if selected[kind] || (kind != operatingRealtime && kind != operatingMileage && kind != operatingHydrogen) {
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: invalid dataTypes", ErrInvalidRequest)
}
selected[kind] = true
}
if len(selected) == 0 {
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: empty dataTypes", ErrInvalidRequest)
}
var day time.Time
if r.DailyDate != "" || selected[operatingMileage] || selected[operatingHydrogen] {
day, err = time.ParseInLocation("2006-01-02", r.DailyDate, s.location)
if err != nil || day.Format("2006-01-02") != r.DailyDate {
return nil, nil, nil, time.Time{}, &operatingDateError{"INVALID_DATE_FORMAT"}
}
if day.After(now) {
return nil, nil, nil, time.Time{}, &operatingDateError{"INVALID_DATE_RANGE"}
}
}
if r.ProtocolPriority.Present && !selected[operatingMileage] {
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: protocolPriority requires DAILY_MILEAGE", ErrInvalidRequest)
}
protocols, err := normalizeProtocolPriority(r.ProtocolPriority)
return plates, selected, protocols, day, err
}
// Validate every selected authorization scope before reading business data. The
// same frozen VIN mapping is then reused by all three batched repositories.
func (s *Service) authorizeOperatingScopes(ctx context.Context, key string, app AppCredential, plates []string, selected map[string]bool, now, day time.Time) (AppCredential, map[string]AuthorizedVehicle, error) {
hash := sha256.Sum256([]byte(strings.ToLower(key)))
allVehicles := len(plates) == 0
var vehicles map[string]AuthorizedVehicle
check := func(start, end time.Time) error {
found, err := s.repository.AuthorizedVehicles(ctx, app.ID, plates, start, end)
if err != nil {
return err
}
if !allVehicles && len(found) != len(plates) {
return ErrForbidden
}
checkedPlates := plates
if allVehicles {
checkedPlates = vehiclePlates(found)
}
for _, plate := range checkedPlates {
v, ok := found[plate]
if !ok || strings.TrimSpace(v.VIN) == "" || (v.Plate != "" && v.Plate != plate) {
return ErrForbidden
}
if previous, exists := vehicles[plate]; exists && previous.VIN != v.VIN {
return errors.New("operating query identity changed")
}
}
if allVehicles && vehicles != nil {
// Only return vehicles authorized for every selected time window.
for plate := range vehicles {
if _, exists := found[plate]; !exists {
delete(vehicles, plate)
}
}
} else {
vehicles = found
}
if vehicles == nil {
vehicles = make(map[string]AuthorizedVehicle)
}
return nil
}
if selected[operatingRealtime] {
if err := check(now, now); err != nil {
return app, nil, err
}
}
if selected[operatingMileage] || selected[operatingHydrogen] {
end := day.AddDate(0, 0, 1)
historicalApp, err := s.repository.Authenticate(ctx, hash, now, day, end)
if errors.Is(err, ErrUnauthorized) {
return app, nil, ErrForbidden
}
if err != nil {
return app, nil, err
}
if historicalApp.ID != app.ID {
return app, nil, errors.New("operating query app identity changed")
}
if err := check(day, end); err != nil {
return app, nil, err
}
}
return app, vehicles, nil
}
func (s *Service) QueryOperatingData(ctx context.Context, key, traceID string, request OperatingDataRequest) (*OperatingDataResponse, error) {
now := s.now().In(s.location)
plates, selected, protocols, day, err := s.validateOperatingData(request, now)
if err != nil {
return nil, err
}
bounded, cancel := context.WithTimeout(ctx, operatingBudget)
defer cancel()
// Authenticate before applying the existing per-app limiter. Independent state
// avoids consuming the historical-hydrogen endpoint's quota.
if !appKeyPattern.MatchString(key) {
return nil, ErrUnauthorized
}
app, err := s.repository.Authenticate(bounded, sha256.Sum256([]byte(strings.ToLower(key))), now, now, now)
if err != nil {
return nil, err
}
release, err := s.operatingLimiter.acquire(app.ID, now)
if err != nil {
return nil, err
}
defer release()
select {
case s.operatingSlots <- struct{}{}:
defer func() { <-s.operatingSlots }()
default:
return nil, &historicalHydrogenRateError{RetryAfter: 1}
}
// Authentication is shared with scope checks below, not repeated per type.
app, vehicles, err := s.authorizeOperatingScopes(bounded, key, app, plates, selected, now, day)
if err != nil {
_ = s.repository.Audit(bounded, app.ID, "operating_data_query", "denied", traceID, len(plates), "scope validation failed")
return nil, err
}
if len(plates) == 0 {
plates = vehiclePlates(vehicles)
}
result := &OperatingDataResponse{Code: "SUCCESS", Message: "success", TraceID: traceID, Timezone: "Asia/Shanghai", RequestAt: now.Format(time.RFC3339Nano), Data: make([]OperatingVehicleResult, len(plates)), Summary: OperatingDataSummary{RequestedCount: len(plates), ReturnedCount: len(plates)}}
if selected[operatingMileage] || selected[operatingHydrogen] {
result.QueryDate = &request.DailyDate
}
if len(plates) == 0 {
_ = s.repository.Audit(bounded, app.ID, "operating_data_query", "success", traceID, 0, "")
result.GeneratedAt = s.now().In(s.location).Format(time.RFC3339Nano)
return result, nil
}
vins := vehicleVINs(vehicles)
type completed struct {
kind string
blocks []OperatingDataBlock
}
done := make(chan completed, len(selected))
queryCtx, queryCancel := context.WithTimeout(bounded, operatingQueryBudget)
defer queryCancel()
for kind := range selected {
go func(kind string) {
started := time.Now()
var blocks []OperatingDataBlock
var err error
switch kind {
case operatingRealtime:
var points map[string]RealtimeVehiclePoint
points, err = s.repository.RealtimeVehicles(queryCtx, vins, now)
if err == nil {
values := buildRealtimeVehicleResults(plates, vehicles, points, now, s.location)
if request.ResponseMode == "SIMPLE" {
prepareSimpleRealtime(values, plates, vehicles, points, s.location)
}
blocks = operatingBlocks(values, s.location)
}
case operatingMileage:
var values map[string]DailyMileage
values, err = s.repository.ReconciledMileageRange(queryCtx, vins, request.DailyDate, request.DailyDate, protocols)
if err == nil {
blocks = operatingBlocks(buildMileageResults(plates, vehicles, values, request.DailyDate), s.location)
}
case operatingHydrogen:
var values map[string]DailyHydrogen
values, err = s.repository.DailyHydrogen(queryCtx, vins, request.DailyDate)
if err == nil {
blocks = operatingBlocks(buildHydrogenResults(plates, vehicles, values, request.DailyDate), s.location)
}
}
if err != nil {
blocks = make([]OperatingDataBlock, len(plates))
for i := range blocks {
blocks[i] = operatingFailure(kind, errors.Is(err, context.DeadlineExceeded))
}
}
if err != nil {
log.Printf("openplatform operating query unavailable: traceId=%s appId=%d type=%s durationMs=%d errorType=%T", traceID, app.ID, kind, time.Since(started).Milliseconds(), err)
}
done <- completed{kind, blocks}
}(kind)
}
byKind := make(map[string][]OperatingDataBlock, len(selected))
for len(byKind) < len(selected) {
select {
case result := <-done:
byKind[result.kind] = result.blocks
case <-queryCtx.Done():
// Drain already completed work before timing out only the outstanding types.
for {
select {
case result := <-done:
byKind[result.kind] = result.blocks
default:
goto drained
}
}
drained:
for kind := range selected {
if _, ok := byKind[kind]; !ok {
blocks := make([]OperatingDataBlock, len(plates))
for i := range blocks {
blocks[i] = operatingFailure(kind, true)
}
byKind[kind] = blocks
}
}
}
}
for i, plate := range plates {
row := OperatingVehicleResult{VIN: vehicles[plate].VIN, PlateNumber: plate}
succeeded := 0
for kind, blocks := range byKind {
block := blocks[i]
if string(block["queryStatus"]) == `"SUCCESS"` {
succeeded++
}
switch kind {
case operatingRealtime:
row.Realtime = block
case operatingMileage:
row.DailyMileage = block
case operatingHydrogen:
row.DailyHydrogen = block
}
}
switch {
case succeeded == len(selected):
result.Summary.SuccessCount++
case succeeded == 0:
result.Summary.FailedCount++
default:
result.Summary.PartialSuccessCount++
}
result.Data[i] = row
}
status := "success"
if result.Summary.SuccessCount != len(plates) {
status = "partial"
if result.Summary.FailedCount == len(plates) {
status = "error"
}
}
detail := ""
if request.ResponseMode == "SIMPLE" {
detail = "responseMode=SIMPLE"
}
_ = s.repository.Audit(bounded, app.ID, "operating_data_query", status, traceID, len(plates), detail)
result.GeneratedAt = s.now().In(s.location).Format(time.RFC3339Nano)
return result, nil
}
func operatingFailure(kind string, timeout bool) OperatingDataBlock {
prefix := map[string]string{operatingRealtime: "REALTIME", operatingMileage: "MILEAGE", operatingHydrogen: "HYDROGEN"}[kind]
suffix := "_QUERY_FAILED"
if timeout {
suffix = "_QUERY_TIMEOUT"
}
code, _ := json.Marshal(prefix + suffix)
return OperatingDataBlock{"queryStatus": json.RawMessage(`"FAILED"`), "status": json.RawMessage("null"), "errorCode": code, "errorMessage": json.RawMessage(`"data query unavailable"`)}
}
func operatingBlocks[T any](values []T, location *time.Location) []OperatingDataBlock {
blocks := make([]OperatingDataBlock, len(values))
for i, value := range values {
block, err := operatingBlock(value, location)
if err != nil {
block = OperatingDataBlock{"queryStatus": json.RawMessage(`"FAILED"`), "status": json.RawMessage("null"), "errorCode": json.RawMessage(`"SOURCE_DATA_INVALID"`), "errorMessage": json.RawMessage(`"source data cannot be serialized"`)}
}
blocks[i] = block
}
return blocks
}
func operatingBlock(value any, location *time.Location) (OperatingDataBlock, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
var block OperatingDataBlock
if err = json.Unmarshal(data, &block); err != nil {
return nil, err
}
delete(block, "vin")
delete(block, "plateNumber")
var optional []string
switch value.(type) {
case RealtimeVehicleResult:
optional = []string{"protocol", "recordTime", "socPercent", "timeDifferenceSeconds"}
case MileageResult:
optional = []string{"dataQuality"}
case HydrogenResult:
optional = []string{"calculationPhase", "algorithmVersion", "qualityStatus", "qualityReason"}
}
for _, name := range optional {
if _, exists := block[name]; !exists {
block[name] = json.RawMessage("null")
}
}
for _, name := range []string{"recordTime", "locationRecordTime", "hydrogenRecordTime", "dataTime", "updatedAt", "statisticsStartTime", "statisticsEndTime"} {
raw, ok := block[name]
if !ok || string(raw) == "null" {
continue
}
var text string
if err = json.Unmarshal(raw, &text); err != nil {
return nil, err
}
at, parseErr := time.Parse(time.RFC3339Nano, text)
if parseErr != nil {
at, parseErr = time.ParseInLocation("2006-01-02 15:04:05", text, location)
}
if parseErr != nil {
return nil, parseErr
}
block[name], _ = json.Marshal(at.In(location).Format(time.RFC3339Nano))
}
block["queryStatus"] = json.RawMessage(`"SUCCESS"`)
block["errorCode"] = json.RawMessage("null")
block["errorMessage"] = json.RawMessage("null")
return block, nil
}
func (h *Handler) operatingData(w http.ResponseWriter, r *http.Request) {
traceID := newExternalTraceID()
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
var request OperatingDataRequest
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&request); err != nil {
writeOperatingDecodeError(w, traceID, err)
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
writeOperatingDecodeError(w, traceID, err)
return
}
result, err := h.service.QueryOperatingData(r.Context(), externalBearer(r), traceID, request)
if err != nil {
var limit *historicalHydrogenRateError
var date *operatingDateError
var batch *operatingBatchSizeError
switch {
case errors.As(err, &limit):
w.Header().Set("Retry-After", strconv.Itoa(limit.RetryAfter))
writeExternal(w, http.StatusTooManyRequests, ExternalResponse{Code: "RATE_LIMIT_EXCEEDED", Message: "请求过于频繁,请稍后重试", TraceID: traceID})
case errors.As(err, &batch):
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "BATCH_SIZE_EXCEEDED", Message: "plateNumbers最多200项;查询全部授权车辆请省略plateNumbers或传空数组", TraceID: traceID})
case errors.As(err, &date):
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: date.code, Message: "dailyDate无效或超出允许范围", TraceID: traceID})
default:
writeExternalError(w, traceID, err)
}
return
}
// Large batches need no pretty-printing: keep the new endpoint compact while
// preserving all existing endpoints' response formatting.
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if request.ResponseMode == "SIMPLE" {
response := simplifyOperatingData(result, h.service.now().In(h.service.location))
response.GeneratedAt = h.service.now().In(h.service.location).Format(time.RFC3339Nano)
_ = json.NewEncoder(w).Encode(response)
return
}
_ = json.NewEncoder(w).Encode(result)
}
func writeOperatingDecodeError(w http.ResponseWriter, traceID string, err error) {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeExternal(w, http.StatusRequestEntityTooLarge, ExternalResponse{Code: "PAYLOAD_TOO_LARGE", Message: "请求体超过64KiB", TraceID: traceID})
return
}
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "INVALID_REQUEST", Message: "请求参数不正确", TraceID: traceID})
}