/** * 文件URL管理 Composable * 用于管理文件的临时访问URL,支持缓存和自动刷新 * * 支持两种访问模式: * - 私有文件:需要临时访问令牌,URL会自动缓存和刷新 * - 公开文件:直接返回URL,无需认证 */ import { ref, watch } from 'vue'; import { getFileUrlWithToken, getPublicFileUrl } from '#/api/core/file'; // 全局URL缓存(临时令牌URL) const urlCache = new Map(); // 全局 Blob ObjectURL 缓存(图片等二进制内容,长期有效) const blobCache = new Map(); // 正在进行的请求去重(避免同一 fileId 并发重复请求) const pendingRequests = new Map>(); // Blob 缓存最大条目数,防止内存无限增长 const BLOB_CACHE_MAX_SIZE = 500; // 默认过期时间(秒) const DEFAULT_EXPIRES_IN = 3600; // 提前刷新时间(秒)- 在过期前5分钟刷新 const REFRESH_BEFORE = 300; /** * 清理过期的缓存 */ function cleanupExpiredCache() { const now = Date.now(); for (const [key, value] of urlCache.entries()) { if (value.expiresAt < now) { urlCache.delete(key); } } } /** * 获取公开文件的URL(无需认证,同步返回) * @param fileId 文件ID * @returns string 文件访问URL */ export function getFileUrlPublic(fileId: string): string { if (!fileId) return ''; return getPublicFileUrl(fileId); } /** * 获取临时令牌URL(内部方法,不含 Blob 缓存) */ async function getTokenUrl( fileId: string, expiresIn: number = DEFAULT_EXPIRES_IN, ): Promise { const now = Date.now(); const cached = urlCache.get(fileId); if (cached && cached.expiresAt - REFRESH_BEFORE * 1000 > now) { return cached.url; } const result = await getFileUrlWithToken(fileId, expiresIn); const expiresAt = new Date(result.expiresAt).getTime(); const url = `/basic-api${result.url}`; urlCache.set(fileId, { url, expiresAt }); return url; } /** * 获取文件的访问URL(带 Blob 缓存 + 令牌URL缓存) * * 缓存策略: * 1. 如果 Blob 缓存命中(ObjectURL),直接返回,零网络请求 * 2. 否则获取临时令牌URL,fetch 下载为 Blob,生成 ObjectURL 缓存 * 3. 如果 fetch 失败,降级返回临时令牌URL * 4. 并发请求同一 fileId 会自动去重 * * @param fileId 文件ID * @param expiresIn 过期时间(秒) * @returns Promise 文件访问URL */ export async function getFileUrl( fileId: string, expiresIn: number = DEFAULT_EXPIRES_IN, ): Promise { if (!fileId) return ''; // 1. Blob 缓存命中,直接返回 const blobUrl = blobCache.get(fileId); if (blobUrl) { return blobUrl; } // 2. 去重:如果已有相同 fileId 的请求在进行中,等待它完成 const pending = pendingRequests.get(fileId); if (pending) { return pending; } // 3. 发起新请求 const request = (async () => { try { const tokenUrl = await getTokenUrl(fileId, expiresIn); // 尝试 fetch 并缓存为 Blob ObjectURL try { const response = await fetch(tokenUrl); if (response.ok) { const blob = await response.blob(); // 限制缓存大小:超出时清理最早的条目 if (blobCache.size >= BLOB_CACHE_MAX_SIZE) { const firstKey = blobCache.keys().next().value; if (firstKey) { const oldUrl = blobCache.get(firstKey); if (oldUrl) URL.revokeObjectURL(oldUrl); blobCache.delete(firstKey); } } const objectUrl = URL.createObjectURL(blob); blobCache.set(fileId, objectUrl); return objectUrl; } } catch { // fetch 失败,降级返回令牌URL } return tokenUrl; } catch (error) { console.error('获取文件URL失败:', error); const cached = urlCache.get(fileId); if (cached) return cached.url; return ''; } finally { pendingRequests.delete(fileId); } })(); pendingRequests.set(fileId, request); return request; } /** * 批量获取文件URL * @param fileIds 文件ID数组 * @param expiresIn 过期时间(秒) * @returns Promise> 文件ID到URL的映射 */ export async function getFileUrls( fileIds: string[], expiresIn: number = DEFAULT_EXPIRES_IN, ): Promise> { const result = new Map(); const promises = fileIds.map(async (fileId) => { const url = await getFileUrl(fileId, expiresIn); result.set(fileId, url); }); await Promise.all(promises); return result; } /** * 清除文件URL缓存 * @param fileId 可选,指定文件ID;不传则清除所有缓存 */ export function clearFileUrlCache(fileId?: string) { if (fileId) { urlCache.delete(fileId); const blobUrl = blobCache.get(fileId); if (blobUrl) { URL.revokeObjectURL(blobUrl); blobCache.delete(fileId); } } else { urlCache.clear(); for (const url of blobCache.values()) { URL.revokeObjectURL(url); } blobCache.clear(); } } /** * useFileUrl Composable * 用于在组件中响应式地获取文件URL * * @example * ```vue * * * ``` */ export function useFileUrl( fileId: (() => string | undefined) | string | undefined, options: { expiresIn?: number; immediate?: boolean; } = {}, ) { const { expiresIn = DEFAULT_EXPIRES_IN, immediate = true } = options; const url = ref(''); const loading = ref(false); const error = ref(null); const getFileIdValue = () => { if (typeof fileId === 'function') { return fileId(); } return fileId; }; async function refresh() { const id = getFileIdValue(); if (!id) { url.value = ''; return; } loading.value = true; error.value = null; try { // 清除缓存以强制刷新 clearFileUrlCache(id); url.value = await getFileUrl(id, expiresIn); } catch (error_) { error.value = error_ as Error; console.error('获取文件URL失败:', error_); } finally { loading.value = false; } } async function load() { const id = getFileIdValue(); if (!id) { url.value = ''; return; } loading.value = true; error.value = null; try { url.value = await getFileUrl(id, expiresIn); } catch (error_) { error.value = error_ as Error; console.error('获取文件URL失败:', error_); } finally { loading.value = false; } } // 监听fileId变化 if (typeof fileId === 'function') { watch(fileId, (newId) => { if (newId) { load(); } else { url.value = ''; } }); } // 立即加载 if (immediate) { load(); } return { url, loading, error, refresh, load, }; } /** * useFileUrls Composable * 用于批量获取多个文件的URL */ export function useFileUrls( fileIds: (() => string[]) | string[], options: { expiresIn?: number; immediate?: boolean; } = {}, ) { const { expiresIn = DEFAULT_EXPIRES_IN, immediate = true } = options; const urls = ref>(new Map()); const loading = ref(false); const error = ref(null); const getFileIdsValue = () => { if (typeof fileIds === 'function') { return fileIds(); } return fileIds; }; async function load() { const ids = getFileIdsValue(); if (!ids || ids.length === 0) { urls.value = new Map(); return; } loading.value = true; error.value = null; try { urls.value = await getFileUrls(ids, expiresIn); } catch (error_) { error.value = error_ as Error; console.error('批量获取文件URL失败:', error_); } finally { loading.value = false; } } async function refresh() { const ids = getFileIdsValue(); ids.forEach((id) => clearFileUrlCache(id)); await load(); } // 监听fileIds变化 if (typeof fileIds === 'function') { watch(fileIds, () => { load(); }); } // 立即加载 if (immediate) { load(); } /** * 获取单个文件的URL */ function getUrl(fileId: string): string { return urls.value.get(fileId) || ''; } return { urls, loading, error, refresh, load, getUrl, }; } // 定期清理过期缓存(每5分钟) setInterval(cleanupExpiredCache, 5 * 60 * 1000); export default useFileUrl;