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.
503 lines
18 KiB
Go
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})
|
|
}
|