feat(history): scope export tasks to owners

This commit is contained in:
lingniu
2026-07-16 17:00:33 +08:00
parent a2722e4afd
commit 38b056f5f1
18 changed files with 528 additions and 100 deletions

View File

@@ -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") {

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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)...),
}

View File

@@ -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) {

View File

@@ -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();

View File

@@ -141,6 +141,29 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
}
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<HistoryExportJob>('/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<AccessSummary>('/api/v2/access/summary', withSignal({
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
}, signal)),

View File

@@ -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; }

View File

@@ -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');

View File

@@ -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 <button className="v2-secondary-button" type="button" disabled={disabled || mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '最多 100 万行;任务按创建顺序单并发流式执行'} onClick={() => mutation.mutate(request)}><IconDownload />{label}</button>;
}
function ExportDownloadButton({ id }: { id: string }) {
const mutation = useMutation({
mutationFn: () => api.downloadHistoryExport(id),
onSuccess: ({ blob, filename }) => downloadBlob(blob, filename)
});
return <button type="button" className="v2-export-download" disabled={mutation.isPending} title={mutation.error instanceof Error ? mutation.error.message : '下载当前账号有权访问的导出文件'} onClick={() => mutation.mutate()}><IconDownload />{mutation.isPending ? '下载中' : mutation.isError ? '重试' : '下载'}</button>;
}
function ExportJobsPanel() {
const query = useQuery({
queryKey: ['history-exports'],
@@ -62,7 +71,7 @@ function ExportJobsPanel() {
gcTime: QUERY_MEMORY.highVolumeGcTime
});
const jobs = query.data ?? [];
return <section className="v2-export-jobs"><header><strong></strong><span>{jobs.length}</span></header><div>{jobs.slice(0, 6).map((job) => <article key={job.id} title={job.evidence}><i className={`is-${job.status}`} /><div><strong>{job.name}</strong><small>{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 || '失败'}</small></div>{job.downloadUrl ? <a href={job.downloadUrl}><IconDownload /></a> : <em>{job.status === 'running' ? `${job.progress}%` : '—'}</em>}</article>)}{query.isError ? <div className="v2-history-side-empty"></div> : !jobs.length ? <div className="v2-history-side-empty"></div> : null}</div></section>;
return <section className="v2-export-jobs"><header><strong></strong><span>{jobs.length}</span></header><div>{jobs.slice(0, 6).map((job) => <article key={job.id} title={job.evidence}><i className={`is-${job.status}`} /><div><strong>{job.name}</strong><small>{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 || '失败'}</small><small className="v2-export-owner">{job.ownerUsername || job.ownerName || '历史任务'} · {job.vehicleVins?.length ?? job.keywords.length} · {job.dateFrom && job.dateTo ? `${job.dateFrom.replace('T', ' ')}${job.dateTo.replace('T', ' ')}` : job.createdAt}</small></div>{job.downloadUrl ? <ExportDownloadButton id={job.id} /> : <em>{job.status === 'running' ? `${job.progress}%` : '—'}</em>}</article>)}{query.isError ? <div className="v2-history-side-empty"></div> : !jobs.length ? <div className="v2-history-side-empty"></div> : null}</div></section>;
}
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') || '' };

View File

@@ -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; }