feat: expand vehicle data platform capabilities

This commit is contained in:
lingniu
2026-07-27 16:46:15 +08:00
parent e3a1f80f86
commit 3c4bece72c
650 changed files with 62155 additions and 2552 deletions

View File

@@ -29,6 +29,45 @@ func TestHandlerDashboardSummary(t *testing.T) {
}
}
func TestHandlerRetriesFailedNotificationAndReturnsPersistentAudit(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
principal := Principal{Name: "通知操作员", Username: "operator-a", Role: "operator", UserType: "operator"}
retry := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v2/alerts/notifications/10/retry", strings.NewReader(`{"expectedAttemptCount":2,"reason":"已确认短信网关恢复","idempotencyKey":"notification-retry-10-attempt-3"}`))
request = request.WithContext(WithPrincipal(request.Context(), principal))
handler.ServeHTTP(retry, request)
if retry.Code != http.StatusOK || !strings.Contains(retry.Body.String(), `"attemptCount":3`) || !strings.Contains(retry.Body.String(), `"nextStatus":"reserved"`) {
t.Fatalf("retry status=%d body=%s", retry.Code, retry.Body.String())
}
audit := httptest.NewRecorder()
auditRequest := httptest.NewRequest(http.MethodGet, "/api/v2/alerts/notifications/10/retry-audit", nil)
auditRequest = auditRequest.WithContext(WithPrincipal(auditRequest.Context(), principal))
handler.ServeHTTP(audit, auditRequest)
if audit.Code != http.StatusOK || !strings.Contains(audit.Body.String(), `"reason":"已确认短信网关恢复"`) {
t.Fatalf("audit status=%d body=%s", audit.Code, audit.Body.String())
}
}
func TestHandlerHistoryCleanupAutomationExposesVersionedApprovalPolicy(t *testing.T) {
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()})
handler := NewHandler(service)
admin := WithPrincipal(context.Background(), Principal{SubjectID: "admin-1", Name: "平台管理员", Username: "admin", Role: "admin", UserType: "admin"})
get := httptest.NewRecorder()
handler.ServeHTTP(get, httptest.NewRequest(http.MethodGet, "/api/v2/exports/cleanup/automation", nil).WithContext(admin))
if get.Code != http.StatusOK || !strings.Contains(get.Body.String(), `"revision":1`) || !strings.Contains(get.Body.String(), `"approvalWindowHours":48`) {
t.Fatalf("automation status=%d body=%s", get.Code, get.Body.String())
}
update := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPut, "/api/v2/exports/cleanup/automation", strings.NewReader(`{"enabled":true,"olderThanDays":180,"intervalDays":7,"approvalWindowHours":24,"expectedRevision":1}`)).WithContext(admin)
handler.ServeHTTP(update, request)
if update.Code != http.StatusOK || !strings.Contains(update.Body.String(), `"enabled":true`) || !strings.Contains(update.Body.String(), `"revision":2`) || !strings.Contains(update.Body.String(), `"status":"no_candidates"`) {
t.Fatalf("automation update status=%d body=%s", update.Code, update.Body.String())
}
}
func TestHandlerReconciliationQueueAndReview(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
@@ -59,6 +98,111 @@ func TestHandlerReconciliationQueueAndReview(t *testing.T) {
}
}
func TestHandlerReconciliationBatchReviewReturnsPerItemResult(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/batch-actions", strings.NewReader(`{
"items":[
{"id":"reconciliation-demo-position","version":1},
{"id":"missing-issue","version":1}
],
"status":"fixed",
"note":"已核对定位设备与原始报文,修复结果通过复测"
}`))
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "运维乙", Role: "operator", UserType: "operator"}))
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("batch review status=%d body=%s", response.Code, response.Body.String())
}
for _, expected := range []string{`"requested":2`, `"id":"reconciliation-demo-position"`, `"actor":"运维乙"`, `"id":"missing-issue"`, `"code":"RECONCILIATION_NOT_FOUND"`} {
if !strings.Contains(response.Body.String(), expected) {
t.Fatalf("batch review response missing %s: %s", expected, response.Body.String())
}
}
}
func TestHandlerReconciliationAssignmentAndOwnerFilter(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
dueAt := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-position/assignment", strings.NewReader(fmt.Sprintf(`{"version":1,"assignee":"定位运维组","dueAt":%q}`, dueAt)))
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "运维主管", Role: "operator", UserType: "operator"}))
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"assignee":"定位运维组"`) || !strings.Contains(response.Body.String(), `"action":"assign"`) {
t.Fatalf("assignment status=%d body=%s", response.Code, response.Body.String())
}
list := httptest.NewRecorder()
handler.ServeHTTP(list, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues", strings.NewReader(`{"status":"active","owner":"assigned","limit":20}`)))
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"total":1`) || !strings.Contains(list.Body.String(), `"assignee":"定位运维组"`) {
t.Fatalf("assigned filter status=%d body=%s", list.Code, list.Body.String())
}
}
func TestHandlerReconciliationBatchAssignmentReturnsPerItemResult(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
response := httptest.NewRecorder()
dueAt := time.Now().Add(8 * time.Hour).UTC().Format(time.RFC3339)
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/batch-assignments", strings.NewReader(fmt.Sprintf(`{"items":[{"id":"reconciliation-demo-position","version":1},{"id":"missing-issue","version":1}],"assignee":"夜班运维组","dueAt":%q}`, dueAt)))
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "值班主管", Role: "operator", UserType: "operator"}))
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("batch assignment status=%d body=%s", response.Code, response.Body.String())
}
for _, expected := range []string{`"requested":2`, `"assignee":"夜班运维组"`, `"id":"missing-issue"`, `"code":"RECONCILIATION_NOT_FOUND"`} {
if !strings.Contains(response.Body.String(), expected) {
t.Fatalf("batch assignment response missing %s: %s", expected, response.Body.String())
}
}
}
func TestHandlerReconciliationDirectoryAndFilteredExport(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
directory := httptest.NewRecorder()
directoryRequest := httptest.NewRequest(http.MethodGet, "/api/v2/reconciliation/assignees?search=current", nil)
directoryRequest = directoryRequest.WithContext(WithPrincipal(directoryRequest.Context(), Principal{Name: "Current Operator", Username: "current", Role: "operator", UserType: "operator"}))
handler.ServeHTTP(directory, directoryRequest)
if directory.Code != http.StatusOK || !strings.Contains(directory.Body.String(), `"current":true`) || !strings.Contains(directory.Body.String(), `"name":"Current Operator"`) {
t.Fatalf("directory status=%d body=%s", directory.Code, directory.Body.String())
}
exported := httptest.NewRecorder()
handler.ServeHTTP(exported, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/export", strings.NewReader(`{"status":"active","owner":"unassigned"}`)))
if exported.Code != http.StatusOK || exported.Header().Get("Content-Type") != "text/csv; charset=utf-8" || exported.Header().Get("X-Export-Count") != "1" || !strings.Contains(exported.Body.String(), "多来源实时位置漂移") {
t.Fatalf("export status=%d headers=%v body=%s", exported.Code, exported.Header(), exported.Body.String())
}
}
func TestHandlerReconciliationArchiveAndRestoreExposeAuditReceipt(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
admin := Principal{Name: "质量治理管理员", Username: "quality-admin", Role: "admin", UserType: "admin"}
archive := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-source/archive", strings.NewReader(`{"version":2,"reason":"已完成周期复核,转入审计归档"}`))
handler.ServeHTTP(archive, request.WithContext(WithPrincipal(request.Context(), admin)))
if archive.Code != http.StatusOK {
t.Fatalf("archive status=%d body=%s", archive.Code, archive.Body.String())
}
for _, expected := range []string{`"archivedBy":"质量治理管理员"`, `"archiveReason":"已完成周期复核,转入审计归档"`, `"action":"archive"`, `"version":3`} {
if !strings.Contains(archive.Body.String(), expected) {
t.Fatalf("archive response missing %s: %s", expected, archive.Body.String())
}
}
list := httptest.NewRecorder()
handler.ServeHTTP(list, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues", strings.NewReader(`{"scope":"archived","keyword":"周期复核","status":"all","limit":20}`)))
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"total":1`) || !strings.Contains(list.Body.String(), `"id":"reconciliation-demo-source"`) {
t.Fatalf("archive list status=%d body=%s", list.Code, list.Body.String())
}
restore := httptest.NewRecorder()
request = httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-source/restore", strings.NewReader(`{"version":3,"reason":"新的来源证据需要重新核对"}`))
handler.ServeHTTP(restore, request.WithContext(WithPrincipal(request.Context(), admin)))
if restore.Code != http.StatusOK || !strings.Contains(restore.Body.String(), `"action":"restore"`) || !strings.Contains(restore.Body.String(), `"archivedAt":""`) {
t.Fatalf("restore status=%d body=%s", restore.Code, restore.Body.String())
}
}
func TestHandlerV2MonitorSummaryAndMap(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
for _, test := range []struct {
@@ -119,6 +263,29 @@ func TestHandlerV2AccessManagement(t *testing.T) {
}
}
func TestHandlerClaimsAccessIdentityWithAuthenticatedActor(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
claim := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v2/access/unresolved-identities/mock-unresolved-jt808/claim", strings.NewReader(`{"vin":"LNXNEGRR7SR318212","note":"已核对来源注册信息与车辆档案"}`))
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "治理管理员", Role: "admin", UserType: "operator"}))
handler.ServeHTTP(claim, request)
if claim.Code != http.StatusOK {
t.Fatalf("claim status=%d body=%s", claim.Code, claim.Body.String())
}
for _, expected := range []string{`"vin":"LNXNEGRR7SR318212"`, `"claimedBy":"治理管理员"`, `"profileComplete":false`, `"auditId":`} {
if !strings.Contains(claim.Body.String(), expected) {
t.Fatalf("claim response missing %s: %s", expected, claim.Body.String())
}
}
queue := httptest.NewRecorder()
handler.ServeHTTP(queue, httptest.NewRequest(http.MethodPost, "/api/v2/access/unresolved-identities", strings.NewReader(`{"limit":20}`)))
if queue.Code != http.StatusOK || !strings.Contains(queue.Body.String(), `"total":0`) {
t.Fatalf("claimed identity must leave unresolved queue: status=%d body=%s", queue.Code, queue.Body.String())
}
}
func TestHandlerV2TrackPlayback(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
@@ -166,14 +333,46 @@ func TestHandlerV2HistoryCatalogAndMultiVehicleQuery(t *testing.T) {
}
}
func TestHistorySeriesGrainAndBounds(t *testing.T) {
func TestHandlerV2HistoryReturnsPerVehicleQueryReceipt(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v2/history/query?keywords=%E5%B7%9DAHTWO1,NO_HISTORY_VEHICLE&category=location&limit=10", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data HistoryDataResponse `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode history response: %v body=%s", err, rec.Body.String())
}
if body.Data.Summary.RequestedVehicleCount != 2 || len(body.Data.Summary.Vehicles) != 2 {
t.Fatalf("history query receipt must preserve both requested vehicles: %+v", body.Data.Summary)
}
if body.Data.Summary.Vehicles[0].Status != "matched" || body.Data.Summary.Vehicles[0].RowCount == 0 {
t.Fatalf("known vehicle should expose matched row evidence: %+v", body.Data.Summary.Vehicles[0])
}
if body.Data.Summary.Vehicles[1].Keyword != "NO_HISTORY_VEHICLE" || body.Data.Summary.Vehicles[1].Status != "no_data" || body.Data.Summary.Vehicles[1].RowCount != 0 {
t.Fatalf("empty vehicle should remain visible as a no-data receipt: %+v", body.Data.Summary.Vehicles[1])
}
}
func TestHistoryWindowGrainAndBounds(t *testing.T) {
if got := historySeriesGrain(24*time.Hour, 240); got != 900 {
t.Fatalf("24h / 240 should select the next safe nice bucket, got %d", got)
}
if got := historySeriesGrain(30*24*time.Hour, 240); got != 21600 {
t.Fatalf("30d / 240 should use six-hour buckets, got %d", got)
}
if _, _, _, _, err := historySeriesWindow("2026-06-01", "2026-07-02", time.Now()); err != nil {
t.Fatalf("an exact 31-day history window should remain valid: %v", err)
}
handler := NewHandler(NewService(NewMockStore()))
for _, test := range []struct{ path, code string }{
{path: "/api/v2/history/series?category=raw&keyword=%E5%B7%9DAHTWO1", code: "HISTORY_SERIES_CATEGORY_UNSUPPORTED"},
{path: "/api/v2/history/series?keyword=%E5%B7%9DAHTWO1&dateFrom=2026-01-01T00%3A00&dateTo=2026-03-01T00%3A00", code: "HISTORY_TIME_RANGE_TOO_LARGE"},
{path: "/api/v2/history/query?keyword=%E5%B7%9DAHTWO1&dateFrom=2026-01-01T00%3A00&dateTo=2026-03-01T00%3A00", code: "HISTORY_TIME_RANGE_TOO_LARGE"},
} {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, test.path, nil))
@@ -403,6 +602,21 @@ func TestHandlerVehicleDetail(t *testing.T) {
t.Fatalf("response missing %q: %s", want, rec.Body.String())
}
}
var body struct {
Data VehicleDetail `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
}
if !containsString(body.Data.Sources, "GB32960") || !containsString(body.Data.Sources, "JT808") {
t.Fatalf("vehicle detail should expose protocols backed by observed data, got %+v", body.Data.Sources)
}
if containsString(body.Data.Sources, "YUTONG_MQTT") {
t.Fatalf("vehicle detail must not expose an empty canonical slot as an available protocol, got %+v", body.Data.Sources)
}
if len(body.Data.SourceStatus) < len(canonicalVehicleProtocols) {
t.Fatalf("diagnostic source status should retain canonical readiness slots, got %+v", body.Data.SourceStatus)
}
}
func TestHandlerVehicleSourceEvidence(t *testing.T) {
@@ -1129,6 +1343,47 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) {
}
}
func TestHandlerMileagePostCarriesLargeArrayQuery(t *testing.T) {
store := newCountingStore()
handler := NewHandler(NewService(store))
vins := make([]string, 250)
for index := range vins {
vins[index] = fmt.Sprintf("VIN%014d", index)
}
body, err := json.Marshal(MileageQuery{
DateFrom: "2026-07-01", DateTo: "2026-07-31", VINs: vins,
Protocols: []string{"GB32960", "JT808"}, Deduplicate: true, Limit: 10_000,
})
if err != nil {
t.Fatal(err)
}
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status = %d body=%s", path, rec.Code, rec.Body.String())
}
}
if got := len(strings.Split(store.lastDailyMileageQuery.Get("vins"), ",")); got != len(vins) {
t.Fatalf("daily mileage VIN count = %d, want %d", got, len(vins))
}
if got := store.lastMileageStatisticsQuery.Get("protocols"); got != "GB32960,JT808" {
t.Fatalf("statistics protocols = %q", got)
}
}
func TestHandlerMileagePostRejectsUnknownJSONField(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v2/statistics/mileage", bytes.NewBufferString(`{"dateFrom":"2026-07-01","unknown":true}`))
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "BAD_JSON") {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
}
func TestHandlerQualityIncludesNoSourceVehicles(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()