344 lines
11 KiB
Go
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 ""
|
|
}
|