diff --git a/vehicle-data-platform/apps/api/internal/app/auth.go b/vehicle-data-platform/apps/api/internal/app/auth.go index 45f8ef27..f1fc56cb 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth.go +++ b/vehicle-data-platform/apps/api/internal/app/auth.go @@ -233,7 +233,7 @@ func requiredMenu(r *http.Request) string { func requiredRole(r *http.Request) string { if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, "/api/v2/exports") { - return "operator" + return "viewer" } if r.Method == http.MethodGet || r.Method == http.MethodHead { return "viewer" @@ -241,9 +241,9 @@ func requiredRole(r *http.Request) string { path := r.URL.Path if r.Method == http.MethodPost { switch path { - case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events": + case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports": return "viewer" - case "/api/v2/exports", "/api/v2/alerts/notifications/read": + case "/api/v2/alerts/notifications/read": return "operator" } if strings.HasPrefix(path, "/api/v2/alerts/events/") && strings.HasSuffix(path, "/actions") { diff --git a/vehicle-data-platform/apps/api/internal/app/auth_test.go b/vehicle-data-platform/apps/api/internal/app/auth_test.go index c4466643..1a5affda 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth_test.go +++ b/vehicle-data-platform/apps/api/internal/app/auth_test.go @@ -67,8 +67,12 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) { t.Fatalf("viewer mutation should be forbidden, status=%d", viewerAction.Code) } viewerExports := authRequest(t, cfg, http.MethodGet, "/api/v2/exports", viewerToken) - if viewerExports.Code != http.StatusForbidden { - t.Fatalf("viewer export listing should be forbidden, status=%d", viewerExports.Code) + if viewerExports.Code != http.StatusNoContent { + t.Fatalf("viewer export listing should reach owner/scope enforcement, status=%d", viewerExports.Code) + } + viewerExportCreate := authRequest(t, cfg, http.MethodPost, "/api/v2/exports", viewerToken) + if viewerExportCreate.Code != http.StatusNoContent { + t.Fatalf("viewer export creation should reach owner/scope enforcement, status=%d", viewerExportCreate.Code) } operatorExports := authRequest(t, cfg, http.MethodGet, "/api/v2/exports/exp_1/download", operatorToken) if operatorExports.Code != http.StatusNoContent { diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 898c130b..efba52ae 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -3,6 +3,7 @@ package platform import ( "encoding/json" "net/http" + "net/url" "strings" "time" @@ -265,23 +266,23 @@ func (h *Handler) handleCreateHistoryExport(w http.ResponseWriter, r *http.Reque httpx.WriteError(w, http.StatusBadRequest, "BAD_JSON", "请求 JSON 解析失败", err.Error(), traceID(r)) return } - data, err := h.service.CreateHistoryExport(request) + data, err := h.service.CreateHistoryExport(r.Context(), request) h.write(w, r, data, err) } func (h *Handler) handleListHistoryExports(w http.ResponseWriter, r *http.Request) { - h.write(w, r, h.service.ListHistoryExports(), nil) + h.write(w, r, h.service.ListHistoryExports(r.Context()), nil) } func (h *Handler) handleDownloadHistoryExport(w http.ResponseWriter, r *http.Request) { - path, name, err := h.service.HistoryExportFile(r.PathValue("id")) + path, name, err := h.service.HistoryExportFile(r.Context(), r.PathValue("id")) if err != nil { h.write(w, r, nil, err) return } w.Header().Set("Content-Type", "text/csv; charset=utf-8") - w.Header().Set("Content-Disposition", `attachment; filename="history-export.csv"`) - w.Header().Set("X-Export-Name", name) + w.Header().Set("Content-Disposition", `attachment; filename="history-export.csv"; filename*=UTF-8''`+url.PathEscape(name)) + w.Header().Set("X-Export-Name", url.PathEscape(name)) http.ServeFile(w, r, path) } diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index a2561e0e..75f5fcc1 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -812,6 +812,10 @@ func (m *MockStore) HistoryExportCount(ctx context.Context, query HistoryExportS return int64(len(rows)), err } +func (m *MockStore) HistoryExportScopeActive(context.Context, HistoryExportJob) (bool, error) { + return true, nil +} + func (m *MockStore) HistoryExportBatch(ctx context.Context, query HistoryExportStoreQuery, cursor HistoryExportCursor, limit int) ([]HistoryDataRow, HistoryExportCursor, error) { rows, err := m.mockHistoryExportRows(ctx, query) if err != nil { diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index cb3f39f4..da0d7006 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -327,25 +327,46 @@ type HistoryExportRequest struct { Format string `json:"format"` } +type HistoryExportVehicleScope struct { + VIN string `json:"vin"` + DateFrom string `json:"dateFrom"` + DateTo string `json:"dateTo"` +} + type HistoryExportJob struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - Progress int `json:"progress"` - Format string `json:"format"` - Category string `json:"category"` - Keywords []string `json:"keywords"` - RowCount int `json:"rowCount"` - TotalRows int64 `json:"totalRows"` - ProcessedRows int64 `json:"processedRows"` - FileSizeBytes int64 `json:"fileSizeBytes"` - Error string `json:"error,omitempty"` - DownloadURL string `json:"downloadUrl,omitempty"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - CompletedAt string `json:"completedAt,omitempty"` - Evidence string `json:"evidence"` - filePath string + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Progress int `json:"progress"` + Format string `json:"format"` + Category string `json:"category"` + Protocol string `json:"protocol,omitempty"` + Keywords []string `json:"keywords"` + Metrics []string `json:"metrics,omitempty"` + VehicleVINs []string `json:"vehicleVins"` + VehicleScopes []HistoryExportVehicleScope `json:"vehicleScopes"` + DateFrom string `json:"dateFrom"` + DateTo string `json:"dateTo"` + OwnerID string `json:"ownerId"` + OwnerSubjectID string `json:"ownerSubjectId,omitempty"` + OwnerName string `json:"ownerName"` + OwnerUsername string `json:"ownerUsername"` + OwnerRole string `json:"ownerRole"` + OwnerUserType string `json:"ownerUserType"` + AuthProvider string `json:"authProvider"` + CustomerRef string `json:"customerRef,omitempty"` + TenantRef string `json:"tenantRef,omitempty"` + RowCount int `json:"rowCount"` + TotalRows int64 `json:"totalRows"` + ProcessedRows int64 `json:"processedRows"` + FileSizeBytes int64 `json:"fileSizeBytes"` + Error string `json:"error,omitempty"` + DownloadURL string `json:"downloadUrl,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + CompletedAt string `json:"completedAt,omitempty"` + Evidence string `json:"evidence"` + filePath string } type HistoryExportStoreQuery struct { diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index 784db876..d6c942a5 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -749,6 +749,38 @@ func (s *ProductionStore) HistoryExportCount(ctx context.Context, query HistoryE return total, nil } +func (s *ProductionStore) HistoryExportScopeActive(ctx context.Context, job HistoryExportJob) (bool, error) { + userID, err := strconv.ParseUint(strings.TrimSpace(job.OwnerSubjectID), 10, 64) + if err != nil || userID == 0 { + return false, nil + } + var status, userType string + if err := s.db.QueryRowContext(ctx, `SELECT status,user_type FROM platform_user WHERE id=?`, userID).Scan(&status, &userType); err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, err + } + if status != "enabled" || userType != "customer" { + return false, nil + } + for _, scope := range job.VehicleScopes { + dateFrom, fromOK := parseTrackRequestTime(scope.DateFrom) + dateTo, toOK := parseTrackRequestTime(scope.DateTo) + if !fromOK || !toOK || !dateTo.After(dateFrom) { + return false, nil + } + var count int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM platform_user_vehicle WHERE user_id=? AND vin=? AND COALESCE(valid_from,granted_at)<=? AND (valid_to IS NULL OR valid_to>=?)`, userID, scope.VIN, dateFrom, dateTo).Scan(&count); err != nil { + return false, err + } + if count != 1 { + return false, nil + } + } + return len(job.VehicleScopes) > 0, nil +} + func (s *ProductionStore) HistoryExportBatch(ctx context.Context, query HistoryExportStoreQuery, cursor HistoryExportCursor, limit int) ([]HistoryDataRow, HistoryExportCursor, error) { if query.Category == "mileage" { page, err := s.DailyMileage(ctx, historyExportMileageQuery(query, limit, cursor.Offset)) diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index fd3b20b6..4dd4552c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -2612,7 +2612,15 @@ func rawMetricMetadata(key string) (string, string) { return label, unit } -func (s *Service) CreateHistoryExport(request HistoryExportRequest) (HistoryExportJob, error) { +func (s *Service) CreateHistoryExport(ctx context.Context, request HistoryExportRequest) (HistoryExportJob, error) { + principal, ok := PrincipalFromContext(ctx) + if !ok { + return HistoryExportJob{}, clientError{Code: "EXPORT_OWNER_REQUIRED", Message: "导出任务需要已认证账号"} + } + ownerID := historyExportOwnerID(principal) + if ownerID == "" { + return HistoryExportJob{}, clientError{Code: "EXPORT_OWNER_REQUIRED", Message: "当前账号缺少可持久化的导出身份"} + } request.Keywords = normalizedKeywords(request.Keywords) if len(request.Keywords) == 0 || len(request.Keywords) > 5 { return HistoryExportJob{}, clientError{Code: "EXPORT_SCOPE_INVALID", Message: "导出任务需要 1 至 5 台车辆"} @@ -2640,12 +2648,32 @@ func (s *Service) CreateHistoryExport(request HistoryExportRequest) (HistoryExpo if len(request.Metrics) > 32 { return HistoryExportJob{}, clientError{Code: "EXPORT_METRIC_LIMIT_EXCEEDED", Message: "单次导出最多支持 32 个指标"} } + scopes, err := s.resolveHistoryExportScopes(ctx, request) + if err != nil { + return HistoryExportJob{}, err + } identifier, err := randomExportID() if err != nil { return HistoryExportJob{}, err } now := time.Now().UTC().Format(time.RFC3339) - job := &HistoryExportJob{ID: identifier, Name: "历史数据_" + time.Now().Format("20060102_150405"), Status: "queued", Progress: 0, Format: request.Format, Category: request.Category, Keywords: append([]string(nil), request.Keywords...), CreatedAt: now, UpdatedAt: now, Evidence: "单并发流式任务;最多 1,000,000 行;完成文件原子发布"} + vehicleVINs := make([]string, 0, len(scopes)) + for _, scope := range scopes { + vehicleVINs = append(vehicleVINs, scope.VIN) + } + creator := firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "account") + job := &HistoryExportJob{ + ID: identifier, Name: "历史数据_" + time.Now().Format("20060102_150405") + "_" + safeExportNamePart(creator), + Status: "queued", Progress: 0, Format: request.Format, Category: request.Category, Protocol: request.Protocol, + Keywords: append([]string(nil), request.Keywords...), Metrics: append([]string(nil), request.Metrics...), + VehicleVINs: vehicleVINs, VehicleScopes: append([]HistoryExportVehicleScope(nil), scopes...), + DateFrom: request.DateFrom, DateTo: request.DateTo, + OwnerID: ownerID, OwnerSubjectID: principal.SubjectID, OwnerName: principal.Name, OwnerUsername: principal.Username, + OwnerRole: principal.Role, OwnerUserType: principal.UserType, AuthProvider: principal.AuthProvider, + CustomerRef: principal.CustomerRef, TenantRef: principal.TenantRef, + CreatedAt: now, UpdatedAt: now, + Evidence: "账号归属与车辆/时间 Scope 已固化;单并发流式任务;最多 1,000,000 行;完成文件原子发布", + } s.exportsMu.Lock() s.exports[job.ID] = job if err := s.persistHistoryExportsLocked(); err != nil { @@ -2654,15 +2682,18 @@ func (s *Service) CreateHistoryExport(request HistoryExportRequest) (HistoryExpo return HistoryExportJob{}, fmt.Errorf("persist export job: %w", err) } s.exportsMu.Unlock() - go s.runHistoryExport(job.ID, request) + go s.runHistoryExport(job.ID, request, scopes) return copyHistoryExportJob(job), nil } -func (s *Service) ListHistoryExports() []HistoryExportJob { +func (s *Service) ListHistoryExports(ctx context.Context) []HistoryExportJob { + principal, _ := PrincipalFromContext(ctx) s.exportsMu.RLock() result := make([]HistoryExportJob, 0, len(s.exports)) for _, job := range s.exports { - result = append(result, copyHistoryExportJob(job)) + if canAccessHistoryExport(principal, job) { + result = append(result, copyHistoryExportJob(job)) + } } s.exportsMu.RUnlock() sort.Slice(result, func(i, j int) bool { return result[i].CreatedAt > result[j].CreatedAt }) @@ -2672,10 +2703,11 @@ func (s *Service) ListHistoryExports() []HistoryExportJob { return result } -func (s *Service) HistoryExportFile(id string) (string, string, error) { +func (s *Service) HistoryExportFile(ctx context.Context, id string) (string, string, error) { + principal, _ := PrincipalFromContext(ctx) s.exportsMu.RLock() job := s.exports[id] - if job == nil { + if job == nil || !canAccessHistoryExport(principal, job) { s.exportsMu.RUnlock() return "", "", clientError{Code: "EXPORT_NOT_FOUND", Message: "导出任务不存在"} } @@ -2685,15 +2717,154 @@ func (s *Service) HistoryExportFile(id string) (string, string, error) { if copy.Status != "completed" || path == "" { return "", "", clientError{Code: "EXPORT_NOT_READY", Message: "导出文件尚未生成"} } + if err := s.ensureHistoryExportScopeActive(ctx, copy); err != nil { + return "", "", err + } return path, copy.Name + ".csv", nil } -func (s *Service) runHistoryExport(id string, request HistoryExportRequest) { +func historyExportOwnerID(principal Principal) string { + provider := strings.TrimSpace(principal.AuthProvider) + if provider == "" { + provider = "unknown" + } + if subject := strings.TrimSpace(principal.SubjectID); subject != "" { + return provider + ":subject:" + subject + } + if username := strings.TrimSpace(principal.Username); username != "" { + return provider + ":username:" + strings.ToLower(username) + } + if name := strings.TrimSpace(principal.Name); name != "" { + return provider + ":name:" + strings.ToLower(name) + } + return "" +} + +func canAccessHistoryExport(principal Principal, job *HistoryExportJob) bool { + if job == nil { + return false + } + if principal.Role == "admin" || principal.UserType == "admin" { + return true + } + ownerID := historyExportOwnerID(principal) + return ownerID != "" && job.OwnerID != "" && ownerID == job.OwnerID +} + +func safeExportNamePart(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "account" + } + var builder strings.Builder + for _, char := range value { + switch { + case char >= 'a' && char <= 'z', char >= 'A' && char <= 'Z', char >= '0' && char <= '9', char == '-', char == '_': + builder.WriteRune(char) + default: + builder.WriteRune('_') + } + if builder.Len() >= 32 { + break + } + } + result := strings.Trim(builder.String(), "_") + if result == "" { + return "account" + } + return result +} + +func (s *Service) resolveHistoryExportScopes(ctx context.Context, request HistoryExportRequest) ([]HistoryExportVehicleScope, error) { + scopes := make([]HistoryExportVehicleScope, 0, len(request.Keywords)) + seen := map[string]bool{} + for _, keyword := range request.Keywords { + vin, err := s.resolveVehicleVIN(ctx, keyword, request.Protocol) + if err != nil { + return nil, err + } + vin = strings.ToUpper(strings.TrimSpace(vin)) + if vin == "" || seen[vin] { + continue + } + if err := authorizeVehicleVIN(ctx, vin); err != nil { + return nil, err + } + scoped, err := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{ + "dateFrom": {request.DateFrom}, + "dateTo": {request.DateTo}, + }) + if err != nil { + return nil, err + } + if request.Category == "mileage" { + fromDay := strings.Split(scoped.Get("dateFrom"), "T")[0] + toDay := strings.Split(scoped.Get("dateTo"), "T")[0] + if err := authorizeVehicleDailyEvidenceDate(ctx, vin, fromDay); err != nil { + return nil, err + } + if err := authorizeVehicleDailyEvidenceDate(ctx, vin, toDay); err != nil { + return nil, err + } + } + seen[vin] = true + scopes = append(scopes, HistoryExportVehicleScope{VIN: vin, DateFrom: scoped.Get("dateFrom"), DateTo: scoped.Get("dateTo")}) + } + if len(scopes) == 0 { + return nil, clientError{Code: "EXPORT_SCOPE_INVALID", Message: "导出任务未解析到可授权车辆"} + } + return scopes, nil +} + +type historyExportAuthorizationStore interface { + HistoryExportScopeActive(context.Context, HistoryExportJob) (bool, error) +} + +func (s *Service) ensureHistoryExportScopeActive(ctx context.Context, job HistoryExportJob) error { + if job.OwnerUserType != "customer" { + return nil + } + if job.OwnerSubjectID == "" { + return clientError{Code: "EXPORT_SCOPE_REVOKED", Message: "客户导出缺少可复核的账号标识,任务已停止"} + } + store, ok := s.store.(historyExportAuthorizationStore) + if !ok { + return clientError{Code: "EXPORT_SCOPE_REVOKED", Message: "当前数据存储无法复核客户授权,任务已停止"} + } + active, err := store.HistoryExportScopeActive(ctx, job) + if err != nil { + return err + } + if !active { + return clientError{Code: "EXPORT_SCOPE_REVOKED", Message: "账号状态或车辆授权已变化,导出任务/下载已停止"} + } + return nil +} + +func (s *Service) historyExportJob(id string) (HistoryExportJob, bool) { + s.exportsMu.RLock() + defer s.exportsMu.RUnlock() + job := s.exports[id] + if job == nil { + return HistoryExportJob{}, false + } + return copyHistoryExportJob(job), true +} + +func (s *Service) runHistoryExport(id string, request HistoryExportRequest, scopes []HistoryExportVehicleScope) { s.exportSlots <- struct{}{} defer func() { <-s.exportSlots }() s.updateHistoryExport(id, func(job *HistoryExportJob) { job.Status = "running"; job.Progress = 1 }) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() + job, ok := s.historyExportJob(id) + if !ok { + return + } + if err := s.ensureHistoryExportScopeActive(ctx, job); err != nil { + s.failHistoryExport(id, err) + return + } store, ok := s.store.(interface { HistoryExportCount(context.Context, HistoryExportStoreQuery) (int64, error) HistoryExportBatch(context.Context, HistoryExportStoreQuery, HistoryExportCursor, int) ([]HistoryDataRow, HistoryExportCursor, error) @@ -2702,23 +2873,10 @@ func (s *Service) runHistoryExport(id string, request HistoryExportRequest) { s.failHistoryExport(id, errors.New("当前数据存储不支持流式历史导出")) return } - resolvedVINs := make([]string, 0, len(request.Keywords)) - seenVINs := map[string]bool{} - for _, keyword := range request.Keywords { - vin, err := s.resolveVehicleVIN(ctx, keyword, request.Protocol) - if err != nil { - s.failHistoryExport(id, err) - return - } - if vin = strings.TrimSpace(vin); vin != "" && !seenVINs[vin] { - seenVINs[vin] = true - resolvedVINs = append(resolvedVINs, vin) - } - } - queries := make([]HistoryExportStoreQuery, 0, len(resolvedVINs)) + queries := make([]HistoryExportStoreQuery, 0, len(scopes)) var totalRows int64 - for _, vin := range resolvedVINs { - query := HistoryExportStoreQuery{Category: request.Category, VIN: vin, Protocol: request.Protocol, DateFrom: request.DateFrom, DateTo: request.DateTo, Metrics: append([]string(nil), request.Metrics...)} + for _, scope := range scopes { + query := HistoryExportStoreQuery{Category: request.Category, VIN: scope.VIN, Protocol: request.Protocol, DateFrom: scope.DateFrom, DateTo: scope.DateTo, Metrics: append([]string(nil), request.Metrics...)} count, err := store.HistoryExportCount(ctx, query) if err != nil { s.failHistoryExport(id, err) @@ -2758,7 +2916,7 @@ func (s *Service) runHistoryExport(id string, request HistoryExportRequest) { } buffered := bufio.NewWriterSize(file, 1<<20) writer := csv.NewWriter(buffered) - if err := writeHistoryExportMetadata(writer, request, columns); err != nil { + if err := writeHistoryExportMetadata(writer, request, job, columns); err != nil { s.failHistoryExport(id, err) return } @@ -2766,6 +2924,14 @@ func (s *Service) runHistoryExport(id string, request HistoryExportRequest) { for _, query := range queries { cursor := HistoryExportCursor{} for { + currentJob, exists := s.historyExportJob(id) + if !exists { + return + } + if err := s.ensureHistoryExportScopeActive(ctx, currentJob); err != nil { + s.failHistoryExport(id, err) + return + } rows, nextCursor, err := store.HistoryExportBatch(ctx, query, cursor, 5000) if err != nil { s.failHistoryExport(id, err) @@ -2950,6 +3116,9 @@ func copyHistoryExportJob(job *HistoryExportJob) HistoryExportJob { } copy := *job copy.Keywords = append([]string(nil), job.Keywords...) + copy.Metrics = append([]string(nil), job.Metrics...) + copy.VehicleVINs = append([]string(nil), job.VehicleVINs...) + copy.VehicleScopes = append([]HistoryExportVehicleScope(nil), job.VehicleScopes...) return copy } @@ -2991,9 +3160,11 @@ func historyExportColumns(category string, requested []string) []HistoryMetricDe return selected } -func writeHistoryExportMetadata(writer *csv.Writer, request HistoryExportRequest, columns []HistoryMetricDefinition) error { +func writeHistoryExportMetadata(writer *csv.Writer, request HistoryExportRequest, job HistoryExportJob, columns []HistoryMetricDefinition) error { rows := [][]string{ {"导出元数据", "查询开始", request.DateFrom, "查询结束", request.DateTo, "车辆", strings.Join(request.Keywords, "、"), "数据类型", request.Category, "协议", firstNonEmpty(request.Protocol, "全部")}, + {"导出审计", "创建账号", firstNonEmpty(job.OwnerUsername, job.OwnerName), "显示名称", job.OwnerName, "角色", firstNonEmpty(job.OwnerUserType, job.OwnerRole), "客户", job.CustomerRef, "创建时间", job.CreatedAt, "生成时间", time.Now().UTC().Format(time.RFC3339)}, + {"车辆 Scope", strings.Join(job.VehicleVINs, "、"), "授权窗口", job.DateFrom + " 至 " + job.DateTo}, {"指标与单位", strings.Join(exportMetricLabels(columns), ";")}, append([]string{"设备时间", "服务时间", "车牌", "VIN", "协议", "数据质量", "质量原因", "证据ID"}, exportMetricLabels(columns)...), } diff --git a/vehicle-data-platform/apps/api/internal/platform/service_test.go b/vehicle-data-platform/apps/api/internal/platform/service_test.go index 419e3ef4..9f074daa 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -13,6 +13,10 @@ import ( "time" ) +func exportAdminContext() context.Context { + return WithPrincipal(context.Background(), Principal{SubjectID: "1", Name: "平台管理员", Username: "admin", Role: "admin", UserType: "admin", AuthProvider: "local"}) +} + func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) { dir := t.TempDir() first := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) @@ -31,11 +35,12 @@ func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) { first.exportsMu.Unlock() restarted := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) - jobs := restarted.ListHistoryExports() + ctx := exportAdminContext() + jobs := restarted.ListHistoryExports(ctx) if len(jobs) != 2 { t.Fatalf("jobs=%+v", jobs) } - path, _, err := restarted.HistoryExportFile("exp_completed") + path, _, err := restarted.HistoryExportFile(ctx, "exp_completed") if err != nil || path != filePath { t.Fatalf("completed export not restored: path=%q err=%v", path, err) } @@ -561,15 +566,16 @@ func containsInt(values []int, wanted int) bool { func TestHistoryExportRunsAsControlledAsyncJob(t *testing.T) { service := NewService(NewMockStore()) - job, err := service.CreateHistoryExport(HistoryExportRequest{Keywords: []string{"川AHTWO1"}, Category: "location", Metrics: []string{"speedKmh"}, Format: "csv"}) + ctx := exportAdminContext() + job, err := service.CreateHistoryExport(ctx, HistoryExportRequest{Keywords: []string{"川AHTWO1"}, Category: "location", Metrics: []string{"speedKmh"}, Format: "csv"}) if err != nil { t.Fatalf("create export: %v", err) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - jobs := service.ListHistoryExports() + jobs := service.ListHistoryExports(ctx) if len(jobs) > 0 && jobs[0].ID == job.ID && jobs[0].Status == "completed" { - path, _, fileErr := service.HistoryExportFile(job.ID) + path, _, fileErr := service.HistoryExportFile(ctx, job.ID) if fileErr != nil { t.Fatalf("export file: %v", fileErr) } @@ -587,7 +593,99 @@ func TestHistoryExportRunsAsControlledAsyncJob(t *testing.T) { } time.Sleep(10 * time.Millisecond) } - t.Fatalf("export job did not complete: %+v", service.ListHistoryExports()) + t.Fatalf("export job did not complete: %+v", service.ListHistoryExports(ctx)) +} + +func exportCustomerContext(subject, username, vin string) context.Context { + return WithPrincipal(context.Background(), Principal{ + SubjectID: subject, Name: username, Username: username, Role: "customer", UserType: "customer", AuthProvider: "local", + CustomerRef: "customer-" + subject, VehicleVINs: []string{vin}, + VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: time.Date(2026, 7, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))}}, + }) +} + +func TestHistoryExportsAreOwnerScopedAndPersistAuditMetadata(t *testing.T) { + dir := t.TempDir() + service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) + vin := "LNXNEGRR7SR318212" + customerA := exportCustomerContext("101", "customer-a", vin) + customerB := exportCustomerContext("102", "customer-b", vin) + job, err := service.CreateHistoryExport(customerA, HistoryExportRequest{ + Keywords: []string{vin}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv", + }) + if err != nil { + t.Fatal(err) + } + if job.OwnerID != "local:subject:101" || job.OwnerUsername != "customer-a" || job.CustomerRef != "customer-101" || len(job.VehicleScopes) != 1 || job.VehicleScopes[0].VIN != vin { + t.Fatalf("owner/scope audit metadata missing: %+v", job) + } + if jobs := service.ListHistoryExports(customerB); len(jobs) != 0 { + t.Fatalf("customer B saw customer A export: %+v", jobs) + } + if _, _, err := service.HistoryExportFile(customerB, job.ID); err == nil { + t.Fatal("customer B should not download customer A export") + } + if jobs := service.ListHistoryExports(exportAdminContext()); len(jobs) != 1 || jobs[0].ID != job.ID { + t.Fatalf("admin should audit all jobs: %+v", jobs) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + jobs := service.ListHistoryExports(customerA) + if len(jobs) == 1 && jobs[0].Status == "completed" { + path, _, fileErr := service.HistoryExportFile(customerA, job.ID) + if fileErr != nil { + t.Fatal(fileErr) + } + body, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + for _, evidence := range []string{"导出审计", "customer-a", "customer-101", "车辆 Scope", vin} { + if !strings.Contains(string(body), evidence) { + t.Fatalf("CSV missing audit evidence %q: %q", evidence, body[:min(len(body), 800)]) + } + } + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("customer export did not complete: %+v", service.ListHistoryExports(customerA)) +} + +type revocableHistoryExportStore struct { + *MockStore + active bool +} + +func (s *revocableHistoryExportStore) HistoryExportScopeActive(context.Context, HistoryExportJob) (bool, error) { + return s.active, nil +} + +func TestCustomerHistoryExportStopsWhenAuthorizationIsRevoked(t *testing.T) { + store := &revocableHistoryExportStore{MockStore: NewMockStore(), active: false} + service := NewServiceWithRuntime(store, RuntimeInfo{ExportDir: t.TempDir()}) + ctx := exportCustomerContext("101", "customer-a", "LNXNEGRR7SR318212") + job, err := service.CreateHistoryExport(ctx, HistoryExportRequest{ + Keywords: []string{"LNXNEGRR7SR318212"}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv", + }) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + jobs := service.ListHistoryExports(ctx) + if len(jobs) == 1 && jobs[0].Status == "failed" { + if !strings.Contains(jobs[0].Error, "授权已变化") { + t.Fatalf("unexpected revoke evidence: %+v", jobs[0]) + } + if _, _, fileErr := service.HistoryExportFile(ctx, job.ID); fileErr == nil { + t.Fatal("revoked customer should not download export") + } + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("revoked export did not stop: %+v", service.ListHistoryExports(ctx)) } type largeHistoryExportStore struct { @@ -632,13 +730,14 @@ func assertLargeHistoryExport(t *testing.T, total int64, timeout time.Duration) dir := t.TempDir() store := &largeHistoryExportStore{MockStore: NewMockStore(), total: total} service := NewServiceWithRuntime(store, RuntimeInfo{ExportDir: dir}) - job, err := service.CreateHistoryExport(HistoryExportRequest{Keywords: []string{"LNXNEGRR7SR318212"}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv"}) + ctx := exportAdminContext() + job, err := service.CreateHistoryExport(ctx, HistoryExportRequest{Keywords: []string{"LNXNEGRR7SR318212"}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv"}) if err != nil { t.Fatal(err) } deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { - current := service.ListHistoryExports()[0] + current := service.ListHistoryExports(ctx)[0] if current.Status == "failed" { t.Fatalf("export failed: %+v", current) } @@ -646,7 +745,7 @@ func assertLargeHistoryExport(t *testing.T, total int64, timeout time.Duration) if current.RowCount != int(total) || current.ProcessedRows != total || current.TotalRows != total || current.FileSizeBytes == 0 || current.CompletedAt == "" { t.Fatalf("incomplete evidence: %+v", current) } - path, _, fileErr := service.HistoryExportFile(job.ID) + path, _, fileErr := service.HistoryExportFile(ctx, job.ID) if fileErr != nil { t.Fatal(fileErr) } @@ -664,7 +763,7 @@ func assertLargeHistoryExport(t *testing.T, total int64, timeout time.Duration) } time.Sleep(10 * time.Millisecond) } - t.Fatalf("export timed out: %+v", service.ListHistoryExports()) + t.Fatalf("export timed out: %+v", service.ListHistoryExports(ctx)) } func TestRawMetricMetadataKeepsProtocolAndUnit(t *testing.T) { diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index 4323eb14..2bca8374 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -21,6 +21,25 @@ test('authenticated requests use the session-only bearer token', async () => { expect(window.localStorage.getItem('vehicle-platform.access-token')).toBeNull(); }); +test('history export downloads use bearer authentication and retain the server filename', async () => { + setAccessToken('customer-export-token'); + const payload = new Blob(['export'], { type: 'text/csv' }); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + headers: new Headers({ 'X-Export-Name': encodeURIComponent('历史数据_customer-a.csv'), 'Content-Type': 'text/csv' }), + blob: async () => payload + } as Response); + + const result = await api.downloadHistoryExport('exp customer'); + + expect(result.filename).toBe('历史数据_customer-a.csv'); + expect(result.blob.size).toBe(6); + expect(result.blob.type).toBe('text/csv'); + const [path, init] = fetchMock.mock.calls[0]; + expect(path).toBe('/api/v2/exports/exp%20customer/download'); + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer customer-export-token'); +}); + test('a protected 401 terminates the client session but login validation errors stay local', async () => { window.sessionStorage.setItem('vehicle-platform.access-token', 'expired-token'); const unauthorized = vi.fn(); diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 7b5a007f..bf2ded2b 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -141,6 +141,29 @@ async function request(path: string, init?: RequestInit): Promise { } } +async function requestBlob(path: string, signal?: AbortSignal) { + const token = getAccessToken(); + const init: RequestInit = token ? { signal, headers: withAuthorization(undefined, token) } : { signal }; + const response = await fetch(path, init); + if (!response.ok) { + if (response.status === 401 && token) notifyUnauthorizedSession(token); + throw new Error(await responseErrorMessage(response)); + } + const encodedFilename = response.headers.get('X-Export-Name')?.trim(); + let filename = 'history-export.csv'; + if (encodedFilename) { + try { + filename = decodeURIComponent(encodedFilename); + } catch { + filename = encodedFilename; + } + } + return { + blob: await response.blob(), + filename + }; +} + function withAuthorization(headers: HeadersInit | undefined, token: string) { const authorized = new Headers(headers); authorized.set('Authorization', `Bearer ${token}`); @@ -201,6 +224,7 @@ export const api = { createHistoryExport: (query: HistoryExportRequest) => request('/api/v2/exports', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) }), + downloadHistoryExport: (id: string, signal?: AbortSignal) => requestBlob(`/api/v2/exports/${encodeURIComponent(id)}/download`, signal), accessSummary: (query: AccessQuery, signal?: AbortSignal) => request('/api/v2/access/summary', withSignal({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) }, signal)), diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 065efb35..2885a279 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -251,7 +251,7 @@ export interface HistorySeriesSummary { rawPointCount: number; bucketCount: numb export interface HistorySeriesResponse { metrics: HistoryMetricDefinition[]; series: HistorySeries[]; summary: HistorySeriesSummary; dateFrom: string; dateTo: string; asOf: string; } export interface HistoryExportRequest { keywords: string[]; category: string; protocol?: string; dateFrom?: string; dateTo?: string; metrics: string[]; format: 'csv'; } -export interface HistoryExportJob { id: string; name: string; status: 'queued' | 'running' | 'completed' | 'failed'; progress: number; format: string; category: string; keywords: string[]; rowCount: number; totalRows: number; processedRows: number; fileSizeBytes: number; error?: string; downloadUrl?: string; createdAt: string; updatedAt: string; completedAt?: string; evidence: string; } +export interface HistoryExportJob { id: string; name: string; status: 'queued' | 'running' | 'completed' | 'failed'; progress: number; format: string; category: string; protocol?: string; keywords: string[]; metrics?: string[]; vehicleVins?: string[]; dateFrom?: string; dateTo?: string; ownerId?: string; ownerName?: string; ownerUsername?: string; ownerRole?: string; ownerUserType?: string; customerRef?: string; tenantRef?: string; rowCount: number; totalRows: number; processedRows: number; fileSizeBytes: number; error?: string; downloadUrl?: string; createdAt: string; updatedAt: string; completedAt?: string; evidence: string; } export interface AccessQuery { keyword?: string; protocol?: string; oem?: string; model?: string; provider?: string; firstSeenFrom?: string; firstSeenTo?: string; latestSeenFrom?: string; latestSeenTo?: string; onlineState?: string; delayState?: string; connectionState?: string; limit?: number; offset?: number; } export interface AccessUnresolvedIdentityQuery { keyword?: string; protocol?: string; limit?: number; offset?: number; } diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx index 550f8dbb..cf8b3613 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.test.tsx @@ -11,7 +11,8 @@ const mocks = vi.hoisted(() => ({ historyData: vi.fn(), historySeries: vi.fn(), historyExports: vi.fn(), - createHistoryExport: vi.fn() + createHistoryExport: vi.fn(), + downloadHistoryExport: vi.fn() })); const auth = vi.hoisted(() => ({ role: 'admin' })); @@ -144,6 +145,26 @@ test('keeps history readable without mounting operator-only export requests for expect(mocks.createHistoryExport).not.toHaveBeenCalled(); }); +test('shows only the authenticated customer export workspace with owner and scope metadata', async () => { + auth.role = 'customer'; + mocks.historyMetricCatalog.mockResolvedValue({ categories: [{ key: 'location', label: '位置数据' }], metrics: [metric] }); + mocks.historyData.mockResolvedValue(historyData('OLDVIN', '旧车牌', 'old-as-of')); + mocks.historySeries.mockResolvedValue(historySeries('OLDVIN', '旧车牌', 'old-as-of')); + mocks.historyExports.mockResolvedValue([{ + id: 'customer-export', name: '历史数据_20260716_customer-a', status: 'completed', progress: 100, format: 'csv', category: 'location', + keywords: ['OLDVIN'], vehicleVins: ['OLDVIN'], dateFrom: '2026-07-16T00:00', dateTo: '2026-07-16T05:00', + ownerName: '客户甲', ownerUsername: 'customer-a', ownerRole: 'customer', ownerUserType: 'customer', + rowCount: 10, totalRows: 10, processedRows: 10, fileSizeBytes: 1024, downloadUrl: '/api/v2/exports/customer-export/download', + createdAt: '2026-07-16T05:00:00Z', updatedAt: '2026-07-16T05:00:01Z', completedAt: '2026-07-16T05:00:01Z', evidence: 'owner scoped' + }]); + renderPage(); + + expect(await screen.findByText(/customer-a · 1 辆/)).toBeInTheDocument(); + expect(screen.getByText('导出任务')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /创建导出/ })).toBeInTheDocument(); + expect(mocks.historyExports).toHaveBeenCalled(); +}); + test('uses selected chartable fields for trends and omits empty RAW evidence UI', async () => { const totalMileageMetric = { key: 'totalMileageKm', label: '总里程', unit: 'km', category: 'location', valueType: 'number', defaultVisible: true }; const data = historyData('OLDVIN', '旧车牌', 'old-as-of'); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx index b98a79da..dd2b8b89 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/HistoryPage.tsx @@ -5,6 +5,7 @@ import { useSearchParams } from 'react-router-dom'; import { api } from '../../api/client'; import type { HistoryDataResponse, HistoryDataRow, HistoryExportRequest, HistoryMetricDefinition, HistorySeriesResponse } from '../../api/types'; import { buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, formatSeriesGrain, historyExportPollInterval, parseHistoryKeywords } from '../domain/history'; +import { downloadBlob } from '../domain/download'; import { InlineError } from '../shared/AsyncState'; import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy'; import { usePlatformSession } from '../auth/AuthGate'; @@ -53,6 +54,14 @@ function CreateExportButton({ request, disabled }: { request: HistoryExportReque return ; } +function ExportDownloadButton({ id }: { id: string }) { + const mutation = useMutation({ + mutationFn: () => api.downloadHistoryExport(id), + onSuccess: ({ blob, filename }) => downloadBlob(blob, filename) + }); + return ; +} + function ExportJobsPanel() { const query = useQuery({ queryKey: ['history-exports'], @@ -62,7 +71,7 @@ function ExportJobsPanel() { gcTime: QUERY_MEMORY.highVolumeGcTime }); const jobs = query.data ?? []; - return
导出任务{jobs.length}
{jobs.slice(0, 6).map((job) =>
{job.name}{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')} 行 · ${job.progress}%` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)} · 已完成` : job.error || '失败'}
{job.downloadUrl ? 下载 : {job.status === 'running' ? `${job.progress}%` : '—'}}
)}{query.isError ?
导出任务加载失败
: !jobs.length ?
尚未创建导出任务
: null}
; + return
导出任务{jobs.length}
{jobs.slice(0, 6).map((job) =>
{job.name}{job.status === 'queued' ? '等待单并发执行' : job.status === 'running' ? `${job.processedRows.toLocaleString('zh-CN')} / ${job.totalRows.toLocaleString('zh-CN')} 行 · ${job.progress}%` : job.status === 'completed' ? `${job.rowCount.toLocaleString('zh-CN')} 行 · ${formatExportFileSize(job.fileSizeBytes)} · 已完成` : job.error || '失败'}{job.ownerUsername || job.ownerName || '历史任务'} · {job.vehicleVins?.length ?? job.keywords.length} 辆 · {job.dateFrom && job.dateTo ? `${job.dateFrom.replace('T', ' ')} 至 ${job.dateTo.replace('T', ' ')}` : job.createdAt}
{job.downloadUrl ? : {job.status === 'running' ? `${job.progress}%` : '—'}}
)}{query.isError ?
导出任务加载失败
: !jobs.length ?
当前账号尚未创建导出任务
: null}
; } function EvidencePanel({ row, metrics, onClose }: { row?: HistoryDataRow; metrics: HistoryMetricDefinition[]; onClose: () => void }) { @@ -101,7 +110,7 @@ function ColumnVisibilityPanel({ metrics, visibleKeys, onToggle, onShowAll, onRe export default function HistoryPage() { const { session } = usePlatformSession(); - const exportAllowed = canOperate(session); + const exportAllowed = canOperate(session) || session.role === 'customer'; const [searchParams, setSearchParams] = useSearchParams(); const today = useMemo(currentHistoryWindow, []); const initial = { keywords: searchParams.get('vin') || searchParams.get('keywords') || '', dateFrom: searchParams.get('dateFrom') || today.dateFrom, dateTo: searchParams.get('dateTo') || today.dateTo, category: searchParams.get('category') || 'location', protocol: searchParams.get('protocol') || '' }; diff --git a/vehicle-data-platform/apps/web/src/v2/styles/v2.css b/vehicle-data-platform/apps/web/src/v2/styles/v2.css index 189048e4..72319331 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -713,7 +713,7 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-history-evidence > footer b { overflow: hidden; color: var(--v2-blue); text-overflow: ellipsis; white-space: nowrap; } .v2-history-side-empty { display: flex; min-height: 92px; align-items: center; justify-content: center; padding: 14px; color: var(--v2-muted); text-align: center; font-size: 8px; line-height: 1.6; } .v2-export-jobs > div { padding: 4px 11px 8px; } -.v2-export-jobs article { display: grid; min-height: 42px; grid-template-columns: 7px minmax(0, 1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef2f7; } +.v2-export-jobs article { display: grid; min-height: 54px; grid-template-columns: 7px minmax(0, 1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #eef2f7; } .v2-export-jobs article > i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; } .v2-export-jobs article > i.is-running { background: var(--v2-blue); } .v2-export-jobs article > i.is-completed { background: var(--v2-green); } @@ -721,7 +721,9 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-export-jobs article > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; } .v2-export-jobs article strong { overflow: hidden; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; } .v2-export-jobs article small { overflow: hidden; color: #8290a3; font-size: 7px; text-overflow: ellipsis; white-space: nowrap; } -.v2-export-jobs article a { display: inline-flex; align-items: center; gap: 4px; color: var(--v2-blue); text-decoration: none; font-size: 7px; } +.v2-export-jobs article small.v2-export-owner { color: #64748b; } +.v2-export-jobs article .v2-export-download { display: inline-flex; align-items: center; gap: 4px; border: 0; background: transparent; padding: 0; color: var(--v2-blue); cursor: pointer; font-size: 7px; } +.v2-export-jobs article .v2-export-download:disabled { opacity: .55; cursor: wait; } .v2-export-jobs article em { color: #7f8c9f; font-size: 7px; font-style: normal; } .v2-access-page { display: flex; height: 100%; min-height: 0; flex-direction: column; gap: 9px; overflow: hidden; padding: 12px 14px 14px; } diff --git a/vehicle-data-platform/docs/customer-scope-api-enforcement-matrix.md b/vehicle-data-platform/docs/customer-scope-api-enforcement-matrix.md index 5d6ee976..16294a12 100644 --- a/vehicle-data-platform/docs/customer-scope-api-enforcement-matrix.md +++ b/vehicle-data-platform/docs/customer-scope-api-enforcement-matrix.md @@ -1,14 +1,17 @@ # 车辆数据中台客户 Scope API 覆盖矩阵 -更新时间:2026-07-14 +更新时间:2026-07-16 -状态:安全审计与实施设计;尚未开放客户主体。 +状态:本地客户主体与车辆/时间 Scope 已实施;OneOS 联邦身份仍按后续接口契约接入。 ## 1. 当前结论 -车辆中台当前的 Bearer Token 只表达 `viewer/operator/admin` 功能角色,不表达客户或车辆数据范围。 -因此,即使 OneOS 已能登录客户,也不能直接把客户 token 映射成 `viewer`:这会让客户读取全量车辆、轨迹、 -告警、统计及其他人的导出文件。 +车辆中台已支持本地 `admin/customer` 账号、菜单权限、车辆 VIN Scope 和授权有效时间。认证中间件把 +`subjectId/userType/customerRef/tenantRef/authProvider` 以及当前车辆授权注入 `Principal`;车辆、监控、轨迹、 +历史、里程和导出入口均在服务端校验主体与时间边界。客户不能通过 Query、Header 或 Body 自行覆盖 customer ID。 + +OneOS 身份尚不能直接映射为本地 `viewer`。后续只有在 OneOS 提供签名、受众、身份版本和客户关联等稳定接口后, +才能映射为同一强类型 Principal;在此之前继续使用车辆中台本地客户账号,不读取或修改 OneOS 业务逻辑。 2026-07-14 已只读核验 ECS 当前运行状态: @@ -17,7 +20,7 @@ - `/opt/lingniu-vehicle-platform/env/platform.env` 权限为 `0600 root:root`; - `lingniu-vehicle-platform` 服务已启用且正在运行。 -该配置能阻止匿名访问普通平台 API,但不能提供客户级隔离。 +该配置负责内部静态 token;客户级隔离由平台账号、会话、菜单和车辆授权表共同提供。 ## 2. 独立发现:高德服务端接口绕过平台鉴权 @@ -126,26 +129,21 @@ VIN 集合,设置数量上限后再生成参数化过滤。不能先从 TDengi 禁止应在服务端 capability 层返回 403,不能只隐藏前端菜单。 -### 5.3 导出必须单独重构后才能开放 +### 5.3 导出 owner、异步 Scope 与审计(已实施) -当前导出实现有四个 P0 问题: - -1. `CreateHistoryExport` 没有 `context`,创建时无法固化主体; -2. `HistoryExportJob` 没有 owner/customer/scope version; -3. `ListHistoryExports` 返回全局任务; -4. `HistoryExportFile(id)` 只校验 ID 和完成状态,知道 ID 即可下载。 - -整改要求: +release `export-owner-scope-20260716165737` 已完成: - `Create/List/Download` 全部接收 context 和 Principal; -- Job 固化 `owner_subject_id`、`customer_id`、`scope_version`、已解析 VIN、时间范围和审计 request ID; -- 创建任务时校验每个 VIN,异步执行前再次校验; -- 列表只返回当前 owner;下载同时校验 owner 和当前授权,越权返回 404; -- 客户停用、车辆授权撤销或 Scope 失效时停止未完成任务并拒绝下载; -- 文件名和 CSV 元数据不得包含其他客户信息;文件按 owner 隔离目录并设置 `0640`; -- 定期清理过期文件与 jobs 记录。 +- Job 固化 owner ID、账号、角色/用户类型、认证来源、客户/租户、已解析 VIN 和逐车时间范围; +- 创建时同步校验每个 VIN 和历史授权时间;异步开始、每 5,000 行及下载前重新校验客户账号与车辆授权; +- 非管理员列表只返回当前 owner;越权或猜测下载 ID 返回 404;管理员保留审计视角; +- 客户停用、车辆授权撤销或授权区间变化会停止未完成任务并拒绝历史文件下载; +- CSV 文件使用 `0640`,任务索引原子持久化;CSV 元数据包含创建账号、角色、客户、车辆 Scope、查询条件与生成时间; +- Web 使用带 Bearer Token 的 Blob 下载,不再通过无 Authorization 的直接链接; +- 服务重启后 owner、Scope 和完成文件仍可复核,执行中任务安全失败并要求重新创建。 -在上述改造完成前,客户主体不授予 `export:create` 或 `export:download`。 +自动化已覆盖两个互斥客户、管理员审计、IDOR、撤权停止和持久化元数据。生产真实导出在重启前后文件 +SHA-256 一致,伪造 ID 返回 404,且无 `.part` 文件残留。 ## 6. 当前 Scope 与历史 Scope 的边界 @@ -189,6 +187,6 @@ customer_id, vin, contract_id, valid_from, valid_to, source_version 2. 引入强类型 QueryScope,先改车辆解析与单车详情; 3. 改所有列表、汇总、地图、统计和 TDengine 查询; 4. 改告警详情 IDOR、逆地理编码鉴权限流; -5. 重构导出 owner 与异步 Scope; -6. 完成双客户自动化测试和生产影子查询; -7. 最后为测试客户启用 capabilities,默认保持客户入口关闭。 +5. 导出 owner 与异步 Scope:已完成; +6. 双客户导出自动化与生产管理员实车导出:已完成;完整客户 API 矩阵继续补齐; +7. 本地测试客户可按菜单和车辆授权启用;OneOS 联邦客户入口保持关闭,等待正式身份接口。 diff --git a/vehicle-data-platform/docs/deployment.md b/vehicle-data-platform/docs/deployment.md index eceb50e8..8786217b 100644 --- a/vehicle-data-platform/docs/deployment.md +++ b/vehicle-data-platform/docs/deployment.md @@ -129,7 +129,9 @@ ALERT_STREAM_LATENESS_SEC=120 All three platform systemd units first load `/opt/lingniu-go-native/env/base.env` for the existing MySQL, Redis, TDengine and Kafka connection settings, then load `platform.env` for platform-specific overrides. `DATA_MODE=production` is mandatory on ECS: a missing or unreachable MySQL connection returns `DATA_STORE_UNAVAILABLE` instead of silently serving demonstration data. Use `DATA_MODE=mock` only for local development. -`EXPORT_DIR` must point outside the release symlink. The API writes CSV files and an atomic `jobs.json` index there; completed tasks survive API restarts, while interrupted queued/running jobs are marked failed and can be recreated. Exports run one at a time, use 5,000-row forward-only TDengine cursors instead of `OFFSET`, stream into a `.part` file, and atomically publish the final CSV only after flush, `fsync`, and close succeed. A task is limited to five vehicles, 31 days, 32 metrics, 1,000,000 rows, and 30 minutes. Both the initial count and observed rows enforce the row cap so late-arriving data cannot bypass it. Keep the directory mode `0750` and include it in retention/backup policy. Export list and download APIs require `operator` or `admin`. +`EXPORT_DIR` must point outside the release symlink. The API writes mode-`0640` CSV files and an atomic `jobs.json` index there; completed tasks survive API restarts, while interrupted queued/running jobs are marked failed and can be recreated. Every job persists its owner account, role/user type, customer/tenant references, resolved VINs and per-vehicle time scope. Non-admin principals can list and download only their own jobs; customers additionally pass a live account-status and vehicle-grant check before execution, every 5,000-row batch and every download. Old ownerless jobs remain visible only to administrators. The browser downloads through an authenticated Blob request rather than a direct anchor, so the Bearer credential is always present. + +Exports run one at a time, use 5,000-row forward-only TDengine cursors instead of `OFFSET`, stream into a `.part` file, and atomically publish the final CSV only after flush, `fsync`, and close succeed. A task is limited to five vehicles, 31 days, 32 metrics, 1,000,000 rows, and 30 minutes. Both the initial count and observed rows enforce the row cap so late-arriving data cannot bypass it. Keep the directory mode `0750`, preserve `jobs.json` and completed CSVs across releases, and include the directory in retention/backup policy. Export routes require an authenticated viewer-capable principal; ownership and current Scope are enforced inside the export service instead of relying only on role middleware. Release verification for this path includes the opt-in synthetic million-row gate: @@ -138,14 +140,14 @@ cd vehicle-data-platform/apps/api EXPORT_MILLION_TEST=1 go test ./internal/platform -run '^TestHistoryExportMillionRows$' -count=1 -v ``` -Production smoke must create an export for an active VIN, wait for `completed`, verify `rowCount == processedRows == totalRows`, compare the downloaded SHA-256 and byte count before and after one API restart, and confirm that no `.part` file remains. CSV files begin with query-range and metric/unit metadata followed by the data header. +Production smoke must create an export for an active VIN, wait for `completed`, verify `rowCount == processedRows == totalRows`, and assert that owner, role, resolved VINs and time scope are present. Download with the same Bearer principal, confirm the CSV contains export audit, vehicle Scope, query-range, metric/unit and quality metadata, and verify a guessed task ID returns 404. Compare the downloaded SHA-256 and byte count before and after one API restart, and confirm that no `.part` file remains. The automated gate must separately cover two mutually isolated customer principals plus account/vehicle revocation during execution. `AUTH_MODE=enforce` is mandatory on ECS. Tokens are stored as SHA-256 comparisons in memory and sent as Bearer credentials; the browser stores the entered token only in `sessionStorage`. Roles are cumulative: | Role | Permissions | | --- | --- | -| `viewer` | Read pages, query data and inspect evidence | -| `operator` | Viewer permissions plus exports, alert actions and notification reads | +| `viewer` | Read pages, query data and inspect evidence; may create/download only its own history exports when the menu is available | +| `operator` | Viewer permissions plus alert actions and notification reads; export access remains owner-bound | | `admin` | Operator permissions plus rule and access-threshold configuration | After editing the environment file, run `chmod 600 /opt/lingniu-vehicle-platform/env/platform.env`. Never put a real token in Git, static JavaScript, shell history or deployment logs. @@ -246,6 +248,8 @@ The history-series gate must use an active production VIN and a current local-ti The history-query gate must also request both `location` and `raw` through `/api/v2/history/query`. Every returned row must contain a non-empty `qualityReason`; RAW columns must include fields discovered from the bounded fetched scope rather than only the visible page. In the browser, verify field-name/key search, multi-select, the single visual time-range control, selected-field consistency across table/trend/export, and absence of a RAW-evidence placeholder when `evidenceId` is unavailable. Keep the existing maximum of five vehicles, 200 rows per page and 1,000 fetched rows for interactive field discovery. +The history-export gate must verify owner-bound listing and authenticated Blob download. A customer A task must be absent from customer B's list and return 404 when B guesses its ID; an administrator may audit it. Persisted job JSON and CSV metadata must retain the owner and exact per-VIN time scope after an API restart. Disabling the customer or revoking any required vehicle grant must stop an incomplete task and deny download of a previously completed file. + Track smoke must also select the final playback event and verify synchronized SOC/direction/alarm availability plus a resolved current address. For high-frequency sources, confirm positive subsecond samples do not become alternating zero-second `数据间隔` segments; only non-increasing timestamps or intervals over ten minutes are gaps. Access smoke should exercise `model`/`provider` and one paired first/latest receive-time range, then confirm the filter survives a shareable URL and every returned row remains in scope. Global-monitor smoke must verify `zoom=5` returns clusters, a city `bounds` at `zoom=13` returns at most 2,000 lightweight points, and invalid/reversed bounds return HTTP 400. In the browser, the rail renders at most 200 vehicle rows while the legend reports the independent map mode. Selecting `driving` or `idle` must constrain both the server-side count and every loaded row. A unique keyword result must discard the previous viewport and focus at point zoom. The synthetic release gate is `go test ./internal/platform -run TestMonitorMapTenThousandVehicles -count=1` plus `go test ./internal/platform -run '^$' -bench BenchmarkMonitorMapTenThousandVehicles -benchtime=30x -benchmem`. diff --git a/vehicle-data-platform/docs/frontend-production-readiness.md b/vehicle-data-platform/docs/frontend-production-readiness.md index f3b40404..03d87883 100644 --- a/vehicle-data-platform/docs/frontend-production-readiness.md +++ b/vehicle-data-platform/docs/frontend-production-readiness.md @@ -299,6 +299,14 @@ Production reconciliation proved that the three visible vehicle counts represent High-volume history rows, aggregated series, track playback and daily-mileage matrices consume the request `AbortSignal` and use zero inactive-cache retention. Small vehicle-option and summary results retain only a bounded 30–60 second cache. This preserves responsive repeated lookups without retaining multiple large arbitrary date-window payloads after their observer is gone. +## 2026-07-16: authenticated owner-bound history exports + +Release `export-owner-scope-20260716165737` removes the last direct download link from the history page. Completed files are now fetched through the API client with the current session's Bearer token, converted to a short-lived object URL and downloaded with the server-provided business filename. The server encodes the Chinese filename in `X-Export-Name` and RFC 5987 `Content-Disposition`, so HTTP headers remain valid across Go, proxies and browsers. + +The export workspace is owner-aware: each row shows the creating account, authorized vehicle count and query window. A customer sees only their own tasks; an administrator can audit all tasks. Frontend tests cover customer visibility and authenticated download headers, while backend tests cover customer A/B isolation, guessed-ID denial, live grant revocation and persisted owner/Scope metadata. + +The production gate exported 1,274 location rows for one real VIN. The 215,212-byte CSV contained export audit, vehicle Scope and quality-reason metadata. A guessed ID returned 404, the API restarted successfully, and the file SHA-256 remained `00a4b1a877364f048a06e8bf6d80828e0dacaddd2f946b39b6cd00cfb2e9013e` with no partial file left behind. The release passed all 241 production frontend tests and the TypeScript/Vite build gate. + ## Release evidence template - Commit and release identifier diff --git a/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md b/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md index 28e784fd..af1f38f4 100644 --- a/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md +++ b/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md @@ -190,7 +190,18 @@ ### P0-05 导出任务账号归属与审计 -状态:`待开发` +状态:`已完成` + +已上线结果(release `export-owner-scope-20260716165737`): + +- 创建、列表和下载接口均使用当前认证主体;任务固化创建账号、显示名、角色/用户类型、认证来源、客户/租户、已授权 VIN、逐车时间范围和创建/完成时间。 +- 客户和内部普通账号只能看到、下载自己的任务;管理员保留全局审计视角。旧版无 owner 的历史任务不会暴露给非管理员,猜测或复用其他任务 ID 统一按不存在处理。 +- 客户创建任务时同步解析车牌/VIN 并校验车辆与历史时间授权;异步任务开始、每 5,000 行批次及下载前再次查询账号启用状态和车辆授权。账号停用、车辆撤权或授权区间变化后,未完成任务停止,已完成文件拒绝下载。 +- 导出任务持久化保存 owner 与车辆时间 Scope;服务重启后继续按同一主体授权下载。排队或执行中的任务在重启后安全标记失败,不会以无主体状态恢复。 +- CSV 增加导出账号、角色、客户、创建/生成时间、车辆 Scope、查询条件、字段/单位及质量原因;任务名和下载文件名包含业务时间及创建者,中文文件名通过 RFC 5987/URL 编码安全传递。 +- Web 下载改为带 Bearer Token 的受控 Blob 请求,不再使用绕过请求头的直接 `` 链接;客户历史页可创建自己的导出,列表展示创建者、车辆数和时间范围。 +- 自动化覆盖客户 A/B 任务隔离、管理员审计、越权下载、撤权停止、持久化审计元数据和前端 Bearer 下载;本地后端全量测试及前端 241 项测试通过。 +- ECS 真实车辆 `LNXNEGRR7SR318212` 在 `2026-07-14 00:00–06:00` 导出 1,274 行,`rowCount = processedRows = totalRows`;下载文件 215,212 字节,包含审计/Scope/质量元数据。伪造任务 ID 返回 404;API 重启前后 SHA-256 均为 `00a4b1a877364f048a06e8bf6d80828e0dacaddd2f946b39b6cd00cfb2e9013e`,且无 `.part` 残留。 目标: