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

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