feat(platform): consolidate production vehicle data workflows
This commit is contained in:
139
vehicle-data-platform/apps/api/cmd/oneos-scope-sync/main.go
Normal file
139
vehicle-data-platform/apps/api/cmd/oneos-scope-sync/main.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/businessscope"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Printf("OneOS scope sync failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
timeout := time.Duration(envInt("ONEOS_SCOPE_SYNC_TIMEOUT_SEC", 60)) * time.Second
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open vehicle platform database: %w", err)
|
||||
}
|
||||
defer target.Close()
|
||||
candidates, err := businessscope.ReadCandidates(ctx, source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot, err := businessscope.BuildSnapshot(candidates, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maxRejected := envInt("ONEOS_SCOPE_MAX_REJECTED", 100)
|
||||
maxRejectedRatio, err := envFloat("ONEOS_SCOPE_MAX_REJECT_RATIO", 0.10)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := businessscope.ValidateSnapshot(snapshot, maxRejected, maxRejectedRatio); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := businessscope.Publish(ctx, target, snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("OneOS scope sync complete changed=%t candidates=%d accepted=%d rejected=%d version=%s",
|
||||
result.Changed, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.SourceVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizedDSN(name, raw string, requireOneOSDatabase bool) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("%s is required", name)
|
||||
}
|
||||
config, err := mysql.ParseDSN(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse %s: %w", name, err)
|
||||
}
|
||||
if requireOneOSDatabase && config.DBName != "ln_asset_management" {
|
||||
return "", fmt.Errorf("%s must select ln_asset_management", name)
|
||||
}
|
||||
location, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load Asia/Shanghai timezone: %w", err)
|
||||
}
|
||||
config.ParseTime = true
|
||||
config.Loc = location
|
||||
if config.Timeout <= 0 {
|
||||
config.Timeout = 10 * time.Second
|
||||
}
|
||||
if config.ReadTimeout <= 0 {
|
||||
config.ReadTimeout = 30 * time.Second
|
||||
}
|
||||
if config.WriteTimeout <= 0 {
|
||||
config.WriteTimeout = 10 * time.Second
|
||||
}
|
||||
return config.FormatDSN(), nil
|
||||
}
|
||||
|
||||
func openDB(ctx context.Context, dsn string, maxConnections int) (*sql.DB, error) {
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(maxConnections)
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envFloat(key string, fallback float64) (float64, error) {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil || parsed < 0 || parsed > 1 {
|
||||
return 0, fmt.Errorf("%s must be a number between 0 and 1", key)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func TestNormalizedOneOSDSNForcesTimeParsingAndTimeouts(t *testing.T) {
|
||||
dsn, err := normalizedDSN("ONEOS_MYSQL_DSN", "reader:secret@tcp(mysql:3306)/ln_asset_management", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, err := mysql.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !config.ParseTime || config.Loc == nil || config.Loc.String() != "Asia/Shanghai" {
|
||||
t.Fatalf("time settings not normalized: %#v", config)
|
||||
}
|
||||
if config.Timeout != 10*time.Second || config.ReadTimeout != 30*time.Second || config.WriteTimeout != 10*time.Second {
|
||||
t.Fatalf("timeouts not normalized: %#v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizedOneOSDSNRejectsWrongDatabase(t *testing.T) {
|
||||
if _, err := normalizedDSN("ONEOS_MYSQL_DSN", "reader:secret@tcp(mysql:3306)/other", true); err == nil {
|
||||
t.Fatal("wrong OneOS database should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvFloatValidation(t *testing.T) {
|
||||
t.Setenv("RATIO_TEST", "1.1")
|
||||
if _, err := envFloat("RATIO_TEST", 0.1); err == nil {
|
||||
t.Fatal("ratio above one should be rejected")
|
||||
}
|
||||
}
|
||||
@@ -88,8 +88,10 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "DATA_STORE_UNAVAILABLE", "生产数据源不可用", storeErr.Error(), requestTraceID(r))
|
||||
})
|
||||
}
|
||||
// Reverse geocoding consumes the server-side AMap credential, so it must
|
||||
// stay behind the same authentication boundary as the platform API.
|
||||
api = withAMapReverseGeocodeAPI(api, cfg, "https://restapi.amap.com", http.DefaultClient)
|
||||
handler := static.Handler(cfg.StaticDir, withAPIAuth(api, cfg))
|
||||
handler = withAMapReverseGeocodeAPI(handler, cfg, "https://restapi.amap.com", http.DefaultClient)
|
||||
handler = withAppConfig(handler, cfg)
|
||||
handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient)
|
||||
return withRequestTimeout(handler, cfg.RequestTimeout)
|
||||
|
||||
@@ -256,3 +256,21 @@ func TestAMapReverseGeocodeAPIRejectsBadCoordinate(t *testing.T) {
|
||||
t.Fatalf("body should mention bad coordinate: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRequiresAuthenticationForAMapReverseGeocode(t *testing.T) {
|
||||
handler := NewServer(config.Config{
|
||||
AuthMode: "enforce",
|
||||
AuthTokensJSON: `[{"token":"0123456789abcdef","name":"test-viewer","role":"viewer"}]`,
|
||||
DataMode: "mock",
|
||||
AMapAPIKey: "server-api-key",
|
||||
RequestTimeout: time.Second,
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/map/reverse-geocode?longitude=121.47&latitude=31.23", nil)
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous reverse geocode status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
205
vehicle-data-platform/apps/api/internal/businessscope/model.go
Normal file
205
vehicle-data-platform/apps/api/internal/businessscope/model.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package businessscope
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const SourceSystem = "oneos"
|
||||
|
||||
const (
|
||||
ReasonVehicleMissing = "VEHICLE_MISSING"
|
||||
ReasonVINMissing = "VIN_MISSING"
|
||||
ReasonCustomerMissing = "CUSTOMER_MISSING"
|
||||
ReasonCustomerProfileMissing = "CUSTOMER_PROFILE_MISSING"
|
||||
ReasonContractMissing = "CONTRACT_MISSING"
|
||||
ReasonContractProfileMissing = "CONTRACT_PROFILE_MISSING"
|
||||
ReasonContractCustomerMissing = "CONTRACT_CUSTOMER_MISSING"
|
||||
ReasonCustomerMismatch = "CUSTOMER_MISMATCH"
|
||||
ReasonDuplicateVehicle = "DUPLICATE_VEHICLE_SCOPE"
|
||||
ReasonDuplicateVIN = "DUPLICATE_VIN_SCOPE"
|
||||
)
|
||||
|
||||
type Candidate struct {
|
||||
RowNumber int
|
||||
VehicleID int64
|
||||
VehiclePresent bool
|
||||
VIN string
|
||||
PlateNumber string
|
||||
CustomerID int64
|
||||
CustomerPresent bool
|
||||
CustomerProfileExists bool
|
||||
ContractID int64
|
||||
ContractPresent bool
|
||||
ContractProfileExists bool
|
||||
EffectiveCustomerID int64
|
||||
EffectiveCustomerSet bool
|
||||
ContractCode string
|
||||
ProjectName string
|
||||
OperationStatus string
|
||||
ScopeStartAt time.Time
|
||||
SourceUpdatedAt *time.Time
|
||||
}
|
||||
|
||||
type ScopeItem struct {
|
||||
VehicleID int64
|
||||
VIN string
|
||||
PlateNumber string
|
||||
CustomerID int64
|
||||
ContractID int64
|
||||
ContractCode string
|
||||
ProjectName string
|
||||
OperationStatus string
|
||||
ScopeStartAt time.Time
|
||||
SourceUpdatedAt *time.Time
|
||||
}
|
||||
|
||||
type Rejection struct {
|
||||
RowNumber int
|
||||
VehicleID *int64
|
||||
VIN string
|
||||
CustomerID *int64
|
||||
ContractID *int64
|
||||
ReasonCode string
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
RunID string
|
||||
SourceVersion string
|
||||
Checksum string
|
||||
GeneratedAt time.Time
|
||||
Candidates int
|
||||
Items []ScopeItem
|
||||
Rejections []Rejection
|
||||
}
|
||||
|
||||
func BuildSnapshot(candidates []Candidate, generatedAt time.Time) (Snapshot, error) {
|
||||
generatedAt = generatedAt.UTC()
|
||||
reasons := make([]string, len(candidates))
|
||||
vehicleRows := map[int64][]int{}
|
||||
vinRows := map[string][]int{}
|
||||
for i, candidate := range candidates {
|
||||
candidate.VIN = normalizeVIN(candidate.VIN)
|
||||
candidates[i] = candidate
|
||||
reasons[i] = validateCandidate(candidate)
|
||||
if reasons[i] == "" {
|
||||
vehicleRows[candidate.VehicleID] = append(vehicleRows[candidate.VehicleID], i)
|
||||
vinRows[candidate.VIN] = append(vinRows[candidate.VIN], i)
|
||||
}
|
||||
}
|
||||
for _, indexes := range vehicleRows {
|
||||
if len(indexes) > 1 {
|
||||
for _, index := range indexes {
|
||||
reasons[index] = ReasonDuplicateVehicle
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, indexes := range vinRows {
|
||||
if len(indexes) > 1 {
|
||||
for _, index := range indexes {
|
||||
if reasons[index] == "" {
|
||||
reasons[index] = ReasonDuplicateVIN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]ScopeItem, 0, len(candidates))
|
||||
rejections := make([]Rejection, 0)
|
||||
for index, candidate := range candidates {
|
||||
if reasons[index] != "" {
|
||||
rejections = append(rejections, rejectionFor(candidate, reasons[index]))
|
||||
continue
|
||||
}
|
||||
items = append(items, ScopeItem{
|
||||
VehicleID: candidate.VehicleID, VIN: candidate.VIN, PlateNumber: strings.TrimSpace(candidate.PlateNumber),
|
||||
CustomerID: candidate.CustomerID, ContractID: candidate.ContractID,
|
||||
ContractCode: strings.TrimSpace(candidate.ContractCode), ProjectName: strings.TrimSpace(candidate.ProjectName),
|
||||
OperationStatus: strings.TrimSpace(candidate.OperationStatus), ScopeStartAt: candidate.ScopeStartAt,
|
||||
SourceUpdatedAt: candidate.SourceUpdatedAt,
|
||||
})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].CustomerID != items[j].CustomerID {
|
||||
return items[i].CustomerID < items[j].CustomerID
|
||||
}
|
||||
return items[i].VIN < items[j].VIN
|
||||
})
|
||||
sort.Slice(rejections, func(i, j int) bool { return rejections[i].RowNumber < rejections[j].RowNumber })
|
||||
canonical, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("marshal canonical scope: %w", err)
|
||||
}
|
||||
sum := sha256.Sum256(canonical)
|
||||
checksum := hex.EncodeToString(sum[:])
|
||||
runID, err := randomHex(16)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return Snapshot{
|
||||
RunID: runID, SourceVersion: "oneos-v1:" + checksum, Checksum: checksum,
|
||||
GeneratedAt: generatedAt, Candidates: len(candidates), Items: items, Rejections: rejections,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateCandidate(candidate Candidate) string {
|
||||
if !candidate.VehiclePresent || candidate.VehicleID <= 0 {
|
||||
return ReasonVehicleMissing
|
||||
}
|
||||
if normalizeVIN(candidate.VIN) == "" {
|
||||
return ReasonVINMissing
|
||||
}
|
||||
if !candidate.CustomerPresent || candidate.CustomerID <= 0 {
|
||||
return ReasonCustomerMissing
|
||||
}
|
||||
if !candidate.CustomerProfileExists {
|
||||
return ReasonCustomerProfileMissing
|
||||
}
|
||||
if !candidate.ContractPresent || candidate.ContractID <= 0 {
|
||||
return ReasonContractMissing
|
||||
}
|
||||
if !candidate.ContractProfileExists {
|
||||
return ReasonContractProfileMissing
|
||||
}
|
||||
if !candidate.EffectiveCustomerSet || candidate.EffectiveCustomerID <= 0 {
|
||||
return ReasonContractCustomerMissing
|
||||
}
|
||||
if candidate.CustomerID != candidate.EffectiveCustomerID {
|
||||
return ReasonCustomerMismatch
|
||||
}
|
||||
if candidate.ScopeStartAt.IsZero() {
|
||||
return ReasonVehicleMissing
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func rejectionFor(candidate Candidate, reason string) Rejection {
|
||||
rejection := Rejection{RowNumber: candidate.RowNumber, VIN: normalizeVIN(candidate.VIN), ReasonCode: reason}
|
||||
if candidate.VehicleID > 0 {
|
||||
rejection.VehicleID = int64Pointer(candidate.VehicleID)
|
||||
}
|
||||
if candidate.CustomerPresent && candidate.CustomerID > 0 {
|
||||
rejection.CustomerID = int64Pointer(candidate.CustomerID)
|
||||
}
|
||||
if candidate.ContractPresent && candidate.ContractID > 0 {
|
||||
rejection.ContractID = int64Pointer(candidate.ContractID)
|
||||
}
|
||||
return rejection
|
||||
}
|
||||
|
||||
func normalizeVIN(value string) string { return strings.ToUpper(strings.TrimSpace(value)) }
|
||||
func int64Pointer(value int64) *int64 { return &value }
|
||||
|
||||
func randomHex(bytes int) (string, error) {
|
||||
buffer := make([]byte, bytes)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", fmt.Errorf("generate run id: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(buffer), nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package businessscope
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func validCandidate(row int, vehicleID, customerID int64, vin string) Candidate {
|
||||
return Candidate{
|
||||
RowNumber: row, VehicleID: vehicleID, VehiclePresent: true, VIN: vin, PlateNumber: "沪A00001",
|
||||
CustomerID: customerID, CustomerPresent: true, CustomerProfileExists: true,
|
||||
ContractID: vehicleID + 1000, ContractPresent: true, ContractProfileExists: true,
|
||||
EffectiveCustomerID: customerID, EffectiveCustomerSet: true,
|
||||
ContractCode: "HT-1", ProjectName: "项目", OperationStatus: "1",
|
||||
ScopeStartAt: time.Date(2026, 7, 1, 8, 0, 0, 0, time.FixedZone("CST", 8*3600)),
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnapshotClassifiesAndNormalizes(t *testing.T) {
|
||||
candidates := []Candidate{
|
||||
validCandidate(1, 10, 100, " lvin0001 "),
|
||||
func() Candidate {
|
||||
value := validCandidate(2, 11, 101, "LVIN0002")
|
||||
value.CustomerProfileExists = false
|
||||
return value
|
||||
}(),
|
||||
func() Candidate {
|
||||
value := validCandidate(3, 12, 102, "LVIN0003")
|
||||
value.EffectiveCustomerID = 999
|
||||
return value
|
||||
}(),
|
||||
}
|
||||
snapshot, err := BuildSnapshot(candidates, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Candidates != 3 || len(snapshot.Items) != 1 || len(snapshot.Rejections) != 2 {
|
||||
t.Fatalf("unexpected accounting: %#v", snapshot)
|
||||
}
|
||||
if snapshot.Items[0].VIN != "LVIN0001" {
|
||||
t.Fatalf("VIN not normalized: %q", snapshot.Items[0].VIN)
|
||||
}
|
||||
if snapshot.Rejections[0].ReasonCode != ReasonCustomerProfileMissing || snapshot.Rejections[1].ReasonCode != ReasonCustomerMismatch {
|
||||
t.Fatalf("unexpected reasons: %#v", snapshot.Rejections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSnapshotRejectsEveryDuplicateVehicle(t *testing.T) {
|
||||
snapshot, err := BuildSnapshot([]Candidate{
|
||||
validCandidate(1, 10, 100, "LVIN0001"),
|
||||
validCandidate(2, 10, 100, "LVIN0001"),
|
||||
}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshot.Items) != 0 || len(snapshot.Rejections) != 2 {
|
||||
t.Fatalf("duplicates must all be quarantined: %#v", snapshot)
|
||||
}
|
||||
for _, rejection := range snapshot.Rejections {
|
||||
if rejection.ReasonCode != ReasonDuplicateVehicle {
|
||||
t.Fatalf("unexpected duplicate reason: %#v", rejection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotVersionIsContentAddressed(t *testing.T) {
|
||||
first := []Candidate{validCandidate(1, 10, 100, "LVIN0001"), validCandidate(2, 11, 101, "LVIN0002")}
|
||||
second := []Candidate{first[1], first[0]}
|
||||
a, err := BuildSnapshot(first, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := BuildSnapshot(second, time.Now().Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.SourceVersion != b.SourceVersion || a.Checksum != b.Checksum {
|
||||
t.Fatalf("same content produced different versions: %s %s", a.SourceVersion, b.SourceVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSnapshotUsesCountAndRatioGates(t *testing.T) {
|
||||
snapshot := Snapshot{Candidates: 639, Items: make([]ScopeItem, 613), Rejections: make([]Rejection, 26)}
|
||||
if err := ValidateSnapshot(snapshot, 100, 0.10); err != nil {
|
||||
t.Fatalf("production baseline should pass: %v", err)
|
||||
}
|
||||
if err := ValidateSnapshot(snapshot, 20, 0.10); err == nil {
|
||||
t.Fatal("rejection count limit should fail")
|
||||
}
|
||||
if err := ValidateSnapshot(snapshot, 100, 0.01); err == nil {
|
||||
t.Fatal("rejection ratio limit should fail")
|
||||
}
|
||||
}
|
||||
105
vehicle-data-platform/apps/api/internal/businessscope/publish.go
Normal file
105
vehicle-data-platform/apps/api/internal/businessscope/publish.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package businessscope
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PublishResult struct {
|
||||
Changed bool
|
||||
ActiveVersion string
|
||||
}
|
||||
|
||||
func ValidateSnapshot(snapshot Snapshot, maxRejected int, maxRejectRatio float64) error {
|
||||
if snapshot.Candidates <= 0 {
|
||||
return fmt.Errorf("OneOS scope snapshot contains no candidate rows")
|
||||
}
|
||||
if len(snapshot.Items) <= 0 {
|
||||
return fmt.Errorf("OneOS scope snapshot contains no publishable rows")
|
||||
}
|
||||
if snapshot.Candidates != len(snapshot.Items)+len(snapshot.Rejections) {
|
||||
return fmt.Errorf("scope accounting mismatch: candidates=%d accepted=%d rejected=%d", snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections))
|
||||
}
|
||||
if maxRejected >= 0 && len(snapshot.Rejections) > maxRejected {
|
||||
return fmt.Errorf("scope rejected rows exceed limit: rejected=%d limit=%d", len(snapshot.Rejections), maxRejected)
|
||||
}
|
||||
ratio := float64(len(snapshot.Rejections)) / float64(snapshot.Candidates)
|
||||
if maxRejectRatio >= 0 && ratio > maxRejectRatio {
|
||||
return fmt.Errorf("scope rejected ratio exceeds limit: ratio=%.6f limit=%.6f", ratio, maxRejectRatio)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Publish(ctx context.Context, db *sql.DB, snapshot Snapshot) (PublishResult, error) {
|
||||
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||||
if err != nil {
|
||||
return PublishResult{}, fmt.Errorf("begin scope publish: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var activeVersion sql.NullString
|
||||
if err := tx.QueryRowContext(ctx, `SELECT active_version FROM business_scope_state WHERE id=1 FOR UPDATE`).Scan(&activeVersion); err != nil {
|
||||
return PublishResult{}, fmt.Errorf("lock business scope state; apply migration 012_business_scope_projection.sql: %w", err)
|
||||
}
|
||||
status := "published"
|
||||
changed := !activeVersion.Valid || activeVersion.String != snapshot.SourceVersion
|
||||
if !changed {
|
||||
status = "unchanged"
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO business_scope_sync_run(
|
||||
run_id,source_system,source_version,status,candidate_count,accepted_count,rejected_count,source_checksum,started_at,finished_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?)`, snapshot.RunID, SourceSystem, snapshot.SourceVersion, status, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.Checksum, snapshot.GeneratedAt, now); err != nil {
|
||||
return PublishResult{}, fmt.Errorf("insert business scope sync run: %w", err)
|
||||
}
|
||||
if changed {
|
||||
// A source version can become active again after the source data reverts.
|
||||
// Rebuild that inactive version while holding the state lock so publishing
|
||||
// remains atomic and cannot fail on its existing primary keys.
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM business_customer_vehicle_scope WHERE source_version=?`, snapshot.SourceVersion); err != nil {
|
||||
return PublishResult{}, fmt.Errorf("clear inactive business scope version: %w", err)
|
||||
}
|
||||
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
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
if err != nil {
|
||||
return PublishResult{}, fmt.Errorf("prepare business scope insert: %w", err)
|
||||
}
|
||||
defer statement.Close()
|
||||
for _, item := range snapshot.Items {
|
||||
var contractID any
|
||||
if item.ContractID > 0 {
|
||||
contractID = item.ContractID
|
||||
}
|
||||
if _, err := statement.ExecContext(ctx, snapshot.SourceVersion, item.CustomerID, item.VIN, item.VehicleID, contractID,
|
||||
item.ContractCode, item.PlateNumber, item.ProjectName, item.OperationStatus, item.ScopeStartAt, item.SourceUpdatedAt, now); err != nil {
|
||||
return PublishResult{}, fmt.Errorf("insert business scope item: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(snapshot.Rejections) > 0 {
|
||||
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
|
||||
) VALUES(?,?,?,?,?,?,?,?)`)
|
||||
if err != nil {
|
||||
return PublishResult{}, fmt.Errorf("prepare scope rejection insert: %w", err)
|
||||
}
|
||||
defer statement.Close()
|
||||
for _, rejected := range snapshot.Rejections {
|
||||
if _, err := statement.ExecContext(ctx, snapshot.RunID, rejected.RowNumber, rejected.VehicleID, rejected.VIN,
|
||||
rejected.CustomerID, rejected.ContractID, rejected.ReasonCode, now); err != nil {
|
||||
return PublishResult{}, fmt.Errorf("insert business scope rejection: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE business_scope_state SET
|
||||
active_version=?,source_checksum=?,candidate_count=?,accepted_count=?,rejected_count=?,generated_at=?,published_at=?,last_success_at=?
|
||||
WHERE id=1`, snapshot.SourceVersion, snapshot.Checksum, snapshot.Candidates, len(snapshot.Items), len(snapshot.Rejections), snapshot.GeneratedAt, now, now); err != nil {
|
||||
return PublishResult{}, fmt.Errorf("activate business scope snapshot: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return PublishResult{}, fmt.Errorf("commit business scope snapshot: %w", err)
|
||||
}
|
||||
return PublishResult{Changed: changed, ActiveVersion: snapshot.SourceVersion}, nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package businessscope
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
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
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`
|
||||
|
||||
func publishSnapshot(version string) Snapshot {
|
||||
generatedAt := time.Date(2026, 7, 14, 8, 0, 0, 0, time.UTC)
|
||||
return Snapshot{
|
||||
RunID: "0123456789abcdef0123456789abcdef", SourceVersion: version,
|
||||
Checksum: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
GeneratedAt: generatedAt, Candidates: 1,
|
||||
Items: []ScopeItem{{
|
||||
VehicleID: 10, VIN: "LVIN0001", PlateNumber: "沪A00001", CustomerID: 100,
|
||||
ContractID: 1010, ContractCode: "HT-1", ProjectName: "项目", OperationStatus: "1",
|
||||
ScopeStartAt: generatedAt.Add(-time.Hour),
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRebuildsPreviouslyStoredVersionBeforeActivation(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
snapshot := publishSnapshot("oneos-v1:new")
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT active_version FROM business_scope_state WHERE id=1 FOR UPDATE`)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"active_version"}).AddRow("oneos-v1:current"))
|
||||
mock.ExpectExec(`INSERT INTO business_scope_sync_run`).
|
||||
WithArgs(snapshot.RunID, SourceSystem, snapshot.SourceVersion, "published", 1, 1, 0, snapshot.Checksum, snapshot.GeneratedAt, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM business_customer_vehicle_scope WHERE source_version=?`)).
|
||||
WithArgs(snapshot.SourceVersion).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
prepared := mock.ExpectPrepare(regexp.QuoteMeta(scopeInsertSQL))
|
||||
item := snapshot.Items[0]
|
||||
prepared.ExpectExec().WithArgs(
|
||||
snapshot.SourceVersion, item.CustomerID, item.VIN, item.VehicleID, item.ContractID,
|
||||
item.ContractCode, item.PlateNumber, item.ProjectName, item.OperationStatus, item.ScopeStartAt, nil, sqlmock.AnyArg(),
|
||||
).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(`UPDATE business_scope_state SET`).
|
||||
WithArgs(snapshot.SourceVersion, snapshot.Checksum, 1, 1, 0, snapshot.GeneratedAt, sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
result, err := Publish(context.Background(), db, snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Changed || result.ActiveVersion != snapshot.SourceVersion {
|
||||
t.Fatalf("unexpected publish result: %#v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishUnchangedDoesNotRewriteScopeRows(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
snapshot := publishSnapshot("oneos-v1:same")
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT active_version FROM business_scope_state WHERE id=1 FOR UPDATE`)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"active_version"}).AddRow(snapshot.SourceVersion))
|
||||
mock.ExpectExec(`INSERT INTO business_scope_sync_run`).
|
||||
WithArgs(snapshot.RunID, SourceSystem, snapshot.SourceVersion, "unchanged", 1, 1, 0, snapshot.Checksum, snapshot.GeneratedAt, sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(`UPDATE business_scope_state SET`).
|
||||
WithArgs(snapshot.SourceVersion, snapshot.Checksum, 1, 1, 0, snapshot.GeneratedAt, sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
result, err := Publish(context.Background(), db, snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Changed {
|
||||
t.Fatalf("unchanged snapshot reported as changed: %#v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRollsBackWhenVersionCannotBeCleared(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
snapshot := publishSnapshot("oneos-v1:new")
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT active_version FROM business_scope_state WHERE id=1 FOR UPDATE`)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"active_version"}).AddRow("oneos-v1:current"))
|
||||
mock.ExpectExec(`INSERT INTO business_scope_sync_run`).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM business_customer_vehicle_scope WHERE source_version=?`)).
|
||||
WithArgs(snapshot.SourceVersion).WillReturnError(errors.New("delete failed"))
|
||||
mock.ExpectRollback()
|
||||
|
||||
if _, err := Publish(context.Background(), db, snapshot); err == nil {
|
||||
t.Fatal("publish should fail when stale version cannot be rebuilt")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
200
vehicle-data-platform/apps/api/internal/businessscope/source.go
Normal file
200
vehicle-data-platform/apps/api/internal/businessscope/source.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package businessscope
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// candidateQuery deliberately derives the active lease lifecycle from delivery and
|
||||
// return task facts. vehicle_lease_order_record remains an independent customer
|
||||
// ownership check, but its last_return_time is not authoritative: saving a return
|
||||
// draft currently updates that aggregate field before the return is completed.
|
||||
const candidateQuery = `SELECT
|
||||
dv.vehicle_id,
|
||||
v.id,
|
||||
COALESCE(v.vin, ''),
|
||||
COALESCE(v.plate_number, dv.plate_number, r.plate_number, ''),
|
||||
r.customer_id,
|
||||
ci.id,
|
||||
dv.contract_id,
|
||||
co.id,
|
||||
COALESCE(co.other_customer_id, co.customer_id),
|
||||
COALESCE(co.contract_code, r.contract_code, ''),
|
||||
COALESCE(co.project_name, r.project_name, ''),
|
||||
COALESCE(vs.operation_status, ''),
|
||||
dv.delivery_time,
|
||||
dv.update_time
|
||||
FROM delivery_vehicle dv
|
||||
LEFT JOIN vehicle_info v ON v.id = dv.vehicle_id AND v.del_flag = '0'
|
||||
LEFT JOIN vehicle_lease_order_record r ON r.vehicle_id = dv.vehicle_id AND r.del_flag = '0'
|
||||
LEFT JOIN customer_info ci ON ci.id = r.customer_id AND ci.del_flag = '0'
|
||||
LEFT JOIN vehicle_lease_contract_info co ON co.id = dv.contract_id AND co.del_flag = '0'
|
||||
LEFT JOIN vehicle_status vs ON vs.vehicle_id = dv.vehicle_id AND vs.del_flag = '0'
|
||||
WHERE dv.del_flag = '0'
|
||||
AND dv.delivery_status IN (2, 3)
|
||||
AND dv.vehicle_id IS NOT NULL
|
||||
AND dv.delivery_time IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM delivery_vehicle newer
|
||||
WHERE newer.del_flag = '0'
|
||||
AND newer.vehicle_id = dv.vehicle_id
|
||||
AND newer.vehicle_id IS NOT NULL
|
||||
AND (
|
||||
COALESCE(newer.delivery_time, '1000-01-01') > COALESCE(dv.delivery_time, '1000-01-01')
|
||||
OR (
|
||||
COALESCE(newer.delivery_time, '1000-01-01') = COALESCE(dv.delivery_time, '1000-01-01')
|
||||
AND newer.id > dv.id
|
||||
)
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM return_vehicle_task rt
|
||||
WHERE rt.delivery_vehicle_id = dv.id
|
||||
AND rt.del_flag = '0'
|
||||
AND rt.status IN (2, 3, 5)
|
||||
)
|
||||
ORDER BY dv.vehicle_id, dv.id, r.id`
|
||||
|
||||
func ReadCandidates(ctx context.Context, db *sql.DB) ([]Candidate, error) {
|
||||
connection, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acquire OneOS connection: %w", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
if err := VerifyReadOnlyGrants(ctx, connection); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := connection.ExecContext(ctx, `SET SESSION TRANSACTION READ ONLY`); err != nil {
|
||||
return nil, fmt.Errorf("force OneOS session read only: %w", err)
|
||||
}
|
||||
if _, err := connection.ExecContext(ctx, `SET SESSION MAX_EXECUTION_TIME = 10000`); err != nil {
|
||||
return nil, fmt.Errorf("set OneOS query timeout: %w", err)
|
||||
}
|
||||
tx, err := connection.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin OneOS read-only snapshot: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
rows, err := tx.QueryContext(ctx, candidateQuery)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query OneOS customer vehicle candidates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
candidates := make([]Candidate, 0, 1024)
|
||||
for rows.Next() {
|
||||
var vehicleID, vehicleProfileID, customerID, customerProfileID sql.NullInt64
|
||||
var contractID, contractProfileID, effectiveCustomerID sql.NullInt64
|
||||
var vin, plate, contractCode, projectName, operationStatus string
|
||||
var scopeStart time.Time
|
||||
var updatedAt sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&vehicleID, &vehicleProfileID, &vin, &plate, &customerID, &customerProfileID,
|
||||
&contractID, &contractProfileID, &effectiveCustomerID, &contractCode, &projectName,
|
||||
&operationStatus, &scopeStart, &updatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan OneOS scope candidate: %w", err)
|
||||
}
|
||||
candidate := Candidate{
|
||||
RowNumber: len(candidates) + 1,
|
||||
VehicleID: vehicleID.Int64, VehiclePresent: vehicleID.Valid && vehicleProfileID.Valid,
|
||||
VIN: vin, PlateNumber: plate,
|
||||
CustomerID: customerID.Int64, CustomerPresent: customerID.Valid,
|
||||
CustomerProfileExists: customerProfileID.Valid,
|
||||
ContractID: contractID.Int64, ContractPresent: contractID.Valid,
|
||||
ContractProfileExists: contractProfileID.Valid,
|
||||
EffectiveCustomerID: effectiveCustomerID.Int64, EffectiveCustomerSet: effectiveCustomerID.Valid,
|
||||
ContractCode: contractCode, ProjectName: projectName, OperationStatus: operationStatus,
|
||||
ScopeStartAt: scopeStart,
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
value := updatedAt.Time
|
||||
candidate.SourceUpdatedAt = &value
|
||||
}
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate OneOS scope candidates: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("finish OneOS read-only snapshot: %w", err)
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func VerifyReadOnlyGrants(ctx context.Context, connection *sql.Conn) error {
|
||||
rows, err := connection.QueryContext(ctx, `SHOW GRANTS FOR CURRENT_USER`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect OneOS database grants: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
grants := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var grant string
|
||||
if err := rows.Scan(&grant); err != nil {
|
||||
return fmt.Errorf("scan OneOS database grant: %w", err)
|
||||
}
|
||||
grants = append(grants, grant)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("iterate OneOS database grants: %w", err)
|
||||
}
|
||||
if err := ValidateReadOnlyGrants(grants); err != nil {
|
||||
return fmt.Errorf("OneOS database account is not read-only: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateReadOnlyGrants(grants []string) error {
|
||||
if len(grants) == 0 {
|
||||
return fmt.Errorf("SHOW GRANTS returned no rows")
|
||||
}
|
||||
allowedSelectScopes := map[string]bool{
|
||||
"LN_ASSET_MANAGEMENT.VEHICLE_LEASE_ORDER_RECORD": true,
|
||||
"LN_ASSET_MANAGEMENT.DELIVERY_VEHICLE": true,
|
||||
"LN_ASSET_MANAGEMENT.RETURN_VEHICLE_TASK": true,
|
||||
"LN_ASSET_MANAGEMENT.VEHICLE_INFO": true,
|
||||
"LN_ASSET_MANAGEMENT.CUSTOMER_INFO": true,
|
||||
"LN_ASSET_MANAGEMENT.VEHICLE_LEASE_CONTRACT_INFO": true,
|
||||
"LN_ASSET_MANAGEMENT.VEHICLE_STATUS": true,
|
||||
}
|
||||
for _, raw := range grants {
|
||||
grant := strings.ToUpper(strings.TrimSpace(raw))
|
||||
if strings.HasPrefix(grant, "SET DEFAULT ROLE ") {
|
||||
return fmt.Errorf("role-based grants are not accepted; grant SELECT directly to the sync account")
|
||||
}
|
||||
if !strings.HasPrefix(grant, "GRANT ") {
|
||||
return fmt.Errorf("unsupported grant statement")
|
||||
}
|
||||
onIndex := strings.Index(grant, " ON ")
|
||||
if onIndex < 0 {
|
||||
return fmt.Errorf("unsupported role or dynamic grant")
|
||||
}
|
||||
privileges := strings.TrimSpace(strings.TrimPrefix(grant[:onIndex], "GRANT "))
|
||||
toIndex := strings.Index(grant[onIndex+4:], " TO ")
|
||||
if toIndex < 0 {
|
||||
return fmt.Errorf("unsupported grant scope")
|
||||
}
|
||||
scope := strings.ReplaceAll(strings.TrimSpace(grant[onIndex+4:onIndex+4+toIndex]), "`", "")
|
||||
for _, privilege := range strings.Split(privileges, ",") {
|
||||
privilege = strings.TrimSpace(privilege)
|
||||
switch privilege {
|
||||
case "USAGE":
|
||||
if scope != "*.*" {
|
||||
return fmt.Errorf("USAGE has unsupported scope %q", scope)
|
||||
}
|
||||
case "SELECT":
|
||||
if !allowedSelectScopes[scope] {
|
||||
return fmt.Errorf("SELECT scope %q is not required by the sync query", scope)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("disallowed privilege %q", privilege)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package businessscope
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCandidateQueryUsesCompletedLifecycleFactsInsteadOfAggregateReturnTime(t *testing.T) {
|
||||
assertions := []string{
|
||||
"FROM delivery_vehicle dv",
|
||||
"dv.delivery_status IN (2, 3)",
|
||||
"FROM return_vehicle_task rt",
|
||||
"rt.status IN (2, 3, 5)",
|
||||
"FROM delivery_vehicle newer",
|
||||
"LEFT JOIN vehicle_lease_order_record r",
|
||||
}
|
||||
for _, expected := range assertions {
|
||||
if !strings.Contains(candidateQuery, expected) {
|
||||
t.Fatalf("candidate query missing %q", expected)
|
||||
}
|
||||
}
|
||||
if strings.Contains(candidateQuery, "r.last_return_time") {
|
||||
t.Fatal("candidate query must not trust aggregate last_return_time written by return drafts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReadOnlyGrantsAllowsOnlyRequiredTableSelects(t *testing.T) {
|
||||
grants := []string{
|
||||
"GRANT USAGE ON *.* TO `reader`@`%`",
|
||||
"GRANT SELECT ON `ln_asset_management`.`vehicle_lease_order_record` TO `reader`@`%`",
|
||||
"GRANT SELECT ON `ln_asset_management`.`delivery_vehicle` TO `reader`@`%`",
|
||||
"GRANT SELECT ON `ln_asset_management`.`return_vehicle_task` TO `reader`@`%`",
|
||||
"GRANT SELECT ON `ln_asset_management`.`vehicle_info` TO `reader`@`%`",
|
||||
"GRANT SELECT ON `ln_asset_management`.`customer_info` TO `reader`@`%`",
|
||||
"GRANT SELECT ON `ln_asset_management`.`vehicle_lease_contract_info` TO `reader`@`%`",
|
||||
"GRANT SELECT ON `ln_asset_management`.`vehicle_status` TO `reader`@`%`",
|
||||
}
|
||||
if err := ValidateReadOnlyGrants(grants); err != nil {
|
||||
t.Fatalf("read-only grants rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReadOnlyGrantsRejectsBroadOrWritePrivileges(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"GRANT ALL PRIVILEGES ON `ln_asset_management`.* TO `reader`@`%`"},
|
||||
{"GRANT SELECT, UPDATE ON `ln_asset_management`.* TO `reader`@`%`"},
|
||||
{"GRANT SELECT ON *.* TO `reader`@`%`"},
|
||||
{"GRANT SELECT ON `ln_asset_management`.* TO `reader`@`%`"},
|
||||
{"GRANT SELECT ON `ry-cloud`.`sys_user` TO `reader`@`%`"},
|
||||
{"GRANT SHOW VIEW ON `ln_asset_management`.`vehicle_info` TO `reader`@`%`"},
|
||||
{"GRANT PROCESS, REPLICATION CLIENT ON *.* TO `reader`@`%`"},
|
||||
{"GRANT `scope_reader_role`@`%` TO `reader`@`%`"},
|
||||
{"SET DEFAULT ROLE `scope_reader_role`@`%` TO `reader`@`%`"},
|
||||
}
|
||||
for _, grants := range cases {
|
||||
if err := ValidateReadOnlyGrants(grants); err == nil {
|
||||
t.Fatalf("unsafe grants accepted: %#v", grants)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,14 @@ func (s *Service) AccessSummary(ctx context.Context, query AccessQuery) (AccessS
|
||||
protocols := map[string]*AccessDistribution{}
|
||||
oems := map[string]*AccessDistribution{}
|
||||
for _, row := range rows {
|
||||
switch row.ConnectionState {
|
||||
case "healthy":
|
||||
result.HealthyVehicles++
|
||||
case "incomplete":
|
||||
result.IncompleteVehicles++
|
||||
case "degraded":
|
||||
result.DegradedVehicles++
|
||||
}
|
||||
switch row.OnlineState {
|
||||
case "online":
|
||||
result.OnlineVehicles++
|
||||
@@ -143,7 +151,11 @@ func (s *Service) AccessSummary(ctx context.Context, query AccessQuery) (AccessS
|
||||
if reportedOnDay(row.LatestReceivedAt, now) {
|
||||
result.ReportedToday++
|
||||
}
|
||||
addAccessDistribution(protocols, firstNonEmpty(row.Protocol, "未识别"), row.OnlineState == "online")
|
||||
for _, status := range row.ProtocolStatuses {
|
||||
if status.Connected {
|
||||
addAccessDistribution(protocols, status.Protocol, status.OnlineState == "online")
|
||||
}
|
||||
}
|
||||
addAccessDistribution(oems, firstNonEmpty(row.OEM, "未维护"), row.OnlineState == "online")
|
||||
}
|
||||
if result.TotalVehicles > 0 {
|
||||
@@ -171,9 +183,18 @@ func (s *Service) accessRows(ctx context.Context, query AccessQuery) ([]AccessVe
|
||||
return nil, AccessThresholdConfig{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
rows := make([]AccessVehicleRow, 0, len(evidence))
|
||||
byVIN := make(map[string][]AccessEvidenceRow, len(evidence))
|
||||
order := make([]string, 0, len(evidence))
|
||||
for _, item := range evidence {
|
||||
row := buildAccessVehicleRow(item, config, now)
|
||||
vin := strings.TrimSpace(item.VIN)
|
||||
if _, exists := byVIN[vin]; !exists {
|
||||
order = append(order, vin)
|
||||
}
|
||||
byVIN[vin] = append(byVIN[vin], item)
|
||||
}
|
||||
rows := make([]AccessVehicleRow, 0, len(byVIN))
|
||||
for _, vin := range order {
|
||||
row := buildAccessVehicleGroup(byVIN[vin], config, now)
|
||||
if keepAccessRow(row, query) {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
@@ -191,6 +212,159 @@ func (s *Service) accessRows(ctx context.Context, query AccessQuery) ([]AccessVe
|
||||
return rows, config, nil
|
||||
}
|
||||
|
||||
func buildAccessVehicleGroup(items []AccessEvidenceRow, config AccessThresholdConfig, now time.Time) AccessVehicleRow {
|
||||
if len(items) == 0 {
|
||||
return AccessVehicleRow{}
|
||||
}
|
||||
base := items[0]
|
||||
row := AccessVehicleRow{
|
||||
VIN: strings.TrimSpace(base.VIN),
|
||||
Plate: strings.TrimSpace(base.Plate),
|
||||
OEM: strings.TrimSpace(base.OEM),
|
||||
Model: strings.TrimSpace(base.Model),
|
||||
Company: strings.TrimSpace(base.Company),
|
||||
ExpectedProtocols: append([]string(nil), canonicalVehicleProtocols...),
|
||||
ActualProtocols: []string{},
|
||||
MissingProtocols: []string{},
|
||||
ProtocolStatuses: []AccessProtocolStatus{},
|
||||
ExpectationEvidence: "平台标准接入基线:GB32960 / JT808 / YUTONG_MQTT",
|
||||
ConnectionState: "not_connected",
|
||||
OnlineState: "never_reported",
|
||||
FirstSeenEvidence: "尚未形成任何协议接入快照",
|
||||
ReportIntervalProof: "需要连续上报样本后才能计算",
|
||||
}
|
||||
byProtocol := make(map[string]AccessEvidenceRow, len(items))
|
||||
for _, item := range items {
|
||||
protocol := strings.ToUpper(strings.TrimSpace(item.Protocol))
|
||||
if protocol != "" {
|
||||
byProtocol[protocol] = item
|
||||
}
|
||||
if row.Plate == "" {
|
||||
row.Plate = strings.TrimSpace(item.Plate)
|
||||
}
|
||||
if row.OEM == "" {
|
||||
row.OEM = strings.TrimSpace(item.OEM)
|
||||
}
|
||||
if row.Model == "" {
|
||||
row.Model = strings.TrimSpace(item.Model)
|
||||
}
|
||||
if row.Company == "" {
|
||||
row.Company = strings.TrimSpace(item.Company)
|
||||
}
|
||||
}
|
||||
onlineCount := 0
|
||||
unknownCount := 0
|
||||
connectedCount := 0
|
||||
for _, protocol := range canonicalVehicleProtocols {
|
||||
item, connected := byProtocol[protocol]
|
||||
if !connected {
|
||||
threshold := accessProtocolThreshold(config, protocol)
|
||||
row.MissingProtocols = append(row.MissingProtocols, protocol)
|
||||
row.ProtocolStatuses = append(row.ProtocolStatuses, AccessProtocolStatus{
|
||||
Protocol: protocol, Expected: true, Connected: false, OnlineState: "never_reported", ThresholdSec: threshold,
|
||||
FirstSeenEvidence: "应接协议尚未形成实时快照", ReportIntervalEvidence: "尚无接收样本",
|
||||
})
|
||||
continue
|
||||
}
|
||||
source := buildAccessVehicleRow(item, config, now)
|
||||
connectedCount++
|
||||
row.ActualProtocols = append(row.ActualProtocols, protocol)
|
||||
if source.OnlineState == "online" {
|
||||
onlineCount++
|
||||
}
|
||||
if source.DelayAbnormal {
|
||||
row.DelayAbnormal = true
|
||||
}
|
||||
row.ProtocolStatuses = append(row.ProtocolStatuses, AccessProtocolStatus{
|
||||
Protocol: protocol, Expected: true, Connected: true, Provider: source.Provider,
|
||||
FirstSeenAt: source.FirstSeenAt, LatestEventAt: source.LatestEventAt, LatestReceivedAt: source.LatestReceivedAt,
|
||||
ReportIntervalSec: source.ReportIntervalSec, DataDelaySec: source.DataDelaySec, FreshnessSec: source.FreshnessSec,
|
||||
OnlineState: source.OnlineState, ThresholdSec: source.ThresholdSec, DelayAbnormal: source.DelayAbnormal,
|
||||
FirstSeenEvidence: source.FirstSeenEvidence, ReportIntervalEvidence: source.ReportIntervalProof,
|
||||
Longitude: item.Longitude, Latitude: item.Latitude, SpeedKmh: item.SpeedKmh, SOCPercent: item.SOCPercent, TotalMileageKm: item.TotalMileageKm, DailyMileageKm: item.DailyMileageKm,
|
||||
})
|
||||
if row.FirstSeenAt == "" || source.FirstSeenAt != "" && source.FirstSeenAt < row.FirstSeenAt {
|
||||
row.FirstSeenAt = source.FirstSeenAt
|
||||
row.FirstSeenEvidence = source.FirstSeenEvidence
|
||||
row.FirstSeenSource = source.FirstSeenSource
|
||||
}
|
||||
if row.LatestReceivedAt == "" || source.LatestReceivedAt > row.LatestReceivedAt {
|
||||
copyAccessPrimaryFields(&row, source)
|
||||
}
|
||||
}
|
||||
for protocol, item := range byProtocol {
|
||||
if containsString(canonicalVehicleProtocols, protocol) {
|
||||
continue
|
||||
}
|
||||
source := buildAccessVehicleRow(item, config, now)
|
||||
row.ActualProtocols = append(row.ActualProtocols, protocol)
|
||||
if source.OnlineState == "online" {
|
||||
onlineCount++
|
||||
}
|
||||
if source.OnlineState == "unknown" {
|
||||
unknownCount++
|
||||
}
|
||||
row.ProtocolStatuses = append(row.ProtocolStatuses, AccessProtocolStatus{
|
||||
Protocol: protocol, Expected: false, Connected: true, Provider: source.Provider,
|
||||
FirstSeenAt: source.FirstSeenAt, LatestEventAt: source.LatestEventAt, LatestReceivedAt: source.LatestReceivedAt,
|
||||
ReportIntervalSec: source.ReportIntervalSec, DataDelaySec: source.DataDelaySec, FreshnessSec: source.FreshnessSec,
|
||||
OnlineState: source.OnlineState, ThresholdSec: source.ThresholdSec, DelayAbnormal: source.DelayAbnormal,
|
||||
FirstSeenEvidence: source.FirstSeenEvidence, ReportIntervalEvidence: source.ReportIntervalProof,
|
||||
Longitude: item.Longitude, Latitude: item.Latitude, SpeedKmh: item.SpeedKmh, SOCPercent: item.SOCPercent, TotalMileageKm: item.TotalMileageKm, DailyMileageKm: item.DailyMileageKm,
|
||||
})
|
||||
if row.LatestReceivedAt == "" || source.LatestReceivedAt > row.LatestReceivedAt {
|
||||
copyAccessPrimaryFields(&row, source)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case connectedCount == 0 && len(row.ActualProtocols) == 0:
|
||||
row.ConnectionState = "not_connected"
|
||||
row.OnlineState = "never_reported"
|
||||
case connectedCount == len(canonicalVehicleProtocols) && onlineCount == connectedCount:
|
||||
row.ConnectionState = "healthy"
|
||||
row.OnlineState = "online"
|
||||
case onlineCount == 0 && unknownCount > 0:
|
||||
row.ConnectionState = "incomplete"
|
||||
row.OnlineState = "unknown"
|
||||
case onlineCount == 0:
|
||||
row.ConnectionState = "offline"
|
||||
row.OnlineState = "offline"
|
||||
case connectedCount < len(canonicalVehicleProtocols):
|
||||
row.ConnectionState = "incomplete"
|
||||
row.OnlineState = "online"
|
||||
default:
|
||||
row.ConnectionState = "degraded"
|
||||
row.OnlineState = "online"
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func accessProtocolThreshold(config AccessThresholdConfig, protocol string) int {
|
||||
for _, override := range config.Protocols {
|
||||
if strings.EqualFold(override.Protocol, protocol) {
|
||||
return override.ThresholdSec
|
||||
}
|
||||
}
|
||||
return config.DefaultThresholdSec
|
||||
}
|
||||
|
||||
func copyAccessPrimaryFields(target *AccessVehicleRow, source AccessVehicleRow) {
|
||||
target.Protocol = source.Protocol
|
||||
target.Provider = source.Provider
|
||||
target.Source = source.Source
|
||||
target.LatestEventAt = source.LatestEventAt
|
||||
target.LatestReceivedAt = source.LatestReceivedAt
|
||||
target.ReportIntervalSec = source.ReportIntervalSec
|
||||
target.DataDelaySec = source.DataDelaySec
|
||||
target.FreshnessSec = source.FreshnessSec
|
||||
target.ThresholdSec = source.ThresholdSec
|
||||
target.LatestMessageType = source.LatestMessageType
|
||||
target.LatestEventID = source.LatestEventID
|
||||
target.LatestError = source.LatestError
|
||||
target.ReportIntervalProof = source.ReportIntervalProof
|
||||
target.ReportSampleCount = source.ReportSampleCount
|
||||
}
|
||||
|
||||
func buildAccessVehicleRow(item AccessEvidenceRow, config AccessThresholdConfig, now time.Time) AccessVehicleRow {
|
||||
threshold := config.DefaultThresholdSec
|
||||
for _, override := range config.Protocols {
|
||||
@@ -266,10 +440,10 @@ func buildAccessVehicleRow(item AccessEvidenceRow, config AccessThresholdConfig,
|
||||
|
||||
func keepAccessRow(row AccessVehicleRow, query AccessQuery) bool {
|
||||
keyword := strings.ToLower(strings.TrimSpace(query.Keyword))
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(row.VIN), keyword) && !strings.Contains(strings.ToLower(row.Plate), keyword) {
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(row.VIN), keyword) && !strings.Contains(strings.ToLower(row.Plate), keyword) && !strings.Contains(strings.ToLower(row.OEM), keyword) && !strings.Contains(strings.ToLower(row.Model), keyword) && !strings.Contains(strings.ToLower(row.Company), keyword) {
|
||||
return false
|
||||
}
|
||||
if value := strings.TrimSpace(query.Protocol); value != "" && !strings.EqualFold(value, row.Protocol) {
|
||||
if value := strings.TrimSpace(query.Protocol); value != "" && !containsString(row.ActualProtocols, value) {
|
||||
return false
|
||||
}
|
||||
if value := strings.TrimSpace(query.OEM); value != "" && !strings.EqualFold(value, row.OEM) {
|
||||
@@ -278,8 +452,17 @@ func keepAccessRow(row AccessVehicleRow, query AccessQuery) bool {
|
||||
if value := strings.ToLower(strings.TrimSpace(query.Model)); value != "" && !strings.Contains(strings.ToLower(row.Model), value) {
|
||||
return false
|
||||
}
|
||||
if value := strings.ToLower(strings.TrimSpace(query.Provider)); value != "" && !strings.Contains(strings.ToLower(row.Provider), value) {
|
||||
return false
|
||||
if value := strings.ToLower(strings.TrimSpace(query.Provider)); value != "" {
|
||||
matched := false
|
||||
for _, status := range row.ProtocolStatuses {
|
||||
if strings.Contains(strings.ToLower(status.Provider), value) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !accessTimeMatches(row.FirstSeenAt, query.FirstSeenFrom, query.FirstSeenTo) || !accessTimeMatches(row.LatestReceivedAt, query.LatestSeenFrom, query.LatestSeenTo) {
|
||||
return false
|
||||
@@ -287,6 +470,15 @@ func keepAccessRow(row AccessVehicleRow, query AccessQuery) bool {
|
||||
if value := strings.TrimSpace(query.OnlineState); value != "" && value != "all" && value != row.OnlineState {
|
||||
return false
|
||||
}
|
||||
if value := strings.TrimSpace(query.ConnectionState); value != "" && value != "all" {
|
||||
if value == "attention" {
|
||||
if row.ConnectionState == "healthy" {
|
||||
return false
|
||||
}
|
||||
} else if value != row.ConnectionState {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch strings.TrimSpace(query.DelayState) {
|
||||
case "abnormal":
|
||||
return row.DelayAbnormal
|
||||
|
||||
@@ -77,21 +77,27 @@ COALESCE(DATE_FORMAT(s.access_latest_received_at, '%Y-%m-%d %H:%i:%s.%f'), '') A
|
||||
COALESCE(DATE_FORMAT(s.access_latest_received_at, '%Y-%m-%d %H:%i:%s.%f'), '') AS updated_at,
|
||||
COALESCE(s.access_report_interval_ms, -1) AS report_interval_ms,
|
||||
COALESCE(s.access_sample_count, 0) AS report_sample_count,
|
||||
COALESCE(s.event_id, '') AS event_id
|
||||
COALESCE(s.event_id, '') AS event_id,
|
||||
COALESCE(l.longitude, 0) AS longitude,
|
||||
COALESCE(l.latitude, 0) AS latitude,
|
||||
COALESCE(l.speed_kmh, 0) AS speed_kmh,
|
||||
COALESCE(l.soc_percent, 0) AS soc_percent,
|
||||
COALESCE(l.total_mileage_km, 0) AS total_mileage_km,
|
||||
COALESCE(m.daily_mileage_km, 0) AS daily_mileage_km
|
||||
FROM (
|
||||
SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> ''
|
||||
UNION
|
||||
SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin <> ''
|
||||
SELECT DISTINCT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> ''
|
||||
) v
|
||||
LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin
|
||||
LEFT JOIN vehicle_profile p ON p.vin = v.vin
|
||||
LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM vehicle_realtime_snapshot newer
|
||||
WHERE newer.vin = s.vin
|
||||
AND (newer.access_latest_received_at > s.access_latest_received_at OR (newer.access_latest_received_at = s.access_latest_received_at AND newer.protocol < s.protocol))
|
||||
)
|
||||
ORDER BY s.access_latest_received_at DESC, v.vin ASC
|
||||
LEFT JOIN vehicle_realtime_location l ON l.vin = s.vin AND l.protocol = s.protocol
|
||||
LEFT JOIN (
|
||||
SELECT vin, protocol, MAX(daily_mileage_km) AS daily_mileage_km
|
||||
FROM vehicle_daily_mileage
|
||||
WHERE stat_date = CURDATE()
|
||||
GROUP BY vin, protocol
|
||||
) m ON m.vin = s.vin AND m.protocol = s.protocol
|
||||
ORDER BY v.vin ASC, s.protocol ASC
|
||||
LIMIT ?`, accessEvidenceLimit+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -101,7 +107,7 @@ LIMIT ?`, accessEvidenceLimit+1)
|
||||
for rows.Next() {
|
||||
var row AccessEvidenceRow
|
||||
var reportIntervalMS int64
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.OEM, &row.Model, &row.Company, &row.Protocol, &row.Provider, &row.FirstSeenAt, &row.FirstSeenSource, &row.LatestEventAt, &row.LatestReceivedAt, &row.LatestUpdatedAt, &reportIntervalMS, &row.ReportSampleCount, &row.LatestEventID); err != nil {
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.OEM, &row.Model, &row.Company, &row.Protocol, &row.Provider, &row.FirstSeenAt, &row.FirstSeenSource, &row.LatestEventAt, &row.LatestReceivedAt, &row.LatestUpdatedAt, &reportIntervalMS, &row.ReportSampleCount, &row.LatestEventID, &row.Longitude, &row.Latitude, &row.SpeedKmh, &row.SOCPercent, &row.TotalMileageKm, &row.DailyMileageKm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reportIntervalMS >= 0 {
|
||||
|
||||
@@ -23,6 +23,9 @@ func TestAccessSummaryUsesDynamicFreshnessAndDelay(t *testing.T) {
|
||||
if summary.DelayAbnormal != 1 || summary.ThresholdVersion != 1 {
|
||||
t.Fatalf("summary must expose dynamic delay and threshold version: %+v", summary)
|
||||
}
|
||||
if summary.HealthyVehicles != 0 || summary.IncompleteVehicles != 3 || summary.DegradedVehicles != 0 {
|
||||
t.Fatalf("connection-state counters must be mutually exclusive: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessVehiclesFiltersAndKeepsEvidenceGapsExplicit(t *testing.T) {
|
||||
@@ -42,6 +45,11 @@ func TestAccessVehiclesFiltersAndKeepsEvidenceGapsExplicit(t *testing.T) {
|
||||
if err != nil || delayed.Total != 1 || delayed.Items[0].DataDelaySec == nil || *delayed.Items[0].DataDelaySec != 45 {
|
||||
t.Fatalf("delay filter should use event/receive difference: page=%+v err=%v", delayed, err)
|
||||
}
|
||||
|
||||
attention, err := service.AccessVehicles(context.Background(), AccessQuery{ConnectionState: "attention", Limit: 20})
|
||||
if err != nil || attention.Total != 6 {
|
||||
t.Fatalf("attention filter should return every vehicle with an access difference: page=%+v err=%v", attention, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessVehiclesSupportsModelProviderAndTimeFilters(t *testing.T) {
|
||||
@@ -96,6 +104,42 @@ func TestAccessVehicleCarriesDurableProjectionEvidenceAndMasterData(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessVehiclesKeywordMatchesFleetAndModel(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
fleet, err := service.AccessVehicles(t.Context(), AccessQuery{Keyword: "岭牛示范车队", Limit: 20})
|
||||
if err != nil || fleet.Total != 1 || fleet.Items[0].VIN != "LB9A32A24R0LS1426" {
|
||||
t.Fatalf("fleet keyword should find its vehicles: page=%+v err=%v", fleet, err)
|
||||
}
|
||||
model, err := service.AccessVehicles(t.Context(), AccessQuery{Keyword: "氢燃料车型", Limit: 20})
|
||||
if err != nil || model.Total != 1 || model.Items[0].VIN != "LNXNEGRR7SR318212" {
|
||||
t.Fatalf("model keyword should find its vehicles: page=%+v err=%v", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessVehicleGroupsCanonicalProtocolsByMasterVehicle(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
page, err := service.AccessVehicles(t.Context(), AccessQuery{Keyword: "LB9A32A24R0LS1426", Limit: 10})
|
||||
if err != nil || len(page.Items) != 1 {
|
||||
t.Fatalf("access vehicle query failed: page=%+v err=%v", page, err)
|
||||
}
|
||||
row := page.Items[0]
|
||||
if len(row.ExpectedProtocols) != 3 || len(row.ProtocolStatuses) != 3 {
|
||||
t.Fatalf("vehicle must expose three canonical protocol slots: %+v", row)
|
||||
}
|
||||
if len(row.ActualProtocols) != 1 || row.ActualProtocols[0] != "JT808" || len(row.MissingProtocols) != 2 {
|
||||
t.Fatalf("actual and missing protocols are incorrect: %+v", row)
|
||||
}
|
||||
if row.ConnectionState != "incomplete" || row.OnlineState != "online" {
|
||||
t.Fatalf("single online source should be an online but incomplete vehicle: %+v", row)
|
||||
}
|
||||
for _, status := range row.ProtocolStatuses {
|
||||
if status.Protocol == "JT808" && (!status.Connected || status.LatestReceivedAt == "") {
|
||||
t.Fatalf("connected protocol lost evidence: %+v", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionAccessEvidenceReadsDurableWriterProjection(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -103,12 +147,12 @@ func TestProductionAccessEvidenceReadsDurableWriterProjection(t *testing.T) {
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectQuery("(?s)SELECT.*access_first_seen_at.*access_first_seen_source.*access_report_interval_ms.*vehicle_profile.*access_latest_received_at").
|
||||
mock.ExpectQuery("(?s)SELECT.*access_first_seen_at.*access_latest_received_at.*FROM.*SELECT DISTINCT vin FROM vehicle_identity_binding.*vehicle_profile.*vehicle_realtime_snapshot").
|
||||
WithArgs(accessEvidenceLimit + 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "plate", "oem", "model_name", "company_name", "protocol", "provider", "first_seen_at", "first_seen_source",
|
||||
"event_time", "received_at", "updated_at", "report_interval_ms", "report_sample_count", "event_id",
|
||||
}).AddRow("VIN001", "粤A1", "示范厂家", "纯电客车", "示范公交", "GB32960", "车厂平台", "2026-07-14 05:00:00.000", "snapshot_backfill", "2026-07-14 05:01:00.000", "2026-07-14 05:01:30.500", "2026-07-14 05:01:30.500", int64(30500), int64(3), "evt-3"))
|
||||
"event_time", "received_at", "updated_at", "report_interval_ms", "report_sample_count", "event_id", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "daily_mileage_km",
|
||||
}).AddRow("VIN001", "粤A1", "示范厂家", "纯电客车", "示范公交", "GB32960", "车厂平台", "2026-07-14 05:00:00.000", "snapshot_backfill", "2026-07-14 05:01:00.000", "2026-07-14 05:01:30.500", "2026-07-14 05:01:30.500", int64(30500), int64(3), "evt-3", 113.1, 23.1, 32.5, 76.0, 12000.5, 38.6))
|
||||
rows, err := store.AccessEvidence(context.Background())
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("AccessEvidence rows=%+v err=%v", rows, err)
|
||||
@@ -117,6 +161,9 @@ func TestProductionAccessEvidenceReadsDurableWriterProjection(t *testing.T) {
|
||||
if row.ReportIntervalSec == nil || *row.ReportIntervalSec != 31 || row.ReportSampleCount != 3 || row.Model != "纯电客车" || row.Company != "示范公交" {
|
||||
t.Fatalf("unexpected durable projection row: %+v", row)
|
||||
}
|
||||
if row.SpeedKmh != 32.5 || row.TotalMileageKm != 12000.5 || row.DailyMileageKm != 38.6 {
|
||||
t.Fatalf("realtime source metrics missing: %+v", row)
|
||||
}
|
||||
if row.FirstSeenEvidence != "上线基线:由实时快照回填(非历史首次接入)" || row.ReportIntervalProof == "" {
|
||||
t.Fatalf("projection evidence boundary missing: %+v", row)
|
||||
}
|
||||
|
||||
@@ -871,6 +871,27 @@ func TestHandlerVehicleRealtimeAcceptsKeyword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerVehicleRealtimeAcceptsBatchKeywords(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles?keywords=%E7%B2%A4AG18312%2C%E5%B7%9DAHTWO1%2C%E7%B2%A4AG18312&limit=10", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data struct {
|
||||
Items []VehicleRealtimeRow `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if len(body.Data.Items) != 2 {
|
||||
t.Fatalf("batch search should return two deduplicated vehicles, got %+v body=%s", body.Data.Items, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerHistoryMileageQualityOps(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
|
||||
@@ -485,10 +485,16 @@ func buildArchiveMissingFieldStats(missingPlate, missingPhone, missingOEM int) [
|
||||
|
||||
func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
|
||||
rows := m.locations
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
keyword := strings.ToLower(vin)
|
||||
if keywords := vehicleSearchKeywords(query); len(keywords) > 0 {
|
||||
rows = keep(rows, func(row RealtimeLocationRow) bool {
|
||||
return strings.Contains(strings.ToLower(row.VIN+row.Plate), keyword)
|
||||
vehicle := m.vehicleByVIN(row.VIN)
|
||||
haystack := strings.ToLower(row.VIN + row.Plate + vehicle.Plate + vehicle.Phone + vehicle.OEM)
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(haystack, strings.ToLower(keyword)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
@@ -898,6 +904,13 @@ func (m *MockStore) dailyMileageRows(query url.Values) []DailyMileageRow {
|
||||
return strings.Contains(strings.ToLower(row.VIN+row.Plate), keyword)
|
||||
})
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("vins")); raw != "" {
|
||||
selected := map[string]bool{}
|
||||
for _, vin := range strings.Split(raw, ",") {
|
||||
selected[strings.TrimSpace(vin)] = true
|
||||
}
|
||||
rows = keep(rows, func(row DailyMileageRow) bool { return selected[row.VIN] })
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
rows = keep(rows, func(row DailyMileageRow) bool { return row.Source == protocol })
|
||||
}
|
||||
|
||||
@@ -353,19 +353,20 @@ type HistoryExportCursor struct {
|
||||
}
|
||||
|
||||
type AccessQuery struct {
|
||||
Keyword string `json:"keyword"`
|
||||
Protocol string `json:"protocol"`
|
||||
OEM string `json:"oem"`
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider"`
|
||||
FirstSeenFrom string `json:"firstSeenFrom"`
|
||||
FirstSeenTo string `json:"firstSeenTo"`
|
||||
LatestSeenFrom string `json:"latestSeenFrom"`
|
||||
LatestSeenTo string `json:"latestSeenTo"`
|
||||
OnlineState string `json:"onlineState"`
|
||||
DelayState string `json:"delayState"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Keyword string `json:"keyword"`
|
||||
Protocol string `json:"protocol"`
|
||||
OEM string `json:"oem"`
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider"`
|
||||
FirstSeenFrom string `json:"firstSeenFrom"`
|
||||
FirstSeenTo string `json:"firstSeenTo"`
|
||||
LatestSeenFrom string `json:"latestSeenFrom"`
|
||||
LatestSeenTo string `json:"latestSeenTo"`
|
||||
OnlineState string `json:"onlineState"`
|
||||
DelayState string `json:"delayState"`
|
||||
ConnectionState string `json:"connectionState"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
type AccessEvidenceRow struct {
|
||||
@@ -389,33 +390,69 @@ type AccessEvidenceRow struct {
|
||||
FirstSeenSource string
|
||||
ReportIntervalProof string
|
||||
ReportSampleCount int64
|
||||
Longitude float64
|
||||
Latitude float64
|
||||
SpeedKmh float64
|
||||
SOCPercent float64
|
||||
TotalMileageKm float64
|
||||
DailyMileageKm float64
|
||||
}
|
||||
|
||||
type AccessVehicleRow struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
OEM string `json:"oem"`
|
||||
Model string `json:"model"`
|
||||
Company string `json:"company"`
|
||||
Protocol string `json:"protocol"`
|
||||
Provider string `json:"provider"`
|
||||
Source string `json:"source"`
|
||||
FirstSeenAt string `json:"firstSeenAt"`
|
||||
LatestEventAt string `json:"latestEventAt"`
|
||||
LatestReceivedAt string `json:"latestReceivedAt"`
|
||||
ReportIntervalSec *int `json:"reportIntervalSec"`
|
||||
DataDelaySec *int `json:"dataDelaySec"`
|
||||
FreshnessSec *int `json:"freshnessSec"`
|
||||
OnlineState string `json:"onlineState"`
|
||||
ThresholdSec int `json:"thresholdSec"`
|
||||
LatestMessageType string `json:"latestMessageType"`
|
||||
LatestEventID string `json:"latestEventId"`
|
||||
LatestError string `json:"latestError"`
|
||||
DelayAbnormal bool `json:"delayAbnormal"`
|
||||
FirstSeenEvidence string `json:"firstSeenEvidence"`
|
||||
FirstSeenSource string `json:"firstSeenSource"`
|
||||
ReportIntervalProof string `json:"reportIntervalEvidence"`
|
||||
ReportSampleCount int64 `json:"reportSampleCount"`
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
OEM string `json:"oem"`
|
||||
Model string `json:"model"`
|
||||
Company string `json:"company"`
|
||||
Protocol string `json:"protocol"`
|
||||
Provider string `json:"provider"`
|
||||
Source string `json:"source"`
|
||||
FirstSeenAt string `json:"firstSeenAt"`
|
||||
LatestEventAt string `json:"latestEventAt"`
|
||||
LatestReceivedAt string `json:"latestReceivedAt"`
|
||||
ReportIntervalSec *int `json:"reportIntervalSec"`
|
||||
DataDelaySec *int `json:"dataDelaySec"`
|
||||
FreshnessSec *int `json:"freshnessSec"`
|
||||
OnlineState string `json:"onlineState"`
|
||||
ThresholdSec int `json:"thresholdSec"`
|
||||
LatestMessageType string `json:"latestMessageType"`
|
||||
LatestEventID string `json:"latestEventId"`
|
||||
LatestError string `json:"latestError"`
|
||||
DelayAbnormal bool `json:"delayAbnormal"`
|
||||
FirstSeenEvidence string `json:"firstSeenEvidence"`
|
||||
FirstSeenSource string `json:"firstSeenSource"`
|
||||
ReportIntervalProof string `json:"reportIntervalEvidence"`
|
||||
ReportSampleCount int64 `json:"reportSampleCount"`
|
||||
ExpectedProtocols []string `json:"expectedProtocols"`
|
||||
ActualProtocols []string `json:"actualProtocols"`
|
||||
MissingProtocols []string `json:"missingProtocols"`
|
||||
ProtocolStatuses []AccessProtocolStatus `json:"protocolStatuses"`
|
||||
ConnectionState string `json:"connectionState"`
|
||||
ExpectationEvidence string `json:"expectationEvidence"`
|
||||
}
|
||||
|
||||
type AccessProtocolStatus struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Expected bool `json:"expected"`
|
||||
Connected bool `json:"connected"`
|
||||
Provider string `json:"provider"`
|
||||
FirstSeenAt string `json:"firstSeenAt"`
|
||||
LatestEventAt string `json:"latestEventAt"`
|
||||
LatestReceivedAt string `json:"latestReceivedAt"`
|
||||
ReportIntervalSec *int `json:"reportIntervalSec"`
|
||||
DataDelaySec *int `json:"dataDelaySec"`
|
||||
FreshnessSec *int `json:"freshnessSec"`
|
||||
OnlineState string `json:"onlineState"`
|
||||
ThresholdSec int `json:"thresholdSec"`
|
||||
DelayAbnormal bool `json:"delayAbnormal"`
|
||||
FirstSeenEvidence string `json:"firstSeenEvidence"`
|
||||
ReportIntervalEvidence string `json:"reportIntervalEvidence"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
SpeedKmh float64 `json:"speedKmh"`
|
||||
SOCPercent float64 `json:"socPercent"`
|
||||
TotalMileageKm float64 `json:"totalMileageKm"`
|
||||
DailyMileageKm float64 `json:"dailyMileageKm"`
|
||||
}
|
||||
|
||||
type AccessUnresolvedIdentityQuery struct {
|
||||
@@ -457,6 +494,9 @@ type AccessSummary struct {
|
||||
UnknownVehicles int `json:"unknownVehicles"`
|
||||
DelayAbnormal int `json:"delayAbnormal"`
|
||||
ReportedToday int `json:"reportedToday"`
|
||||
HealthyVehicles int `json:"healthyVehicles"`
|
||||
IncompleteVehicles int `json:"incompleteVehicles"`
|
||||
DegradedVehicles int `json:"degradedVehicles"`
|
||||
OnlineRate float64 `json:"onlineRate"`
|
||||
Protocols []AccessDistribution `json:"protocols"`
|
||||
OEMs []AccessDistribution `json:"oems"`
|
||||
|
||||
@@ -326,10 +326,14 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
||||
where = append(where, "l.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
if keyword := strings.TrimSpace(query.Get("vin")); keyword != "" {
|
||||
where = append(where, "(l.vin LIKE ? OR l.plate LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like, like, like)
|
||||
if keywords := vehicleSearchKeywords(query); len(keywords) > 0 {
|
||||
matches := make([]string, 0, len(keywords))
|
||||
for _, keyword := range keywords {
|
||||
matches = append(matches, "(l.vin LIKE ? OR l.plate LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like, like, like)
|
||||
}
|
||||
where = append(where, "("+strings.Join(matches, " OR ")+")")
|
||||
}
|
||||
switch strings.TrimSpace(query.Get("online")) {
|
||||
case "online":
|
||||
@@ -415,12 +419,19 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
|
||||
offset := parsePositive(query.Get("offset"), 0)
|
||||
args := []any{}
|
||||
where := []string{"1 = 1"}
|
||||
if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") {
|
||||
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''")
|
||||
}
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + vin + "%"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
||||
protocols := parseMileageProtocols(query.Get("protocols"))
|
||||
if len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
||||
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "m.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
@@ -435,6 +446,26 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
|
||||
countArgs := append([]any(nil), args...)
|
||||
args = append(args, limit, offset)
|
||||
fromSQL := `FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + strings.Join(where, " AND ")
|
||||
if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") {
|
||||
selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC`
|
||||
dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))`
|
||||
if len(protocols) > 0 {
|
||||
selectionOrder = mileageProtocolOrder("m.protocol", protocols)
|
||||
dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
}
|
||||
groupSQL := fromSQL + ` GROUP BY m.vin, m.stat_date`
|
||||
return SQLQuery{
|
||||
Text: `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km - m.daily_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS start_mileage_km, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS end_mileage_km, ` +
|
||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` +
|
||||
groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) FROM (SELECT m.vin ` + groupSQL + `) vehicle_daily_mileage_count`,
|
||||
CountArgs: countArgs,
|
||||
}
|
||||
}
|
||||
return SQLQuery{
|
||||
Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
||||
`COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` +
|
||||
@@ -449,12 +480,18 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
|
||||
func buildMileageSummarySQL(query url.Values) SQLQuery {
|
||||
args := []any{}
|
||||
where := []string{"1 = 1"}
|
||||
if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") {
|
||||
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''")
|
||||
}
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + vin + "%"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
||||
if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
||||
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "m.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
@@ -477,12 +514,18 @@ func buildMileageSummarySQL(query url.Values) SQLQuery {
|
||||
func buildMileageStatisticsWhere(query url.Values) (string, []any) {
|
||||
where := []string{"1 = 1"}
|
||||
args := []any{}
|
||||
if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") {
|
||||
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''")
|
||||
}
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + vin + "%"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
||||
if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
||||
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "m.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
@@ -499,9 +542,17 @@ func buildMileageStatisticsWhere(query url.Values) (string, []any) {
|
||||
|
||||
func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
|
||||
where, args := buildMileageStatisticsWhere(query)
|
||||
protocols := parseMileageProtocols(query.Get("protocols"))
|
||||
dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))`
|
||||
latestMileageExpression := `MAX(COALESCE(m.latest_total_mileage_km, 0))`
|
||||
if len(protocols) > 0 {
|
||||
selectionOrder := mileageProtocolOrder("m.protocol", protocols)
|
||||
dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
latestMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.latest_total_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
}
|
||||
return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
|
||||
`MAX(COALESCE(m.daily_mileage_km, 0)) AS daily_mileage_km, ` +
|
||||
`MAX(COALESCE(m.latest_total_mileage_km, 0)) AS latest_mileage_km ` +
|
||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||
latestMileageExpression + ` AS latest_mileage_km ` +
|
||||
`FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin ` +
|
||||
`WHERE ` + where + ` GROUP BY m.vin, m.stat_date`, args
|
||||
}
|
||||
@@ -535,22 +586,90 @@ func buildMileageStatisticsSourceCountSQL(query url.Values) SQLQuery {
|
||||
func buildFleetLatestMileageSQL(query url.Values) SQLQuery {
|
||||
where := []string{"l.total_mileage_km IS NOT NULL", "l.total_mileage_km >= 0"}
|
||||
args := []any{}
|
||||
if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") {
|
||||
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''")
|
||||
}
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
where = append(where, "(l.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + vin + "%"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where, args = appendVINListFilter(where, args, "l.vin", query.Get("vins"))
|
||||
protocols := parseMileageProtocols(query.Get("protocols"))
|
||||
if len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "l.protocol", protocols)
|
||||
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "l.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
selectionOrder := `l.updated_at DESC, l.protocol ASC`
|
||||
if len(protocols) > 0 {
|
||||
selectionOrder = mileageProtocolOrder("l.protocol", protocols) + `, l.updated_at DESC`
|
||||
}
|
||||
inner := `SELECT l.vin, CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ` +
|
||||
`ORDER BY l.updated_at DESC, l.protocol ASC), ',', 1) AS DECIMAL(18,3)) AS latest_mileage_km ` +
|
||||
`ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)) AS latest_mileage_km ` +
|
||||
`FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin WHERE ` +
|
||||
strings.Join(where, " AND ") + ` GROUP BY l.vin`
|
||||
return SQLQuery{Text: `SELECT COALESCE(SUM(x.latest_mileage_km), 0) FROM (` + inner + `) x`, Args: args}
|
||||
}
|
||||
|
||||
func parseMileageProtocols(raw string) []string {
|
||||
seen := map[string]bool{}
|
||||
values := make([]string, 0, 3)
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
protocol := strings.ToUpper(strings.TrimSpace(item))
|
||||
switch protocol {
|
||||
case "GB32960", "JT808", "YUTONG_MQTT":
|
||||
if !seen[protocol] {
|
||||
seen[protocol] = true
|
||||
values = append(values, protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func appendMileageProtocolFilter(where []string, args []any, column string, protocols []string) ([]string, []any) {
|
||||
placeholders := make([]string, len(protocols))
|
||||
for index, protocol := range protocols {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, protocol)
|
||||
}
|
||||
return append(where, column+" IN ("+strings.Join(placeholders, ",")+")"), args
|
||||
}
|
||||
|
||||
func mileageProtocolOrder(column string, protocols []string) string {
|
||||
parts := make([]string, 0, len(protocols)+1)
|
||||
parts = append(parts, "CASE "+column)
|
||||
for index, protocol := range protocols {
|
||||
parts = append(parts, "WHEN '"+protocol+"' THEN "+strconv.Itoa(index+1))
|
||||
}
|
||||
parts = append(parts, "ELSE 999 END")
|
||||
return strings.Join(parts, " ") + ", " + column + " ASC"
|
||||
}
|
||||
|
||||
func appendVINListFilter(where []string, args []any, column string, raw string) ([]string, []any) {
|
||||
seen := map[string]bool{}
|
||||
values := make([]string, 0)
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
vin := strings.TrimSpace(item)
|
||||
if vin == "" || seen[vin] {
|
||||
continue
|
||||
}
|
||||
seen[vin] = true
|
||||
values = append(values, vin)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return where, args
|
||||
}
|
||||
placeholders := make([]string, len(values))
|
||||
for index, vin := range values {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, vin)
|
||||
}
|
||||
return append(where, column+" IN ("+strings.Join(placeholders, ",")+")"), args
|
||||
}
|
||||
|
||||
func buildLimitOffset(query url.Values) (int, int) {
|
||||
return parsePositive(query.Get("limit"), 20), parsePositive(query.Get("offset"), 0)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -214,6 +215,19 @@ func TestBuildVehicleRealtimeSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLFiltersMultipleVehicleKeywords(t *testing.T) {
|
||||
built := buildVehicleRealtimeSQL(url.Values{"keywords": {"粤AG18312, 川AHTWO1, 粤AG18312"}, "limit": {"10"}})
|
||||
if !strings.Contains(built.Text, ") OR (l.vin LIKE ?") || !strings.Contains(built.CountText, ") OR (l.vin LIKE ?") {
|
||||
t.Fatalf("batch vehicle search must match any keyword in rows and count: %s / %s", built.Text, built.CountText)
|
||||
}
|
||||
if len(built.Args) != 12 || built.Args[0] != "%粤AG18312%" || built.Args[5] != "%川AHTWO1%" || built.Args[10] != 10 {
|
||||
t.Fatalf("batch args = %#v", built.Args)
|
||||
}
|
||||
if len(built.CountArgs) != 10 || built.CountArgs[0] != "%粤AG18312%" || built.CountArgs[5] != "%川AHTWO1%" {
|
||||
t.Fatalf("batch count args = %#v", built.CountArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLFiltersServiceStatus(t *testing.T) {
|
||||
query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}}
|
||||
built := buildVehicleRealtimeSQL(query)
|
||||
@@ -264,6 +278,96 @@ func TestBuildDailyMileageSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageQueriesFilterExactVINSelection(t *testing.T) {
|
||||
query := url.Values{"vins": {"VIN001,VIN002,VIN001"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
|
||||
daily := buildDailyMileageSQL(query)
|
||||
if !strings.Contains(daily.Text, "m.vin IN (?,?)") || daily.Args[0] != "VIN001" || daily.Args[1] != "VIN002" {
|
||||
t.Fatalf("daily mileage should use an exact de-duplicated VIN list: %s %#v", daily.Text, daily.Args)
|
||||
}
|
||||
statistics := buildMileageStatisticsSummarySQL(query)
|
||||
if !strings.Contains(statistics.Text, "m.vin IN (?,?)") || len(statistics.Args) != 4 {
|
||||
t.Fatalf("statistics should share the selected VIN scope: %s %#v", statistics.Text, statistics.Args)
|
||||
}
|
||||
fleet := buildFleetLatestMileageSQL(query)
|
||||
if !strings.Contains(fleet.Text, "l.vin IN (?,?)") || len(fleet.Args) != 2 {
|
||||
t.Fatalf("latest mileage should share the selected VIN scope: %s %#v", fleet.Text, fleet.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageQueriesCanRestrictFleetScopeToAuthoritativelyBoundVehicles(t *testing.T) {
|
||||
query := url.Values{"vehicleScope": {"bound"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
|
||||
for name, built := range map[string]SQLQuery{
|
||||
"daily": buildDailyMileageSQL(query),
|
||||
"statistics": buildMileageStatisticsSummarySQL(query),
|
||||
"latest": buildFleetLatestMileageSQL(query),
|
||||
} {
|
||||
if !strings.Contains(built.Text, "b.vin IS NOT NULL") || !strings.Contains(built.Text, "b.vin <> ''") {
|
||||
t.Fatalf("%s mileage SQL must exclude unbound realtime-only identities: %s", name, built.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDailyMileageSQLCanMatchStatisticsVehicleDayScope(t *testing.T) {
|
||||
built := buildDailyMileageSQL(url.Values{"deduplicate": {"1"}, "limit": {"50"}})
|
||||
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km, 0))", "GROUP_CONCAT(m.protocol ORDER BY m.daily_mileage_km DESC", "vehicle_daily_mileage_count"} {
|
||||
if !strings.Contains(built.Text+built.CountText, want) {
|
||||
t.Fatalf("deduplicated daily mileage SQL missing %q: %s / %s", want, built.Text, built.CountText)
|
||||
}
|
||||
}
|
||||
if built.Args[len(built.Args)-2] != 50 || built.Args[len(built.Args)-1] != 0 {
|
||||
t.Fatalf("deduplicated daily mileage pagination args = %#v", built.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageQueriesRespectEnabledProtocolPriority(t *testing.T) {
|
||||
query := url.Values{
|
||||
"protocols": {"JT808,GB32960"},
|
||||
"dateFrom": {"2026-07-01"},
|
||||
"dateTo": {"2026-07-03"},
|
||||
"deduplicate": {"1"},
|
||||
}
|
||||
daily := buildDailyMileageSQL(query)
|
||||
for _, want := range []string{
|
||||
"m.protocol IN (?,?)",
|
||||
"CASE m.protocol WHEN 'JT808' THEN 1 WHEN 'GB32960' THEN 2 ELSE 999 END",
|
||||
"ELSE 999 END, m.protocol ASC",
|
||||
"GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY",
|
||||
} {
|
||||
if !strings.Contains(daily.Text, want) {
|
||||
t.Fatalf("priority daily mileage SQL missing %q: %s", want, daily.Text)
|
||||
}
|
||||
}
|
||||
if daily.Args[0] != "JT808" || daily.Args[1] != "GB32960" {
|
||||
t.Fatalf("enabled protocol filter should preserve priority order: %#v", daily.Args)
|
||||
}
|
||||
|
||||
statistics := buildMileageStatisticsSummarySQL(query)
|
||||
for _, want := range []string{
|
||||
"m.protocol IN (?,?)",
|
||||
"CASE m.protocol WHEN 'JT808' THEN 1 WHEN 'GB32960' THEN 2 ELSE 999 END",
|
||||
"SUBSTRING_INDEX(GROUP_CONCAT",
|
||||
} {
|
||||
if !strings.Contains(statistics.Text, want) {
|
||||
t.Fatalf("priority statistics SQL missing %q: %s", want, statistics.Text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(statistics.Text, "MAX(COALESCE(m.daily_mileage_km, 0))") {
|
||||
t.Fatalf("configured priority must select the preferred source instead of the maximum mileage: %s", statistics.Text)
|
||||
}
|
||||
|
||||
fleet := buildFleetLatestMileageSQL(query)
|
||||
if !strings.Contains(fleet.Text, "l.protocol IN (?,?)") || !strings.Contains(fleet.Text, "CASE l.protocol WHEN 'JT808' THEN 1 WHEN 'GB32960' THEN 2") {
|
||||
t.Fatalf("latest mileage should share the configured source strategy: %s", fleet.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageProtocolsRejectUnknownValues(t *testing.T) {
|
||||
protocols := parseMileageProtocols("JT808,invalid');DROP TABLE vehicle_daily_mileage;--,JT808,YUTONG_MQTT")
|
||||
if fmt.Sprint(protocols) != "[JT808 YUTONG_MQTT]" {
|
||||
t.Fatalf("unexpected sanitized mileage protocols: %#v", protocols)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMileageSummarySQL(t *testing.T) {
|
||||
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
|
||||
built := buildMileageSummarySQL(query)
|
||||
@@ -315,6 +419,20 @@ func TestNormalizeMileageStatisticsWindow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMileageVINSelection(t *testing.T) {
|
||||
normalized, err := normalizeMileageVINSelection(url.Values{"vins": {" VIN001,VIN002,VIN001 "}})
|
||||
if err != nil || normalized.Get("vins") != "VIN001,VIN002" {
|
||||
t.Fatalf("normalize VIN selection = %q, %v", normalized.Get("vins"), err)
|
||||
}
|
||||
tooMany := make([]string, 21)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = fmt.Sprintf("VIN%03d", index)
|
||||
}
|
||||
if _, err := normalizeMileageVINSelection(url.Values{"vins": {strings.Join(tooMany, ",")}}); err == nil {
|
||||
t.Fatal("more than 20 selected vehicles should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFleetLatestMileageSQLUsesOneLatestValuePerVehicle(t *testing.T) {
|
||||
built := buildFleetLatestMileageSQL(url.Values{"vin": {"VIN001"}, "protocol": {"JT808"}})
|
||||
for _, want := range []string{"GROUP_CONCAT", "ORDER BY l.updated_at DESC", "GROUP BY l.vin", "SUM(x.latest_mileage_km)"} {
|
||||
|
||||
@@ -364,10 +364,11 @@ func (s *Service) DashboardSummary(ctx context.Context) (DashboardSummary, error
|
||||
}
|
||||
|
||||
const (
|
||||
monitorVehicleLimit = 10000
|
||||
monitorPointLimit = 2000
|
||||
monitorPointZoom = 11
|
||||
monitorClusterGridPixels = 64
|
||||
monitorVehicleLimit = 10000
|
||||
monitorPointLimit = 2000
|
||||
monitorPointZoom = 11
|
||||
monitorClusterGridPixels = 64
|
||||
vehicleSearchKeywordLimit = 100
|
||||
)
|
||||
|
||||
type monitorBounds struct {
|
||||
@@ -428,7 +429,7 @@ func normalizeMonitorQuery(query url.Values) url.Values {
|
||||
}
|
||||
next.Set("limit", strconv.Itoa(monitorVehicleLimit))
|
||||
next.Set("offset", "0")
|
||||
if next.Get("vin") == "" && strings.TrimSpace(next.Get("keyword")) != "" {
|
||||
if next.Get("vin") == "" && strings.TrimSpace(next.Get("keywords")) == "" && strings.TrimSpace(next.Get("keyword")) != "" {
|
||||
next.Set("vin", strings.TrimSpace(next.Get("keyword")))
|
||||
}
|
||||
switch strings.TrimSpace(next.Get("status")) {
|
||||
@@ -440,6 +441,41 @@ func normalizeMonitorQuery(query url.Values) url.Values {
|
||||
return next
|
||||
}
|
||||
|
||||
func vehicleSearchKeywords(query url.Values) []string {
|
||||
rawValues := query["keywords"]
|
||||
if len(rawValues) == 0 {
|
||||
rawValues = []string{firstNonEmpty(query.Get("vin"), query.Get("keyword"))}
|
||||
}
|
||||
result := make([]string, 0, len(rawValues))
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range rawValues {
|
||||
parts := strings.FieldsFunc(raw, func(char rune) bool {
|
||||
switch char {
|
||||
case ',', ',', '、', ';', ';', '\n', '\r', '\t', ' ':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(part)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, part)
|
||||
if len(result) == vehicleSearchKeywordLimit {
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func matchesMonitorStatus(row VehicleRealtimeRow, requested string) bool {
|
||||
requested = strings.TrimSpace(requested)
|
||||
return requested == "" || monitorVehicleStatus(row) == requested || requested == "online" && row.Online
|
||||
@@ -3103,6 +3139,10 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
|
||||
if err != nil {
|
||||
return Page[DailyMileageRow]{}, err
|
||||
}
|
||||
resolvedQuery, err = normalizeMileageVINSelection(resolvedQuery)
|
||||
if err != nil {
|
||||
return Page[DailyMileageRow]{}, err
|
||||
}
|
||||
return s.store.DailyMileage(ctx, resolvedQuery)
|
||||
}
|
||||
|
||||
@@ -3111,6 +3151,10 @@ func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (Mile
|
||||
if err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
resolvedQuery, err = normalizeMileageVINSelection(resolvedQuery)
|
||||
if err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
resolvedQuery, err = normalizeMileageStatisticsWindow(resolvedQuery, time.Now())
|
||||
if err != nil {
|
||||
return MileageStatistics{}, err
|
||||
@@ -3118,6 +3162,29 @@ func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (Mile
|
||||
return s.store.MileageStatistics(ctx, resolvedQuery)
|
||||
}
|
||||
|
||||
func normalizeMileageVINSelection(query url.Values) (url.Values, error) {
|
||||
next := cloneValues(query)
|
||||
seen := map[string]bool{}
|
||||
vins := make([]string, 0)
|
||||
for _, item := range strings.Split(next.Get("vins"), ",") {
|
||||
vin := strings.TrimSpace(item)
|
||||
if vin == "" || seen[vin] {
|
||||
continue
|
||||
}
|
||||
seen[vin] = true
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
if len(vins) > 20 {
|
||||
return nil, clientError{Code: "TOO_MANY_VEHICLES", Message: "里程查询一次最多选择 20 辆车"}
|
||||
}
|
||||
if len(vins) == 0 {
|
||||
next.Del("vins")
|
||||
} else {
|
||||
next.Set("vins", strings.Join(vins, ","))
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func normalizeMileageStatisticsWindow(query url.Values, now time.Time) (url.Values, error) {
|
||||
next := cloneValues(query)
|
||||
dateTo := strings.TrimSpace(next.Get("dateTo"))
|
||||
@@ -3502,6 +3569,9 @@ func cloneStringMap(values map[string]string) map[string]string {
|
||||
|
||||
func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (url.Values, error) {
|
||||
resolved := cloneValues(query)
|
||||
if strings.TrimSpace(resolved.Get("keywords")) != "" {
|
||||
return resolved, nil
|
||||
}
|
||||
vin := firstNonEmpty(resolved.Get("vin"), resolved.Get("keyword"))
|
||||
if vin == "" {
|
||||
return resolved, nil
|
||||
|
||||
Reference in New Issue
Block a user