feat: build vehicle data platform and production pipeline
This commit is contained in:
309
vehicle-data-platform/apps/api/internal/platform/profile.go
Normal file
309
vehicle-data-platform/apps/api/internal/platform/profile.go
Normal file
@@ -0,0 +1,309 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user