Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/source_policy.go

273 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
VehicleSourceProvider(context.Context, string, string, string) (string, 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
}
}
representedProtocols := make(map[string]bool, len(evidence.LocationSources))
for _, source := range evidence.LocationSources {
representedProtocols[source.Protocol] = true
}
if access != nil {
for _, status := range access.ProtocolStatuses {
if !status.Connected || representedProtocols[status.Protocol] {
continue
}
sourceKey := status.Protocol + ":canonical"
providerOverride, providerErr := store.VehicleSourceProvider(ctx, evidence.VIN, status.Protocol, sourceKey)
if providerErr != nil {
return VehicleSourceDiagnostic{}, providerErr
}
source := VehicleLocationSourceEvidence{
Protocol: status.Protocol,
SourceLabel: firstNonEmpty(providerOverride, status.Provider, status.Protocol),
ProviderOverride: providerOverride,
SourceKind: "CANONICAL",
SelectedWithinProtocol: true,
Recommended: evidence.RecommendedLocationProtocol == status.Protocol,
Enabled: true,
Priority: 100,
Online: status.OnlineState == "online",
QualityStatus: "NO_LOCATION",
QualityReason: "协议已形成接入快照,但当前没有可展开的独立位置来源",
EventTime: status.LatestEventAt,
ReceivedAt: status.LatestReceivedAt,
FirstSeenAt: status.FirstSeenAt,
ReportIntervalSec: status.ReportIntervalSec,
sourceKey: sourceKey,
}
evidence.LocationSources = append(evidence.LocationSources, source)
representedProtocols[status.Protocol] = true
if source.Recommended {
evidence.RecommendedLocationLabel = source.SourceLabel
}
}
}
for index := range evidence.LocationSources {
source := &evidence.LocationSources[index]
if source.sourceKey != "" {
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.ProviderName = strings.TrimSpace(update.ProviderName)
update.ProviderEvidence = strings.TrimSpace(update.ProviderEvidence)
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 个字符"}
}
if len([]rune(update.ProviderName)) > 128 {
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_PROVIDER_TOO_LONG", Message: "提供方名称不能超过 128 个字符"}
}
if len([]rune(update.ProviderEvidence)) > 255 {
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_PROVIDER_EVIDENCE_TOO_LONG", Message: "提供方核验依据不能超过 255 个字符"}
}
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 source.sourceKey != "" && sourceReference(vin, source.Protocol, source.sourceKey) == update.SourceRef {
selected = source
break
}
}
if selected == nil || selected.sourceKey == "" {
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_NOT_FOUND", Message: "当前车辆不存在该来源,请刷新后重试"}
}
policyConfigurable := !strings.EqualFold(selected.SourceKind, "CANONICAL")
if !policyConfigurable && (selected.Enabled != update.Enabled ||
selected.Priority != update.Priority ||
strings.TrimSpace(selected.PolicyRemark) != update.Remark) {
return VehicleSourceDiagnostic{}, clientError{
Code: "SOURCE_POLICY_CANONICAL_READ_ONLY",
Message: "协议融合快照只能维护提供方,不能调整启停、优先级或策略备注",
}
}
providerChanged := strings.TrimSpace(selected.ProviderOverride) != update.ProviderName
if providerChanged && update.ProviderEvidence == "" {
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_PROVIDER_REASON_REQUIRED", 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,
CurrentRemark: selected.PolicyRemark,
CurrentProvider: selected.ProviderOverride,
PolicyConfigurable: policyConfigurable,
}); 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 "当前没有明确推荐来源;请检查来源启用状态、质量、新鲜度和网关下一次有效上报。"
}