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

310 lines
11 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 := 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) {
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
}
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.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{
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.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.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.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, 7)
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 = (7 - len(missing)) * 100 / 7
if profile.OperationStatus == "" {
profile.OperationStatus = "unknown"
}
if profile.SourceSystem == "" {
profile.SourceSystem = "unconfigured"
}
return profile
}