Compare commits
2 Commits
1243efc7dd
...
bbab018d55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbab018d55 | ||
|
|
46f2026c03 |
@@ -23,10 +23,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() error {
|
func run() error {
|
||||||
sourceDSN, err := normalizedDSN("ONEOS_MYSQL_DSN", os.Getenv("ONEOS_MYSQL_DSN"), true)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
targetDSN, err := normalizedDSN("MYSQL_DSN", os.Getenv("MYSQL_DSN"), false)
|
targetDSN, err := normalizedDSN("MYSQL_DSN", os.Getenv("MYSQL_DSN"), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -34,21 +30,12 @@ func run() error {
|
|||||||
timeout := time.Duration(envInt("ONEOS_SCOPE_SYNC_TIMEOUT_SEC", 60)) * time.Second
|
timeout := time.Duration(envInt("ONEOS_SCOPE_SYNC_TIMEOUT_SEC", 60)) * time.Second
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
source, err := openDB(ctx, sourceDSN, 2)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open OneOS read-only database: %w", err)
|
|
||||||
}
|
|
||||||
defer source.Close()
|
|
||||||
target, err := openDB(ctx, targetDSN, 4)
|
target, err := openDB(ctx, targetDSN, 4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("open vehicle platform database: %w", err)
|
return fmt.Errorf("open vehicle platform database: %w", err)
|
||||||
}
|
}
|
||||||
defer target.Close()
|
defer target.Close()
|
||||||
candidates, err := businessscope.ReadCandidates(ctx, source)
|
snapshot, sourceKind, err := readSnapshot(ctx)
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
snapshot, err := businessscope.BuildSnapshot(candidates, time.Now())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -64,11 +51,50 @@ func run() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Printf("OneOS scope sync complete changed=%t candidates=%d accepted=%d rejected=%d version=%s",
|
log.Printf("OneOS scope sync complete source=%s changed=%t candidates=%d accepted=%d rejected=%d version=%s",
|
||||||
result.Changed, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.SourceVersion)
|
sourceKind, result.Changed, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.SourceVersion)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readSnapshot(ctx context.Context) (businessscope.Snapshot, string, error) {
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(os.Getenv("ONEOS_SCOPE_SOURCE")))
|
||||||
|
if mode == "" {
|
||||||
|
if strings.TrimSpace(os.Getenv("ONEOS_SCOPE_API_URL")) != "" {
|
||||||
|
mode = "api"
|
||||||
|
} else {
|
||||||
|
mode = "database"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch mode {
|
||||||
|
case "api":
|
||||||
|
snapshot, err := businessscope.ReadAPISnapshot(ctx, businessscope.APIConfig{
|
||||||
|
URL: os.Getenv("ONEOS_SCOPE_API_URL"),
|
||||||
|
ServiceToken: os.Getenv("ONEOS_SCOPE_API_SERVICE_TOKEN"),
|
||||||
|
SigningSecret: os.Getenv("ONEOS_SCOPE_API_SIGNING_SECRET"),
|
||||||
|
MaxAttempts: envInt("ONEOS_SCOPE_API_MAX_ATTEMPTS", 3),
|
||||||
|
})
|
||||||
|
return snapshot, mode, err
|
||||||
|
case "database":
|
||||||
|
sourceDSN, err := normalizedDSN("ONEOS_MYSQL_DSN", os.Getenv("ONEOS_MYSQL_DSN"), true)
|
||||||
|
if err != nil {
|
||||||
|
return businessscope.Snapshot{}, mode, err
|
||||||
|
}
|
||||||
|
source, err := openDB(ctx, sourceDSN, 2)
|
||||||
|
if err != nil {
|
||||||
|
return businessscope.Snapshot{}, mode, fmt.Errorf("open OneOS read-only database: %w", err)
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
candidates, err := businessscope.ReadCandidates(ctx, source)
|
||||||
|
if err != nil {
|
||||||
|
return businessscope.Snapshot{}, mode, err
|
||||||
|
}
|
||||||
|
snapshot, err := businessscope.BuildSnapshot(candidates, time.Now())
|
||||||
|
return snapshot, mode, err
|
||||||
|
default:
|
||||||
|
return businessscope.Snapshot{}, mode, fmt.Errorf("ONEOS_SCOPE_SOURCE must be api or database")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizedDSN(name, raw string, requireOneOSDatabase bool) (string, error) {
|
func normalizedDSN(name, raw string, requireOneOSDatabase bool) (string, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package businessscope
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadAPISnapshotAuthenticatesPaginatesAndPreservesDimensions(t *testing.T) {
|
||||||
|
const token = "service-token"
|
||||||
|
const secret = "signing-secret"
|
||||||
|
now := time.Date(2026, 7, 16, 10, 0, 0, 0, time.UTC)
|
||||||
|
var requests atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests.Add(1)
|
||||||
|
if r.Header.Get("Authorization") != "Service "+token {
|
||||||
|
t.Fatalf("missing service token")
|
||||||
|
}
|
||||||
|
canonical := r.Method + "\n" + r.URL.RequestURI() + "\n" + r.Header.Get("X-Request-Timestamp") + "\n" + r.Header.Get("X-Request-Id")
|
||||||
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
_, _ = mac.Write([]byte(canonical))
|
||||||
|
if r.Header.Get("X-Request-Signature") != hex.EncodeToString(mac.Sum(nil)) {
|
||||||
|
t.Fatalf("invalid request signature")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.URL.Query().Get("cursor") == "" {
|
||||||
|
fmt.Fprint(w, `{"code":0,"data":{"scopeVersion":"scope-42","generatedAt":"2026-07-16T10:00:00Z","complete":true,"nextCursor":"page-2","items":[{"vehicleId":"10","vin":" lvin0001 ","plateNumber":"沪A00001","customerId":"100","customerName":"客户甲","contractId":"1010","contractCode":"HT-1","projectName":"项目甲","departmentId":"20","departmentName":"运营一部","responsibleUserId":"30","responsibleUserName":"张三","operationStatus":"active","scopeStartAt":"2026-07-01T08:00:00+08:00","sourceUpdatedAt":"2026-07-16T09:59:00Z"}],"rejected":[]}}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `{"code":0,"data":{"scopeVersion":"scope-42","generatedAt":"2026-07-16T10:00:00Z","complete":true,"nextCursor":"","items":[{"vehicleId":"11","vin":"LVIN0002","plateNumber":"沪A00002","customerId":"100","customerName":"客户甲","contractId":"1011","contractCode":"HT-2","projectName":"项目甲","departmentId":"20","departmentName":"运营一部","responsibleUserId":"31","responsibleUserName":"李四","operationStatus":"active","scopeStartAt":"2026-07-02T08:00:00+08:00"}],"rejected":[{"vehicleId":"12","vin":"","customerId":"100","contractId":"1012","reasonCode":"VIN_MISSING"}]}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
snapshot, err := ReadAPISnapshot(context.Background(), APIConfig{
|
||||||
|
URL: server.URL, ServiceToken: token, SigningSecret: secret,
|
||||||
|
MaxAttempts: 1, HTTPClient: server.Client(), Now: func() time.Time { return now },
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if requests.Load() != 2 || snapshot.Candidates != 3 || len(snapshot.Items) != 2 || len(snapshot.Rejections) != 1 {
|
||||||
|
t.Fatalf("unexpected API snapshot: requests=%d snapshot=%+v", requests.Load(), snapshot)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(snapshot.SourceVersion, apiSourcePrefix) || len(snapshot.SourceVersion) > 96 {
|
||||||
|
t.Fatalf("unexpected source version %q", snapshot.SourceVersion)
|
||||||
|
}
|
||||||
|
item := snapshot.Items[0]
|
||||||
|
if item.VIN != "LVIN0001" || item.CustomerName != "客户甲" || item.DepartmentName != "运营一部" ||
|
||||||
|
item.ResponsibleUserName != "张三" {
|
||||||
|
t.Fatalf("business dimensions lost: %+v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadAPISnapshotFailsClosedOnVersionDrift(t *testing.T) {
|
||||||
|
var request int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
request++
|
||||||
|
version := "scope-1"
|
||||||
|
cursor := "next"
|
||||||
|
if request == 2 {
|
||||||
|
version = "scope-2"
|
||||||
|
cursor = ""
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `{"code":0,"data":{"scopeVersion":%q,"generatedAt":"2026-07-16T10:00:00Z","complete":true,"nextCursor":%q,"items":[],"rejected":[]}}`, version, cursor)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
_, err := ReadAPISnapshot(context.Background(), APIConfig{
|
||||||
|
URL: server.URL, ServiceToken: "token", SigningSecret: "secret", MaxAttempts: 1, HTTPClient: server.Client(),
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "changed snapshot version") {
|
||||||
|
t.Fatalf("version drift must fail closed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadAPISnapshotRetriesOnlyTransientHTTPFailure(t *testing.T) {
|
||||||
|
var requests atomic.Int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if requests.Add(1) == 1 {
|
||||||
|
http.Error(w, "temporary", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, `{"code":0,"data":{"scopeVersion":"scope-1","generatedAt":"2026-07-16T10:00:00Z","complete":true,"nextCursor":"","items":[{"vehicleId":"10","vin":"LVIN0001","customerId":"100","contractId":"1010","scopeStartAt":"2026-07-01T00:00:00+08:00"}],"rejected":[]}}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
snapshot, err := ReadAPISnapshot(context.Background(), APIConfig{
|
||||||
|
URL: server.URL, ServiceToken: "token", SigningSecret: "secret", MaxAttempts: 2, HTTPClient: server.Client(),
|
||||||
|
})
|
||||||
|
if err != nil || requests.Load() != 2 || len(snapshot.Items) != 1 {
|
||||||
|
t.Fatalf("transient retry failed: requests=%d snapshot=%+v err=%v", requests.Load(), snapshot, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ type Candidate struct {
|
|||||||
VIN string
|
VIN string
|
||||||
PlateNumber string
|
PlateNumber string
|
||||||
CustomerID int64
|
CustomerID int64
|
||||||
|
CustomerName string
|
||||||
CustomerPresent bool
|
CustomerPresent bool
|
||||||
CustomerProfileExists bool
|
CustomerProfileExists bool
|
||||||
ContractID int64
|
ContractID int64
|
||||||
@@ -42,6 +43,10 @@ type Candidate struct {
|
|||||||
EffectiveCustomerSet bool
|
EffectiveCustomerSet bool
|
||||||
ContractCode string
|
ContractCode string
|
||||||
ProjectName string
|
ProjectName string
|
||||||
|
DepartmentID string
|
||||||
|
DepartmentName string
|
||||||
|
ResponsibleUserID string
|
||||||
|
ResponsibleUserName string
|
||||||
OperationStatus string
|
OperationStatus string
|
||||||
ScopeStartAt time.Time
|
ScopeStartAt time.Time
|
||||||
SourceUpdatedAt *time.Time
|
SourceUpdatedAt *time.Time
|
||||||
@@ -52,9 +57,14 @@ type ScopeItem struct {
|
|||||||
VIN string
|
VIN string
|
||||||
PlateNumber string
|
PlateNumber string
|
||||||
CustomerID int64
|
CustomerID int64
|
||||||
|
CustomerName string
|
||||||
ContractID int64
|
ContractID int64
|
||||||
ContractCode string
|
ContractCode string
|
||||||
ProjectName string
|
ProjectName string
|
||||||
|
DepartmentID string
|
||||||
|
DepartmentName string
|
||||||
|
ResponsibleUserID string
|
||||||
|
ResponsibleUserName string
|
||||||
OperationStatus string
|
OperationStatus string
|
||||||
ScopeStartAt time.Time
|
ScopeStartAt time.Time
|
||||||
SourceUpdatedAt *time.Time
|
SourceUpdatedAt *time.Time
|
||||||
@@ -119,8 +129,10 @@ func BuildSnapshot(candidates []Candidate, generatedAt time.Time) (Snapshot, err
|
|||||||
}
|
}
|
||||||
items = append(items, ScopeItem{
|
items = append(items, ScopeItem{
|
||||||
VehicleID: candidate.VehicleID, VIN: candidate.VIN, PlateNumber: strings.TrimSpace(candidate.PlateNumber),
|
VehicleID: candidate.VehicleID, VIN: candidate.VIN, PlateNumber: strings.TrimSpace(candidate.PlateNumber),
|
||||||
CustomerID: candidate.CustomerID, ContractID: candidate.ContractID,
|
CustomerID: candidate.CustomerID, CustomerName: strings.TrimSpace(candidate.CustomerName), ContractID: candidate.ContractID,
|
||||||
ContractCode: strings.TrimSpace(candidate.ContractCode), ProjectName: strings.TrimSpace(candidate.ProjectName),
|
ContractCode: strings.TrimSpace(candidate.ContractCode), ProjectName: strings.TrimSpace(candidate.ProjectName),
|
||||||
|
DepartmentID: strings.TrimSpace(candidate.DepartmentID), DepartmentName: strings.TrimSpace(candidate.DepartmentName),
|
||||||
|
ResponsibleUserID: strings.TrimSpace(candidate.ResponsibleUserID), ResponsibleUserName: strings.TrimSpace(candidate.ResponsibleUserName),
|
||||||
OperationStatus: strings.TrimSpace(candidate.OperationStatus), ScopeStartAt: candidate.ScopeStartAt,
|
OperationStatus: strings.TrimSpace(candidate.OperationStatus), ScopeStartAt: candidate.ScopeStartAt,
|
||||||
SourceUpdatedAt: candidate.SourceUpdatedAt,
|
SourceUpdatedAt: candidate.SourceUpdatedAt,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -61,8 +61,9 @@ run_id,source_system,source_version,status,candidate_count,accepted_count,reject
|
|||||||
return PublishResult{}, fmt.Errorf("clear inactive business scope version: %w", err)
|
return PublishResult{}, fmt.Errorf("clear inactive business scope version: %w", err)
|
||||||
}
|
}
|
||||||
statement, err := tx.PrepareContext(ctx, `INSERT INTO business_customer_vehicle_scope(
|
statement, err := tx.PrepareContext(ctx, `INSERT INTO business_customer_vehicle_scope(
|
||||||
source_version,customer_id,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,operation_status,scope_start_at,source_updated_at,published_at
|
source_version,customer_id,customer_name,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,
|
||||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)
|
department_id,department_name,responsible_user_id,responsible_user_name,operation_status,scope_start_at,source_updated_at,published_at
|
||||||
|
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PublishResult{}, fmt.Errorf("prepare business scope insert: %w", err)
|
return PublishResult{}, fmt.Errorf("prepare business scope insert: %w", err)
|
||||||
}
|
}
|
||||||
@@ -72,15 +73,16 @@ source_version,customer_id,vin,vehicle_id,contract_id,contract_code,plate_number
|
|||||||
if item.ContractID > 0 {
|
if item.ContractID > 0 {
|
||||||
contractID = item.ContractID
|
contractID = item.ContractID
|
||||||
}
|
}
|
||||||
if _, err := statement.ExecContext(ctx, snapshot.SourceVersion, item.CustomerID, item.VIN, item.VehicleID, contractID,
|
if _, err := statement.ExecContext(ctx, snapshot.SourceVersion, item.CustomerID, item.CustomerName, item.VIN, item.VehicleID, contractID,
|
||||||
item.ContractCode, item.PlateNumber, item.ProjectName, item.OperationStatus, item.ScopeStartAt, item.SourceUpdatedAt, now); err != nil {
|
item.ContractCode, item.PlateNumber, item.ProjectName, item.DepartmentID, item.DepartmentName,
|
||||||
|
item.ResponsibleUserID, item.ResponsibleUserName, item.OperationStatus, item.ScopeStartAt, item.SourceUpdatedAt, now); err != nil {
|
||||||
return PublishResult{}, fmt.Errorf("insert business scope item: %w", err)
|
return PublishResult{}, fmt.Errorf("insert business scope item: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(snapshot.Rejections) > 0 {
|
if len(snapshot.Rejections) > 0 {
|
||||||
statement, err := tx.PrepareContext(ctx, `INSERT INTO business_scope_rejection(
|
statement, err := tx.PrepareContext(ctx, `INSERT INTO business_scope_rejection(
|
||||||
run_id,row_number,vehicle_id,vin,customer_id,contract_id,reason_code,created_at
|
run_id,`+"`row_number`"+`,vehicle_id,vin,customer_id,contract_id,reason_code,created_at
|
||||||
) VALUES(?,?,?,?,?,?,?,?)`)
|
) VALUES(?,?,?,?,?,?,?,?)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return PublishResult{}, fmt.Errorf("prepare scope rejection insert: %w", err)
|
return PublishResult{}, fmt.Errorf("prepare scope rejection insert: %w", err)
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const scopeInsertSQL = `INSERT INTO business_customer_vehicle_scope(
|
const scopeInsertSQL = `INSERT INTO business_customer_vehicle_scope(
|
||||||
source_version,customer_id,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,operation_status,scope_start_at,source_updated_at,published_at
|
source_version,customer_id,customer_name,vin,vehicle_id,contract_id,contract_code,plate_number,project_name,
|
||||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`
|
department_id,department_name,responsible_user_id,responsible_user_name,operation_status,scope_start_at,source_updated_at,published_at
|
||||||
|
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||||
|
|
||||||
func publishSnapshot(version string) Snapshot {
|
func publishSnapshot(version string) Snapshot {
|
||||||
generatedAt := time.Date(2026, 7, 14, 8, 0, 0, 0, time.UTC)
|
generatedAt := time.Date(2026, 7, 14, 8, 0, 0, 0, time.UTC)
|
||||||
@@ -21,8 +22,9 @@ func publishSnapshot(version string) Snapshot {
|
|||||||
Checksum: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
Checksum: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
GeneratedAt: generatedAt, Candidates: 1,
|
GeneratedAt: generatedAt, Candidates: 1,
|
||||||
Items: []ScopeItem{{
|
Items: []ScopeItem{{
|
||||||
VehicleID: 10, VIN: "LVIN0001", PlateNumber: "沪A00001", CustomerID: 100,
|
VehicleID: 10, VIN: "LVIN0001", PlateNumber: "沪A00001", CustomerID: 100, CustomerName: "示例客户",
|
||||||
ContractID: 1010, ContractCode: "HT-1", ProjectName: "项目", OperationStatus: "1",
|
ContractID: 1010, ContractCode: "HT-1", ProjectName: "项目", OperationStatus: "1",
|
||||||
|
DepartmentID: "20", DepartmentName: "运营一部", ResponsibleUserID: "30", ResponsibleUserName: "负责人",
|
||||||
ScopeStartAt: generatedAt.Add(-time.Hour),
|
ScopeStartAt: generatedAt.Add(-time.Hour),
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
@@ -47,8 +49,9 @@ func TestPublishRebuildsPreviouslyStoredVersionBeforeActivation(t *testing.T) {
|
|||||||
prepared := mock.ExpectPrepare(regexp.QuoteMeta(scopeInsertSQL))
|
prepared := mock.ExpectPrepare(regexp.QuoteMeta(scopeInsertSQL))
|
||||||
item := snapshot.Items[0]
|
item := snapshot.Items[0]
|
||||||
prepared.ExpectExec().WithArgs(
|
prepared.ExpectExec().WithArgs(
|
||||||
snapshot.SourceVersion, item.CustomerID, item.VIN, item.VehicleID, item.ContractID,
|
snapshot.SourceVersion, item.CustomerID, item.CustomerName, item.VIN, item.VehicleID, item.ContractID,
|
||||||
item.ContractCode, item.PlateNumber, item.ProjectName, item.OperationStatus, item.ScopeStartAt, nil, sqlmock.AnyArg(),
|
item.ContractCode, item.PlateNumber, item.ProjectName, item.DepartmentID, item.DepartmentName,
|
||||||
|
item.ResponsibleUserID, item.ResponsibleUserName, item.OperationStatus, item.ScopeStartAt, nil, sqlmock.AnyArg(),
|
||||||
).WillReturnResult(sqlmock.NewResult(1, 1))
|
).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
mock.ExpectExec(`UPDATE business_scope_state SET`).
|
mock.ExpectExec(`UPDATE business_scope_state SET`).
|
||||||
WithArgs(snapshot.SourceVersion, snapshot.Checksum, 1, 1, 0, snapshot.GeneratedAt, sqlmock.AnyArg(), sqlmock.AnyArg()).
|
WithArgs(snapshot.SourceVersion, snapshot.Checksum, 1, 1, 0, snapshot.GeneratedAt, sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ set -euo pipefail
|
|||||||
RELEASE_ID=${1:?release id is required}
|
RELEASE_ID=${1:?release id is required}
|
||||||
ARCHIVE=${2:?web archive is required}
|
ARCHIVE=${2:?web archive is required}
|
||||||
API_BINARY=${3:-}
|
API_BINARY=${3:-}
|
||||||
|
ONEOS_SCOPE_BINARY=${4:-}
|
||||||
ROOT=${PLATFORM_ROOT:-/opt/lingniu-vehicle-platform}
|
ROOT=${PLATFORM_ROOT:-/opt/lingniu-vehicle-platform}
|
||||||
BASE_URL=${PLATFORM_BASE_URL:-http://127.0.0.1:20300}
|
BASE_URL=${PLATFORM_BASE_URL:-http://127.0.0.1:20300}
|
||||||
SERVICE=${PLATFORM_SERVICE:-lingniu-vehicle-platform}
|
SERVICE=${PLATFORM_SERVICE:-lingniu-vehicle-platform}
|
||||||
@@ -21,6 +22,9 @@ test -f "$ARCHIVE" || { printf 'web archive is missing: %s\n' "$ARCHIVE" >&2; ex
|
|||||||
if test -n "$API_BINARY"; then
|
if test -n "$API_BINARY"; then
|
||||||
test -f "$API_BINARY" || { printf 'platform API binary is missing: %s\n' "$API_BINARY" >&2; exit 1; }
|
test -f "$API_BINARY" || { printf 'platform API binary is missing: %s\n' "$API_BINARY" >&2; exit 1; }
|
||||||
fi
|
fi
|
||||||
|
if test -n "$ONEOS_SCOPE_BINARY"; then
|
||||||
|
test -f "$ONEOS_SCOPE_BINARY" || { printf 'OneOS scope sync binary is missing: %s\n' "$ONEOS_SCOPE_BINARY" >&2; exit 1; }
|
||||||
|
fi
|
||||||
test -x "$SCRIPT_DIR/prepare-web-release-tree.sh" || { printf 'release tree helper is missing\n' >&2; exit 1; }
|
test -x "$SCRIPT_DIR/prepare-web-release-tree.sh" || { printf 'release tree helper is missing\n' >&2; exit 1; }
|
||||||
test -f "$SCRIPT_DIR/prune-release-history.py" || { printf 'release pruning helper is missing\n' >&2; exit 1; }
|
test -f "$SCRIPT_DIR/prune-release-history.py" || { printf 'release pruning helper is missing\n' >&2; exit 1; }
|
||||||
test -f "$SCRIPT_DIR/verify-customer-demo.py" || { printf 'customer demo gate is missing\n' >&2; exit 1; }
|
test -f "$SCRIPT_DIR/verify-customer-demo.py" || { printf 'customer demo gate is missing\n' >&2; exit 1; }
|
||||||
@@ -81,6 +85,9 @@ test -f "$next/platform-api" || { printf 'current release is missing platform-ap
|
|||||||
if test -n "$API_BINARY"; then
|
if test -n "$API_BINARY"; then
|
||||||
cp "$API_BINARY" "$next/platform-api"
|
cp "$API_BINARY" "$next/platform-api"
|
||||||
fi
|
fi
|
||||||
|
if test -n "$ONEOS_SCOPE_BINARY"; then
|
||||||
|
cp "$ONEOS_SCOPE_BINARY" "$next/oneos-scope-sync"
|
||||||
|
fi
|
||||||
if test -f "$old/lingniu-vehicle-platform.service"; then
|
if test -f "$old/lingniu-vehicle-platform.service"; then
|
||||||
cp "$old/lingniu-vehicle-platform.service" "$next/lingniu-vehicle-platform.service"
|
cp "$old/lingniu-vehicle-platform.service" "$next/lingniu-vehicle-platform.service"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -27,22 +27,24 @@ printf '<main>new</main>\n' > "$new_web/index.html"
|
|||||||
printf 'window.__LINGNIU_APP_CONFIG__={"amapSecurityServiceHost":"/_AMapService"};\n' > "$new_web/app-config.js"
|
printf 'window.__LINGNIU_APP_CONFIG__={"amapSecurityServiceHost":"/_AMapService"};\n' > "$new_web/app-config.js"
|
||||||
tar -C "$new_web" -czf "$fixture/new.tar.gz" .
|
tar -C "$new_web" -czf "$fixture/new.tar.gz" .
|
||||||
printf 'new platform API\n' > "$fixture/new-platform-api"
|
printf 'new platform API\n' > "$fixture/new-platform-api"
|
||||||
|
printf 'new OneOS scope sync\n' > "$fixture/new-oneos-scope-sync"
|
||||||
|
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$fixture/bin/systemctl"
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$fixture/bin/systemctl"
|
||||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$fixture/bin/curl"
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$fixture/bin/curl"
|
||||||
printf '#!/usr/bin/env bash\nprintf "mock_verify=ok\\n"\n' > "$fixture/bin/verify"
|
printf '#!/usr/bin/env bash\nprintf "mock_verify=ok\\n"\n' > "$fixture/bin/verify"
|
||||||
chmod +x "$fixture/bin/"*
|
chmod +x "$fixture/bin/"*
|
||||||
|
|
||||||
PLATFORM_ROOT="$root" SYSTEMCTL_BIN="$fixture/bin/systemctl" CURL_BIN="$fixture/bin/curl" VERIFY_WEB_RELEASE_BIN="$fixture/bin/verify" RELEASE_HISTORY_LIMIT=3 "$SCRIPT_DIR/install-web-release.sh" new-release "$fixture/new.tar.gz" "$fixture/new-platform-api" > "$fixture/install.out"
|
PLATFORM_ROOT="$root" SYSTEMCTL_BIN="$fixture/bin/systemctl" CURL_BIN="$fixture/bin/curl" VERIFY_WEB_RELEASE_BIN="$fixture/bin/verify" RELEASE_HISTORY_LIMIT=3 "$SCRIPT_DIR/install-web-release.sh" new-release "$fixture/new.tar.gz" "$fixture/new-platform-api" "$fixture/new-oneos-scope-sync" > "$fixture/install.out"
|
||||||
test "$(readlink -f "$root/current")" = "$(cd "$root/releases/new-release" && pwd -P)"
|
test "$(readlink -f "$root/current")" = "$(cd "$root/releases/new-release" && pwd -P)"
|
||||||
grep -q '^PLATFORM_RELEASE=new-release$' "$root/env/platform.env"
|
grep -q '^PLATFORM_RELEASE=new-release$' "$root/env/platform.env"
|
||||||
test "$(cat "$root/current/platform-api")" = 'new platform API'
|
test "$(cat "$root/current/platform-api")" = 'new platform API'
|
||||||
|
test "$(cat "$root/current/oneos-scope-sync")" = 'new OneOS scope sync'
|
||||||
test "$(cat "$root/current/alert-evaluator")" = 'old evaluator'
|
test "$(cat "$root/current/alert-evaluator")" = 'old evaluator'
|
||||||
test -f "$root/current/web/assets/new.js"
|
test -f "$root/current/web/assets/new.js"
|
||||||
test -f "$root/current/web/assets/old.js"
|
test -f "$root/current/web/assets/old.js"
|
||||||
test -f "$root/current/web/.compatibility-manifests/1.assets"
|
test -f "$root/current/web/.compatibility-manifests/1.assets"
|
||||||
test -x "$root/current/deploy/verify-customer-demo.py"
|
test -x "$root/current/deploy/verify-customer-demo.py"
|
||||||
test -f "$root/current/deploy/migrations/015_vehicle_source_policy_audit.sql"
|
test -f "$root/current/deploy/migrations/016_business_scope_dimensions.sql"
|
||||||
test ! -e "$root/current/lingniu-vehicle-platform.service"
|
test ! -e "$root/current/lingniu-vehicle-platform.service"
|
||||||
test "$(find "$root/releases" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 3
|
test "$(find "$root/releases" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 3
|
||||||
grep -q 'web_release_install=ok release=new-release previous=old-release' "$fixture/install.out"
|
grep -q 'web_release_install=ok release=new-release previous=old-release' "$fixture/install.out"
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ CREATE TABLE IF NOT EXISTS business_customer_vehicle_scope (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS business_scope_rejection (
|
CREATE TABLE IF NOT EXISTS business_scope_rejection (
|
||||||
run_id CHAR(32) NOT NULL,
|
run_id CHAR(32) NOT NULL,
|
||||||
row_number INT UNSIGNED NOT NULL,
|
`row_number` INT UNSIGNED NOT NULL,
|
||||||
vehicle_id BIGINT NULL,
|
vehicle_id BIGINT NULL,
|
||||||
vin VARCHAR(64) NOT NULL DEFAULT '',
|
vin VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
customer_id BIGINT NULL,
|
customer_id BIGINT NULL,
|
||||||
contract_id BIGINT NULL,
|
contract_id BIGINT NULL,
|
||||||
reason_code VARCHAR(64) NOT NULL,
|
reason_code VARCHAR(64) NOT NULL,
|
||||||
created_at DATETIME(3) NOT NULL,
|
created_at DATETIME(3) NOT NULL,
|
||||||
PRIMARY KEY (run_id, row_number),
|
PRIMARY KEY (run_id, `row_number`),
|
||||||
INDEX idx_business_scope_rejection_reason (reason_code, created_at),
|
INDEX idx_business_scope_rejection_reason (reason_code, created_at),
|
||||||
CONSTRAINT fk_business_scope_rejection_run
|
CONSTRAINT fk_business_scope_rejection_run
|
||||||
FOREIGN KEY (run_id) REFERENCES business_scope_sync_run(run_id)
|
FOREIGN KEY (run_id) REFERENCES business_scope_sync_run(run_id)
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
ALTER TABLE business_customer_vehicle_scope
|
||||||
|
ADD COLUMN customer_name VARCHAR(255) NOT NULL DEFAULT '' AFTER customer_id;
|
||||||
|
|
||||||
|
ALTER TABLE business_customer_vehicle_scope
|
||||||
|
ADD COLUMN department_id VARCHAR(64) NOT NULL DEFAULT '' AFTER project_name;
|
||||||
|
|
||||||
|
ALTER TABLE business_customer_vehicle_scope
|
||||||
|
ADD COLUMN department_name VARCHAR(255) NOT NULL DEFAULT '' AFTER department_id;
|
||||||
|
|
||||||
|
ALTER TABLE business_customer_vehicle_scope
|
||||||
|
ADD COLUMN responsible_user_id VARCHAR(64) NOT NULL DEFAULT '' AFTER department_name;
|
||||||
|
|
||||||
|
ALTER TABLE business_customer_vehicle_scope
|
||||||
|
ADD COLUMN responsible_user_name VARCHAR(255) NOT NULL DEFAULT '' AFTER responsible_user_id;
|
||||||
|
|
||||||
|
CREATE INDEX idx_business_scope_department
|
||||||
|
ON business_customer_vehicle_scope(source_version, department_id, vin);
|
||||||
|
|
||||||
|
CREATE INDEX idx_business_scope_responsible
|
||||||
|
ON business_customer_vehicle_scope(source_version, responsible_user_id, vin);
|
||||||
@@ -66,13 +66,14 @@ PREVIOUS_WEB=/opt/lingniu-vehicle-platform/releases/$PREVIOUS_RELEASE/web
|
|||||||
"$PREVIOUS_WEB/.release-assets"
|
"$PREVIOUS_WEB/.release-assets"
|
||||||
```
|
```
|
||||||
|
|
||||||
`deploy/install-web-release.sh` also accepts an optional third argument containing a newly built `platform-api`. When provided, it atomically publishes the new API and Web together. The installer inherits every runtime binary that exists in the previous release (`alert-evaluator`, `alert-stream-evaluator`, `platform-migrate`, `oneos-scope-sync` and the optional benchmark) before switching the symlink, so a later systemd restart cannot fail because a Web-oriented release omitted an unchanged binary:
|
`deploy/install-web-release.sh` also accepts an optional third argument containing a newly built `platform-api` and an optional fourth argument containing a newly built `oneos-scope-sync`. When provided, it atomically publishes those binaries with the Web. The installer inherits every runtime binary that exists in the previous release (`alert-evaluator`, `alert-stream-evaluator`, `platform-migrate`, `oneos-scope-sync` and the optional benchmark) before switching the symlink, so a later systemd restart cannot fail because a Web-oriented release omitted an unchanged binary:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
deploy/install-web-release.sh \
|
deploy/install-web-release.sh \
|
||||||
"$PLATFORM_RELEASE" \
|
"$PLATFORM_RELEASE" \
|
||||||
"/tmp/$PLATFORM_RELEASE-web.tar.gz" \
|
"/tmp/$PLATFORM_RELEASE-web.tar.gz" \
|
||||||
"/tmp/$PLATFORM_RELEASE-platform-api"
|
"/tmp/$PLATFORM_RELEASE-platform-api" \
|
||||||
|
"/tmp/$PLATFORM_RELEASE-oneos-scope-sync"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
@@ -98,6 +99,12 @@ BOOTSTRAP_ADMIN_USERNAME=admin
|
|||||||
BOOTSTRAP_ADMIN_PASSWORD=<strong-first-admin-password>
|
BOOTSTRAP_ADMIN_PASSWORD=<strong-first-admin-password>
|
||||||
AUTH_SESSION_TTL_HOURS=12
|
AUTH_SESSION_TTL_HOURS=12
|
||||||
ONEOS_MYSQL_DSN=vehicle_scope_reader:***@tcp(rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306)/ln_asset_management?parseTime=true&loc=Asia%2FShanghai
|
ONEOS_MYSQL_DSN=vehicle_scope_reader:***@tcp(rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306)/ln_asset_management?parseTime=true&loc=Asia%2FShanghai
|
||||||
|
# 正式模式使用 OneOS HTTP 内部接口;database 仅用于接口上线前的只读影子核对。
|
||||||
|
ONEOS_SCOPE_SOURCE=api
|
||||||
|
ONEOS_SCOPE_API_URL=https://<oneos-private-host>/inner/v1/vehicle-data-platform/customer-vehicle-scopes
|
||||||
|
ONEOS_SCOPE_API_SERVICE_TOKEN=<dedicated-service-token>
|
||||||
|
ONEOS_SCOPE_API_SIGNING_SECRET=<independent-hmac-secret>
|
||||||
|
ONEOS_SCOPE_API_MAX_ATTEMPTS=3
|
||||||
ONEOS_SCOPE_SYNC_TIMEOUT_SEC=60
|
ONEOS_SCOPE_SYNC_TIMEOUT_SEC=60
|
||||||
ONEOS_SCOPE_MAX_REJECTED=100
|
ONEOS_SCOPE_MAX_REJECTED=100
|
||||||
ONEOS_SCOPE_MAX_REJECT_RATIO=0.10
|
ONEOS_SCOPE_MAX_REJECT_RATIO=0.10
|
||||||
@@ -158,7 +165,9 @@ Before a customer demonstration, run `deploy/verify-customer-demo.py` with the p
|
|||||||
|
|
||||||
After editing the environment file, run `chmod 600 /opt/lingniu-vehicle-platform/env/platform.env`. Never put a real token in Git, static JavaScript, shell history or deployment logs.
|
After editing the environment file, run `chmod 600 /opt/lingniu-vehicle-platform/env/platform.env`. Never put a real token in Git, static JavaScript, shell history or deployment logs.
|
||||||
|
|
||||||
`ONEOS_MYSQL_DSN` must use a dedicated account with direct table-level `SELECT` grants only. The sync binary runs `SHOW GRANTS FOR CURRENT_USER` before every read and accepts only global `USAGE` plus `SELECT` on the seven tables used by its query. It refuses database/global reads, `ALL PRIVILEGES`, DML, DDL, PROCESS, replication, roles, `SHOW VIEW`, or any other privilege. It then opens a repeatable-read, read-only transaction, applies a 10-second statement timeout, classifies invalid customer/contract relationships, and atomically publishes a content-addressed local snapshot. Never point it at the existing `ln-bi` account: production audit showed that account still has broad write and replication privileges.
|
`ONEOS_SCOPE_SOURCE=api` is the intended production integration. The client sends a dedicated service token plus a timestamp/request-ID HMAC signature, requires one stable `scopeVersion` and `generatedAt` across every page, and fails closed on incomplete data, cursor loops, version drift, malformed IDs/timestamps, nonzero business codes, row/page limits or exhausted transient retries. HTTP is accepted only for literal private/loopback IP addresses; all other endpoints require HTTPS. Never log either secret.
|
||||||
|
|
||||||
|
`ONEOS_SCOPE_SOURCE=database` is a temporary shadow/audit path before the OneOS team publishes the formal endpoint. In that mode, `ONEOS_MYSQL_DSN` must use a dedicated account with direct table-level `SELECT` grants only. The sync binary runs `SHOW GRANTS FOR CURRENT_USER` before every read and accepts only global `USAGE` plus `SELECT` on the seven tables used by its query. It refuses database/global reads, `ALL PRIVILEGES`, DML, DDL, PROCESS, replication, roles, `SHOW VIEW`, or any other privilege. It then opens a repeatable-read, read-only transaction, applies a 10-second statement timeout, classifies invalid customer/contract relationships, and atomically publishes a content-addressed local snapshot. Never point it at the existing `ln-bi` account: production audit showed that account still has broad write and replication privileges.
|
||||||
|
|
||||||
Have an RDS administrator review and run `docs/oneos-scope-reader-provision.sql` separately. It restricts the login source to the verified ECS private address and grants `SELECT` on only the seven source tables used by the query. Do not include that DDL in application deployment or migration automation. Store `platform.env` as root-owned mode `0600`, and verify `SHOW GRANTS` before enabling the timer.
|
Have an RDS administrator review and run `docs/oneos-scope-reader-provision.sql` separately. It restricts the login source to the verified ECS private address and grants `SELECT` on only the seven source tables used by the query. Do not include that DDL in application deployment or migration automation. Store `platform.env` as root-owned mode `0600`, and verify `SHOW GRANTS` before enabling the timer.
|
||||||
|
|
||||||
@@ -188,10 +197,13 @@ test -n "$MYSQL_DSN"
|
|||||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/012_business_scope_projection.sql \
|
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/012_business_scope_projection.sql \
|
||||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/013_platform_identity_access.sql \
|
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/013_platform_identity_access.sql \
|
||||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/014_customer_vehicle_grant_time.sql \
|
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/014_customer_vehicle_grant_time.sql \
|
||||||
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/015_vehicle_source_policy_audit.sql
|
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/015_vehicle_source_policy_audit.sql \
|
||||||
|
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/016_business_scope_dimensions.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
The API guards the access-threshold tables for compatibility, while alert APIs deliberately require the alert migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed. Migration `014` backfills active vehicle-grant start times, creates the grant-interval history table and adds the active time lookup index; apply it before starting an API binary that writes grant history. Migration `015` adds platform-owned optimistic versions and immutable audits for per-vehicle location-source policy changes. It does not alter the gateway election SQL or expose the gateway `source_key`; the API resolves an opaque `sourceRef` server-side and the gateway applies the saved policy on the next valid vehicle report.
|
The API guards the access-threshold tables for compatibility, while alert APIs deliberately require the alert migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed. Migration `012` creates the atomic, versioned business Scope projection; its reserved `row_number` column is quoted for production MySQL compatibility. Migration `014` backfills active vehicle-grant start times, creates the grant-interval history table and adds the active time lookup index; apply it before starting an API binary that writes grant history. Migration `015` adds platform-owned optimistic versions and immutable audits for per-vehicle location-source policy changes. It does not alter the gateway election SQL or expose the gateway `source_key`; the API resolves an opaque `sourceRef` server-side and the gateway applies the saved policy on the next valid vehicle report. Migration `016` adds customer name, department and responsible-person dimensions plus bounded lookup indexes to the platform-owned Scope projection.
|
||||||
|
|
||||||
|
Release `oneos-api-client-20260716184601` applied migrations `012` and `016` and deployed the switchable API/database sync binary. The projection is intentionally empty (`active_version` unset) until a verified OneOS endpoint is configured. A production fail-closed smoke with `ONEOS_SCOPE_SOURCE=api` and no endpoint exited nonzero without publishing any row. Do not install or enable the timer until the endpoint, service token, signing secret, source-IP restriction and a representative snapshot have passed the contract checks below.
|
||||||
|
|
||||||
Production release `source-diagnosis-stable-20260716173740` applied migration `015` before switching API traffic. The release gate verified 23 current assets and 42 compatibility assets. The authenticated diagnostic smoke used a real multi-source vehicle, confirmed that `source_key` was absent, and exercised the admin PUT route with values identical to the current policy; version, audit count and recommended source remained unchanged. Viewer/operator/admin access returned 403/200/200, median response time across 20 reads was approximately 70 ms (P95 79 ms), and the platform plus both alert evaluators remained active.
|
Production release `source-diagnosis-stable-20260716173740` applied migration `015` before switching API traffic. The release gate verified 23 current assets and 42 compatibility assets. The authenticated diagnostic smoke used a real multi-source vehicle, confirmed that `source_key` was absent, and exercised the admin PUT route with values identical to the current policy; version, audit count and recommended source remained unchanged. Viewer/operator/admin access returned 403/200/200, median response time across 20 reads was approximately 70 ms (P95 79 ms), and the platform plus both alert evaluators remained active.
|
||||||
|
|
||||||
@@ -203,11 +215,13 @@ sudo cp deploy/systemd/lingniu-vehicle-alert-stream-evaluator.service /etc/syste
|
|||||||
sudo cp deploy/systemd/lingniu-vehicle-oneos-scope-sync.service /etc/systemd/system/
|
sudo cp deploy/systemd/lingniu-vehicle-oneos-scope-sync.service /etc/systemd/system/
|
||||||
sudo cp deploy/systemd/lingniu-vehicle-oneos-scope-sync.timer /etc/systemd/system/
|
sudo cp deploy/systemd/lingniu-vehicle-oneos-scope-sync.timer /etc/systemd/system/
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable --now lingniu-vehicle-platform lingniu-vehicle-alert-evaluator lingniu-vehicle-alert-stream-evaluator lingniu-vehicle-oneos-scope-sync.timer
|
sudo systemctl enable --now lingniu-vehicle-platform lingniu-vehicle-alert-evaluator lingniu-vehicle-alert-stream-evaluator
|
||||||
sudo systemctl status --no-pager lingniu-vehicle-alert-evaluator
|
sudo systemctl status --no-pager lingniu-vehicle-alert-evaluator
|
||||||
sudo systemctl status --no-pager lingniu-vehicle-alert-stream-evaluator
|
sudo systemctl status --no-pager lingniu-vehicle-alert-stream-evaluator
|
||||||
sudo journalctl -u lingniu-vehicle-alert-evaluator -n 100 --no-pager
|
sudo journalctl -u lingniu-vehicle-alert-evaluator -n 100 --no-pager
|
||||||
sudo journalctl -u lingniu-vehicle-alert-stream-evaluator -n 100 --no-pager
|
sudo journalctl -u lingniu-vehicle-alert-stream-evaluator -n 100 --no-pager
|
||||||
|
# Only after a successful manual OneOS API sync and projection audit:
|
||||||
|
sudo systemctl enable --now lingniu-vehicle-oneos-scope-sync.timer
|
||||||
sudo systemctl status --no-pager lingniu-vehicle-oneos-scope-sync.timer
|
sudo systemctl status --no-pager lingniu-vehicle-oneos-scope-sync.timer
|
||||||
sudo journalctl -u lingniu-vehicle-oneos-scope-sync.service -n 100 --no-pager
|
sudo journalctl -u lingniu-vehicle-oneos-scope-sync.service -n 100 --no-pager
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -14,23 +14,32 @@
|
|||||||
|
|
||||||
## 2. 推荐接口
|
## 2. 推荐接口
|
||||||
|
|
||||||
### 查询客户车辆快照
|
### 查询车辆中台全量业务范围快照
|
||||||
|
|
||||||
|
车辆数据中台的定时同步需要一次读取全部客户的当前车辆范围,避免先拉客户列表再产生 N+1 请求。推荐由 OneOS 提供专用平台快照:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /inner/v1/customer-vehicle-scopes/{customerId}?cursor=&limit=500
|
GET /inner/v1/vehicle-data-platform/customer-vehicle-scopes?cursor=&limit=500
|
||||||
Authorization: Service <service-token>
|
Authorization: Service <service-token>
|
||||||
X-Request-Id: <uuid>
|
X-Request-Id: <uuid>
|
||||||
X-Request-Timestamp: <unix-seconds>
|
X-Request-Timestamp: <unix-seconds>
|
||||||
X-Request-Signature: <hmac-sha256>
|
X-Request-Signature: <hmac-sha256>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
签名原文必须逐字节使用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET\n<path-and-sorted-query>\n<unix-seconds>\n<request-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
双方使用独立共享密钥计算 HMAC-SHA256 小写十六进制;服务令牌与签名密钥不能相同。OneOS 应拒绝超过 60 秒的时间戳、重复 request ID、错误 audience 和外网来源。网关必须剥离外部传入的同名身份头。
|
||||||
|
|
||||||
响应:
|
响应:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"data": {
|
"data": {
|
||||||
"customerId": "1001",
|
|
||||||
"scopeVersion": "2026-07-14T12:00:00.123Z/987654",
|
"scopeVersion": "2026-07-14T12:00:00.123Z/987654",
|
||||||
"generatedAt": "2026-07-14T12:00:00.123Z",
|
"generatedAt": "2026-07-14T12:00:00.123Z",
|
||||||
"complete": true,
|
"complete": true,
|
||||||
@@ -41,9 +50,14 @@ X-Request-Signature: <hmac-sha256>
|
|||||||
"vin": "LXXXXXXXXXXXXXXXX",
|
"vin": "LXXXXXXXXXXXXXXXX",
|
||||||
"plateNumber": "沪A00000",
|
"plateNumber": "沪A00000",
|
||||||
"customerId": "1001",
|
"customerId": "1001",
|
||||||
|
"customerName": "示例客户",
|
||||||
"contractId": "3001",
|
"contractId": "3001",
|
||||||
"contractCode": "HT20260001",
|
"contractCode": "HT20260001",
|
||||||
"projectName": "示例项目",
|
"projectName": "示例项目",
|
||||||
|
"departmentId": "4001",
|
||||||
|
"departmentName": "运营一部",
|
||||||
|
"responsibleUserId": "5001",
|
||||||
|
"responsibleUserName": "示例负责人",
|
||||||
"modelName": "示例车型",
|
"modelName": "示例车型",
|
||||||
"brandName": "示例品牌",
|
"brandName": "示例品牌",
|
||||||
"operationStatus": "2",
|
"operationStatus": "2",
|
||||||
@@ -66,10 +80,25 @@ X-Request-Signature: <hmac-sha256>
|
|||||||
|
|
||||||
- 雪花 ID 一律用 JSON 字符串传输,避免 JavaScript 数字精度丢失。
|
- 雪花 ID 一律用 JSON 字符串传输,避免 JavaScript 数字精度丢失。
|
||||||
- `scopeVersion` 对同一业务快照稳定;分页中的每一页必须来自同一个快照。
|
- `scopeVersion` 对同一业务快照稳定;分页中的每一页必须来自同一个快照。
|
||||||
- `complete=false` 表示快照不完整,车辆中台不得发布该版本。
|
- `generatedAt` 和 `scopeVersion` 在所有分页中必须完全一致。
|
||||||
|
- 任意分页 `complete=false` 表示快照不完整,车辆中台不得发布该版本。
|
||||||
- `items` 内 VIN 必须非空、标准化为大写并在当前快照内唯一。
|
- `items` 内 VIN 必须非空、标准化为大写并在当前快照内唯一。
|
||||||
- `rejected` 只返回机器可读原因和内部车辆 ID,不返回敏感客户信息。
|
- `rejected` 只返回机器可读原因和内部车辆 ID,不返回敏感客户信息。
|
||||||
- 建议 `ETag=scopeVersion`,支持 `If-None-Match` 降低同步流量。
|
- 每页最多 500 条;全部快照最多 50,000 条、100 页。游标不能重复。
|
||||||
|
- 客户、车辆、合同等雪花 ID 必须使用 JSON 字符串;部门和负责人允许为空,但不能伪造。
|
||||||
|
- 建议 `ETag=scopeVersion`,后续支持 `If-None-Match` 降低同步流量。
|
||||||
|
|
||||||
|
车辆中台已实现该契约的客户端:总同步超时默认 60 秒,单请求超时 10 秒;网络错误、HTTP 429 和 5xx 最多重试 3 次,其他 4xx、业务 code 非 0、JSON 异常、分页版本漂移、重复游标、越界行数或不完整快照立即失败关闭。
|
||||||
|
|
||||||
|
### 查询单个客户车辆快照
|
||||||
|
|
||||||
|
如 OneOS 还需要为内部业务提供客户级接口,可保留:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /inner/v1/customer-vehicle-scopes/{customerId}?cursor=&limit=500
|
||||||
|
```
|
||||||
|
|
||||||
|
该接口不作为车辆中台全量定时同步的唯一入口,以免形成客户级 N+1。
|
||||||
|
|
||||||
### 批量检查客户车辆归属
|
### 批量检查客户车辆归属
|
||||||
|
|
||||||
|
|||||||
@@ -402,7 +402,23 @@
|
|||||||
|
|
||||||
### P1-04 OneOS 业务接口对接
|
### P1-04 OneOS 业务接口对接
|
||||||
|
|
||||||
状态:`进行中`
|
状态:`进行中(中台接入端已完成,等待 OneOS 正式接口联调)`
|
||||||
|
|
||||||
|
已完成的中台侧结果(release `oneos-api-client-20260716184601`):
|
||||||
|
|
||||||
|
- 新增可切换的 OneOS HTTP 范围快照客户端;正式模式为 `ONEOS_SCOPE_SOURCE=api`,原有数据库直读仅保留为接口上线前的只读影子核对,不修改 `ln-asset-management`。
|
||||||
|
- 请求使用独立 Service Token、请求 ID、Unix 时间戳和 HMAC-SHA256 防重放签名;非私网字面 IP 强制 HTTPS,服务令牌与签名密钥均不写日志。
|
||||||
|
- 支持每页 500 条的稳定游标分页,最多 100 页/50,000 条;要求所有分页 `scopeVersion`、`generatedAt` 完全一致且 `complete=true`。网络错误、429 和 5xx 最多重试 3 次,版本漂移、重复游标、业务错误、格式错误或越界立即失败关闭。
|
||||||
|
- 客户、车辆、合同雪花 ID 按 JSON 字符串读取;投影已增加客户名称、部门、负责人、合同、项目、启用时间和来源更新时间,并继续以内容地址版本原子发布。
|
||||||
|
- 修复旧迁移 012 中生产 MySQL 保留关键字 `row_number` 的兼容问题;生产已成功应用 012 和 016,投影字段及索引齐全。
|
||||||
|
- 同步定时器继续保持未安装/未启用,当前 `active_version` 为空、accepted/rejected 均为 0。缺少正式接口配置时生产手工运行返回退出码 1,不发布空快照,也不退化成全量车辆。
|
||||||
|
- 发布安装器支持同时原子替换新版 `oneos-scope-sync`;Go 全量测试、接口签名/分页/版本漂移/瞬时重试测试和安装器测试通过,ECS 三个现有服务均为 active。
|
||||||
|
|
||||||
|
尚待 OneOS 团队交付并联调:
|
||||||
|
|
||||||
|
- 正式内网 URL、独立服务令牌、HMAC 密钥和允许访问的 ECS 私网源地址;
|
||||||
|
- 符合契约的全客户完整快照,包含稳定版本、客户/部门/负责人、合同/项目、正式交车启用时间及机器可读隔离原因;
|
||||||
|
- OneOS 侧完成时间戳窗口、request ID 去重、来源限制和接口监控。取得后先手工影子同步、核对抽样与总数,再启用两分钟定时器。
|
||||||
|
|
||||||
目标:
|
目标:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user