feat(operations): add automated reconciliation center

This commit is contained in:
lingniu
2026-07-16 19:14:53 +08:00
parent bbab018d55
commit d67f42b4f4
25 changed files with 1697 additions and 39 deletions

View File

@@ -0,0 +1,54 @@
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
func main() {
if err := run(); err != nil {
log.Printf("reconciliation evaluation failed: %v", err)
os.Exit(1)
}
}
func run() error {
dsn := strings.TrimSpace(os.Getenv("MYSQL_DSN"))
if dsn == "" {
return fmt.Errorf("MYSQL_DSN is required")
}
timeout := 5 * time.Minute
if value := strings.TrimSpace(os.Getenv("RECONCILIATION_TIMEOUT_SEC")); value != "" {
parsed, err := time.ParseDuration(value + "s")
if err != nil || parsed < 10*time.Second || parsed > 30*time.Minute {
return fmt.Errorf("RECONCILIATION_TIMEOUT_SEC must be between 10 and 1800 seconds")
}
timeout = parsed
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
db, err := platform.OpenSQL(ctx, "mysql", dsn)
if err != nil {
return fmt.Errorf("connect mysql: %w", err)
}
defer db.Close()
service := platform.NewService(platform.NewProductionStore(db, nil, ""))
started := time.Now()
result, err := service.EvaluateReconciliation(ctx)
if err != nil {
return err
}
log.Printf(
"reconciliation evaluation completed run=%s detected=%d new=%d active=%d recovered=%d rules=%v duration=%s",
result.RunID, result.Detected, result.New, result.Active, result.Recovered, result.RuleCounts, time.Since(started),
)
return nil
}

View File

@@ -0,0 +1,21 @@
package main
import (
"strings"
"testing"
)
func TestRunRequiresMySQLDSN(t *testing.T) {
t.Setenv("MYSQL_DSN", "")
if err := run(); err == nil || !strings.Contains(err.Error(), "MYSQL_DSN is required") {
t.Fatalf("run() error=%v", err)
}
}
func TestRunRejectsUnsafeTimeout(t *testing.T) {
t.Setenv("MYSQL_DSN", "ignored")
t.Setenv("RECONCILIATION_TIMEOUT_SEC", "1")
if err := run(); err == nil || !strings.Contains(err.Error(), "between 10 and 1800") {
t.Fatalf("run() error=%v", err)
}
}

View File

@@ -232,6 +232,12 @@ func requiredMenu(r *http.Request) string {
}
func requiredRole(r *http.Request) string {
if strings.HasPrefix(r.URL.Path, "/api/v2/reconciliation/") {
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodPost {
return "operator"
}
return "admin"
}
if strings.HasPrefix(r.URL.Path, "/api/v2/operations/") {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
return "operator"

View File

@@ -106,6 +106,14 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
if operatorSourcePolicy.Code != http.StatusForbidden {
t.Fatalf("operator source policy mutation should be forbidden, status=%d", operatorSourcePolicy.Code)
}
viewerReconciliation := authRequest(t, cfg, http.MethodGet, "/api/v2/reconciliation/summary", viewerToken)
if viewerReconciliation.Code != http.StatusForbidden {
t.Fatalf("viewer reconciliation summary should be forbidden, status=%d", viewerReconciliation.Code)
}
operatorReconciliation := authRequest(t, cfg, http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-1/actions", operatorToken)
if operatorReconciliation.Code != http.StatusNoContent {
t.Fatalf("operator reconciliation action status=%d body=%s", operatorReconciliation.Code, operatorReconciliation.Body.String())
}
adminProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", adminToken)
if adminProfile.Code != http.StatusNoContent || adminProfile.Header().Get("X-Principal") != "admin-a:admin" {
t.Fatalf("admin profile mutation status=%d principal=%s", adminProfile.Code, adminProfile.Header().Get("X-Principal"))

View File

@@ -63,6 +63,10 @@ func (h *Handler) routes() {
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/source-evidence", h.handleVehicleSourceEvidence)
h.mux.HandleFunc("GET /api/v2/operations/vehicles/{vin}/sources", h.handleVehicleSourceDiagnostic)
h.mux.HandleFunc("PUT /api/v2/operations/vehicles/{vin}/sources/{sourceRef}", h.handleUpdateVehicleSourcePolicy)
h.mux.HandleFunc("GET /api/v2/reconciliation/summary", h.handleReconciliationSummary)
h.mux.HandleFunc("POST /api/v2/reconciliation/issues", h.handleReconciliationIssues)
h.mux.HandleFunc("GET /api/v2/reconciliation/issues/{id}", h.handleReconciliationIssue)
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/{id}/actions", h.handleReconciliationAction)
h.mux.HandleFunc("POST /api/v2/vehicle-profiles/sync", h.handleSyncVehicleProfiles)
h.mux.HandleFunc("GET /api/v2/tracks", h.handleTrackPlayback)
h.mux.HandleFunc("GET /api/v2/metrics", h.handleMetricCatalog)
@@ -89,6 +93,34 @@ func (h *Handler) routes() {
h.mux.HandleFunc("POST /api/v2/alerts/notifications/read", h.handleAlertNotificationsRead)
}
func (h *Handler) handleReconciliationSummary(w http.ResponseWriter, r *http.Request) {
data, err := h.service.ReconciliationSummary(r.Context(), parsePositive(r.URL.Query().Get("days"), 30))
h.write(w, r, data, err)
}
func (h *Handler) handleReconciliationIssues(w http.ResponseWriter, r *http.Request) {
var query ReconciliationQuery
if !decodeJSONBody(w, r, &query) {
return
}
data, err := h.service.ReconciliationIssues(r.Context(), query)
h.write(w, r, data, err)
}
func (h *Handler) handleReconciliationIssue(w http.ResponseWriter, r *http.Request) {
data, err := h.service.ReconciliationIssue(r.Context(), r.PathValue("id"))
h.write(w, r, data, err)
}
func (h *Handler) handleReconciliationAction(w http.ResponseWriter, r *http.Request) {
var request ReconciliationActionRequest
if !decodeJSONBody(w, r, &request) {
return
}
data, err := h.service.UpdateReconciliationIssue(r.Context(), r.PathValue("id"), request)
h.write(w, r, data, err)
}
func (h *Handler) handleAccessUnresolvedIdentities(w http.ResponseWriter, r *http.Request) {
var query AccessUnresolvedIdentityQuery
if !decodeJSONBody(w, r, &query) {

View File

@@ -29,6 +29,36 @@ func TestHandlerDashboardSummary(t *testing.T) {
}
}
func TestHandlerReconciliationQueueAndReview(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
summary := httptest.NewRecorder()
handler.ServeHTTP(summary, httptest.NewRequest(http.MethodGet, "/api/v2/reconciliation/summary?days=30", nil))
if summary.Code != http.StatusOK || !strings.Contains(summary.Body.String(), `"active":1`) {
t.Fatalf("summary status=%d body=%s", summary.Code, summary.Body.String())
}
list := httptest.NewRecorder()
handler.ServeHTTP(list, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues", strings.NewReader(`{"status":"active","limit":20}`)))
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), "POSITION_DRIFT") {
t.Fatalf("list status=%d body=%s", list.Code, list.Body.String())
}
detail := httptest.NewRecorder()
handler.ServeHTTP(detail, httptest.NewRequest(http.MethodGet, "/api/v2/reconciliation/issues/reconciliation-demo-position", nil))
if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), "规则首次发现差异") {
t.Fatalf("detail status=%d body=%s", detail.Code, detail.Body.String())
}
review := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-position/actions", strings.NewReader(`{"version":1,"status":"confirmed_source_a","note":"已核对原始报文,来源 A 可信"}`))
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "运维甲", Role: "operator", UserType: "operator"}))
handler.ServeHTTP(review, request)
if review.Code != http.StatusOK || !strings.Contains(review.Body.String(), `"status":"confirmed_source_a"`) || !strings.Contains(review.Body.String(), `"actor":"运维甲"`) {
t.Fatalf("review status=%d body=%s", review.Code, review.Body.String())
}
}
func TestHandlerV2MonitorSummaryAndMap(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
for _, test := range []struct {

View File

@@ -11,20 +11,22 @@ import (
)
type MockStore struct {
vehicles []VehicleRow
locations []RealtimeLocationRow
accessMu sync.RWMutex
accessThresholds AccessThresholdConfig
sourcePolicyMu sync.RWMutex
sourcePolicies map[string]VehicleSourcePolicyConfig
profileMu sync.RWMutex
profiles map[string]VehicleProfile
alertMu sync.RWMutex
alertRules []AlertRule
alertEvents []AlertEvent
alertNotifications []AlertNotification
nextAlertActionID int64
nextNotificationID int64
vehicles []VehicleRow
locations []RealtimeLocationRow
accessMu sync.RWMutex
accessThresholds AccessThresholdConfig
sourcePolicyMu sync.RWMutex
sourcePolicies map[string]VehicleSourcePolicyConfig
profileMu sync.RWMutex
profiles map[string]VehicleProfile
alertMu sync.RWMutex
alertRules []AlertRule
alertEvents []AlertEvent
alertNotifications []AlertNotification
nextAlertActionID int64
nextNotificationID int64
reconciliationMu sync.RWMutex
reconciliationIssues []ReconciliationIssue
}
func NewMockStore() *MockStore {
@@ -49,11 +51,169 @@ func NewMockStore() *MockStore {
},
}
store.seedAlertCenter()
store.seedReconciliationCenter()
return store
}
func int64Pointer(value int64) *int64 { return &value }
func (m *MockStore) seedReconciliationCenter() {
m.reconciliationIssues = []ReconciliationIssue{
{
ID: "reconciliation-demo-position", RuleCode: "POSITION_DRIFT", Category: "location", Severity: "major", Status: "pending",
VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", ProtocolA: "GB32960", ProtocolB: "JT808",
Title: "多来源实时位置漂移", Summary: "两个有效来源位置相差 1,286 米,系统不自动判定哪一来源正确",
Evidence: map[string]any{"distanceM": 1286, "sourceA": map[string]any{"protocol": "GB32960"}, "sourceB": map[string]any{"protocol": "JT808"}},
FirstSeenAt: "2026-07-16 08:00:00", LastSeenAt: "2026-07-16 10:00:00", OccurrenceCount: 3, Version: 1,
Actions: []ReconciliationAction{{ID: 1, Action: "detect", FromStatus: "", ToStatus: "pending", Actor: "reconciliation-evaluator", Note: "规则首次发现差异", CreatedAt: "2026-07-16 08:00:00"}},
},
{
ID: "reconciliation-demo-source", RuleCode: "SOURCE_MISSING", Category: "source", Severity: "minor", Status: "recovered",
VIN: "LB9A32A24P0LS1230", Plate: "粤AFF7936", Title: "主车辆暂无真实数据来源",
Summary: "车辆已建档,但当前没有真实来源证据", Evidence: map[string]any{"vin": "LB9A32A24P0LS1230"},
FirstSeenAt: "2026-07-14 08:00:00", LastSeenAt: "2026-07-15 08:00:00", OccurrenceCount: 2,
RecoveredAt: "2026-07-16 02:15:00", Version: 2,
},
}
}
func cloneReconciliationIssue(item ReconciliationIssue) ReconciliationIssue {
item.Actions = append([]ReconciliationAction(nil), item.Actions...)
if item.Evidence == nil {
item.Evidence = map[string]any{}
} else {
next := make(map[string]any, len(item.Evidence))
for key, value := range item.Evidence {
next[key] = value
}
item.Evidence = next
}
return item
}
func (m *MockStore) ReconciliationSummary(_ context.Context, _ int) (ReconciliationSummary, error) {
m.reconciliationMu.RLock()
defer m.reconciliationMu.RUnlock()
result := ReconciliationSummary{
ByRule: []ReconciliationBucket{}, BySeverity: []ReconciliationBucket{},
Trend: []ReconciliationTrendPoint{{Date: "2026-07-16", Detected: 1, New: 1, Active: 1, Recovered: 1}},
LastRunAt: "2026-07-16 02:15:00", AsOf: time.Now().Format(time.RFC3339),
}
rules := map[string]int{}
severities := map[string]int{}
for _, item := range m.reconciliationIssues {
switch item.Status {
case "pending":
result.Active++
result.Pending++
case "confirmed_source_a", "confirmed_source_b":
result.Active++
result.Confirmed++
case "recovered":
result.Recovered++
}
if item.Status == "pending" || strings.HasPrefix(item.Status, "confirmed_source_") {
rules[item.RuleCode]++
severities[item.Severity]++
}
}
for name, count := range rules {
result.ByRule = append(result.ByRule, ReconciliationBucket{Name: name, Count: count})
}
for name, count := range severities {
result.BySeverity = append(result.BySeverity, ReconciliationBucket{Name: name, Count: count})
}
sort.Slice(result.ByRule, func(i, j int) bool { return result.ByRule[i].Count > result.ByRule[j].Count })
sort.Slice(result.BySeverity, func(i, j int) bool { return result.BySeverity[i].Count > result.BySeverity[j].Count })
return result, nil
}
func (m *MockStore) ReconciliationIssues(_ context.Context, query ReconciliationQuery) (Page[ReconciliationIssue], error) {
m.reconciliationMu.RLock()
defer m.reconciliationMu.RUnlock()
items := make([]ReconciliationIssue, 0, len(m.reconciliationIssues))
for _, item := range m.reconciliationIssues {
if query.Keyword != "" && !strings.Contains(strings.ToLower(item.VIN+" "+item.Plate+" "+item.Title+" "+item.Summary), strings.ToLower(query.Keyword)) {
continue
}
if query.RuleCode != "" && query.RuleCode != "all" && item.RuleCode != query.RuleCode {
continue
}
if query.Category != "" && query.Category != "all" && item.Category != query.Category {
continue
}
if query.Severity != "" && query.Severity != "all" && item.Severity != query.Severity {
continue
}
if query.Status == "active" && item.Status != "pending" && !strings.HasPrefix(item.Status, "confirmed_source_") {
continue
}
if query.Status != "" && query.Status != "all" && query.Status != "active" && item.Status != query.Status {
continue
}
cloned := cloneReconciliationIssue(item)
cloned.Actions = nil
items = append(items, cloned)
}
total := len(items)
if query.Offset >= total {
items = []ReconciliationIssue{}
} else {
end := min(total, query.Offset+query.Limit)
items = items[query.Offset:end]
}
return Page[ReconciliationIssue]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
}
func (m *MockStore) ReconciliationIssue(_ context.Context, id string) (ReconciliationIssue, error) {
m.reconciliationMu.RLock()
defer m.reconciliationMu.RUnlock()
for _, item := range m.reconciliationIssues {
if item.ID == id {
return cloneReconciliationIssue(item), nil
}
}
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
}
func (m *MockStore) UpdateReconciliationIssue(_ context.Context, id string, request ReconciliationActionRequest) (ReconciliationIssue, error) {
m.reconciliationMu.Lock()
defer m.reconciliationMu.Unlock()
for index := range m.reconciliationIssues {
item := &m.reconciliationIssues[index]
if item.ID != id {
continue
}
if item.Version != request.Version {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
}
from := item.Status
item.Status = request.Status
item.ResolutionNote = request.Note
item.ResolvedBy = request.Actor
item.Version++
if request.Status == "fixed" || request.Status == "no_action" {
item.RecoveredAt = time.Now().Format("2006-01-02 15:04:05")
} else {
item.RecoveredAt = ""
}
item.Actions = append(item.Actions, ReconciliationAction{
ID: int64(len(item.Actions) + 1), Action: "review", FromStatus: from, ToStatus: request.Status,
Actor: request.Actor, Note: request.Note, CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
})
return cloneReconciliationIssue(*item), nil
}
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
}
func (m *MockStore) EvaluateReconciliation(_ context.Context) (ReconciliationEvaluationResult, error) {
summary, _ := m.ReconciliationSummary(context.Background(), 30)
return ReconciliationEvaluationResult{
RunID: "reconciliation-run-demo", Detected: summary.Active, Active: summary.Active,
RuleCounts: map[string]int{"POSITION_DRIFT": 1}, AsOf: time.Now().Format(time.RFC3339),
}, nil
}
func (m *MockStore) VehicleLocationSourceHistory(_ context.Context, vin string) ([]vehicleLocationSourceHistory, error) {
if vin != "LB9A32A24R0LS1426" {
return []vehicleLocationSourceHistory{}, nil

View File

@@ -736,6 +736,92 @@ type AlertNotificationReadRequest struct {
Actor string `json:"actor"`
}
type ReconciliationQuery struct {
Keyword string `json:"keyword"`
RuleCode string `json:"ruleCode"`
Category string `json:"category"`
Severity string `json:"severity"`
Status string `json:"status"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type ReconciliationIssue struct {
ID string `json:"id"`
RuleCode string `json:"ruleCode"`
Category string `json:"category"`
Severity string `json:"severity"`
Status string `json:"status"`
VIN string `json:"vin"`
Plate string `json:"plate"`
ProtocolA string `json:"protocolA"`
ProtocolB string `json:"protocolB"`
Title string `json:"title"`
Summary string `json:"summary"`
Evidence map[string]any `json:"evidence"`
FirstSeenAt string `json:"firstSeenAt"`
LastSeenAt string `json:"lastSeenAt"`
OccurrenceCount int64 `json:"occurrenceCount"`
RecoveredAt string `json:"recoveredAt"`
ResolutionNote string `json:"resolutionNote"`
ResolvedBy string `json:"resolvedBy"`
Version int `json:"version"`
Actions []ReconciliationAction `json:"actions,omitempty"`
}
type ReconciliationAction struct {
ID int64 `json:"id"`
Action string `json:"action"`
FromStatus string `json:"fromStatus"`
ToStatus string `json:"toStatus"`
Actor string `json:"actor"`
Note string `json:"note"`
CreatedAt string `json:"createdAt"`
}
type ReconciliationActionRequest struct {
Version int `json:"version"`
Status string `json:"status"`
Note string `json:"note"`
Actor string `json:"actor"`
}
type ReconciliationBucket struct {
Name string `json:"name"`
Count int `json:"count"`
}
type ReconciliationTrendPoint struct {
Date string `json:"date"`
Detected int `json:"detected"`
New int `json:"new"`
Active int `json:"active"`
Recovered int `json:"recovered"`
}
type ReconciliationSummary struct {
Active int `json:"active"`
Pending int `json:"pending"`
Confirmed int `json:"confirmed"`
Recovered int `json:"recovered"`
OverSLA int `json:"overSla"`
ByRule []ReconciliationBucket `json:"byRule"`
BySeverity []ReconciliationBucket `json:"bySeverity"`
Trend []ReconciliationTrendPoint `json:"trend"`
LastRunAt string `json:"lastRunAt"`
AsOf string `json:"asOf"`
}
type ReconciliationEvaluationResult struct {
RunID string `json:"runId"`
Detected int `json:"detected"`
New int `json:"new"`
Active int `json:"active"`
Recovered int `json:"recovered"`
RuleCounts map[string]int `json:"ruleCounts"`
AsOf string `json:"asOf"`
}
type AlertEvaluationResult struct {
RulesEvaluated int `json:"rulesEvaluated"`
VehiclesScanned int `json:"vehiclesScanned"`

View File

@@ -12,19 +12,21 @@ import (
)
type ProductionStore struct {
db *sql.DB
tdengine *sql.DB
tdDatabase string
redisOnline redisOnlineKeyCounter
capacityCheck capacityChecker
alertStreamGroup string
alertStreamMode string
accessSchemaOnce sync.Once
accessSchemaErr error
alertSchemaOnce sync.Once
alertSchemaErr error
profileSchemaOnce sync.Once
profileSchemaErr error
db *sql.DB
tdengine *sql.DB
tdDatabase string
redisOnline redisOnlineKeyCounter
capacityCheck capacityChecker
alertStreamGroup string
alertStreamMode string
accessSchemaOnce sync.Once
accessSchemaErr error
alertSchemaOnce sync.Once
alertSchemaErr error
profileSchemaOnce sync.Once
profileSchemaErr error
reconciliationSchemaOnce sync.Once
reconciliationSchemaErr error
}
type redisOnlineKeyCounter interface {

View File

@@ -0,0 +1,78 @@
package platform
import (
"context"
"fmt"
"strings"
)
func (s *Service) reconciliationStore() (ReconciliationStore, error) {
store, ok := s.store.(ReconciliationStore)
if !ok {
return nil, fmt.Errorf("store does not provide reconciliation center")
}
return store, nil
}
func (s *Service) ReconciliationSummary(ctx context.Context, days int) (ReconciliationSummary, error) {
store, err := s.reconciliationStore()
if err != nil {
return ReconciliationSummary{}, err
}
if days <= 0 || days > 90 {
days = 30
}
return store.ReconciliationSummary(ctx, days)
}
func (s *Service) ReconciliationIssues(ctx context.Context, query ReconciliationQuery) (Page[ReconciliationIssue], error) {
store, err := s.reconciliationStore()
if err != nil {
return Page[ReconciliationIssue]{}, err
}
query.Keyword = strings.TrimSpace(query.Keyword)
query.RuleCode = strings.TrimSpace(query.RuleCode)
query.Category = strings.TrimSpace(query.Category)
query.Severity = strings.TrimSpace(query.Severity)
query.Status = strings.TrimSpace(query.Status)
if query.Limit <= 0 || query.Limit > 200 {
query.Limit = 50
}
if query.Offset < 0 {
query.Offset = 0
}
return store.ReconciliationIssues(ctx, query)
}
func (s *Service) ReconciliationIssue(ctx context.Context, id string) (ReconciliationIssue, error) {
id = strings.TrimSpace(id)
if id == "" || len(id) > 64 {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "差异记录编号无效"}
}
store, err := s.reconciliationStore()
if err != nil {
return ReconciliationIssue{}, err
}
return store.ReconciliationIssue(ctx, id)
}
func (s *Service) UpdateReconciliationIssue(ctx context.Context, id string, request ReconciliationActionRequest) (ReconciliationIssue, error) {
id = strings.TrimSpace(id)
if id == "" || len(id) > 64 {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "差异记录编号无效"}
}
request.Actor = ActorFromContext(ctx)
store, err := s.reconciliationStore()
if err != nil {
return ReconciliationIssue{}, err
}
return store.UpdateReconciliationIssue(ctx, id, request)
}
func (s *Service) EvaluateReconciliation(ctx context.Context) (ReconciliationEvaluationResult, error) {
store, err := s.reconciliationStore()
if err != nil {
return ReconciliationEvaluationResult{}, err
}
return store.EvaluateReconciliation(ctx)
}

View File

@@ -0,0 +1,619 @@
package platform
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
)
const reconciliationFindingLimit = 50000
type ReconciliationStore interface {
ReconciliationSummary(context.Context, int) (ReconciliationSummary, error)
ReconciliationIssues(context.Context, ReconciliationQuery) (Page[ReconciliationIssue], error)
ReconciliationIssue(context.Context, string) (ReconciliationIssue, error)
UpdateReconciliationIssue(context.Context, string, ReconciliationActionRequest) (ReconciliationIssue, error)
EvaluateReconciliation(context.Context) (ReconciliationEvaluationResult, error)
}
type reconciliationFinding struct {
SubjectKey string
RuleCode string
Category string
Severity string
VIN string
Plate string
ProtocolA string
ProtocolB string
Title string
Summary string
Evidence map[string]any
}
type reconciliationExisting struct {
ID string
Status string
Version int
}
func (s *ProductionStore) ensureReconciliationSchema(ctx context.Context) error {
s.reconciliationSchemaOnce.Do(func() {
var count int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name IN ('vehicle_reconciliation_run','vehicle_reconciliation_issue','vehicle_reconciliation_action')`).Scan(&count); err != nil {
s.reconciliationSchemaErr = err
return
}
if count != 3 {
s.reconciliationSchemaErr = fmt.Errorf("reconciliation schema unavailable; apply deploy/migrations/017_reconciliation_center.sql")
}
})
return s.reconciliationSchemaErr
}
func (s *ProductionStore) EvaluateReconciliation(ctx context.Context) (ReconciliationEvaluationResult, error) {
if err := s.ensureReconciliationSchema(ctx); err != nil {
return ReconciliationEvaluationResult{}, err
}
startedAt := time.Now()
runID, err := newAlertID("reconciliation-run")
if err != nil {
return ReconciliationEvaluationResult{}, err
}
findings, err := s.collectReconciliationFindings(ctx)
if err != nil {
return ReconciliationEvaluationResult{}, err
}
if len(findings) > reconciliationFindingLimit {
return ReconciliationEvaluationResult{}, fmt.Errorf("reconciliation findings exceed limit: %d", len(findings))
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return ReconciliationEvaluationResult{}, err
}
defer tx.Rollback()
existing, err := loadReconciliationExisting(ctx, tx)
if err != nil {
return ReconciliationEvaluationResult{}, err
}
now := time.Now()
seen := make(map[string]bool, len(findings))
result := ReconciliationEvaluationResult{RunID: runID, RuleCounts: map[string]int{}, AsOf: now.Format(time.RFC3339)}
for _, finding := range findings {
fingerprint := reconciliationFingerprint(finding)
if seen[fingerprint] {
continue
}
seen[fingerprint] = true
result.Detected++
result.RuleCounts[finding.RuleCode]++
evidence, err := json.Marshal(finding.Evidence)
if err != nil {
return ReconciliationEvaluationResult{}, err
}
current, exists := existing[fingerprint]
if !exists {
id, err := newAlertID("reconciliation")
if err != nil {
return ReconciliationEvaluationResult{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_issue(
id,fingerprint,rule_code,category,severity,status,vin,plate,protocol_a,protocol_b,title,summary,evidence_json,
first_seen_at,last_seen_at,occurrence_count,version
) VALUES(?,?,?,?,?,'pending',?,?,?,?,?,?,?,?,?,1,1)`,
id, fingerprint, finding.RuleCode, finding.Category, finding.Severity, finding.VIN, finding.Plate,
finding.ProtocolA, finding.ProtocolB, finding.Title, finding.Summary, string(evidence), now, now); err != nil {
return ReconciliationEvaluationResult{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(
issue_id,action,from_status,to_status,actor,note
) VALUES(?,'detect','','pending','reconciliation-evaluator','规则首次发现差异')`, id); err != nil {
return ReconciliationEvaluationResult{}, err
}
result.New++
continue
}
nextStatus := current.Status
if current.Status == "recovered" || current.Status == "fixed" {
nextStatus = "pending"
}
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
rule_code=?,category=?,severity=?,status=?,vin=?,plate=?,protocol_a=?,protocol_b=?,title=?,summary=?,evidence_json=?,
last_seen_at=?,occurrence_count=occurrence_count+1,recovered_at=NULL,version=version+1
WHERE id=?`, finding.RuleCode, finding.Category, finding.Severity, nextStatus, finding.VIN, finding.Plate,
finding.ProtocolA, finding.ProtocolB, finding.Title, finding.Summary, string(evidence), now, current.ID); err != nil {
return ReconciliationEvaluationResult{}, err
}
if nextStatus != current.Status {
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(
issue_id,action,from_status,to_status,actor,note
) VALUES(?,'reopen',?,?,'reconciliation-evaluator','已恢复或已修复的差异再次出现')`, current.ID, current.Status, nextStatus); err != nil {
return ReconciliationEvaluationResult{}, err
}
}
}
for fingerprint, current := range existing {
if seen[fingerprint] || !reconciliationAutoRecoverable(current.Status) {
continue
}
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
status='recovered',recovered_at=?,version=version+1 WHERE id=?`, now, current.ID); err != nil {
return ReconciliationEvaluationResult{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(
issue_id,action,from_status,to_status,actor,note
) VALUES(?,'recover',?,'recovered','reconciliation-evaluator','本轮检测未再发现该差异')`, current.ID, current.Status); err != nil {
return ReconciliationEvaluationResult{}, err
}
result.Recovered++
}
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_reconciliation_issue
WHERE status IN ('pending','confirmed_source_a','confirmed_source_b')`).Scan(&result.Active); err != nil {
return ReconciliationEvaluationResult{}, err
}
ruleCountsJSON, _ := json.Marshal(result.RuleCounts)
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_run(
run_id,status,detected_count,new_count,active_count,recovered_count,rule_counts_json,started_at,finished_at
) VALUES(?,'completed',?,?,?,?,?,?,?)`, runID, result.Detected, result.New, result.Active, result.Recovered, string(ruleCountsJSON), startedAt, now); err != nil {
return ReconciliationEvaluationResult{}, err
}
if err := tx.Commit(); err != nil {
return ReconciliationEvaluationResult{}, err
}
return result, nil
}
func reconciliationAutoRecoverable(status string) bool {
return status == "pending" || status == "confirmed_source_a" || status == "confirmed_source_b"
}
func reconciliationFingerprint(finding reconciliationFinding) string {
key := strings.Join([]string{
strings.TrimSpace(finding.RuleCode), strings.TrimSpace(finding.SubjectKey),
strings.ToUpper(strings.TrimSpace(finding.VIN)), strings.TrimSpace(finding.ProtocolA), strings.TrimSpace(finding.ProtocolB),
}, "|")
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
func loadReconciliationExisting(ctx context.Context, tx *sql.Tx) (map[string]reconciliationExisting, error) {
rows, err := tx.QueryContext(ctx, `SELECT fingerprint,id,status,version FROM vehicle_reconciliation_issue FOR UPDATE`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]reconciliationExisting{}
for rows.Next() {
var fingerprint string
var item reconciliationExisting
if err := rows.Scan(&fingerprint, &item.ID, &item.Status, &item.Version); err != nil {
return nil, err
}
out[fingerprint] = item
}
return out, rows.Err()
}
func (s *ProductionStore) collectReconciliationFindings(ctx context.Context) ([]reconciliationFinding, error) {
queries := []struct {
name string
text string
}{
{name: "identity", text: reconciliationIdentitySQL},
{name: "coverage", text: reconciliationCoverageSQL},
{name: "position", text: reconciliationPositionSQL},
{name: "mileage", text: reconciliationMileageSQL},
{name: "fleet_count", text: reconciliationFleetCountSQL},
{name: "business_scope", text: reconciliationBusinessScopeSQL},
}
out := make([]reconciliationFinding, 0, 1024)
for _, query := range queries {
rows, err := s.db.QueryContext(ctx, query.text)
if err != nil {
return nil, fmt.Errorf("reconciliation query %s: %w", query.name, err)
}
for rows.Next() {
var item reconciliationFinding
var evidenceJSON string
if err := rows.Scan(&item.SubjectKey, &item.RuleCode, &item.Category, &item.Severity, &item.VIN, &item.Plate,
&item.ProtocolA, &item.ProtocolB, &item.Title, &item.Summary, &evidenceJSON); err != nil {
rows.Close()
return nil, fmt.Errorf("scan reconciliation query %s: %w", query.name, err)
}
item.Evidence = map[string]any{}
if err := json.Unmarshal([]byte(evidenceJSON), &item.Evidence); err != nil {
rows.Close()
return nil, fmt.Errorf("decode reconciliation evidence for %s: %w", item.RuleCode, err)
}
out = append(out, item)
if len(out) > reconciliationFindingLimit {
rows.Close()
return nil, fmt.Errorf("reconciliation findings exceed limit")
}
}
if err := rows.Close(); err != nil {
return nil, fmt.Errorf("close reconciliation query %s: %w", query.name, err)
}
}
return out, nil
}
const reconciliationIdentitySQL = `
SELECT CONCAT('plate:',duplicate.plate),'DUPLICATE_PLATE','identity','major','',duplicate.plate,'','','同一车牌关联多个 VIN',
CONCAT('车牌 ',duplicate.plate,' 当前关联 ',duplicate.vin_count,' 个 VIN需核对权威身份绑定'),
JSON_OBJECT('plate',duplicate.plate,'vins',duplicate.vins,'vinCount',duplicate.vin_count)
FROM (
SELECT TRIM(plate) plate,GROUP_CONCAT(DISTINCT vin ORDER BY vin) vins,COUNT(DISTINCT vin) vin_count
FROM vehicle_identity_binding
WHERE plate IS NOT NULL AND TRIM(plate)<>''
GROUP BY TRIM(plate) HAVING COUNT(DISTINCT vin)>1
) duplicate
UNION ALL
SELECT CONCAT('phone:',duplicate.identifier),'DUPLICATE_PHONE','identity','major','',duplicate.plate,'JT808','','同一终端关联多个 VIN',
CONCAT('JT808 终端标识当前关联 ',duplicate.vin_count,' 个 VIN需核对设备换绑或误挂'),
JSON_OBJECT('identifierHash',SHA2(duplicate.identifier,256),'vins',duplicate.vins,'vinCount',duplicate.vin_count)
FROM (
SELECT TRIM(phone) identifier,MAX(COALESCE(plate,'')) plate,
GROUP_CONCAT(DISTINCT vin ORDER BY vin) vins,COUNT(DISTINCT vin) vin_count
FROM vehicle_identity_binding
WHERE phone IS NOT NULL AND TRIM(phone)<>''
GROUP BY TRIM(phone) HAVING COUNT(DISTINCT vin)>1
) duplicate`
const reconciliationCoverageSQL = `
SELECT CONCAT('unbound:',s.vin),'UNBOUND_SOURCE','source','major',s.vin,MAX(COALESCE(s.plate,'')),
GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol),'','实时来源未绑定主车辆',
CONCAT('来源快照存在 VIN但 vehicle_identity_binding 无对应主车辆:',s.vin),
JSON_OBJECT('vin',s.vin,'protocols',GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol),'latestSeen',DATE_FORMAT(MAX(s.updated_at),'%Y-%m-%d %H:%i:%s'))
FROM vehicle_realtime_snapshot s
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY s.vin
WHERE s.vin IS NOT NULL AND s.vin<>'' AND b.vin IS NULL
GROUP BY s.vin
UNION ALL
SELECT CONCAT('missing:',b.vin),'SOURCE_MISSING','source','minor',b.vin,COALESCE(b.plate,''),
'','','主车辆暂无真实数据来源','车辆已建档,但当前没有 GB32960、JT808 或 YUTONG_MQTT 来源证据',
JSON_OBJECT('vin',b.vin,'plate',COALESCE(b.plate,''),'bindingSource','vehicle_identity_binding')
FROM vehicle_identity_binding b
LEFT JOIN vehicle_realtime_snapshot s ON BINARY s.vin=BINARY b.vin
WHERE b.vin IS NOT NULL AND b.vin<>''
GROUP BY b.vin,b.plate HAVING COUNT(s.protocol)=0`
const reconciliationPositionSQL = `
SELECT CONCAT('position:',a.vin,':',LEAST(a.protocol,b.protocol),':',GREATEST(a.protocol,b.protocol),':',SHA2(CONCAT(LEAST(a.source_key,b.source_key),'|',GREATEST(a.source_key,b.source_key)),256)),
'POSITION_DRIFT','location',
CASE WHEN ST_Distance_Sphere(POINT(a.longitude,a.latitude),POINT(b.longitude,b.latitude))>=5000 THEN 'critical' ELSE 'major' END,
a.vin,COALESCE(binding.plate,''),a.protocol,b.protocol,'多来源实时位置漂移',
CONCAT('两个有效来源位置相差 ',ROUND(ST_Distance_Sphere(POINT(a.longitude,a.latitude),POINT(b.longitude,b.latitude))), ' 米,系统不自动判定哪一来源正确'),
JSON_OBJECT(
'sourceA',JSON_OBJECT('protocol',a.protocol,'sourceHash',SHA2(a.source_key,256),'longitude',a.longitude,'latitude',a.latitude,'receivedAt',DATE_FORMAT(a.received_at,'%Y-%m-%d %H:%i:%s')),
'sourceB',JSON_OBJECT('protocol',b.protocol,'sourceHash',SHA2(b.source_key,256),'longitude',b.longitude,'latitude',b.latitude,'receivedAt',DATE_FORMAT(b.received_at,'%Y-%m-%d %H:%i:%s')),
'distanceM',ROUND(ST_Distance_Sphere(POINT(a.longitude,a.latitude),POINT(b.longitude,b.latitude)))
)
FROM vehicle_realtime_location_source a
JOIN vehicle_realtime_location_source b ON BINARY b.vin=BINARY a.vin
AND CONCAT(b.protocol,'|',b.source_key)>CONCAT(a.protocol,'|',a.source_key)
LEFT JOIN vehicle_identity_binding binding ON BINARY binding.vin=BINARY a.vin
WHERE a.received_at>=DATE_SUB(NOW(),INTERVAL 5 MINUTE)
AND b.received_at>=DATE_SUB(NOW(),INTERVAL 5 MINUTE)
AND a.longitude BETWEEN -180 AND 180 AND b.longitude BETWEEN -180 AND 180
AND a.latitude BETWEEN -90 AND 90 AND b.latitude BETWEEN -90 AND 90
AND NOT (a.longitude=0 AND a.latitude=0) AND NOT (b.longitude=0 AND b.latitude=0)
AND ST_Distance_Sphere(POINT(a.longitude,a.latitude),POINT(b.longitude,b.latitude))>=1000`
const reconciliationMileageSQL = `
SELECT CONCAT('mileage:',m.vin,':',m.protocol,':',DATE_FORMAT(m.stat_date,'%Y-%m-%d')),
CASE WHEN m.daily_mileage_km<0 OR m.latest_total_mileage_km<m.daily_mileage_km THEN 'MILEAGE_REVERSE' ELSE 'MILEAGE_JUMP' END,
'mileage',
CASE WHEN m.daily_mileage_km<0 OR m.latest_total_mileage_km<m.daily_mileage_km THEN 'critical' ELSE 'major' END,
m.vin,COALESCE(b.plate,''),m.protocol,'',
CASE WHEN m.daily_mileage_km<0 OR m.latest_total_mileage_km<m.daily_mileage_km THEN '里程倒退或起始值无效' ELSE '单日里程异常跳变' END,
CONCAT(DATE_FORMAT(m.stat_date,'%Y-%m-%d'),' ',m.protocol,' 日里程 ',ROUND(m.daily_mileage_km,3),' km需核对原始总里程与单位'),
JSON_OBJECT('date',DATE_FORMAT(m.stat_date,'%Y-%m-%d'),'protocol',m.protocol,'dailyMileageKm',m.daily_mileage_km,
'latestTotalMileageKm',m.latest_total_mileage_km,'derivedStartMileageKm',m.latest_total_mileage_km-m.daily_mileage_km)
FROM vehicle_daily_mileage m
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY m.vin
WHERE m.stat_date>=DATE_SUB(CURDATE(),INTERVAL 7 DAY)
AND (m.daily_mileage_km<0 OR m.daily_mileage_km>2000 OR m.latest_total_mileage_km<m.daily_mileage_km)
UNION ALL
SELECT CONCAT('mileage-spread:',m.vin,':',DATE_FORMAT(m.stat_date,'%Y-%m-%d')),
'MILEAGE_SOURCE_DIVERGENCE','mileage','major',m.vin,COALESCE(MAX(b.plate),''),
MIN(m.protocol),MAX(m.protocol),'多来源日里程差异偏大',
CONCAT(DATE_FORMAT(m.stat_date,'%Y-%m-%d'),' 多来源日里程最大差 ',ROUND(MAX(m.daily_mileage_km)-MIN(m.daily_mileage_km),3),' km'),
JSON_OBJECT('date',DATE_FORMAT(m.stat_date,'%Y-%m-%d'),'minimumKm',MIN(m.daily_mileage_km),'maximumKm',MAX(m.daily_mileage_km),
'differenceKm',MAX(m.daily_mileage_km)-MIN(m.daily_mileage_km),'protocols',GROUP_CONCAT(DISTINCT m.protocol ORDER BY m.protocol))
FROM vehicle_daily_mileage m
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY m.vin
WHERE m.stat_date>=DATE_SUB(CURDATE(),INTERVAL 7 DAY)
GROUP BY m.vin,m.stat_date
HAVING COUNT(DISTINCT m.protocol)>1 AND MAX(m.daily_mileage_km)-MIN(m.daily_mileage_km)>20`
const reconciliationFleetCountSQL = `
SELECT 'fleet-count','FLEET_COUNT_MISMATCH','fleet','major','','','','','主车辆与来源车辆总数不一致',
CONCAT('主车辆 ',x.bound_count,' 辆,来源并集 ',x.source_union_count,' 辆,来源未绑定 ',x.unbound_count,' 辆'),
JSON_OBJECT('boundVehicles',x.bound_count,'sourceUnionVehicles',x.source_union_count,'unboundSourceVehicles',x.unbound_count)
FROM (
SELECT
(SELECT COUNT(DISTINCT vin) FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin<>'') bound_count,
(SELECT COUNT(*) FROM (
SELECT CAST(vin AS BINARY) vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin<>''
UNION SELECT CAST(vin AS BINARY) vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin<>''
) u) source_union_count,
(SELECT COUNT(DISTINCT s.vin) FROM vehicle_realtime_snapshot s LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY s.vin
WHERE s.vin IS NOT NULL AND s.vin<>'' AND b.vin IS NULL) unbound_count
) x
WHERE x.bound_count<>x.source_union_count OR x.unbound_count>0`
const reconciliationBusinessScopeSQL = `
SELECT
CONVERT(CONCAT('business-unbound:',s.customer_id,':',s.vin) USING utf8mb4) COLLATE utf8mb4_unicode_ci,
'BUSINESS_SCOPE_UNBOUND','business','critical',
CONVERT(s.vin USING utf8mb4) COLLATE utf8mb4_unicode_ci,
CONVERT(s.plate_number USING utf8mb4) COLLATE utf8mb4_unicode_ci,
'ONEOS','','OneOS 业务范围车辆未绑定主车辆',
CONVERT(CONCAT('客户 ',s.customer_id,' 的业务范围包含 VIN ',s.vin,',但中台主车辆不存在') USING utf8mb4) COLLATE utf8mb4_unicode_ci,
JSON_OBJECT('scopeVersion',s.source_version,'customerId',CAST(s.customer_id AS CHAR),'customerName',s.customer_name,
'contractCode',s.contract_code,'projectName',s.project_name,'departmentName',s.department_name,'responsibleUserName',s.responsible_user_name)
FROM business_scope_state st
JOIN business_customer_vehicle_scope s ON BINARY s.source_version=BINARY st.active_version
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY s.vin
WHERE st.id=1 AND st.active_version IS NOT NULL AND b.vin IS NULL
UNION ALL
SELECT
CONVERT(CONCAT('auth-business:',u.id,':',g.vin) USING utf8mb4) COLLATE utf8mb4_unicode_ci,
'AUTH_SCOPE_BUSINESS_MISMATCH','business','major',
CONVERT(g.vin USING utf8mb4) COLLATE utf8mb4_unicode_ci,
CONVERT(COALESCE(b.plate,'') USING utf8mb4) COLLATE utf8mb4_unicode_ci,
'PLATFORM_AUTH','ONEOS','人工授权与 OneOS 业务范围不一致',
CONVERT(CONCAT('客户账号 ',u.username,' 当前授权 VIN 不在同客户的 OneOS 活跃范围内') USING utf8mb4) COLLATE utf8mb4_unicode_ci,
JSON_OBJECT('scopeVersion',st.active_version,'userId',CAST(u.id AS CHAR),'username',u.username,'customerRef',u.customer_ref,
'grantValidFrom',DATE_FORMAT(COALESCE(g.valid_from,g.granted_at),'%Y-%m-%d %H:%i:%s'))
FROM business_scope_state st
JOIN platform_user u ON u.user_type='customer' AND u.status='enabled' AND u.customer_ref<>''
JOIN platform_user_vehicle g ON g.user_id=u.id AND (g.valid_to IS NULL OR g.valid_to>NOW())
LEFT JOIN business_customer_vehicle_scope s ON BINARY s.source_version=BINARY st.active_version
AND BINARY CAST(s.customer_id AS CHAR)=BINARY u.customer_ref AND BINARY s.vin=BINARY g.vin
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
WHERE st.id=1 AND st.active_version IS NOT NULL AND s.vin IS NULL`
func buildReconciliationWhere(query ReconciliationQuery) (string, []any) {
where := []string{"1=1"}
args := []any{}
if value := strings.TrimSpace(query.Keyword); value != "" {
like := "%" + value + "%"
where = append(where, "(i.vin LIKE ? OR i.plate LIKE ? OR i.title LIKE ? OR i.summary LIKE ?)")
args = append(args, like, like, like, like)
}
for column, value := range map[string]string{
"i.rule_code": query.RuleCode, "i.category": query.Category, "i.severity": query.Severity,
} {
if value = strings.TrimSpace(value); value != "" && value != "all" {
where = append(where, column+"=?")
args = append(args, value)
}
}
if status := strings.TrimSpace(query.Status); status == "active" {
where = append(where, "i.status IN ('pending','confirmed_source_a','confirmed_source_b')")
} else if status != "" && status != "all" {
where = append(where, "i.status=?")
args = append(args, status)
}
return strings.Join(where, " AND "), args
}
const reconciliationSelect = `SELECT i.id,i.rule_code,i.category,i.severity,i.status,i.vin,i.plate,i.protocol_a,i.protocol_b,
i.title,i.summary,CAST(i.evidence_json AS CHAR),DATE_FORMAT(i.first_seen_at,'%Y-%m-%d %H:%i:%s'),
DATE_FORMAT(i.last_seen_at,'%Y-%m-%d %H:%i:%s'),i.occurrence_count,
COALESCE(DATE_FORMAT(i.recovered_at,'%Y-%m-%d %H:%i:%s'),''),i.resolution_note,i.resolved_by,i.version
FROM vehicle_reconciliation_issue i `
func scanReconciliationIssue(scanner interface{ Scan(...any) error }) (ReconciliationIssue, error) {
var item ReconciliationIssue
var evidence string
err := scanner.Scan(&item.ID, &item.RuleCode, &item.Category, &item.Severity, &item.Status, &item.VIN, &item.Plate,
&item.ProtocolA, &item.ProtocolB, &item.Title, &item.Summary, &evidence, &item.FirstSeenAt, &item.LastSeenAt,
&item.OccurrenceCount, &item.RecoveredAt, &item.ResolutionNote, &item.ResolvedBy, &item.Version)
if err == nil {
item.Evidence = map[string]any{}
err = json.Unmarshal([]byte(evidence), &item.Evidence)
}
return item, err
}
func (s *ProductionStore) ReconciliationIssues(ctx context.Context, query ReconciliationQuery) (Page[ReconciliationIssue], error) {
if err := s.ensureReconciliationSchema(ctx); err != nil {
return Page[ReconciliationIssue]{}, err
}
if query.Limit <= 0 || query.Limit > 200 {
query.Limit = 50
}
if query.Offset < 0 {
query.Offset = 0
}
where, args := buildReconciliationWhere(query)
var total int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_reconciliation_issue i WHERE `+where, args...).Scan(&total); err != nil {
return Page[ReconciliationIssue]{}, err
}
listArgs := append(append([]any(nil), args...), query.Limit, query.Offset)
rows, err := s.db.QueryContext(ctx, reconciliationSelect+`WHERE `+where+`
ORDER BY FIELD(i.severity,'critical','major','minor'),i.last_seen_at DESC,i.id DESC LIMIT ? OFFSET ?`, listArgs...)
if err != nil {
return Page[ReconciliationIssue]{}, err
}
defer rows.Close()
items := make([]ReconciliationIssue, 0, query.Limit)
for rows.Next() {
item, err := scanReconciliationIssue(rows)
if err != nil {
return Page[ReconciliationIssue]{}, err
}
items = append(items, item)
}
return Page[ReconciliationIssue]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err()
}
func (s *ProductionStore) ReconciliationIssue(ctx context.Context, id string) (ReconciliationIssue, error) {
if err := s.ensureReconciliationSchema(ctx); err != nil {
return ReconciliationIssue{}, err
}
item, err := scanReconciliationIssue(s.db.QueryRowContext(ctx, reconciliationSelect+`WHERE i.id=?`, id))
if err == sql.ErrNoRows {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
}
if err != nil {
return ReconciliationIssue{}, err
}
rows, err := s.db.QueryContext(ctx, `SELECT id,action,from_status,to_status,actor,note,
DATE_FORMAT(created_at,'%Y-%m-%d %H:%i:%s')
FROM vehicle_reconciliation_action WHERE issue_id=? ORDER BY created_at,id`, id)
if err != nil {
return ReconciliationIssue{}, err
}
defer rows.Close()
item.Actions = []ReconciliationAction{}
for rows.Next() {
var action ReconciliationAction
if err := rows.Scan(&action.ID, &action.Action, &action.FromStatus, &action.ToStatus, &action.Actor, &action.Note, &action.CreatedAt); err != nil {
return ReconciliationIssue{}, err
}
item.Actions = append(item.Actions, action)
}
return item, rows.Err()
}
func (s *ProductionStore) UpdateReconciliationIssue(ctx context.Context, id string, request ReconciliationActionRequest) (ReconciliationIssue, error) {
if err := s.ensureReconciliationSchema(ctx); err != nil {
return ReconciliationIssue{}, err
}
allowed := map[string]bool{
"pending": true, "confirmed_source_a": true, "confirmed_source_b": true,
"no_action": true, "fixed": true,
}
request.Status = strings.TrimSpace(request.Status)
request.Note = strings.TrimSpace(request.Note)
if !allowed[request.Status] {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_STATUS_INVALID", Message: "差异处置状态无效"}
}
if request.Status != "pending" && request.Note == "" {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOTE_REQUIRED", Message: "确认结论必须填写说明"}
}
if len([]rune(request.Note)) > 500 {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOTE_TOO_LONG", Message: "处置说明不能超过 500 个字符"}
}
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return ReconciliationIssue{}, err
}
defer tx.Rollback()
var currentStatus string
var currentVersion int
if err := tx.QueryRowContext(ctx, `SELECT status,version FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(&currentStatus, &currentVersion); err != nil {
if err == sql.ErrNoRows {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
}
return ReconciliationIssue{}, err
}
if currentVersion != request.Version {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
}
result, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
status=?,resolution_note=?,resolved_by=?,
recovered_at=CASE WHEN ? IN ('fixed','no_action') THEN NOW(3) ELSE NULL END,
version=version+1
WHERE id=? AND version=?`, request.Status, request.Note, request.Actor, request.Status, id, request.Version)
if err != nil {
return ReconciliationIssue{}, err
}
affected, _ := result.RowsAffected()
if affected != 1 {
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录更新冲突"}
}
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(
issue_id,action,from_status,to_status,actor,note
) VALUES(?,'review',?,?,?,?)`, id, currentStatus, request.Status, request.Actor, request.Note); err != nil {
return ReconciliationIssue{}, err
}
if err := tx.Commit(); err != nil {
return ReconciliationIssue{}, err
}
return s.ReconciliationIssue(ctx, id)
}
const reconciliationTrendSQL = `SELECT DATE_FORMAT(MIN(finished_at),'%Y-%m-%d'),
CAST(SUBSTRING_INDEX(GROUP_CONCAT(detected_count ORDER BY finished_at DESC),',',1) AS UNSIGNED),
SUM(new_count),
CAST(SUBSTRING_INDEX(GROUP_CONCAT(active_count ORDER BY finished_at DESC),',',1) AS UNSIGNED),
SUM(recovered_count)
FROM vehicle_reconciliation_run
WHERE status='completed' AND finished_at>=DATE_SUB(CURDATE(),INTERVAL ? DAY)
GROUP BY DATE(finished_at) ORDER BY DATE(finished_at)`
func (s *ProductionStore) ReconciliationSummary(ctx context.Context, days int) (ReconciliationSummary, error) {
if err := s.ensureReconciliationSchema(ctx); err != nil {
return ReconciliationSummary{}, err
}
if days <= 0 || days > 90 {
days = 30
}
var result ReconciliationSummary
if err := s.db.QueryRowContext(ctx, `SELECT
COALESCE(SUM(status IN ('pending','confirmed_source_a','confirmed_source_b')),0),
COALESCE(SUM(status='pending'),0),
COALESCE(SUM(status IN ('confirmed_source_a','confirmed_source_b')),0),
COALESCE(SUM(status='recovered'),0),
COALESCE(SUM(status IN ('pending','confirmed_source_a','confirmed_source_b') AND first_seen_at<DATE_SUB(NOW(),INTERVAL 24 HOUR)),0)
FROM vehicle_reconciliation_issue`).Scan(&result.Active, &result.Pending, &result.Confirmed, &result.Recovered, &result.OverSLA); err != nil {
return ReconciliationSummary{}, err
}
var err error
if result.ByRule, err = s.reconciliationBuckets(ctx, "rule_code"); err != nil {
return ReconciliationSummary{}, err
}
if result.BySeverity, err = s.reconciliationBuckets(ctx, "severity"); err != nil {
return ReconciliationSummary{}, err
}
rows, err := s.db.QueryContext(ctx, reconciliationTrendSQL, days-1)
if err != nil {
return ReconciliationSummary{}, err
}
defer rows.Close()
result.Trend = []ReconciliationTrendPoint{}
for rows.Next() {
var item ReconciliationTrendPoint
if err := rows.Scan(&item.Date, &item.Detected, &item.New, &item.Active, &item.Recovered); err != nil {
return ReconciliationSummary{}, err
}
result.Trend = append(result.Trend, item)
}
_ = s.db.QueryRowContext(ctx, `SELECT COALESCE(DATE_FORMAT(MAX(finished_at),'%Y-%m-%dT%H:%i:%s.%fZ'),'')
FROM vehicle_reconciliation_run WHERE status='completed'`).Scan(&result.LastRunAt)
result.AsOf = time.Now().Format(time.RFC3339)
return result, rows.Err()
}
func (s *ProductionStore) reconciliationBuckets(ctx context.Context, column string) ([]ReconciliationBucket, error) {
if column != "rule_code" && column != "severity" {
return nil, fmt.Errorf("unsupported reconciliation bucket")
}
rows, err := s.db.QueryContext(ctx, `SELECT `+column+`,COUNT(*) FROM vehicle_reconciliation_issue
WHERE status IN ('pending','confirmed_source_a','confirmed_source_b')
GROUP BY `+column+` ORDER BY COUNT(*) DESC,`+column)
if err != nil {
return nil, err
}
defer rows.Close()
out := []ReconciliationBucket{}
for rows.Next() {
var item ReconciliationBucket
if err := rows.Scan(&item.Name, &item.Count); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,60 @@
package platform
import (
"strings"
"testing"
)
func TestReconciliationSQLUsesCollationSafeCrossTableKeys(t *testing.T) {
checks := map[string][]string{
"coverage": {
"BINARY b.vin=BINARY s.vin",
"BINARY s.vin=BINARY b.vin",
},
"position": {
"BINARY b.vin=BINARY a.vin",
"BINARY binding.vin=BINARY a.vin",
},
"mileage": {
"BINARY b.vin=BINARY m.vin",
},
"fleet count": {
"SELECT CAST(vin AS BINARY) vin FROM vehicle_identity_binding",
"UNION SELECT CAST(vin AS BINARY) vin FROM vehicle_realtime_snapshot",
"BINARY b.vin=BINARY s.vin",
},
"business scope": {
"BINARY s.source_version=BINARY st.active_version",
"BINARY b.vin=BINARY s.vin",
"BINARY CAST(s.customer_id AS CHAR)=BINARY u.customer_ref",
"BINARY s.vin=BINARY g.vin",
"BINARY b.vin=BINARY g.vin",
"CONVERT(s.vin USING utf8mb4) COLLATE utf8mb4_unicode_ci",
"CONVERT(g.vin USING utf8mb4) COLLATE utf8mb4_unicode_ci",
"CONVERT(COALESCE(b.plate,'') USING utf8mb4) COLLATE utf8mb4_unicode_ci",
},
}
sqlByName := map[string]string{
"coverage": reconciliationCoverageSQL,
"position": reconciliationPositionSQL,
"mileage": reconciliationMileageSQL,
"fleet count": reconciliationFleetCountSQL,
"business scope": reconciliationBusinessScopeSQL,
}
for name, fragments := range checks {
for _, fragment := range fragments {
if !strings.Contains(sqlByName[name], fragment) {
t.Errorf("%s reconciliation SQL is missing %q", name, fragment)
}
}
}
}
func TestReconciliationTrendSQLSupportsOnlyFullGroupBy(t *testing.T) {
if !strings.Contains(reconciliationTrendSQL, "DATE_FORMAT(MIN(finished_at),'%Y-%m-%d')") {
t.Fatalf("trend date must be derived from an aggregate: %s", reconciliationTrendSQL)
}
if strings.Contains(reconciliationTrendSQL, "SELECT DATE_FORMAT(DATE(finished_at)") {
t.Fatalf("trend query must not select a non-aggregated finished_at expression: %s", reconciliationTrendSQL)
}
}

View File

@@ -38,6 +38,9 @@ import type {
Page,
QualitySummary,
QualityIssueRow,
ReconciliationIssue,
ReconciliationQuery,
ReconciliationSummary,
RawFrameRow,
RealtimeLocationRow,
SourceReadinessPlan,
@@ -292,6 +295,22 @@ export const api = {
})
}
),
reconciliationSummary: (days = 30, signal?: AbortSignal) => request<ReconciliationSummary>(
`/api/v2/reconciliation/summary?days=${days}`,
withSignal(undefined, signal)
),
reconciliationIssues: (query: ReconciliationQuery, signal?: AbortSignal) => request<Page<ReconciliationIssue>>(
'/api/v2/reconciliation/issues',
withSignal({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) }, signal)
),
reconciliationIssue: (id: string, signal?: AbortSignal) => request<ReconciliationIssue>(
`/api/v2/reconciliation/issues/${encodeURIComponent(id)}`,
withSignal(undefined, signal)
),
updateReconciliationIssue: (id: string, input: { version: number; status: string; note: string }) => request<ReconciliationIssue>(
`/api/v2/reconciliation/issues/${encodeURIComponent(id)}/actions`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
),
updateVehicleProfile: (vin: string, input: VehicleProfileInput) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
}),

View File

@@ -957,6 +957,75 @@ export interface RuntimeInfo {
platformRelease?: string;
}
export interface ReconciliationQuery {
keyword?: string;
ruleCode?: string;
category?: string;
severity?: string;
status?: string;
limit?: number;
offset?: number;
}
export interface ReconciliationAction {
id: number;
action: string;
fromStatus: string;
toStatus: string;
actor: string;
note: string;
createdAt: string;
}
export interface ReconciliationIssue {
id: string;
ruleCode: string;
category: string;
severity: string;
status: string;
vin: string;
plate: string;
protocolA: string;
protocolB: string;
title: string;
summary: string;
evidence: Record<string, unknown>;
firstSeenAt: string;
lastSeenAt: string;
occurrenceCount: number;
recoveredAt: string;
resolutionNote: string;
resolvedBy: string;
version: number;
actions?: ReconciliationAction[];
}
export interface ReconciliationBucket {
name: string;
count: number;
}
export interface ReconciliationTrendPoint {
date: string;
detected: number;
new: number;
active: number;
recovered: number;
}
export interface ReconciliationSummary {
active: number;
pending: number;
confirmed: number;
recovered: number;
overSla: number;
byRule: ReconciliationBucket[];
bySeverity: ReconciliationBucket[];
trend: ReconciliationTrendPoint[];
lastRunAt: string;
asOf: string;
}
export interface MapReverseGeocode {
provider: string;
longitude: number;

View File

@@ -5,7 +5,8 @@ import OperationsPage from './OperationsPage';
const mocks = vi.hoisted(() => ({
opsHealth: vi.fn(), sourceReadiness: vi.fn(), session: vi.fn(), vehicleCoverage: vi.fn(),
vehicleSourceDiagnostic: vi.fn(), updateVehicleSourcePolicy: vi.fn()
vehicleSourceDiagnostic: vi.fn(), updateVehicleSourcePolicy: vi.fn(),
reconciliationSummary: vi.fn(), reconciliationIssues: vi.fn(), reconciliationIssue: vi.fn(), updateReconciliationIssue: vi.fn()
}));
vi.mock('../../api/client', () => ({ api: mocks }));
@@ -13,8 +14,60 @@ afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockRes
function seedSession() {
mocks.session.mockResolvedValue({ name: '平台管理员', role: 'admin', userType: 'admin', authMode: 'enforce', menuKeys: ['operations'] });
mocks.reconciliationSummary.mockResolvedValue({
active: 1, pending: 1, confirmed: 0, recovered: 2, overSla: 1,
byRule: [{ name: 'POSITION_DRIFT', count: 1 }], bySeverity: [{ name: 'major', count: 1 }],
trend: [{ date: '2026-07-16', detected: 1, new: 1, active: 1, recovered: 0 }],
lastRunAt: '2026-07-16 02:15:00', asOf: '2026-07-16 10:00:00'
});
mocks.reconciliationIssues.mockResolvedValue({
items: [{
id: 'issue-1', ruleCode: 'POSITION_DRIFT', category: 'location', severity: 'major', status: 'pending',
vin: 'VIN001', plate: '粤A00001', protocolA: 'GB32960', protocolB: 'JT808', title: '多来源实时位置漂移',
summary: '两个来源相差 1286 米', evidence: { distanceM: 1286 }, firstSeenAt: '2026-07-16 08:00:00',
lastSeenAt: '2026-07-16 10:00:00', occurrenceCount: 3, recoveredAt: '', resolutionNote: '', resolvedBy: '', version: 1
}],
total: 1, limit: 50, offset: 0
});
mocks.reconciliationIssue.mockResolvedValue({
id: 'issue-1', ruleCode: 'POSITION_DRIFT', category: 'location', severity: 'major', status: 'pending',
vin: 'VIN001', plate: '粤A00001', protocolA: 'GB32960', protocolB: 'JT808', title: '多来源实时位置漂移',
summary: '两个来源相差 1286 米', evidence: { distanceM: 1286 }, firstSeenAt: '2026-07-16 08:00:00',
lastSeenAt: '2026-07-16 10:00:00', occurrenceCount: 3, recoveredAt: '', resolutionNote: '', resolvedBy: '', version: 1,
actions: [{ id: 1, action: 'detect', fromStatus: '', toStatus: 'pending', actor: 'reconciliation-evaluator', note: '规则首次发现差异', createdAt: '2026-07-16 08:00:00' }]
});
}
test('renders reconciliation queue, loads evidence on demand and records review conclusion', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
tdengineWritable: true, mysqlWritable: true,
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
});
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
mocks.updateReconciliationIssue.mockResolvedValue({
...(await mocks.reconciliationIssue()),
status: 'confirmed_source_a',
resolutionNote: '来源 A 原始报文可信',
resolvedBy: '平台管理员',
version: 2
});
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
expect(await screen.findByText('数据差异中心')).toBeInTheDocument();
fireEvent.click(await screen.findByText('多来源实时位置漂移'));
expect(await screen.findByText('规则证据')).toBeInTheDocument();
expect(screen.getByText('1286')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('处置状态'), { target: { value: 'confirmed_source_a' } });
fireEvent.change(screen.getByLabelText('说明(必填)'), { target: { value: '来源 A 原始报文可信' } });
fireEvent.click(screen.getByRole('button', { name: '保存复核结论' }));
await waitFor(() => expect(mocks.updateReconciliationIssue).toHaveBeenCalledWith('issue-1', {
version: 1, status: 'confirmed_source_a', note: '来源 A 原始报文可信'
}));
});
test('reconciles service identities with bound and identity-required vehicles', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
@@ -92,8 +145,12 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '粤A' } });
expect(await screen.findByText('粤A00001')).toBeInTheDocument();
fireEvent.click(screen.getByText('粤A00001'));
const candidateButton = await waitFor(() => {
const button = document.querySelector<HTMLButtonElement>('.v2-source-candidates button');
expect(button).toBeTruthy();
return button!;
});
fireEvent.click(candidateButton);
expect(await screen.findByText('当前推荐 G7')).toBeInTheDocument();
expect(screen.getByText('终端 133****0001')).toBeInTheDocument();
expect(screen.getByText('10s')).toBeInTheDocument();

View File

@@ -5,6 +5,7 @@ import { api } from '../../api/client';
import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types';
import { InlineError } from '../shared/AsyncState';
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
import ReconciliationCenter from './ReconciliationCenter';
function statusLabel(status: string) {
return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status;
@@ -142,6 +143,7 @@ export default function OperationsPage() {
const refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
return <div className="v2-ops-page">
<header className="v2-ops-heading"><div><h2></h2><p></p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh /></button></header>
<ReconciliationCenter />
<SourceDiagnosticWorkspace />
{health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null}
{readiness.isError ? <InlineError message={readiness.error instanceof Error ? readiness.error.message : '协议来源就绪度读取失败'} onRetry={() => readiness.refetch()} /> : null}

View File

@@ -0,0 +1,208 @@
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import type { ReconciliationIssue } from '../../api/types';
import { InlineError } from '../shared/AsyncState';
import { QUERY_MEMORY } from '../queryPolicy';
const PAGE_SIZE = 50;
const activeStatuses = new Set(['pending', 'confirmed_source_a', 'confirmed_source_b']);
const reviewStatuses = [
{ value: 'pending', label: '待处理' },
{ value: 'confirmed_source_a', label: '确认来源 A' },
{ value: 'confirmed_source_b', label: '确认来源 B' },
{ value: 'no_action', label: '无需处理' },
{ value: 'fixed', label: '已修复' }
];
function statusLabel(status: string) {
return {
pending: '待处理',
confirmed_source_a: '确认来源 A',
confirmed_source_b: '确认来源 B',
no_action: '无需处理',
fixed: '已修复',
recovered: '已恢复'
}[status] ?? status;
}
function severityLabel(severity: string) {
return { critical: '严重', major: '重要', minor: '一般' }[severity] ?? severity;
}
function ruleLabel(rule: string) {
return {
DUPLICATE_PLATE: '重复车牌',
DUPLICATE_PHONE: '重复终端',
UNBOUND_SOURCE: '来源未绑定',
SOURCE_MISSING: '主车无来源',
POSITION_DRIFT: '位置漂移',
MILEAGE_REVERSE: '里程倒退',
MILEAGE_JUMP: '里程跳变',
MILEAGE_SOURCE_DIVERGENCE: '里程来源差异',
FLEET_COUNT_MISMATCH: '车辆总数不一致',
BUSINESS_SCOPE_UNBOUND: '业务车辆未绑定',
AUTH_SCOPE_BUSINESS_MISMATCH: '授权与业务范围不一致'
}[rule] ?? rule;
}
function fmt(value?: string) {
if (!value) return '—';
const parsed = new Date(value.replace(' ', 'T'));
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false });
}
function evidenceValue(value: unknown) {
if (value == null || value === '') return '—';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
return JSON.stringify(value, null, 2);
}
function ReconciliationDetail({ issue, onClose }: { issue: ReconciliationIssue; onClose: () => void }) {
const queryClient = useQueryClient();
const [status, setStatus] = useState(issue.status === 'recovered' ? 'pending' : issue.status);
const [note, setNote] = useState(issue.resolutionNote ?? '');
useEffect(() => {
setStatus(issue.status === 'recovered' ? 'pending' : issue.status);
setNote(issue.resolutionNote ?? '');
}, [issue.id, issue.resolutionNote, issue.status]);
const save = useMutation({
mutationFn: () => api.updateReconciliationIssue(issue.id, { version: issue.version, status, note: note.trim() }),
onSuccess: async (updated) => {
queryClient.setQueryData(['reconciliation-detail', issue.id], updated);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['reconciliation-summary'] }),
queryClient.invalidateQueries({ queryKey: ['reconciliation-issues'] })
]);
}
});
const requiresNote = status !== 'pending';
return <aside className="v2-reconcile-detail" aria-label="差异证据与处置">
<header>
<div><span className={`is-${issue.severity}`}>{severityLabel(issue.severity)}</span><strong>{issue.title}</strong><small>{ruleLabel(issue.ruleCode)}</small></div>
<button type="button" onClick={onClose} aria-label="关闭差异详情">×</button>
</header>
<div className="v2-reconcile-detail-body">
<section className="v2-reconcile-identity">
<div><small></small><strong>{issue.plate || '未登记车牌'}</strong><span>{issue.vin || '非单车差异'}</span></div>
<div><small></small><strong>{[issue.protocolA, issue.protocolB].filter(Boolean).join(' ↔ ') || '平台口径'}</strong><span> {issue.occurrenceCount.toLocaleString('zh-CN')} </span></div>
<div><small></small><strong>{fmt(issue.lastSeenAt)}</strong><span> {fmt(issue.firstSeenAt)}</span></div>
</section>
<p className="v2-reconcile-summary">{issue.summary}</p>
<section className="v2-reconcile-evidence">
<header><strong></strong><span></span></header>
<dl>{Object.entries(issue.evidence ?? {}).map(([key, value]) => <div key={key}><dt>{key}</dt><dd><pre>{evidenceValue(value)}</pre></dd></div>)}</dl>
</section>
<section className="v2-reconcile-review">
<header><strong></strong><span> v{issue.version}</span></header>
<label><span></span><select value={status} onChange={(event) => setStatus(event.target.value)}>{reviewStatuses.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>
<label><span>{requiresNote ? '(必填)' : '(可选)'}</span><textarea value={note} maxLength={500} onChange={(event) => setNote(event.target.value)} placeholder="记录核对来源、原始值、责任人或修复结果" /></label>
<button type="button" disabled={save.isPending || (requiresNote && !note.trim())} onClick={() => save.mutate()}>{save.isPending ? '正在保存…' : '保存复核结论'}</button>
{save.isError ? <p role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</p> : null}
</section>
<section className="v2-reconcile-actions">
<header><strong></strong><span>{issue.actions?.length ?? 0} </span></header>
{issue.actions?.length ? <ol>{issue.actions.map((action) => <li key={action.id}><i /><div><strong>{statusLabel(action.toStatus)}</strong><p>{action.note || action.action}</p><span>{action.actor} · {fmt(action.createdAt)}</span></div></li>)}</ol> : <p></p>}
</section>
</div>
</aside>;
}
export default function ReconciliationCenter() {
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword.trim());
const [status, setStatus] = useState('active');
const [severity, setSeverity] = useState('all');
const [ruleCode, setRuleCode] = useState('all');
const [offset, setOffset] = useState(0);
const [selectedID, setSelectedID] = useState('');
useEffect(() => setOffset(0), [deferredKeyword, ruleCode, severity, status]);
const summary = useQuery({
queryKey: ['reconciliation-summary', 30],
queryFn: ({ signal }) => api.reconciliationSummary(30, signal),
staleTime: 60_000,
gcTime: QUERY_MEMORY.summaryGcTime
});
const issues = useQuery({
queryKey: ['reconciliation-issues', deferredKeyword, ruleCode, severity, status, offset],
queryFn: ({ signal }) => api.reconciliationIssues({
keyword: deferredKeyword, ruleCode, severity, status, limit: PAGE_SIZE, offset
}, signal),
staleTime: 20_000,
gcTime: QUERY_MEMORY.highVolumeGcTime
});
const detail = useQuery({
queryKey: ['reconciliation-detail', selectedID],
queryFn: ({ signal }) => api.reconciliationIssue(selectedID, signal),
enabled: Boolean(selectedID),
staleTime: 10_000,
gcTime: QUERY_MEMORY.optionGcTime
});
const maxTrend = useMemo(() => {
let result = 1;
for (const item of summary.data?.trend ?? []) result = Math.max(result, item.active, item.new, item.recovered);
return result;
}, [summary.data?.trend]);
const refresh = () => Promise.all([summary.refetch(), issues.refetch(), selectedID ? detail.refetch() : Promise.resolve()]);
const data = summary.data;
const page = issues.data;
const activeCount = page?.items.filter((item) => activeStatuses.has(item.status)).length ?? 0;
return <section className="v2-reconcile-center">
<header className="v2-reconcile-heading">
<div><small></small><strong></strong><span></span></div>
<button type="button" onClick={refresh} disabled={summary.isFetching || issues.isFetching}><IconRefresh /></button>
</header>
{summary.isError ? <InlineError message={summary.error.message} onRetry={() => summary.refetch()} /> : null}
<div className="v2-reconcile-kpis">
<article className="is-active"><small></small><strong>{data?.active.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small></small><strong>{data?.pending.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small></small><strong>{data?.confirmed.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article><small></small><strong>{data?.recovered.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
<article className={data?.overSla ? 'is-overdue' : ''}><small> 24 </small><strong>{data?.overSla.toLocaleString('zh-CN') ?? '—'}</strong><span></span></article>
</div>
<div className="v2-reconcile-layout">
<div className="v2-reconcile-main">
<div className="v2-reconcile-toolbar">
<label className="v2-reconcile-search"><IconSearch /><input aria-label="搜索差异车辆或规则" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="车牌、VIN、标题或说明" /></label>
<select aria-label="筛选差异状态" value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="active"></option><option value="pending"></option><option value="confirmed_source_a"> A</option><option value="confirmed_source_b"> B</option><option value="recovered"></option><option value="fixed"></option><option value="no_action"></option><option value="all"></option>
</select>
<select aria-label="筛选严重程度" value={severity} onChange={(event) => setSeverity(event.target.value)}>
<option value="all"></option><option value="critical"></option><option value="major"></option><option value="minor"></option>
</select>
<select aria-label="筛选差异规则" value={ruleCode} onChange={(event) => setRuleCode(event.target.value)}>
<option value="all"></option>{data?.byRule.map((item) => <option key={item.name} value={item.name}>{ruleLabel(item.name)} · {item.count}</option>)}
</select>
</div>
{issues.isError ? <InlineError message={issues.error.message} onRetry={() => issues.refetch()} /> : null}
<div className="v2-reconcile-table-wrap">
<table className="v2-reconcile-table"><thead><tr><th></th><th> / </th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>{page?.items.map((item) => <tr key={item.id} role="button" tabIndex={0} className={selectedID === item.id ? 'is-selected' : ''} onClick={() => setSelectedID(item.id)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedID(item.id); }}>
<td><span className={`v2-reconcile-severity is-${item.severity}`}>{severityLabel(item.severity)}</span></td>
<td><strong>{item.title}</strong><span>{ruleLabel(item.ruleCode)} · {item.occurrenceCount.toLocaleString('zh-CN')} </span></td>
<td><strong>{item.plate || '非单车差异'}</strong><span>{item.vin || '平台级口径'}</span></td>
<td><strong>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</strong><span>{item.category}</span></td>
<td><strong>{fmt(item.lastSeenAt)}</strong><span> {fmt(item.firstSeenAt)}</span></td>
<td><span className={`v2-reconcile-status is-${item.status}`}>{statusLabel(item.status)}</span></td>
</tr>)}</tbody>
</table>
{issues.isPending ? <p className="v2-reconcile-empty"></p> : null}
{!issues.isPending && !page?.items.length ? <p className="v2-reconcile-empty"></p> : null}
</div>
<footer className="v2-reconcile-pagination"><span> {(page?.total ?? 0).toLocaleString('zh-CN')} · {activeCount} </span><div><button type="button" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}></button><button type="button" disabled={offset + PAGE_SIZE >= (page?.total ?? 0)} onClick={() => setOffset(offset + PAGE_SIZE)}></button></div></footer>
</div>
<aside className="v2-reconcile-trend">
<header><strong> 30 </strong><span> {fmt(data?.lastRunAt)}</span></header>
<div>{data?.trend.slice(-14).map((item) => <article key={item.date}><time>{item.date.slice(5)}</time><div title={`存量 ${item.active},新增 ${item.new},恢复 ${item.recovered}`}><i className="is-active" style={{ width: `${Math.max(2, item.active / maxTrend * 100)}%` }} /><i className="is-new" style={{ width: `${Math.max(0, item.new / maxTrend * 100)}%` }} /><i className="is-recovered" style={{ width: `${Math.max(0, item.recovered / maxTrend * 100)}%` }} /></div><b>{item.active}</b></article>)}</div>
{!data?.trend.length ? <p>完成首次每日检测后显示趋势。</p> : null}
<footer><span><i className="is-active" />存量</span><span><i className="is-new" />新增</span><span><i className="is-recovered" />恢复</span></footer>
</aside>
{selectedID && detail.isPending ? <aside className="v2-reconcile-detail"><div className="v2-reconcile-empty">正在读取证据…</div></aside> : null}
{selectedID && detail.isError ? <aside className="v2-reconcile-detail"><InlineError message={detail.error.message} onRetry={() => detail.refetch()} /></aside> : null}
{detail.data ? <ReconciliationDetail issue={detail.data} onClose={() => setSelectedID('')} /> : null}
</div>
</section>;
}

View File

@@ -401,6 +401,18 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-ops-links article { display: grid; min-height: 46px; grid-template-columns: 8px minmax(0,1fr) auto; align-items: center; gap: 9px; border-bottom: 1px solid #eef2f7; padding: 7px 13px; }.v2-ops-links article > i, .v2-ops-sources article i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }.v2-ops-links article > i.is-ok, .v2-ops-sources article i.is-ok { background: var(--v2-green); }.v2-ops-links article > i.is-warning, .v2-ops-sources article i.is-warning { background: var(--v2-orange); }.v2-ops-links article > i.is-error, .v2-ops-sources article i.is-error { background: var(--v2-red); }.v2-ops-links article strong { font-size: 9px; }.v2-ops-links article p { margin: 3px 0 0; color: var(--v2-muted); font-size: 8px; }.v2-ops-links article > span { border-radius: 10px; background: #f1f5f9; padding: 3px 7px; color: #64748b; font-size: 8px; }
.v2-ops-runtime dl { margin: 0; padding: 7px 13px; }.v2-ops-runtime dl div { display: flex; min-height: 31px; align-items: center; justify-content: space-between; border-bottom: 1px solid #eef2f7; font-size: 9px; }.v2-ops-runtime dt { color: var(--v2-muted); }.v2-ops-runtime dd { margin: 0; }.v2-ops-clear, .v2-ops-findings { margin: 4px 13px 12px; border-radius: 6px; background: #f1fbf7; padding: 8px; color: #17815d; font-size: 8px; }.v2-ops-findings { background: #fff7ed; color: #b45309; }.v2-ops-findings p { margin: 3px 0; }
.v2-ops-sources > div { display: grid; grid-template-columns: repeat(3,1fr); }.v2-ops-sources article { min-width: 0; padding: 12px 14px; }.v2-ops-sources article + article { border-left: 1px solid var(--v2-border); }.v2-ops-sources article > div { display: flex; align-items: center; gap: 7px; }.v2-ops-sources article strong { font-size: 10px; }.v2-ops-sources article span { margin-left: auto; color: var(--v2-muted); font-size: 8px; }.v2-ops-sources article b { display: block; margin-top: 9px; font-size: 14px; }.v2-ops-sources article p { margin: 6px 0 0; color: #68768a; font-size: 8px; line-height: 1.45; }.v2-ops-sources article em { display: block; margin-top: 7px; color: var(--v2-blue); font-size: 8px; font-style: normal; }.is-ok { color: var(--v2-green) !important; }.is-warning { color: #b87900 !important; }.is-error { color: var(--v2-red) !important; }
.v2-reconcile-center { position: relative; border: 1px solid #cbd8e8; border-radius: 14px; background: #fff; box-shadow: 0 10px 32px rgba(31,53,80,.09); overflow: hidden; }
.v2-reconcile-heading { display: flex; min-height: 68px; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #e2e9f2; padding: 12px 16px; background: linear-gradient(105deg,#f7fbff 0%,#f2f7ff 58%,#eef5ff 100%); }.v2-reconcile-heading > div { display: grid; gap: 3px; }.v2-reconcile-heading small { color: #557494; font-size: 11px; }.v2-reconcile-heading strong { color: #17283b; font-size: 18px; }.v2-reconcile-heading span { color: #64758a; font-size: 12px; }.v2-reconcile-heading button { display: inline-flex; height: 36px; flex: 0 0 auto; align-items: center; gap: 6px; border: 1px solid #c5d4e5; border-radius: 8px; background: #fff; padding: 0 13px; color: #355272; cursor: pointer; font-size: 12px; }
.v2-reconcile-kpis { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); border-bottom: 1px solid #e3eaf2; }.v2-reconcile-kpis article { position: relative; min-width: 0; padding: 13px 16px; }.v2-reconcile-kpis article + article::before { position: absolute; inset: 13px auto 13px 0; width: 1px; background: #e4ebf3; content: ''; }.v2-reconcile-kpis small { display: block; color: #748398; font-size: 11px; }.v2-reconcile-kpis strong { display: block; margin: 5px 0 2px; color: #17283b; font-size: 22px; line-height: 1.1; }.v2-reconcile-kpis span { color: #8794a6; font-size: 10px; }.v2-reconcile-kpis article.is-active strong { color: #2464c8; }.v2-reconcile-kpis article.is-overdue strong { color: #c2413d; }
.v2-reconcile-layout { position: relative; display: grid; min-height: 480px; grid-template-columns: minmax(0,1fr) 260px; }.v2-reconcile-main { display: flex; min-width: 0; flex-direction: column; border-right: 1px solid #e3eaf2; }.v2-reconcile-toolbar { display: grid; grid-template-columns: minmax(240px,1fr) repeat(3,minmax(130px,auto)); gap: 8px; border-bottom: 1px solid #e7edf4; padding: 10px 12px; }.v2-reconcile-toolbar select, .v2-reconcile-search { height: 36px; border: 1px solid #d7e0eb; border-radius: 8px; background: #fff; color: #33465c; font-size: 12px; }.v2-reconcile-toolbar select { min-width: 0; padding: 0 10px; }.v2-reconcile-search { display: flex; align-items: center; gap: 8px; padding: 0 11px; }.v2-reconcile-search svg { color: #71849a; }.v2-reconcile-search input { width: 100%; min-width: 0; border: 0; outline: 0; color: #23374e; font: inherit; }
.v2-reconcile-table-wrap { min-height: 360px; flex: 1; overflow: auto; }.v2-reconcile-table { width: 100%; min-width: 920px; border-collapse: collapse; table-layout: fixed; }.v2-reconcile-table th { position: sticky; z-index: 1; top: 0; height: 38px; background: #f7f9fc; padding: 0 11px; color: #718095; font-size: 11px; font-weight: 600; text-align: left; }.v2-reconcile-table th:nth-child(1) { width: 72px; }.v2-reconcile-table th:nth-child(2) { width: 27%; }.v2-reconcile-table th:nth-child(3) { width: 19%; }.v2-reconcile-table th:nth-child(4) { width: 15%; }.v2-reconcile-table th:nth-child(5) { width: 21%; }.v2-reconcile-table th:nth-child(6) { width: 112px; }.v2-reconcile-table tbody tr { cursor: pointer; border-top: 1px solid #edf1f6; outline: none; }.v2-reconcile-table tbody tr:hover, .v2-reconcile-table tbody tr:focus-visible { background: #f7fbff; }.v2-reconcile-table tbody tr.is-selected { background: #eef6ff; box-shadow: inset 3px 0 #3478d4; }.v2-reconcile-table td { height: 62px; padding: 8px 11px; vertical-align: middle; }.v2-reconcile-table td strong { display: block; overflow: hidden; color: #263a50; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.v2-reconcile-table td span:not(.v2-reconcile-severity):not(.v2-reconcile-status) { display: block; margin-top: 4px; overflow: hidden; color: #8290a2; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.v2-reconcile-severity, .v2-reconcile-status { display: inline-flex; min-height: 24px; align-items: center; border-radius: 999px; padding: 0 8px; font-size: 10px; font-weight: 600; }.v2-reconcile-severity.is-critical { background: #fff0ef; color: #c53c36; }.v2-reconcile-severity.is-major { background: #fff6e5; color: #a96600; }.v2-reconcile-severity.is-minor { background: #eef4fa; color: #60758d; }.v2-reconcile-status { background: #edf3fb; color: #426485; }.v2-reconcile-status.is-pending { background: #fff4df; color: #a45f00; }.v2-reconcile-status.is-confirmed_source_a, .v2-reconcile-status.is-confirmed_source_b { background: #e9f2ff; color: #2464c8; }.v2-reconcile-status.is-recovered, .v2-reconcile-status.is-fixed { background: #eaf8f2; color: #167759; }
.v2-reconcile-empty { margin: 0; padding: 34px 16px; color: #77879a; font-size: 12px; text-align: center; }.v2-reconcile-pagination { display: flex; min-height: 46px; align-items: center; justify-content: space-between; border-top: 1px solid #e6edf4; padding: 7px 12px; color: #728197; font-size: 11px; }.v2-reconcile-pagination div { display: flex; gap: 6px; }.v2-reconcile-pagination button { height: 30px; border: 1px solid #d5dfeb; border-radius: 7px; background: #fff; padding: 0 11px; color: #405870; cursor: pointer; font-size: 11px; }.v2-reconcile-pagination button:disabled { cursor: not-allowed; opacity: .45; }
.v2-reconcile-trend { padding: 12px; background: #fbfcfe; }.v2-reconcile-trend > header { display: grid; gap: 3px; margin-bottom: 10px; }.v2-reconcile-trend header strong { color: #273b51; font-size: 13px; }.v2-reconcile-trend header span, .v2-reconcile-trend > p { color: #8390a2; font-size: 10px; }.v2-reconcile-trend article { display: grid; min-height: 25px; grid-template-columns: 36px minmax(0,1fr) 28px; align-items: center; gap: 7px; }.v2-reconcile-trend time, .v2-reconcile-trend article b { color: #718197; font-size: 9px; font-weight: 500; }.v2-reconcile-trend article > div { position: relative; height: 12px; border-radius: 3px; background: #edf2f7; overflow: hidden; }.v2-reconcile-trend article i { position: absolute; left: 0; height: 4px; }.v2-reconcile-trend article i.is-active { top: 0; background: #4c7fc4; }.v2-reconcile-trend article i.is-new { top: 4px; background: #e2a237; }.v2-reconcile-trend article i.is-recovered { top: 8px; background: #35a37b; }.v2-reconcile-trend > footer { display: flex; gap: 10px; margin-top: 10px; color: #758398; font-size: 9px; }.v2-reconcile-trend footer span { display: inline-flex; align-items: center; gap: 4px; }.v2-reconcile-trend footer i { width: 7px; height: 7px; border-radius: 2px; }.v2-reconcile-trend footer i.is-active { background: #4c7fc4; }.v2-reconcile-trend footer i.is-new { background: #e2a237; }.v2-reconcile-trend footer i.is-recovered { background: #35a37b; }
.v2-reconcile-detail { position: absolute; z-index: 5; top: 0; right: 0; bottom: 0; width: min(460px,52vw); border-left: 1px solid #c9d7e7; background: #fff; box-shadow: -14px 0 34px rgba(25,48,75,.15); overflow: hidden; }.v2-reconcile-detail > header { display: flex; min-height: 66px; align-items: flex-start; justify-content: space-between; gap: 12px; border-bottom: 1px solid #e3eaf2; padding: 12px 14px; background: #f6f9fd; }.v2-reconcile-detail > header div { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 5px 8px; }.v2-reconcile-detail > header span { border-radius: 999px; background: #edf2f7; padding: 3px 7px; font-size: 10px; }.v2-reconcile-detail > header span.is-critical { background: #fff0ef; color: #c53c36; }.v2-reconcile-detail > header span.is-major { background: #fff6e5; color: #a96600; }.v2-reconcile-detail > header strong { width: 100%; color: #21364d; font-size: 15px; }.v2-reconcile-detail > header small { color: #738297; font-size: 10px; }.v2-reconcile-detail > header button { border: 0; background: transparent; color: #6f8093; cursor: pointer; font-size: 24px; line-height: 1; }.v2-reconcile-detail-body { height: calc(100% - 66px); padding: 12px 14px 20px; overflow: auto; }.v2-reconcile-identity { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); border: 1px solid #e1e8f0; border-radius: 9px; overflow: hidden; }.v2-reconcile-identity div { min-width: 0; padding: 10px; }.v2-reconcile-identity div + div { border-left: 1px solid #e5ebf2; }.v2-reconcile-identity small, .v2-reconcile-identity span { display: block; color: #8491a2; font-size: 9px; }.v2-reconcile-identity strong { display: block; margin: 4px 0; overflow: hidden; color: #2b3e54; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }.v2-reconcile-summary { margin: 11px 0; border-radius: 8px; background: #f3f7fb; padding: 10px 12px; color: #4b5e73; font-size: 11px; line-height: 1.55; }
.v2-reconcile-evidence, .v2-reconcile-review, .v2-reconcile-actions { margin-top: 12px; border: 1px solid #e0e7ef; border-radius: 9px; overflow: hidden; }.v2-reconcile-evidence > header, .v2-reconcile-review > header, .v2-reconcile-actions > header { display: flex; min-height: 39px; align-items: center; justify-content: space-between; border-bottom: 1px solid #e5ebf2; padding: 0 11px; background: #fafbfd; }.v2-reconcile-evidence header strong, .v2-reconcile-review header strong, .v2-reconcile-actions header strong { color: #34495f; font-size: 11px; }.v2-reconcile-evidence header span, .v2-reconcile-review header span, .v2-reconcile-actions header span { color: #8794a4; font-size: 9px; }.v2-reconcile-evidence dl { margin: 0; }.v2-reconcile-evidence dl div { display: grid; grid-template-columns: 118px minmax(0,1fr); border-top: 1px solid #eef2f6; }.v2-reconcile-evidence dl div:first-child { border-top: 0; }.v2-reconcile-evidence dt { padding: 9px 10px; background: #fafbfd; color: #708095; font-size: 9px; }.v2-reconcile-evidence dd { min-width: 0; margin: 0; padding: 8px 10px; }.v2-reconcile-evidence pre { margin: 0; overflow: auto; color: #30475f; font: 10px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; white-space: pre-wrap; word-break: break-word; }
.v2-reconcile-review { padding-bottom: 11px; }.v2-reconcile-review > header { margin-bottom: 9px; }.v2-reconcile-review > label { display: grid; gap: 5px; margin: 8px 11px; color: #66778b; font-size: 10px; }.v2-reconcile-review select, .v2-reconcile-review textarea { border: 1px solid #d5dfeb; border-radius: 7px; background: #fff; padding: 8px 9px; color: #30465d; font: inherit; }.v2-reconcile-review textarea { min-height: 78px; resize: vertical; }.v2-reconcile-review > button { height: 33px; margin: 3px 11px 0; border: 0; border-radius: 7px; background: #316fc3; padding: 0 13px; color: #fff; cursor: pointer; font-size: 11px; }.v2-reconcile-review > button:disabled { cursor: not-allowed; opacity: .5; }.v2-reconcile-review > p { margin: 7px 11px 0; color: #c23c37; font-size: 10px; }
.v2-reconcile-actions > ol { margin: 0; padding: 9px 12px; list-style: none; }.v2-reconcile-actions li { display: grid; grid-template-columns: 10px minmax(0,1fr); gap: 8px; padding: 7px 0; }.v2-reconcile-actions li > i { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: #5d83b5; }.v2-reconcile-actions li strong { color: #354b61; font-size: 10px; }.v2-reconcile-actions li p { margin: 3px 0; color: #627489; font-size: 10px; line-height: 1.4; }.v2-reconcile-actions li span { color: #8b97a7; font-size: 9px; }.v2-reconcile-actions > p { margin: 0; padding: 14px; color: #8290a1; font-size: 10px; }
.v2-source-diagnostic { position: relative; border: 1px solid #cfdbea; border-radius: 12px; background: #fff; box-shadow: 0 8px 28px rgba(31,53,80,.08); overflow: hidden; }.v2-source-diagnostic > header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; border-bottom: 1px solid #e5ebf3; padding: 9px 14px; background: linear-gradient(100deg,#f8fbff,#f4f8ff); }.v2-source-diagnostic > header div { display: grid; gap: 2px; }.v2-source-diagnostic > header small { color: #547297; font-size: 9px; }.v2-source-diagnostic > header strong { font-size: 15px; }.v2-source-diagnostic > header span { color: var(--v2-muted); font-size: 9px; }.v2-source-diagnostic > header button, .v2-source-search > button, .v2-source-policy-cell button { display: inline-flex; height: 32px; align-items: center; justify-content: center; gap: 5px; border: 1px solid #cbd8e8; border-radius: 7px; background: #fff; padding: 0 12px; color: #355272; cursor: pointer; font-size: 10px; }.v2-source-diagnostic button:disabled { cursor: not-allowed; opacity: .5; }
.v2-source-search { position: relative; display: flex; gap: 8px; padding: 12px 14px; }.v2-source-search > label { display: flex; min-width: 280px; flex: 1; height: 36px; align-items: center; gap: 8px; border: 1px solid #cbd8e8; border-radius: 8px; padding: 0 11px; color: #6c7c91; }.v2-source-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font: inherit; font-size: 11px; }.v2-source-search > button { height: 36px; border-color: #2f6fe4; background: #2f6fe4; color: #fff; }.v2-source-candidates { position: absolute; z-index: 12; top: 51px; right: 114px; left: 14px; max-height: 310px; overflow: auto; border: 1px solid #d6e0ec; border-radius: 9px; background: #fff; box-shadow: 0 12px 32px rgba(31,53,80,.18); }.v2-source-candidates > button { display: grid; width: 100%; grid-template-columns: 150px minmax(180px,1fr) auto; gap: 10px; border: 0; border-bottom: 1px solid #edf1f6; background: #fff; padding: 10px 12px; text-align: left; cursor: pointer; }.v2-source-candidates > button:hover { background: #f5f9ff; }.v2-source-candidates strong { font-size: 11px; }.v2-source-candidates span, .v2-source-candidates em, .v2-source-candidates p { color: var(--v2-muted); font-size: 9px; font-style: normal; }.v2-source-candidates p { margin: 0; padding: 14px; }.v2-source-candidates footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid #e5ebf3; background: #fff; padding: 7px 10px; }.v2-source-candidates footer div { display: flex; gap: 5px; }.v2-source-candidates footer button { border: 1px solid #d2dce8; border-radius: 5px; background: #fff; padding: 4px 8px; color: #52657d; font-size: 8px; }
.v2-source-empty { display: grid; min-height: 110px; place-content: center; gap: 6px; color: var(--v2-muted); text-align: center; }.v2-source-empty strong { color: #42556e; font-size: 13px; }.v2-source-empty span { font-size: 10px; }.v2-source-summary { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); border-block: 1px solid #e7edf4; background: #fafcff; }.v2-source-summary article { min-width: 0; padding: 11px 14px; }.v2-source-summary article + article { border-left: 1px solid #e7edf4; }.v2-source-summary small, .v2-source-summary span { display: block; overflow: hidden; color: var(--v2-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }.v2-source-summary strong { display: block; margin: 5px 0; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }.v2-source-recommendation { margin: 12px 14px 8px; border-left: 3px solid #2f6fe4; border-radius: 6px; background: #f3f7ff; padding: 9px 11px; }.v2-source-recommendation strong { font-size: 10px; }.v2-source-recommendation p { margin: 4px 0; color: #405774; font-size: 9px; line-height: 1.5; }.v2-source-recommendation span, .v2-source-readonly { color: #728299; font-size: 8px; }.v2-source-readonly { margin: 0 14px 8px; border-radius: 6px; background: #fff8e8; padding: 7px 9px; color: #966500; }
@@ -1135,6 +1147,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-alert-rules { display: flex; flex-direction: column; }
.v2-alert-rule-list { max-height: 300px; flex: none; }
.v2-ops-kpis { grid-template-columns: repeat(3,1fr); }.v2-ops-grid { grid-template-columns: 1fr; }.v2-ops-sources > div { grid-template-columns: 1fr; }.v2-ops-sources article + article { border-top: 1px solid var(--v2-border); border-left: 0; }
.v2-reconcile-kpis { grid-template-columns: repeat(3,1fr); }.v2-reconcile-layout { grid-template-columns: minmax(0,1fr) 220px; }.v2-reconcile-toolbar { grid-template-columns: minmax(220px,1fr) repeat(2,minmax(120px,auto)); }.v2-reconcile-toolbar select:last-child { grid-column: span 2; }
.v2-source-summary { grid-template-columns: repeat(2,1fr); }.v2-source-summary article:nth-child(3) { border-left: 0; }.v2-source-summary article:nth-child(n+3) { border-top: 1px solid #e7edf4; }
}
@@ -1454,6 +1467,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-alert-rule-editor > footer { align-items: stretch; flex-direction: column; }
.v2-alert-notifications > footer { flex-direction: column; gap: 5px; }
.v2-ops-page { padding: 8px; }.v2-ops-heading { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-ops-kpis { grid-template-columns: 1fr 1fr; }.v2-ops-kpis article + article::before { display: none; }.v2-ops-kpis article { border-bottom: 1px solid var(--v2-border); }
.v2-reconcile-heading { align-items: flex-start; flex-direction: column; }.v2-reconcile-heading button { width: 100%; justify-content: center; }.v2-reconcile-kpis { grid-template-columns: 1fr 1fr; }.v2-reconcile-kpis article + article::before { display: none; }.v2-reconcile-kpis article { border-bottom: 1px solid #e5ebf2; }.v2-reconcile-layout { display: block; min-height: 520px; }.v2-reconcile-main { border-right: 0; }.v2-reconcile-toolbar { grid-template-columns: 1fr 1fr; }.v2-reconcile-search { grid-column: 1 / -1; }.v2-reconcile-toolbar select:last-child { grid-column: 1 / -1; }.v2-reconcile-trend { border-top: 1px solid #e3eaf2; }.v2-reconcile-detail { position: fixed; inset: 54px 0 0; width: auto; border-left: 0; }.v2-reconcile-identity { grid-template-columns: 1fr; }.v2-reconcile-identity div + div { border-top: 1px solid #e5ebf2; border-left: 0; }.v2-reconcile-evidence dl div { grid-template-columns: 96px minmax(0,1fr); }
.v2-source-diagnostic > header { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-source-diagnostic > header button { width: 100%; }.v2-source-search { flex-direction: column; }.v2-source-search > label { width: auto; min-width: 0; height: 42px; }.v2-source-search > button { width: 100%; }.v2-source-candidates { top: 61px; right: 14px; }.v2-source-candidates > button { grid-template-columns: 1fr; gap: 3px; }.v2-source-summary { grid-template-columns: 1fr; }.v2-source-summary article + article, .v2-source-summary article:nth-child(3) { border-top: 1px solid #e7edf4; border-left: 0; }.v2-source-audit > header { height: auto; align-items: flex-start; flex-direction: column; gap: 3px; padding-block: 7px; }.v2-source-audit li { grid-template-columns: 35px minmax(0,1fr); }.v2-source-audit li em { grid-column: 2; }
.v2-mileage-page { gap: 10px; padding: 12px 8px 18px; }
.v2-mileage-heading { align-items: flex-start; flex-direction: row; gap: 10px; }

View File

@@ -6,6 +6,7 @@ RELEASE_ID=${1:?release id is required}
ARCHIVE=${2:?web archive is required}
API_BINARY=${3:-}
ONEOS_SCOPE_BINARY=${4:-}
RECONCILIATION_BINARY=${5:-}
ROOT=${PLATFORM_ROOT:-/opt/lingniu-vehicle-platform}
BASE_URL=${PLATFORM_BASE_URL:-http://127.0.0.1:20300}
SERVICE=${PLATFORM_SERVICE:-lingniu-vehicle-platform}
@@ -25,6 +26,9 @@ fi
if test -n "$ONEOS_SCOPE_BINARY"; then
test -f "$ONEOS_SCOPE_BINARY" || { printf 'OneOS scope sync binary is missing: %s\n' "$ONEOS_SCOPE_BINARY" >&2; exit 1; }
fi
if test -n "$RECONCILIATION_BINARY"; then
test -f "$RECONCILIATION_BINARY" || { printf 'reconciliation evaluator binary is missing: %s\n' "$RECONCILIATION_BINARY" >&2; exit 1; }
fi
test -x "$SCRIPT_DIR/prepare-web-release-tree.sh" || { printf 'release tree helper is missing\n' >&2; exit 1; }
test -f "$SCRIPT_DIR/prune-release-history.py" || { printf 'release pruning helper is missing\n' >&2; exit 1; }
test -f "$SCRIPT_DIR/verify-customer-demo.py" || { printf 'customer demo gate is missing\n' >&2; exit 1; }
@@ -76,7 +80,7 @@ trap rollback ERR
test ! -e "$next" || { printf 'release already exists: %s\n' "$next" >&2; exit 1; }
mkdir -p "$next/web"
for runtime_file in platform-api alert-evaluator alert-stream-evaluator platform-migrate oneos-scope-sync alert-benchmark; do
for runtime_file in platform-api alert-evaluator alert-stream-evaluator platform-migrate oneos-scope-sync reconciliation-evaluator alert-benchmark; do
if test -f "$old/$runtime_file"; then
cp "$old/$runtime_file" "$next/$runtime_file"
fi
@@ -88,6 +92,9 @@ fi
if test -n "$ONEOS_SCOPE_BINARY"; then
cp "$ONEOS_SCOPE_BINARY" "$next/oneos-scope-sync"
fi
if test -n "$RECONCILIATION_BINARY"; then
cp "$RECONCILIATION_BINARY" "$next/reconciliation-evaluator"
fi
if test -f "$old/lingniu-vehicle-platform.service"; then
cp "$old/lingniu-vehicle-platform.service" "$next/lingniu-vehicle-platform.service"
fi
@@ -96,12 +103,16 @@ if test -d "$SCRIPT_DIR/migrations"; then
mkdir -p "$next/deploy/migrations"
cp "$SCRIPT_DIR"/migrations/*.sql "$next/deploy/migrations/"
fi
if test -d "$SCRIPT_DIR/systemd"; then
mkdir -p "$next/deploy/systemd"
cp "$SCRIPT_DIR"/systemd/* "$next/deploy/systemd/"
fi
cp "$SCRIPT_DIR/prepare-web-release-tree.sh" "$next/deploy/prepare-web-release-tree.sh"
cp "$SCRIPT_DIR/prune-release-history.py" "$next/deploy/prune-release-history.py"
cp "$SCRIPT_DIR/verify-customer-demo.py" "$next/deploy/verify-customer-demo.py"
cp "$0" "$next/deploy/install-web-release.sh"
chmod +x "$next/platform-api" "$next/deploy/"*.sh "$next/deploy/prune-release-history.py" "$next/deploy/verify-customer-demo.py"
for runtime_file in alert-evaluator alert-stream-evaluator platform-migrate oneos-scope-sync alert-benchmark; do
for runtime_file in alert-evaluator alert-stream-evaluator platform-migrate oneos-scope-sync reconciliation-evaluator alert-benchmark; do
test ! -f "$next/$runtime_file" || chmod +x "$next/$runtime_file"
done

View File

@@ -28,23 +28,26 @@ printf 'window.__LINGNIU_APP_CONFIG__={"amapSecurityServiceHost":"/_AMapService"
tar -C "$new_web" -czf "$fixture/new.tar.gz" .
printf 'new platform API\n' > "$fixture/new-platform-api"
printf 'new OneOS scope sync\n' > "$fixture/new-oneos-scope-sync"
printf 'new reconciliation evaluator\n' > "$fixture/new-reconciliation-evaluator"
printf '#!/usr/bin/env bash\nexit 0\n' > "$fixture/bin/systemctl"
printf '#!/usr/bin/env bash\nexit 0\n' > "$fixture/bin/curl"
printf '#!/usr/bin/env bash\nprintf "mock_verify=ok\\n"\n' > "$fixture/bin/verify"
chmod +x "$fixture/bin/"*
PLATFORM_ROOT="$root" SYSTEMCTL_BIN="$fixture/bin/systemctl" CURL_BIN="$fixture/bin/curl" VERIFY_WEB_RELEASE_BIN="$fixture/bin/verify" RELEASE_HISTORY_LIMIT=3 "$SCRIPT_DIR/install-web-release.sh" new-release "$fixture/new.tar.gz" "$fixture/new-platform-api" "$fixture/new-oneos-scope-sync" > "$fixture/install.out"
PLATFORM_ROOT="$root" SYSTEMCTL_BIN="$fixture/bin/systemctl" CURL_BIN="$fixture/bin/curl" VERIFY_WEB_RELEASE_BIN="$fixture/bin/verify" RELEASE_HISTORY_LIMIT=3 "$SCRIPT_DIR/install-web-release.sh" new-release "$fixture/new.tar.gz" "$fixture/new-platform-api" "$fixture/new-oneos-scope-sync" "$fixture/new-reconciliation-evaluator" > "$fixture/install.out"
test "$(readlink -f "$root/current")" = "$(cd "$root/releases/new-release" && pwd -P)"
grep -q '^PLATFORM_RELEASE=new-release$' "$root/env/platform.env"
test "$(cat "$root/current/platform-api")" = 'new platform API'
test "$(cat "$root/current/oneos-scope-sync")" = 'new OneOS scope sync'
test "$(cat "$root/current/reconciliation-evaluator")" = 'new reconciliation evaluator'
test "$(cat "$root/current/alert-evaluator")" = 'old evaluator'
test -f "$root/current/web/assets/new.js"
test -f "$root/current/web/assets/old.js"
test -f "$root/current/web/.compatibility-manifests/1.assets"
test -x "$root/current/deploy/verify-customer-demo.py"
test -f "$root/current/deploy/migrations/016_business_scope_dimensions.sql"
test -f "$root/current/deploy/migrations/017_reconciliation_center.sql"
test -f "$root/current/deploy/systemd/lingniu-vehicle-reconciliation-evaluator.timer"
test ! -e "$root/current/lingniu-vehicle-platform.service"
test "$(find "$root/releases" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 3
grep -q 'web_release_install=ok release=new-release previous=old-release' "$fixture/install.out"

View File

@@ -0,0 +1,55 @@
CREATE TABLE IF NOT EXISTS vehicle_reconciliation_run (
run_id VARCHAR(64) NOT NULL PRIMARY KEY,
status VARCHAR(24) NOT NULL,
detected_count INT UNSIGNED NOT NULL DEFAULT 0,
new_count INT UNSIGNED NOT NULL DEFAULT 0,
active_count INT UNSIGNED NOT NULL DEFAULT 0,
recovered_count INT UNSIGNED NOT NULL DEFAULT 0,
rule_counts_json JSON NOT NULL,
error_message VARCHAR(512) NOT NULL DEFAULT '',
started_at DATETIME(3) NOT NULL,
finished_at DATETIME(3) NOT NULL,
INDEX idx_reconciliation_run_finished (finished_at, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS vehicle_reconciliation_issue (
id VARCHAR(64) NOT NULL PRIMARY KEY,
fingerprint CHAR(64) NOT NULL,
rule_code VARCHAR(64) NOT NULL,
category VARCHAR(32) NOT NULL,
severity VARCHAR(16) NOT NULL,
status VARCHAR(32) NOT NULL,
vin VARCHAR(64) NOT NULL DEFAULT '',
plate VARCHAR(64) NOT NULL DEFAULT '',
protocol_a VARCHAR(32) NOT NULL DEFAULT '',
protocol_b VARCHAR(32) NOT NULL DEFAULT '',
title VARCHAR(160) NOT NULL,
summary VARCHAR(500) NOT NULL,
evidence_json JSON NOT NULL,
first_seen_at DATETIME(3) NOT NULL,
last_seen_at DATETIME(3) NOT NULL,
occurrence_count BIGINT UNSIGNED NOT NULL DEFAULT 1,
recovered_at DATETIME(3) NULL,
resolution_note VARCHAR(500) NOT NULL DEFAULT '',
resolved_by VARCHAR(128) NOT NULL DEFAULT '',
version INT UNSIGNED NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_reconciliation_fingerprint (fingerprint),
INDEX idx_reconciliation_status (status, severity, last_seen_at),
INDEX idx_reconciliation_rule (rule_code, last_seen_at),
INDEX idx_reconciliation_vehicle (vin, last_seen_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS vehicle_reconciliation_action (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
issue_id VARCHAR(64) NOT NULL,
action VARCHAR(32) NOT NULL,
from_status VARCHAR(32) NOT NULL,
to_status VARCHAR(32) NOT NULL,
actor VARCHAR(128) NOT NULL,
note VARCHAR(500) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX idx_reconciliation_action_issue (issue_id, created_at),
INDEX idx_reconciliation_action_actor (actor, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,23 @@
[Unit]
Description=Lingniu Vehicle Data Reconciliation Evaluator
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/opt/lingniu-vehicle-platform/current
EnvironmentFile=/opt/lingniu-go-native/env/base.env
EnvironmentFile=/opt/lingniu-vehicle-platform/env/platform.env
ExecStart=/opt/lingniu-vehicle-platform/current/reconciliation-evaluator
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6
TimeoutStartSec=600
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Run vehicle data reconciliation every day
[Timer]
OnCalendar=*-*-* 02:15:00
RandomizedDelaySec=5m
AccuracySec=1m
Persistent=true
Unit=lingniu-vehicle-reconciliation-evaluator.service
[Install]
WantedBy=timers.target

View File

@@ -14,6 +14,7 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/alert-stream-evaluator ./cmd/alert-stream-evaluator
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/platform-migrate ./cmd/platform-migrate
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/oneos-scope-sync ./cmd/oneos-scope-sync
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/reconciliation-evaluator ./cmd/reconciliation-evaluator
# Optional release/performance gate; this binary is run on demand, not installed as a service.
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o ../../dist/alert-benchmark ./cmd/alert-benchmark
```
@@ -66,14 +67,15 @@ PREVIOUS_WEB=/opt/lingniu-vehicle-platform/releases/$PREVIOUS_RELEASE/web
"$PREVIOUS_WEB/.release-assets"
```
`deploy/install-web-release.sh` also accepts an optional third argument containing a newly built `platform-api` and an optional fourth argument containing a newly built `oneos-scope-sync`. When provided, it atomically publishes those binaries with the Web. The installer inherits every runtime binary that exists in the previous release (`alert-evaluator`, `alert-stream-evaluator`, `platform-migrate`, `oneos-scope-sync` and the optional benchmark) before switching the symlink, so a later systemd restart cannot fail because a Web-oriented release omitted an unchanged binary:
`deploy/install-web-release.sh` also accepts an optional third argument containing a newly built `platform-api`, an optional fourth argument containing a newly built `oneos-scope-sync`, and an optional fifth argument containing a newly built `reconciliation-evaluator`. When provided, it atomically publishes those binaries with the Web. The installer inherits every runtime binary that exists in the previous release (`alert-evaluator`, `alert-stream-evaluator`, `platform-migrate`, `oneos-scope-sync`, `reconciliation-evaluator` and the optional benchmark) before switching the symlink, so a later systemd restart cannot fail because a Web-oriented release omitted an unchanged binary:
```bash
deploy/install-web-release.sh \
"$PLATFORM_RELEASE" \
"/tmp/$PLATFORM_RELEASE-web.tar.gz" \
"/tmp/$PLATFORM_RELEASE-platform-api" \
"/tmp/$PLATFORM_RELEASE-oneos-scope-sync"
"/tmp/$PLATFORM_RELEASE-oneos-scope-sync" \
"/tmp/$PLATFORM_RELEASE-reconciliation-evaluator"
```
## Environment
@@ -108,6 +110,7 @@ ONEOS_SCOPE_API_MAX_ATTEMPTS=3
ONEOS_SCOPE_SYNC_TIMEOUT_SEC=60
ONEOS_SCOPE_MAX_REJECTED=100
ONEOS_SCOPE_MAX_REJECT_RATIO=0.10
RECONCILIATION_TIMEOUT_SEC=300
REQUEST_TIMEOUT_MS=5000
AMAP_WEB_JS_KEY=***
AMAP_SECURITY_JS_CODE=***
@@ -198,10 +201,13 @@ test -n "$MYSQL_DSN"
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/013_platform_identity_access.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/014_customer_vehicle_grant_time.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/015_vehicle_source_policy_audit.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/016_business_scope_dimensions.sql
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/016_business_scope_dimensions.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/017_reconciliation_center.sql
```
The API guards the access-threshold tables for compatibility, while alert APIs deliberately require the alert migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed. Migration `012` creates the atomic, versioned business Scope projection; its reserved `row_number` column is quoted for production MySQL compatibility. Migration `014` backfills active vehicle-grant start times, creates the grant-interval history table and adds the active time lookup index; apply it before starting an API binary that writes grant history. Migration `015` adds platform-owned optimistic versions and immutable audits for per-vehicle location-source policy changes. It does not alter the gateway election SQL or expose the gateway `source_key`; the API resolves an opaque `sourceRef` server-side and the gateway applies the saved policy on the next valid vehicle report. Migration `016` adds customer name, department and responsible-person dimensions plus bounded lookup indexes to the platform-owned Scope projection.
The API guards the access-threshold tables for compatibility, while alert and reconciliation APIs deliberately require their migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed. Migration `012` creates the atomic, versioned business Scope projection; its reserved `row_number` column is quoted for production MySQL compatibility. Migration `014` backfills active vehicle-grant start times, creates the grant-interval history table and adds the active time lookup index; apply it before starting an API binary that writes grant history. Migration `015` adds platform-owned optimistic versions and immutable audits for per-vehicle location-source policy changes. It does not alter the gateway election SQL or expose the gateway `source_key`; the API resolves an opaque `sourceRef` server-side and the gateway applies the saved policy on the next valid vehicle report. Migration `016` adds customer name, department and responsible-person dimensions plus bounded lookup indexes to the platform-owned Scope projection. Migration `017` creates the reconciliation run, issue and immutable action tables; the unique fingerprint index is the database-level duplicate-work-item guard.
Production release `reconciliation-summary-fix-20260716191153` applied migration `017`, deployed the API, Web and reconciliation evaluator, and enabled the daily timer. The initial run detected 1,622 active differences in about 1.43 seconds; an immediate repeat completed in about 0.88 seconds with `new=0`. The authenticated smoke verified summary/list/detail for administrator and operator, viewer denial, optimistic-version conflict handling, evidence-key redaction, 50-row pagination, responsive desktop/mobile rendering and no browser console errors.
Release `oneos-api-client-20260716184601` applied migrations `012` and `016` and deployed the switchable API/database sync binary. The projection is intentionally empty (`active_version` unset) until a verified OneOS endpoint is configured. A production fail-closed smoke with `ONEOS_SCOPE_SOURCE=api` and no endpoint exited nonzero without publishing any row. Do not install or enable the timer until the endpoint, service token, signing secret, source-IP restriction and a representative snapshot have passed the contract checks below.
@@ -214,6 +220,8 @@ sudo cp deploy/systemd/lingniu-vehicle-alert-evaluator.service /etc/systemd/syst
sudo cp deploy/systemd/lingniu-vehicle-alert-stream-evaluator.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-oneos-scope-sync.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-oneos-scope-sync.timer /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-reconciliation-evaluator.service /etc/systemd/system/
sudo cp deploy/systemd/lingniu-vehicle-reconciliation-evaluator.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now lingniu-vehicle-platform lingniu-vehicle-alert-evaluator lingniu-vehicle-alert-stream-evaluator
sudo systemctl status --no-pager lingniu-vehicle-alert-evaluator
@@ -224,6 +232,11 @@ sudo journalctl -u lingniu-vehicle-alert-stream-evaluator -n 100 --no-pager
sudo systemctl enable --now lingniu-vehicle-oneos-scope-sync.timer
sudo systemctl status --no-pager lingniu-vehicle-oneos-scope-sync.timer
sudo journalctl -u lingniu-vehicle-oneos-scope-sync.service -n 100 --no-pager
# Run once before enabling the timer; a failure must leave the timer disabled.
sudo systemctl start lingniu-vehicle-reconciliation-evaluator.service
sudo journalctl -u lingniu-vehicle-reconciliation-evaluator.service -n 100 --no-pager
sudo systemctl enable --now lingniu-vehicle-reconciliation-evaluator.timer
sudo systemctl list-timers lingniu-vehicle-reconciliation-evaluator.timer --no-pager
```
## Health
@@ -256,6 +269,10 @@ curl -fsS -X POST -H "$AUTH_HEADER" -H 'Content-Type: application/json' -d '{"li
http://127.0.0.1:20300/api/v2/alerts/events
curl -fsS -H "$AUTH_HEADER" http://127.0.0.1:20300/api/v2/alerts/rules
curl -fsS -H "$AUTH_HEADER" 'http://127.0.0.1:20300/api/v2/alerts/notifications?unreadOnly=true&limit=5'
curl -fsS -H "$AUTH_HEADER" 'http://127.0.0.1:20300/api/v2/reconciliation/summary?days=30'
curl -fsS -X POST -H "$AUTH_HEADER" -H 'Content-Type: application/json' \
-d '{"status":"active","limit":50,"offset":0}' \
http://127.0.0.1:20300/api/v2/reconciliation/issues
curl -fsS http://127.0.0.1:20300/app-config.js
curl -fsS http://127.0.0.1:20300/ | grep -q '<div id="root"></div>'
MAIN_ASSET="$(sed -n 's/.*src="\([^"]*\.js\)".*/\1/p' /opt/lingniu-vehicle-platform/current/web/index.html | head -1)"

View File

@@ -435,7 +435,19 @@
### P1-05 数据差异自动自查与对账
状态:`进行中`
状态:`已完成`
已上线结果release `reconciliation-summary-fix-20260716191153`
- 新增独立的数据差异中心,自动检测重复车牌/终端、来源未绑定、主车辆无来源、多来源位置漂移、里程跳变/倒退、日里程来源差异、车队总数差异、OneOS 业务车辆未绑定及人工授权与业务范围不一致。OneOS 投影尚未启用时,业务范围规则不伪造结果,待正式快照发布后自动参与每日检测。
- 每条差异保存稳定指纹、规则、等级、车辆、来源、首次/最近发现时间、命中次数和结构化证据;原始 `source_key`、终端手机号、来源 IP 不进入接口响应。
- 支持待处理、确认来源 A、确认来源 B、无需处理、已修复和已恢复状态非待处理结论必须填写说明使用乐观版本避免并发覆盖全部处理动作保留账号和时间履历。
- 相同指纹重复检测只增加命中次数,不产生重复工单;已修复/已恢复问题再次出现会重新打开,活跃问题不再命中时自动转为已恢复。
- 独立 `reconciliation-evaluator` 每日定时执行,不加入 API 高频轮询。生产定时器已启用,下一次运行时间为 `2026-07-17 02:19:54 +08:00`,单次全量检测约 0.881.43 秒。
- 首轮生产检测发现 1,622 条活跃差异:车队总数差异 1、里程跳变 112、里程倒退 6、多来源日里程差异 1,197、主车辆无来源 295、来源未绑定 11第二轮同数据运行 `new=0`,证明指纹去重生效。
- 运维页面以 50 行差异队列为主体提供关键词、状态、等级和规则筛选、30 天新增/存量/恢复趋势及按需证据抽屉。摘要缓存 60 秒、列表缓存 20 秒、详情按需读取,不加入 15 秒健康轮询。
- 管理员和运维员可读取、复核;只读查看者返回 403。生产验证中错误版本处置返回 409防止覆盖其他人的结论。
- Go 全量测试、前端 49 个文件/259 项测试、TypeScript/Vite 构建和发布脚本测试通过。生产桌面首屏显示 1,622 条、50 行队列390×844 移动端详情宽度不超过视窗,表格受控横向滚动;两种视图均无页面级横向溢出、运行时异常或控制台错误。
目标: