Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/profile.go
T
lingniu d6b957dfc8 feat: sync vehicle monitoring and accumulated platform updates
Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates.

Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
2026-10-26 18:05:59 +08:00

326 lines
12 KiB
Go

package platform
import (
"context"
"fmt"
"net/url"
"regexp"
"strings"
"time"
)
var vehicleOperationStatuses = map[string]bool{
"unknown": true, "active": true, "inactive": true, "maintenance": true, "retired": true,
}
const (
vehicleProfileSyncLimit = 500
profileSyncCreated = "created"
profileSyncUpdated = "updated"
profileSyncUnchanged = "unchanged"
profileSyncConflictSource = "conflict_source"
profileSyncConflictSourceVersion = "conflict_source_version"
profileSyncMissingVehicle = "missing_vehicle"
profileSyncConflictPolicyPreserve = "preserve"
profileSyncConflictPolicyOverwrite = "overwrite"
)
var vehicleProfileSourceSystemPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._:-]*$`)
func (s *Service) VehicleProfile(ctx context.Context, vin string) (VehicleProfile, error) {
vin = strings.TrimSpace(vin)
if err := authorizeVehicleVIN(ctx, vin); err != nil {
return VehicleProfile{}, err
}
if err := validateProfileVIN(vin); err != nil {
return VehicleProfile{}, err
}
store, ok := s.store.(VehicleProfileStore)
if !ok {
return decorateVehicleProfile(VehicleProfile{VIN: vin, OperationStatus: "unknown", SourceSystem: "unconfigured", MissingFields: []string{}}), nil
}
profile, exists, err := store.VehicleProfile(ctx, vin)
if err != nil {
return VehicleProfile{}, err
}
if !exists {
profile = VehicleProfile{VIN: vin, OperationStatus: "unknown", SourceSystem: "unconfigured"}
}
return decorateVehicleProfile(profile), nil
}
func (s *Service) SaveVehicleProfile(ctx context.Context, vin string, input VehicleProfileInput) (VehicleProfile, error) {
if err := authorizeVehicleVIN(ctx, vin); err != nil {
return VehicleProfile{}, err
}
vin = strings.TrimSpace(vin)
if err := validateProfileVIN(vin); err != nil {
return VehicleProfile{}, err
}
store, ok := s.store.(VehicleProfileStore)
if !ok {
return VehicleProfile{}, fmt.Errorf("store does not provide durable vehicle profiles")
}
exists, err := s.vehicleExists(ctx, vin)
if err != nil {
return VehicleProfile{}, err
}
if !exists {
return VehicleProfile{}, clientError{Code: "VEHICLE_NOT_FOUND", Message: "车辆身份不存在,不能创建主档"}
}
input = normalizeVehicleProfileInput(input)
if err := validateVehicleProfileInput(input); err != nil {
return VehicleProfile{}, err
}
profile, err := store.SaveVehicleProfile(ctx, vin, input)
if err != nil {
return VehicleProfile{}, err
}
return decorateVehicleProfile(profile), nil
}
func (s *Service) SyncVehicleProfiles(ctx context.Context, request VehicleProfileSyncRequest) (VehicleProfileSyncResult, error) {
store, ok := s.store.(VehicleProfileStore)
if !ok {
return VehicleProfileSyncResult{}, fmt.Errorf("store does not provide durable vehicle profiles")
}
request = normalizeVehicleProfileSyncRequest(request)
if err := validateVehicleProfileSyncRequest(request); err != nil {
return VehicleProfileSyncResult{}, err
}
for _, item := range request.Items {
if err := authorizeVehicleVIN(ctx, item.VIN); err != nil {
return VehicleProfileSyncResult{}, err
}
}
return store.SyncVehicleProfiles(ctx, request)
}
func normalizeVehicleProfileSyncRequest(request VehicleProfileSyncRequest) VehicleProfileSyncRequest {
request.SourceSystem = strings.ToLower(strings.TrimSpace(request.SourceSystem))
request.SourceVersion = strings.TrimSpace(request.SourceVersion)
request.ConflictPolicy = strings.ToLower(strings.TrimSpace(request.ConflictPolicy))
request.Actor = strings.TrimSpace(request.Actor)
if request.ConflictPolicy == "" {
request.ConflictPolicy = profileSyncConflictPolicyPreserve
}
for index := range request.Items {
item := &request.Items[index]
item.VIN = strings.ToUpper(strings.TrimSpace(item.VIN))
normalized := normalizeVehicleProfileInput(vehicleProfileSyncInput(*item, request.Actor))
item.BrandName = normalized.BrandName
item.ModelName = normalized.ModelName
item.VehicleType = normalized.VehicleType
item.CompanyName = normalized.CompanyName
item.OperationStatus = normalized.OperationStatus
item.AccessProvider = normalized.AccessProvider
item.FirstAccessAt = normalized.FirstAccessAt
if item.FirstAccessAt != "" {
if parsed, err := parseVehicleProfileTime(item.FirstAccessAt); err == nil {
item.FirstAccessAt = parsed.Format(time.RFC3339)
}
}
}
return request
}
func validateVehicleProfileSyncRequest(request VehicleProfileSyncRequest) error {
if request.SourceSystem == "" || len(request.SourceSystem) > 64 || !vehicleProfileSourceSystemPattern.MatchString(request.SourceSystem) || request.SourceSystem == "manual" || request.SourceSystem == "unconfigured" {
return clientError{Code: "VEHICLE_PROFILE_SYNC_SOURCE_INVALID", Message: "sourceSystem 必须是 64 字符内的外部系统标识"}
}
if request.SourceVersion == "" || len([]rune(request.SourceVersion)) > 128 {
return clientError{Code: "VEHICLE_PROFILE_SYNC_VERSION_INVALID", Message: "sourceVersion 不能为空且不能超过 128 个字符"}
}
if request.ConflictPolicy != profileSyncConflictPolicyPreserve && request.ConflictPolicy != profileSyncConflictPolicyOverwrite {
return clientError{Code: "VEHICLE_PROFILE_SYNC_POLICY_INVALID", Message: "conflictPolicy 仅支持 preserve 或 overwrite"}
}
if len(request.Items) == 0 || len(request.Items) > vehicleProfileSyncLimit {
return clientError{Code: "VEHICLE_PROFILE_SYNC_SIZE_INVALID", Message: "单批车辆主档必须为 1 至 500 条"}
}
if request.Actor == "" {
return clientError{Code: "VEHICLE_PROFILE_ACTOR_REQUIRED", Message: "缺少档案同步操作人"}
}
seen := make(map[string]struct{}, len(request.Items))
for _, item := range request.Items {
if err := validateProfileVIN(item.VIN); err != nil {
return err
}
if _, exists := seen[item.VIN]; exists {
return clientError{Code: "VEHICLE_PROFILE_SYNC_DUPLICATE_VIN", Message: "同一批次不能包含重复 VIN"}
}
seen[item.VIN] = struct{}{}
if err := validateVehicleProfileInput(vehicleProfileSyncInput(item, request.Actor)); err != nil {
return err
}
}
return nil
}
func vehicleProfileSyncInput(item VehicleProfileSyncItem, actor string) VehicleProfileInput {
return VehicleProfileInput{
BrandName: item.BrandName, ModelName: item.ModelName, VehicleType: item.VehicleType, CompanyName: item.CompanyName,
OperationStatus: item.OperationStatus, AccessProvider: item.AccessProvider,
FirstAccessAt: item.FirstAccessAt, RuntimeSeconds: item.RuntimeSeconds, Actor: actor,
}
}
func vehicleProfileSyncDecision(current VehicleProfile, exists bool, request VehicleProfileSyncRequest, item VehicleProfileSyncItem) string {
if !exists {
return profileSyncCreated
}
if current.SourceSystem == request.SourceSystem && current.SourceVersion == request.SourceVersion {
if vehicleProfileSyncFieldsEqual(current, item) {
return profileSyncUnchanged
}
return profileSyncConflictSourceVersion
}
if request.ConflictPolicy == profileSyncConflictPolicyPreserve && current.SourceSystem != request.SourceSystem {
return profileSyncConflictSource
}
return profileSyncUpdated
}
func vehicleProfileSyncFieldsEqual(current VehicleProfile, item VehicleProfileSyncItem) bool {
return current.BrandName == item.BrandName && current.ModelName == item.ModelName && current.VehicleType == item.VehicleType &&
current.CompanyName == item.CompanyName && current.OperationStatus == item.OperationStatus &&
current.AccessProvider == item.AccessProvider && current.FirstAccessAt == item.FirstAccessAt &&
equalOptionalInt64(current.RuntimeSeconds, item.RuntimeSeconds)
}
func equalOptionalInt64(left, right *int64) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}
func countVehicleProfileSyncResult(result *VehicleProfileSyncResult, status string) {
switch status {
case profileSyncCreated:
result.Created++
case profileSyncUpdated:
result.Updated++
case profileSyncUnchanged:
result.Unchanged++
case profileSyncMissingVehicle:
result.Missing++
default:
result.Conflicted++
}
}
func (s *Service) vehicleExists(ctx context.Context, vin string) (bool, error) {
page, err := s.store.Vehicles(ctx, url.Values{"keyword": {vin}, "limit": {"20"}})
if err != nil {
return false, err
}
for _, item := range page.Items {
if strings.EqualFold(strings.TrimSpace(item.VIN), vin) {
return true, nil
}
}
return false, nil
}
func validateProfileVIN(vin string) error {
if vin == "" || len(vin) > 32 {
return clientError{Code: "VEHICLE_PROFILE_VIN_INVALID", Message: "VIN 不能为空且不能超过 32 个字符"}
}
return nil
}
func normalizeVehicleProfileInput(input VehicleProfileInput) VehicleProfileInput {
input.BrandName = strings.TrimSpace(input.BrandName)
input.ModelName = strings.TrimSpace(input.ModelName)
input.VehicleType = strings.TrimSpace(input.VehicleType)
input.CompanyName = strings.TrimSpace(input.CompanyName)
input.OperationStatus = strings.ToLower(strings.TrimSpace(input.OperationStatus))
input.AccessProvider = strings.TrimSpace(input.AccessProvider)
input.FirstAccessAt = strings.TrimSpace(input.FirstAccessAt)
input.Actor = strings.TrimSpace(input.Actor)
if input.OperationStatus == "" {
input.OperationStatus = "unknown"
}
return input
}
func validateVehicleProfileInput(input VehicleProfileInput) error {
lengths := []struct {
value string
max int
name string
}{
{input.BrandName, 128, "品牌"}, {input.ModelName, 128, "车型"}, {input.VehicleType, 64, "车辆类型"},
{input.CompanyName, 128, "所属企业"}, {input.AccessProvider, 128, "接入服务商"},
}
for _, field := range lengths {
if len([]rune(field.value)) > field.max {
return clientError{Code: "VEHICLE_PROFILE_FIELD_INVALID", Message: field.name + "长度超出限制"}
}
}
if !vehicleOperationStatuses[input.OperationStatus] {
return clientError{Code: "VEHICLE_PROFILE_STATUS_INVALID", Message: "运营状态不在允许范围内"}
}
if input.RuntimeSeconds != nil && *input.RuntimeSeconds < 0 {
return clientError{Code: "VEHICLE_PROFILE_RUNTIME_INVALID", Message: "累计运行时长不能为负数"}
}
if input.FirstAccessAt != "" {
if _, err := parseVehicleProfileTime(input.FirstAccessAt); err != nil {
return clientError{Code: "VEHICLE_PROFILE_TIME_INVALID", Message: "首次接入时间格式无效"}
}
}
if input.Version < 0 {
return clientError{Code: "VEHICLE_PROFILE_VERSION_INVALID", Message: "档案版本无效"}
}
if input.Actor == "" {
return clientError{Code: "VEHICLE_PROFILE_ACTOR_REQUIRED", Message: "缺少档案维护人"}
}
return nil
}
func parseVehicleProfileTime(value string) (time.Time, error) {
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02 15:04:05"} {
if parsed, err := time.ParseInLocation(layout, value, time.Local); err == nil {
return parsed, nil
}
}
return time.Time{}, fmt.Errorf("unsupported vehicle profile time")
}
func decorateVehicleProfile(profile VehicleProfile) VehicleProfile {
missing := make([]string, 0, 8)
if profile.BrandName == "" {
missing = append(missing, "brandName")
}
if profile.ModelName == "" {
missing = append(missing, "modelName")
}
if profile.VehicleType == "" {
missing = append(missing, "vehicleType")
}
if profile.CompanyName == "" {
missing = append(missing, "companyName")
}
if profile.OperationStatus == "" || profile.OperationStatus == "unknown" {
missing = append(missing, "operationStatus")
}
if profile.AccessProvider == "" {
missing = append(missing, "accessProvider")
}
if profile.FirstAccessAt == "" {
missing = append(missing, "firstAccessAt")
}
if profile.RuntimeSeconds == nil {
missing = append(missing, "runtimeSeconds")
}
profile.MissingFields = missing
profile.Completeness = (8 - len(missing)) * 100 / 8
if profile.OperationStatus == "" {
profile.OperationStatus = "unknown"
}
if profile.SourceSystem == "" {
profile.SourceSystem = "unconfigured"
}
return profile
}