310 lines
11 KiB
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
|
|
}
|