/** * Export Record Service Module * * Provides methods for export record operations: * - listExportRecords: List export records with pagination * - downloadExportRecord: Download an exported file * - deleteExportRecord: Delete an export record * - getAdminStorage: Get storage usage info (admin only) * * All methods use the configured API client and provide error normalization. * * @module services/exportRecordService */ import apiClient, { getErrorMessage } from './api'; import type { ApiResponse } from '../types/api'; import type { ExportRecord, ListExportRecordsResponse, ExportRecordListFilters, StorageInfo, } from '../types/export'; import { downloadBlob, encodeExportRecordId, getFileNameFromContentDisposition, } from '../utils/download'; function isValidExportRecord(value: unknown): value is ExportRecord { if (!value || typeof value !== 'object') return false; const record = value as Partial; return ( typeof record.recordId === 'string' && record.recordId.length > 0 && typeof record.fileName === 'string' && typeof record.downloadUrl === 'string' && typeof record.userId === 'string' && typeof record.styleId === 'string' && typeof record.fileSize === 'number' && Number.isFinite(record.fileSize) && record.fileSize >= 0 && typeof record.createdAt === 'number' && Number.isFinite(record.createdAt) ); } /** * List export records with pagination * * @param filters - Filters for listing export records (userId is required) * @returns List of export records and pagination information * @throws {Error} When the API request fails */ export const listExportRecords = async ( filters: ExportRecordListFilters ): Promise => { try { // Build query parameters const params: Record = { userId: filters.userId, page: filters.page || 1, pageSize: filters.pageSize || 20, sortOrder: filters.sortOrder || 'desc', }; const response = await apiClient.get>( '/api/v1/export/records', { params } ); const data = response.data.data; if ( !data || !Array.isArray(data.records) || !data.records.every(isValidExportRecord) || !data.pagination || !Number.isInteger(data.pagination.total) || data.pagination.total < 0 ) { throw new Error('导出记录响应格式无效'); } // Fix any incorrect base URLs in download URLs const correctBaseURL = import.meta.env.VITE_API_BASE_URL; if (correctBaseURL && data.records) { data.records = data.records.map((record) => { if (record.downloadUrl && record.downloadUrl.startsWith('http')) { try { const urlObj = new URL(record.downloadUrl); const correctUrlObj = new URL(correctBaseURL); if (urlObj.origin !== correctUrlObj.origin) { return { ...record, downloadUrl: record.downloadUrl.replace(urlObj.origin, correctUrlObj.origin), }; } } catch { // Invalid URL, skip } } return record; }); } return data; } catch (error) { const message = getErrorMessage(error); throw new Error(`获取导出记录列表失败: ${message}`, { cause: error }); } }; /** * Download an exported file * * Opens the file in a new tab/window for download. * The backend returns a FileResponse with appropriate headers. * * @param recordId - The export record ID * @param userId - The user ID who owns the record * @throws {Error} When the download fails */ export const downloadExportRecord = async (recordId: string, userId: string): Promise => { try { const encodedRecordId = encodeExportRecordId(recordId); // Use apiClient to download the file (goes through axios with proper config) // This avoids mixed content warnings by using relative URLs const response = await apiClient.get(`/api/v1/export/records/${encodedRecordId}/download`, { params: { userId }, responseType: 'blob', // Important: tell axios to expect binary data }); // Get filename from Content-Disposition header const fileName = getFileNameFromContentDisposition( response.headers['content-disposition'], `export-${recordId}.doc` ); // Create blob and download const blob = new Blob([response.data], { type: 'application/msword' }); downloadBlob(blob, fileName); } catch (error) { const message = getErrorMessage(error); throw new Error(`下载文件失败: ${message}`, { cause: error }); } }; /** * Delete an export record * * Deletes the export record and its associated file from storage. * * @param recordId - The export record ID to delete * @param userId - The user ID who owns the record * @returns Promise that resolves when deletion is successful * @throws {Error} When the API request fails or record not found */ export const deleteExportRecord = async (recordId: string, userId: string): Promise => { try { const encodedRecordId = encodeExportRecordId(recordId); await apiClient.delete>(`/api/v1/export/records/${encodedRecordId}`, { params: { userId }, }); } catch (error) { const message = getErrorMessage(error); throw new Error(`删除导出记录失败: ${message}`, { cause: error }); } }; /** * Get storage usage information (admin only) * * Retrieves detailed storage usage statistics including: * - Total and used disk space * - Per-user storage usage * - Quota information * * @returns Storage usage information * @throws {Error} When the API request fails or user is not authorized */ export const getAdminStorage = async (): Promise => { try { const response = await apiClient.get>('/api/v1/admin/storage'); return response.data.data; } catch (error) { const message = getErrorMessage(error); throw new Error(`获取存储信息失败: ${message}`, { cause: error }); } }; /** * Export record service object (alternative export pattern) * Groups all export record operations into a single namespace */ export const exportRecordService = { list: listExportRecords, download: downloadExportRecord, delete: deleteExportRecord, getStorage: getAdminStorage, }; export default exportRecordService;