feat(platform): consolidate production vehicle data workflows

This commit is contained in:
lingniu
2026-07-15 23:26:29 +08:00
parent 6cddc0a43d
commit 3fabcf181a
59 changed files with 6849 additions and 3532 deletions

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

View File

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

View File

@@ -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)

View File

@@ -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())
}
}

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

View File

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

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

View File

@@ -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)
}
}

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

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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 })
}

View File

@@ -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"`

View File

@@ -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)
}

View File

@@ -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)"} {

View File

@@ -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