package businessscope import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "strconv" "strings" "time" ) const ( apiPageLimit = 500 apiMaxPages = 100 apiMaxRows = 50000 apiMaxBodyBytes = 8 << 20 apiSourcePrefix = "oneos-api-v1:" ) type APIConfig struct { URL string ServiceToken string SigningSecret string MaxAttempts int HTTPClient *http.Client Now func() time.Time } type apiEnvelope struct { Code int `json:"code"` Message string `json:"message"` Data apiSnapshotPage `json:"data"` RequestID string `json:"requestId"` } type apiSnapshotPage struct { ScopeVersion string `json:"scopeVersion"` GeneratedAt string `json:"generatedAt"` Complete bool `json:"complete"` NextCursor string `json:"nextCursor"` Items []apiScopeItem `json:"items"` Rejected []apiRejected `json:"rejected"` } type apiScopeItem struct { VehicleID json.RawMessage `json:"vehicleId"` VIN string `json:"vin"` PlateNumber string `json:"plateNumber"` CustomerID json.RawMessage `json:"customerId"` CustomerName string `json:"customerName"` ContractID json.RawMessage `json:"contractId"` ContractCode string `json:"contractCode"` ProjectName string `json:"projectName"` DepartmentID json.RawMessage `json:"departmentId"` DepartmentName string `json:"departmentName"` ResponsibleUserID json.RawMessage `json:"responsibleUserId"` ResponsibleName string `json:"responsibleUserName"` OperationStatus string `json:"operationStatus"` ScopeStartAt string `json:"scopeStartAt"` SourceUpdatedAt string `json:"sourceUpdatedAt"` } type apiRejected struct { VehicleID json.RawMessage `json:"vehicleId"` VIN string `json:"vin"` CustomerID json.RawMessage `json:"customerId"` ContractID json.RawMessage `json:"contractId"` Reason string `json:"reason"` ReasonCode string `json:"reasonCode"` } func ReadAPISnapshot(ctx context.Context, config APIConfig) (Snapshot, error) { endpoint, err := url.Parse(strings.TrimSpace(config.URL)) if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { return Snapshot{}, fmt.Errorf("ONEOS_SCOPE_API_URL must be an absolute HTTP(S) URL") } if endpoint.Scheme != "https" && !isPrivateAPIHost(endpoint.Hostname()) { return Snapshot{}, fmt.Errorf("OneOS scope API must use HTTPS outside private networks") } if strings.TrimSpace(config.ServiceToken) == "" || strings.TrimSpace(config.SigningSecret) == "" { return Snapshot{}, fmt.Errorf("OneOS scope API service token and signing secret are required") } if config.MaxAttempts <= 0 { config.MaxAttempts = 3 } if config.MaxAttempts > 5 { config.MaxAttempts = 5 } if config.HTTPClient == nil { config.HTTPClient = &http.Client{Timeout: 10 * time.Second} } if config.Now == nil { config.Now = time.Now } candidates := make([]Candidate, 0, 1024) upstreamRejections := make([]Rejection, 0) cursor := "" seenCursors := map[string]bool{} scopeVersion := "" var generatedAt time.Time for pageNumber := 0; pageNumber < apiMaxPages; pageNumber++ { pageURL := *endpoint query := pageURL.Query() query.Set("limit", strconv.Itoa(apiPageLimit)) if cursor != "" { query.Set("cursor", cursor) } else { query.Del("cursor") } pageURL.RawQuery = query.Encode() page, err := fetchAPIPage(ctx, config, &pageURL) if err != nil { return Snapshot{}, err } if !page.Complete { return Snapshot{}, fmt.Errorf("OneOS scope API returned an incomplete snapshot") } if strings.TrimSpace(page.ScopeVersion) == "" { return Snapshot{}, fmt.Errorf("OneOS scope API omitted scopeVersion") } pageGeneratedAt, err := time.Parse(time.RFC3339Nano, page.GeneratedAt) if err != nil { return Snapshot{}, fmt.Errorf("OneOS scope API generatedAt is invalid: %w", err) } if scopeVersion == "" { scopeVersion = page.ScopeVersion generatedAt = pageGeneratedAt } else if page.ScopeVersion != scopeVersion || !pageGeneratedAt.Equal(generatedAt) { return Snapshot{}, fmt.Errorf("OneOS scope API pagination changed snapshot version") } for _, item := range page.Items { candidate, err := candidateFromAPI(item, len(candidates)+len(upstreamRejections)+1) if err != nil { return Snapshot{}, err } candidates = append(candidates, candidate) } for _, rejected := range page.Rejected { upstreamRejections = append(upstreamRejections, rejectionFromAPI(rejected, len(candidates)+len(upstreamRejections)+1)) } if len(candidates)+len(upstreamRejections) > apiMaxRows { return Snapshot{}, fmt.Errorf("OneOS scope API exceeded %d rows", apiMaxRows) } next := strings.TrimSpace(page.NextCursor) if next == "" { break } if seenCursors[next] { return Snapshot{}, fmt.Errorf("OneOS scope API repeated pagination cursor") } seenCursors[next] = true cursor = next if pageNumber == apiMaxPages-1 { return Snapshot{}, fmt.Errorf("OneOS scope API exceeded %d pages", apiMaxPages) } } snapshot, err := BuildSnapshot(candidates, generatedAt) if err != nil { return Snapshot{}, err } snapshot.Candidates += len(upstreamRejections) snapshot.Rejections = append(snapshot.Rejections, upstreamRejections...) versionHash := sha256.Sum256([]byte(scopeVersion + "\n" + snapshot.Checksum)) snapshot.SourceVersion = apiSourcePrefix + hex.EncodeToString(versionHash[:]) return snapshot, nil } func fetchAPIPage(ctx context.Context, config APIConfig, pageURL *url.URL) (apiSnapshotPage, error) { var lastErr error for attempt := 1; attempt <= config.MaxAttempts; attempt++ { requestID, err := randomHex(16) if err != nil { return apiSnapshotPage{}, err } timestamp := strconv.FormatInt(config.Now().UTC().Unix(), 10) canonical := http.MethodGet + "\n" + pageURL.RequestURI() + "\n" + timestamp + "\n" + requestID mac := hmac.New(sha256.New, []byte(config.SigningSecret)) _, _ = mac.Write([]byte(canonical)) request, err := http.NewRequestWithContext(ctx, http.MethodGet, pageURL.String(), nil) if err != nil { return apiSnapshotPage{}, err } request.Header.Set("Accept", "application/json") request.Header.Set("Authorization", "Service "+config.ServiceToken) request.Header.Set("X-Request-Id", requestID) request.Header.Set("X-Request-Timestamp", timestamp) request.Header.Set("X-Request-Signature", hex.EncodeToString(mac.Sum(nil))) response, err := config.HTTPClient.Do(request) if err != nil { lastErr = err } else { page, retry, responseErr := decodeAPIResponse(response) if responseErr == nil { return page, nil } lastErr = responseErr if !retry { return apiSnapshotPage{}, responseErr } } if attempt < config.MaxAttempts { select { case <-ctx.Done(): return apiSnapshotPage{}, ctx.Err() case <-time.After(time.Duration(attempt*100) * time.Millisecond): } } } return apiSnapshotPage{}, fmt.Errorf("OneOS scope API failed after %d attempts: %w", config.MaxAttempts, lastErr) } func decodeAPIResponse(response *http.Response) (apiSnapshotPage, bool, error) { defer response.Body.Close() retry := response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500 if response.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) return apiSnapshotPage{}, retry, fmt.Errorf("OneOS scope API returned HTTP %d", response.StatusCode) } var envelope apiEnvelope decoder := json.NewDecoder(io.LimitReader(response.Body, apiMaxBodyBytes+1)) if err := decoder.Decode(&envelope); err != nil { return apiSnapshotPage{}, false, fmt.Errorf("decode OneOS scope API response: %w", err) } if envelope.Code != 0 { return apiSnapshotPage{}, false, fmt.Errorf("OneOS scope API returned business code %d", envelope.Code) } return envelope.Data, false, nil } func candidateFromAPI(item apiScopeItem, row int) (Candidate, error) { vehicleID, err := requiredInt64ID(item.VehicleID, "vehicleId") if err != nil { return Candidate{}, err } customerID, err := requiredInt64ID(item.CustomerID, "customerId") if err != nil { return Candidate{}, err } contractID, err := requiredInt64ID(item.ContractID, "contractId") if err != nil { return Candidate{}, err } scopeStart, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(item.ScopeStartAt)) if err != nil { return Candidate{}, fmt.Errorf("OneOS scope API item %d has invalid scopeStartAt", row) } var sourceUpdatedAt *time.Time if value := strings.TrimSpace(item.SourceUpdatedAt); value != "" { parsed, err := time.Parse(time.RFC3339Nano, value) if err != nil { return Candidate{}, fmt.Errorf("OneOS scope API item %d has invalid sourceUpdatedAt", row) } sourceUpdatedAt = &parsed } return Candidate{ RowNumber: row, VehicleID: vehicleID, VehiclePresent: true, VIN: item.VIN, PlateNumber: item.PlateNumber, CustomerID: customerID, CustomerName: item.CustomerName, CustomerPresent: true, CustomerProfileExists: true, ContractID: contractID, ContractPresent: true, ContractProfileExists: true, EffectiveCustomerID: customerID, EffectiveCustomerSet: true, ContractCode: item.ContractCode, ProjectName: item.ProjectName, DepartmentID: rawIDString(item.DepartmentID), DepartmentName: item.DepartmentName, ResponsibleUserID: rawIDString(item.ResponsibleUserID), ResponsibleUserName: item.ResponsibleName, OperationStatus: item.OperationStatus, ScopeStartAt: scopeStart, SourceUpdatedAt: sourceUpdatedAt, }, nil } func rejectionFromAPI(item apiRejected, row int) Rejection { reason := strings.ToUpper(strings.TrimSpace(firstNonEmptyScope(item.ReasonCode, item.Reason))) if reason == "" || len(reason) > 64 { reason = "UPSTREAM_REJECTED" } rejection := Rejection{RowNumber: row, VIN: normalizeVIN(item.VIN), ReasonCode: reason} if value, err := optionalInt64ID(item.VehicleID); err == nil && value > 0 { rejection.VehicleID = int64Pointer(value) } if value, err := optionalInt64ID(item.CustomerID); err == nil && value > 0 { rejection.CustomerID = int64Pointer(value) } if value, err := optionalInt64ID(item.ContractID); err == nil && value > 0 { rejection.ContractID = int64Pointer(value) } return rejection } func requiredInt64ID(raw json.RawMessage, name string) (int64, error) { value, err := optionalInt64ID(raw) if err != nil || value <= 0 { return 0, fmt.Errorf("OneOS scope API %s must be a positive integer string", name) } return value, nil } func optionalInt64ID(raw json.RawMessage) (int64, error) { value := rawIDString(raw) if value == "" { return 0, nil } return strconv.ParseInt(value, 10, 64) } func rawIDString(raw json.RawMessage) string { value := strings.TrimSpace(string(raw)) if value == "" || value == "null" { return "" } if strings.HasPrefix(value, `"`) { var decoded string if json.Unmarshal(raw, &decoded) == nil { return strings.TrimSpace(decoded) } return "" } return value } func isPrivateAPIHost(host string) bool { host = strings.ToLower(strings.TrimSpace(host)) if host == "localhost" { return true } address := net.ParseIP(host) return address != nil && (address.IsPrivate() || address.IsLoopback()) } func firstNonEmptyScope(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" }