feat(operations): add source diagnosis workspace
This commit is contained in:
@@ -232,6 +232,12 @@ func requiredMenu(r *http.Request) string {
|
||||
}
|
||||
|
||||
func requiredRole(r *http.Request) string {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v2/operations/") {
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
||||
return "operator"
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, "/api/v2/exports") {
|
||||
return "viewer"
|
||||
}
|
||||
|
||||
@@ -94,6 +94,18 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
|
||||
if operatorProfileSync.Code != http.StatusForbidden {
|
||||
t.Fatalf("operator profile sync should be forbidden, status=%d", operatorProfileSync.Code)
|
||||
}
|
||||
viewerSourceDiagnostic := authRequest(t, cfg, http.MethodGet, "/api/v2/operations/vehicles/VIN001/sources", viewerToken)
|
||||
if viewerSourceDiagnostic.Code != http.StatusForbidden {
|
||||
t.Fatalf("viewer source diagnostic should be forbidden, status=%d", viewerSourceDiagnostic.Code)
|
||||
}
|
||||
operatorSourceDiagnostic := authRequest(t, cfg, http.MethodGet, "/api/v2/operations/vehicles/VIN001/sources", operatorToken)
|
||||
if operatorSourceDiagnostic.Code != http.StatusNoContent {
|
||||
t.Fatalf("operator source diagnostic status=%d", operatorSourceDiagnostic.Code)
|
||||
}
|
||||
operatorSourcePolicy := authRequest(t, cfg, http.MethodPut, "/api/v2/operations/vehicles/VIN001/sources/ref", operatorToken)
|
||||
if operatorSourcePolicy.Code != http.StatusForbidden {
|
||||
t.Fatalf("operator source policy mutation should be forbidden, status=%d", operatorSourcePolicy.Code)
|
||||
}
|
||||
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"))
|
||||
@@ -106,6 +118,10 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
|
||||
if adminThreshold.Code != http.StatusNoContent {
|
||||
t.Fatalf("admin threshold status=%d body=%s", adminThreshold.Code, adminThreshold.Body.String())
|
||||
}
|
||||
adminSourcePolicy := authRequest(t, cfg, http.MethodPut, "/api/v2/operations/vehicles/VIN001/sources/ref", adminToken)
|
||||
if adminSourcePolicy.Code != http.StatusNoContent {
|
||||
t.Fatalf("admin source policy status=%d body=%s", adminSourcePolicy.Code, adminSourcePolicy.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAuthSessionAndDisabledMode(t *testing.T) {
|
||||
|
||||
@@ -61,6 +61,8 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("PUT /api/v2/vehicles/{vin}/profile", h.handleSaveVehicleProfile)
|
||||
h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/telemetry/latest", h.handleLatestTelemetry)
|
||||
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("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)
|
||||
@@ -111,6 +113,22 @@ func (h *Handler) handleVehicleSourceEvidence(w http.ResponseWriter, r *http.Req
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleVehicleSourceDiagnostic(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.VehicleSourceDiagnostic(r.Context(), r.PathValue("vin"))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateVehicleSourcePolicy(w http.ResponseWriter, r *http.Request) {
|
||||
var update VehicleSourcePolicyUpdate
|
||||
if !decodeJSONBody(w, r, &update) {
|
||||
return
|
||||
}
|
||||
update.SourceRef = r.PathValue("sourceRef")
|
||||
update.Actor = ActorFromContext(r.Context())
|
||||
data, err := h.service.UpdateVehicleSourcePolicy(r.Context(), r.PathValue("vin"), update)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleSaveVehicleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
var input VehicleProfileInput
|
||||
if !decodeJSONBody(w, r, &input) {
|
||||
|
||||
@@ -2,7 +2,9 @@ package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -420,6 +422,31 @@ func TestHandlerVehicleSourceEvidenceRejectsInvalidDate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerVehicleSourceDiagnosticAndPolicyUpdate(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
ctx := WithPrincipal(context.Background(), Principal{Name: "平台管理员", Role: "admin", UserType: "admin"})
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v2/operations/vehicles/LB9A32A24R0LS1426/sources", nil).WithContext(ctx)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("diagnostic status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data VehicleSourceDiagnostic `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := body.Data.Evidence.LocationSources[1]
|
||||
payload := fmt.Sprintf(`{"version":%d,"enabled":false,"priority":%d,"remark":"人工核验"}`, body.Data.Policy.Version, source.Priority)
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v2/operations/vehicles/LB9A32A24R0LS1426/sources/"+source.SourceRef, strings.NewReader(payload)).WithContext(ctx)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"version":2`) {
|
||||
t.Fatalf("policy update status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerVehicleServiceCanonicalEndpoint(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -15,6 +15,8 @@ type MockStore struct {
|
||||
locations []RealtimeLocationRow
|
||||
accessMu sync.RWMutex
|
||||
accessThresholds AccessThresholdConfig
|
||||
sourcePolicyMu sync.RWMutex
|
||||
sourcePolicies map[string]VehicleSourcePolicyConfig
|
||||
profileMu sync.RWMutex
|
||||
profiles map[string]VehicleProfile
|
||||
alertMu sync.RWMutex
|
||||
@@ -36,6 +38,7 @@ func NewMockStore() *MockStore {
|
||||
store := &MockStore{
|
||||
vehicles: vehicles,
|
||||
accessThresholds: defaultAccessThresholds(time.Now()),
|
||||
sourcePolicies: map[string]VehicleSourcePolicyConfig{},
|
||||
profiles: map[string]VehicleProfile{
|
||||
"LB9A32A24R0LS1426": {VIN: "LB9A32A24R0LS1426", ModelName: "新能源运营车", VehicleType: "乘用车", CompanyName: "岭牛示范车队", OperationStatus: "active", AccessProvider: "G7", FirstAccessAt: "2026-03-01T08:00:00+08:00", RuntimeSeconds: int64Pointer(1263600), SourceSystem: "manual", Version: 1, UpdatedBy: "demo-admin", UpdatedAt: "2026-07-03T20:12:10+08:00"},
|
||||
},
|
||||
@@ -51,6 +54,54 @@ func NewMockStore() *MockStore {
|
||||
|
||||
func int64Pointer(value int64) *int64 { return &value }
|
||||
|
||||
func (m *MockStore) VehicleLocationSourceHistory(_ context.Context, vin string) ([]vehicleLocationSourceHistory, error) {
|
||||
if vin != "LB9A32A24R0LS1426" {
|
||||
return []vehicleLocationSourceHistory{}, nil
|
||||
}
|
||||
return []vehicleLocationSourceHistory{
|
||||
{Protocol: "GB32960", sourceKey: "GB32960:canonical", FirstSeenAt: "2026-03-01 08:00:00", LastSeenAt: "2026-07-16 16:20:08", ReportSampleCount: 108000},
|
||||
{Protocol: "JT808", sourceKey: "jt808:g7", FirstSeenAt: "2026-04-01 09:00:00", LastSeenAt: "2026-07-16 16:20:06", ReportSampleCount: 432000},
|
||||
{Protocol: "JT808", sourceKey: "jt808:beidou", FirstSeenAt: "2026-05-01 09:00:00", LastSeenAt: "2026-07-16 16:19:58", ReportSampleCount: 216000},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleSourcePolicy(_ context.Context, vin string) (VehicleSourcePolicyConfig, error) {
|
||||
m.sourcePolicyMu.RLock()
|
||||
defer m.sourcePolicyMu.RUnlock()
|
||||
config, exists := m.sourcePolicies[vin]
|
||||
if !exists {
|
||||
return VehicleSourcePolicyConfig{VIN: vin, Version: 1, UpdatedBy: "system", Audit: []VehicleSourcePolicyAudit{}}, nil
|
||||
}
|
||||
config.Audit = append([]VehicleSourcePolicyAudit(nil), config.Audit...)
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) SaveVehicleSourcePolicy(_ context.Context, update vehicleSourcePolicyStoreUpdate) (VehicleSourcePolicyConfig, error) {
|
||||
m.sourcePolicyMu.Lock()
|
||||
defer m.sourcePolicyMu.Unlock()
|
||||
current, exists := m.sourcePolicies[update.VIN]
|
||||
if !exists {
|
||||
current = VehicleSourcePolicyConfig{VIN: update.VIN, Version: 1, UpdatedBy: "system", Audit: []VehicleSourcePolicyAudit{}}
|
||||
}
|
||||
if current.Version != update.Version {
|
||||
return VehicleSourcePolicyConfig{}, clientError{Code: "SOURCE_POLICY_VERSION_CONFLICT", Message: "来源策略已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
if update.CurrentEnabled == update.Enabled && update.CurrentPriority == update.Priority && update.CurrentRemark == update.Remark {
|
||||
return current, nil
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
current.Version++
|
||||
current.UpdatedBy = update.Actor
|
||||
current.UpdatedAt = now
|
||||
current.Audit = append([]VehicleSourcePolicyAudit{{
|
||||
Version: current.Version, Protocol: update.Protocol, SourceRef: update.SourceRef,
|
||||
SourceLabel: update.SourceLabel, Actor: update.Actor, ChangedAt: now,
|
||||
Summary: update.SourceLabel + ":" + enabledLabel(update.CurrentEnabled) + "→" + enabledLabel(update.Enabled),
|
||||
}}, current.Audit...)
|
||||
m.sourcePolicies[update.VIN] = current
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleProfile(_ context.Context, vin string) (VehicleProfile, bool, error) {
|
||||
m.profileMu.RLock()
|
||||
defer m.profileMu.RUnlock()
|
||||
|
||||
@@ -944,6 +944,7 @@ type VehicleLocationSourceEvidence struct {
|
||||
SourceLabel string `json:"sourceLabel"`
|
||||
TerminalLabel string `json:"terminalLabel"`
|
||||
SourceKind string `json:"sourceKind"`
|
||||
SourceRef string `json:"sourceRef,omitempty"`
|
||||
SelectedWithinProtocol bool `json:"selectedWithinProtocol"`
|
||||
Recommended bool `json:"recommended"`
|
||||
Enabled bool `json:"enabled"`
|
||||
@@ -958,6 +959,10 @@ type VehicleLocationSourceEvidence struct {
|
||||
SOCPercent *float64 `json:"socPercent,omitempty"`
|
||||
EventTime string `json:"eventTime"`
|
||||
ReceivedAt string `json:"receivedAt"`
|
||||
FirstSeenAt string `json:"firstSeenAt,omitempty"`
|
||||
ReportIntervalSec *int `json:"reportIntervalSec,omitempty"`
|
||||
ReportSampleCount int64 `json:"reportSampleCount,omitempty"`
|
||||
SelectionReason string `json:"selectionReason,omitempty"`
|
||||
sourceKey string
|
||||
}
|
||||
|
||||
@@ -988,6 +993,60 @@ type VehicleSourceEvidenceComparison struct {
|
||||
ReportTimeDeltaSeconds float64 `json:"reportTimeDeltaSeconds"`
|
||||
}
|
||||
|
||||
type VehicleSourceDiagnostic struct {
|
||||
Evidence VehicleSourceEvidence `json:"evidence"`
|
||||
Access *AccessVehicleRow `json:"access,omitempty"`
|
||||
Policy VehicleSourcePolicyConfig `json:"policy"`
|
||||
RecommendationReason string `json:"recommendationReason"`
|
||||
RefreshHint string `json:"refreshHint"`
|
||||
}
|
||||
|
||||
type VehicleSourcePolicyConfig struct {
|
||||
VIN string `json:"vin"`
|
||||
Version int `json:"version"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Audit []VehicleSourcePolicyAudit `json:"audit"`
|
||||
}
|
||||
|
||||
type VehicleSourcePolicyAudit struct {
|
||||
Version int `json:"version"`
|
||||
Protocol string `json:"protocol"`
|
||||
SourceRef string `json:"sourceRef"`
|
||||
SourceLabel string `json:"sourceLabel"`
|
||||
Actor string `json:"actor"`
|
||||
ChangedAt string `json:"changedAt"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
type VehicleSourcePolicyUpdate struct {
|
||||
Version int `json:"version"`
|
||||
SourceRef string `json:"sourceRef"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Priority int `json:"priority"`
|
||||
Remark string `json:"remark"`
|
||||
Actor string `json:"actor"`
|
||||
}
|
||||
|
||||
type vehicleSourcePolicyStoreUpdate struct {
|
||||
VehicleSourcePolicyUpdate
|
||||
VIN string
|
||||
Protocol string
|
||||
SourceKey string
|
||||
SourceLabel string
|
||||
CurrentEnabled bool
|
||||
CurrentPriority int
|
||||
CurrentRemark string
|
||||
}
|
||||
|
||||
type vehicleLocationSourceHistory struct {
|
||||
Protocol string
|
||||
sourceKey string
|
||||
FirstSeenAt string
|
||||
LastSeenAt string
|
||||
ReportSampleCount int64
|
||||
}
|
||||
|
||||
type VehicleServiceOverview struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
|
||||
@@ -216,6 +216,59 @@ func TestCustomerSourceEvidenceUsesOnlyCompleteAuthorizedDays(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleSourceDiagnosticExposesOpaqueReferencesAndElectionReasons(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
ctx := WithPrincipal(context.Background(), Principal{Name: "运维员", Role: "operator", UserType: "operator"})
|
||||
diagnostic, err := service.VehicleSourceDiagnostic(ctx, "LB9A32A24R0LS1426")
|
||||
if err != nil {
|
||||
t.Fatalf("VehicleSourceDiagnostic returned error: %v", err)
|
||||
}
|
||||
if diagnostic.Policy.Version != 1 || diagnostic.Access == nil || diagnostic.Evidence.RecommendedLocationProtocol != "JT808" {
|
||||
t.Fatalf("diagnostic lost access or policy evidence: %+v", diagnostic)
|
||||
}
|
||||
if diagnostic.RecommendationReason == "" || diagnostic.RefreshHint == "" {
|
||||
t.Fatalf("diagnostic must explain recommendation and refresh boundary: %+v", diagnostic)
|
||||
}
|
||||
for _, source := range diagnostic.Evidence.LocationSources {
|
||||
if len(source.SourceRef) != 64 || source.SelectionReason == "" || source.FirstSeenAt == "" {
|
||||
t.Fatalf("source diagnostic is incomplete: %+v", source)
|
||||
}
|
||||
if strings.Contains(source.SourceRef, "jt808") || strings.Contains(source.SourceRef, "g7") {
|
||||
t.Fatalf("source reference must stay opaque: %+v", source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleSourcePolicyUpdateRequiresAdminAndUsesOptimisticVersion(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
operator := WithPrincipal(context.Background(), Principal{Name: "运维员", Role: "operator", UserType: "operator"})
|
||||
diagnostic, err := service.VehicleSourceDiagnostic(operator, "LB9A32A24R0LS1426")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := diagnostic.Evidence.LocationSources[1]
|
||||
if _, err := service.UpdateVehicleSourcePolicy(operator, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
|
||||
Version: diagnostic.Policy.Version, SourceRef: source.SourceRef, Enabled: false, Priority: source.Priority, Remark: "人工核验",
|
||||
}); err == nil {
|
||||
t.Fatal("operator must not modify source policy")
|
||||
}
|
||||
admin := WithPrincipal(context.Background(), Principal{Name: "平台管理员", Role: "admin", UserType: "admin"})
|
||||
updated, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
|
||||
Version: diagnostic.Policy.Version, SourceRef: source.SourceRef, Enabled: false, Priority: source.Priority, Remark: "人工核验",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("admin source policy update failed: %v", err)
|
||||
}
|
||||
if updated.Policy.Version != 2 || len(updated.Policy.Audit) != 1 || updated.Policy.Audit[0].Actor != "平台管理员" {
|
||||
t.Fatalf("source policy update must version and audit: %+v", updated.Policy)
|
||||
}
|
||||
if _, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
|
||||
Version: 1, SourceRef: source.SourceRef, Enabled: true, Priority: source.Priority,
|
||||
}); err == nil {
|
||||
t.Fatal("stale source policy update must conflict")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
|
||||
s.overviewBatchCalls++
|
||||
return s.MockStore.VehicleServiceOverviews(ctx, query)
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type vehicleSourcePolicyStore interface {
|
||||
VehicleSourcePolicy(context.Context, string) (VehicleSourcePolicyConfig, error)
|
||||
SaveVehicleSourcePolicy(context.Context, vehicleSourcePolicyStoreUpdate) (VehicleSourcePolicyConfig, error)
|
||||
VehicleLocationSourceHistory(context.Context, string) ([]vehicleLocationSourceHistory, error)
|
||||
}
|
||||
|
||||
func sourceReference(vin, protocol, sourceKey string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(vin) + "\x00" + strings.TrimSpace(protocol) + "\x00" + sourceKey))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *Service) VehicleSourceDiagnostic(ctx context.Context, vin string) (VehicleSourceDiagnostic, error) {
|
||||
if err := authorizeInternalOperations(ctx, false); err != nil {
|
||||
return VehicleSourceDiagnostic{}, err
|
||||
}
|
||||
evidence, err := s.VehicleSourceEvidence(ctx, vin, "")
|
||||
if err != nil {
|
||||
return VehicleSourceDiagnostic{}, err
|
||||
}
|
||||
store, ok := s.store.(vehicleSourcePolicyStore)
|
||||
if !ok {
|
||||
return VehicleSourceDiagnostic{}, errors.New("vehicle source policy store is not configured")
|
||||
}
|
||||
history, err := store.VehicleLocationSourceHistory(ctx, evidence.VIN)
|
||||
if err != nil {
|
||||
return VehicleSourceDiagnostic{}, err
|
||||
}
|
||||
historyBySource := make(map[string]vehicleLocationSourceHistory, len(history))
|
||||
for _, item := range history {
|
||||
historyBySource[item.Protocol+"\x00"+item.sourceKey] = item
|
||||
}
|
||||
var access *AccessVehicleRow
|
||||
page, accessErr := s.AccessVehicles(ctx, AccessQuery{Keyword: evidence.VIN, Limit: 10})
|
||||
if accessErr == nil {
|
||||
for index := range page.Items {
|
||||
if page.Items[index].VIN == evidence.VIN {
|
||||
row := page.Items[index]
|
||||
access = &row
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
protocolStatus := map[string]AccessProtocolStatus{}
|
||||
if access != nil {
|
||||
for _, status := range access.ProtocolStatuses {
|
||||
protocolStatus[status.Protocol] = status
|
||||
}
|
||||
}
|
||||
for index := range evidence.LocationSources {
|
||||
source := &evidence.LocationSources[index]
|
||||
if !strings.EqualFold(source.SourceKind, "CANONICAL") {
|
||||
source.SourceRef = sourceReference(evidence.VIN, source.Protocol, source.sourceKey)
|
||||
}
|
||||
if item, exists := historyBySource[source.Protocol+"\x00"+source.sourceKey]; exists {
|
||||
source.FirstSeenAt = item.FirstSeenAt
|
||||
source.ReportSampleCount = item.ReportSampleCount
|
||||
}
|
||||
if status, exists := protocolStatus[source.Protocol]; exists {
|
||||
source.ReportIntervalSec = status.ReportIntervalSec
|
||||
if source.FirstSeenAt == "" {
|
||||
source.FirstSeenAt = status.FirstSeenAt
|
||||
}
|
||||
}
|
||||
source.SelectionReason = sourceSelectionReason(*source)
|
||||
}
|
||||
policy, err := store.VehicleSourcePolicy(ctx, evidence.VIN)
|
||||
if err != nil {
|
||||
return VehicleSourceDiagnostic{}, err
|
||||
}
|
||||
return VehicleSourceDiagnostic{
|
||||
Evidence: evidence,
|
||||
Access: access,
|
||||
Policy: policy,
|
||||
RecommendationReason: recommendationReason(evidence),
|
||||
RefreshHint: "策略保存后由车辆下一次有效上报触发网关重新选举;页面不会伪造即时切换结果。",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, update VehicleSourcePolicyUpdate) (VehicleSourceDiagnostic, error) {
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return VehicleSourceDiagnostic{}, err
|
||||
}
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
update.SourceRef = strings.ToLower(strings.TrimSpace(update.SourceRef))
|
||||
update.Remark = strings.TrimSpace(update.Remark)
|
||||
if vin == "" {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "VEHICLE_VIN_REQUIRED", Message: "车辆 VIN 不能为空"}
|
||||
}
|
||||
if update.Version < 1 {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_POLICY_VERSION_INVALID", Message: "来源策略版本无效,请刷新后重试"}
|
||||
}
|
||||
if len(update.SourceRef) != sha256.Size*2 {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_REF_INVALID", Message: "来源引用无效"}
|
||||
}
|
||||
if _, err := hex.DecodeString(update.SourceRef); err != nil {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_REF_INVALID", Message: "来源引用无效"}
|
||||
}
|
||||
if update.Priority < 1 || update.Priority > 1000 {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_PRIORITY_INVALID", Message: "来源优先级必须在 1 到 1000 之间"}
|
||||
}
|
||||
if len([]rune(update.Remark)) > 200 {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_REMARK_TOO_LONG", Message: "策略备注不能超过 200 个字符"}
|
||||
}
|
||||
update.Actor = strings.TrimSpace(update.Actor)
|
||||
if update.Actor == "" {
|
||||
update.Actor = ActorFromContext(ctx)
|
||||
}
|
||||
evidenceStore, ok := s.store.(VehicleSourceEvidenceStore)
|
||||
if !ok {
|
||||
return VehicleSourceDiagnostic{}, errors.New("vehicle source evidence store is not configured")
|
||||
}
|
||||
evidence, err := evidenceStore.VehicleSourceEvidence(ctx, vin, "")
|
||||
if err != nil {
|
||||
return VehicleSourceDiagnostic{}, err
|
||||
}
|
||||
var selected *VehicleLocationSourceEvidence
|
||||
for index := range evidence.LocationSources {
|
||||
source := &evidence.LocationSources[index]
|
||||
if !strings.EqualFold(source.SourceKind, "CANONICAL") && sourceReference(vin, source.Protocol, source.sourceKey) == update.SourceRef {
|
||||
selected = source
|
||||
break
|
||||
}
|
||||
}
|
||||
if selected == nil || selected.sourceKey == "" {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_NOT_FOUND", Message: "当前车辆不存在该来源,请刷新后重试"}
|
||||
}
|
||||
store, ok := s.store.(vehicleSourcePolicyStore)
|
||||
if !ok {
|
||||
return VehicleSourceDiagnostic{}, errors.New("vehicle source policy store is not configured")
|
||||
}
|
||||
if _, err := store.SaveVehicleSourcePolicy(ctx, vehicleSourcePolicyStoreUpdate{
|
||||
VehicleSourcePolicyUpdate: update,
|
||||
VIN: vin,
|
||||
Protocol: selected.Protocol,
|
||||
SourceKey: selected.sourceKey,
|
||||
SourceLabel: selected.SourceLabel,
|
||||
CurrentEnabled: selected.Enabled,
|
||||
CurrentPriority: selected.Priority,
|
||||
}); err != nil {
|
||||
return VehicleSourceDiagnostic{}, err
|
||||
}
|
||||
return s.VehicleSourceDiagnostic(ctx, vin)
|
||||
}
|
||||
|
||||
func authorizeInternalOperations(ctx context.Context, adminOnly bool) error {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if principal.UserType == "customer" || principal.Role == "customer" {
|
||||
return clientError{Code: "PERMISSION_DENIED", Message: "客户账号无权访问内部来源策略"}
|
||||
}
|
||||
if adminOnly && principal.Role != "admin" {
|
||||
return clientError{Code: "PERMISSION_DENIED", Message: "只有管理员可以修改来源策略"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sourceSelectionReason(source VehicleLocationSourceEvidence) string {
|
||||
if strings.EqualFold(source.SourceKind, "CANONICAL") {
|
||||
return "这是协议融合结果快照,不是独立终端候选;需要展开实际来源后调整策略。"
|
||||
}
|
||||
if !source.Enabled {
|
||||
return "已由运维策略停用,不参与协议内选举。"
|
||||
}
|
||||
if source.QualityStatus != "" && !strings.EqualFold(source.QualityStatus, "OK") {
|
||||
return fmt.Sprintf("质量状态为 %s,当前不应作为首选;%s", source.QualityStatus, firstNonEmpty(source.QualityReason, "请核对原始证据"))
|
||||
}
|
||||
if source.Recommended {
|
||||
return fmt.Sprintf("当前融合推荐;协议内已选中,策略优先级 %d,并通过在线与质量检查。", source.Priority)
|
||||
}
|
||||
if source.SelectedWithinProtocol {
|
||||
return fmt.Sprintf("当前为 %s 协议内候选,策略优先级 %d;跨协议融合当前推荐其他来源。", source.Protocol, source.Priority)
|
||||
}
|
||||
if !source.Online {
|
||||
return fmt.Sprintf("来源当前离线,策略优先级 %d;在线来源会优先参与选举。", source.Priority)
|
||||
}
|
||||
return fmt.Sprintf("来源可用,策略优先级 %d;当前候选在新鲜度、连续有效样本或稳定性排序中未胜出。", source.Priority)
|
||||
}
|
||||
|
||||
func recommendationReason(evidence VehicleSourceEvidence) string {
|
||||
for _, source := range evidence.LocationSources {
|
||||
if source.Recommended {
|
||||
if strings.EqualFold(source.SourceKind, "CANONICAL") {
|
||||
return fmt.Sprintf("当前推荐 %s 的协议融合结果;本次证据未取得可配置的独立终端候选,不能从该快照反推人工优先级。", source.Protocol)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"当前推荐 %s / %s:来源已启用、质量为 %s、协议内选中,策略优先级为 %d。选举还会综合两分钟新鲜度、漂移冲突保护和连续有效样本。",
|
||||
source.Protocol, source.SourceLabel, firstNonEmpty(source.QualityStatus, "未知"), source.Priority,
|
||||
)
|
||||
}
|
||||
}
|
||||
if len(evidence.LocationSources) == 0 {
|
||||
return "当前车辆没有可用于位置选举的来源证据。"
|
||||
}
|
||||
return "当前没有明确推荐来源;请检查来源启用状态、质量、新鲜度和网关下一次有效上报。"
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *ProductionStore) VehicleLocationSourceHistory(ctx context.Context, vin string) ([]vehicleLocationSourceHistory, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT protocol, source_key,
|
||||
COALESCE(DATE_FORMAT(MIN(first_event_time), '%Y-%m-%d %H:%i:%s'), ''),
|
||||
COALESCE(DATE_FORMAT(MAX(latest_event_time), '%Y-%m-%d %H:%i:%s'), ''),
|
||||
COALESCE(SUM(sample_count), 0)
|
||||
FROM vehicle_daily_mileage_source
|
||||
WHERE vin = ?
|
||||
GROUP BY protocol, source_key
|
||||
ORDER BY protocol, source_key`, vin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]vehicleLocationSourceHistory, 0, 8)
|
||||
for rows.Next() {
|
||||
var item vehicleLocationSourceHistory
|
||||
if err := rows.Scan(&item.Protocol, &item.sourceKey, &item.FirstSeenAt, &item.LastSeenAt, &item.ReportSampleCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleSourcePolicy(ctx context.Context, vin string) (VehicleSourcePolicyConfig, error) {
|
||||
config := VehicleSourcePolicyConfig{VIN: vin, Version: 1, UpdatedBy: "system"}
|
||||
var updatedAt sql.NullString
|
||||
err := s.db.QueryRowContext(ctx, `SELECT version, updated_by,
|
||||
DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s')
|
||||
FROM platform_vehicle_source_policy_version WHERE vin = ?`, vin).Scan(&config.Version, &config.UpdatedBy, &updatedAt)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
config.UpdatedAt = normalizeAccessTime(updatedAt.String)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT version, protocol, source_ref, source_label, actor,
|
||||
DATE_FORMAT(changed_at, '%Y-%m-%d %H:%i:%s'), summary
|
||||
FROM platform_vehicle_source_policy_audit
|
||||
WHERE vin = ?
|
||||
ORDER BY changed_at DESC, id DESC
|
||||
LIMIT 20`, vin)
|
||||
if err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item VehicleSourcePolicyAudit
|
||||
if err := rows.Scan(&item.Version, &item.Protocol, &item.SourceRef, &item.SourceLabel, &item.Actor, &item.ChangedAt, &item.Summary); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
item.ChangedAt = normalizeAccessTime(item.ChangedAt)
|
||||
config.Audit = append(config.Audit, item)
|
||||
}
|
||||
return config, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) SaveVehicleSourcePolicy(ctx context.Context, update vehicleSourcePolicyStoreUpdate) (VehicleSourcePolicyConfig, error) {
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `INSERT IGNORE INTO platform_vehicle_source_policy_version
|
||||
(vin, version, updated_by) VALUES (?, 1, 'system')`, update.VIN); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
var currentVersion int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT version FROM platform_vehicle_source_policy_version
|
||||
WHERE vin = ? FOR UPDATE`, update.VIN).Scan(¤tVersion); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
if currentVersion != update.Version {
|
||||
return VehicleSourcePolicyConfig{}, clientError{Code: "SOURCE_POLICY_VERSION_CONFLICT", Message: "来源策略已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
currentEnabled := update.CurrentEnabled
|
||||
currentPriority := update.CurrentPriority
|
||||
currentRemark := update.CurrentRemark
|
||||
var enabled int
|
||||
err = tx.QueryRowContext(ctx, `SELECT enabled, priority, remark
|
||||
FROM vehicle_location_source_policy
|
||||
WHERE vin = ? AND protocol = ? AND source_key = ?`,
|
||||
update.VIN, update.Protocol, update.SourceKey,
|
||||
).Scan(&enabled, ¤tPriority, ¤tRemark)
|
||||
if err == nil {
|
||||
currentEnabled = enabled == 1
|
||||
} else if err != sql.ErrNoRows {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
if currentEnabled == update.Enabled && currentPriority == update.Priority && strings.TrimSpace(currentRemark) == update.Remark {
|
||||
if err := tx.Rollback(); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
return s.VehicleSourcePolicy(ctx, update.VIN)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_location_source_policy
|
||||
(vin, protocol, source_key, enabled, priority, remark)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), priority = VALUES(priority),
|
||||
remark = VALUES(remark), updated_at = CURRENT_TIMESTAMP`,
|
||||
update.VIN, update.Protocol, update.SourceKey, boolInt(update.Enabled), update.Priority, update.Remark,
|
||||
); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
nextVersion := currentVersion + 1
|
||||
result, err := tx.ExecContext(ctx, `UPDATE platform_vehicle_source_policy_version
|
||||
SET version = ?, updated_by = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE vin = ? AND version = ?`, nextVersion, update.Actor, update.VIN, currentVersion)
|
||||
if err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
|
||||
return VehicleSourcePolicyConfig{}, clientError{Code: "SOURCE_POLICY_VERSION_CONFLICT", Message: "来源策略更新冲突,请刷新后重试"}
|
||||
}
|
||||
summary := fmt.Sprintf(
|
||||
"%s:%s→%s,优先级 %d→%d",
|
||||
update.SourceLabel,
|
||||
enabledLabel(currentEnabled), enabledLabel(update.Enabled),
|
||||
currentPriority, update.Priority,
|
||||
)
|
||||
if update.Remark != "" {
|
||||
summary += ";" + update.Remark
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO platform_vehicle_source_policy_audit
|
||||
(vin, version, protocol, source_ref, source_key, source_label,
|
||||
old_enabled, new_enabled, old_priority, new_priority, old_remark, new_remark,
|
||||
actor, summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
update.VIN, nextVersion, update.Protocol, update.SourceRef, update.SourceKey, update.SourceLabel,
|
||||
boolInt(currentEnabled), boolInt(update.Enabled), currentPriority, update.Priority, currentRemark, update.Remark,
|
||||
update.Actor, summary,
|
||||
); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
return s.VehicleSourcePolicy(ctx, update.VIN)
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func enabledLabel(value bool) string {
|
||||
if value {
|
||||
return "启用"
|
||||
}
|
||||
return "停用"
|
||||
}
|
||||
Reference in New Issue
Block a user