Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/businessscope/api_source.go
2026-07-16 18:49:01 +08:00

344 lines
11 KiB
Go

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