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}) }