import { getOssPresignedUrlApi } from '@/utils/api.js' /** * 预签名 URL 内存缓存 * key = 原始 cover_url(相对路径 / 完整带 Expires 的 URL) * value = 已签名的完整 URL * * 命中规则: * - 命中后再检查一次 isExpiredPresignedUrl,过期则丢弃重签 * - 同一进程内(页面 reload 会清);上限 500 条,超过随机清一半 */ const coverUrlCache = new Map() const COVER_CACHE_LIMIT = 500 function setCoverCache(rawUrl, signedUrl) { if (!rawUrl || !signedUrl) return if (coverUrlCache.size >= COVER_CACHE_LIMIT) { // 简单容量控制:删一半旧的,避免无限增长 const keys = Array.from(coverUrlCache.keys()).slice(0, COVER_CACHE_LIMIT / 2) keys.forEach(k => coverUrlCache.delete(k)) } coverUrlCache.set(rawUrl, signedUrl) } function getCoverCache(rawUrl) { const v = coverUrlCache.get(rawUrl) if (!v) return null if (isExpiredPresignedUrl(v)) { coverUrlCache.delete(rawUrl) return null } return v } /** * 从URL提取文件名 * @param {String} url - URL字符串 * @returns {String|null} 提取的文件名,失败返回null */ export function extractFileNameFromUrl(url) { if (!url || url.startsWith('/static/')) { return null; } try { let urlPath = url; // 如果是完整URL,提取路径部分 if (url.includes('://')) { // 找到协议后的第一个 / 开始的路径 const protocolEndIndex = url.indexOf('://'); const pathStartIndex = url.indexOf('/', protocolEndIndex + 3); if (pathStartIndex !== -1) { urlPath = url.substring(pathStartIndex); } } // 提取最后一个 / 之后的内容作为文件名 const fileName = urlPath.substring(urlPath.lastIndexOf('/') + 1); return fileName || null; } catch (error) { console.error('提取文件名失败:', error); return null; } } /** * 从cover_url提取文件名(兼容旧函数名) * @param {String} coverUrl - 后端返回的cover_url * @returns {String|null} 提取的文件名,失败返回null */ export function extractFileNameFromCoverUrl(coverUrl) { return extractFileNameFromUrl(coverUrl); } /** * 从OSS完整URL中提取对象路径(保留bucket之后的完整路径) * 例如:https://bucket.oss-cn-shanghai.aliyuncs.com/asset/13/87/file.jpg -> asset/13/87/file.jpg * @param {String} url - OSS完整URL * @returns {String|null} 对象路径,失败返回null */ export function extractOssObjectPath(url) { if (!url || url.startsWith('/static/')) return null; try { if (url.includes('aliyuncs.com/')) { return url.substring(url.indexOf('aliyuncs.com/') + 'aliyuncs.com/'.length); } // 兜底:返回域名后的路径 const protocolEnd = url.indexOf('://'); const pathStart = url.indexOf('/', protocolEnd + 3); if (pathStart !== -1) { return url.substring(pathStart + 1); } return null; } catch (e) { return null; } } /** * 判断 URL 是否为带 Expires 的 OSS 预签名 URL,且已过期 * @param {String} url * @returns {Boolean} */ function isExpiredPresignedUrl(url) { if (!url || typeof url !== 'string') return false const m = url.match(/[?&]Expires=(\d+)/) if (!m) return false // OSS Expires 单位是秒,Date.now() 单位是毫秒 return Number(m[1]) * 1000 < Date.now() } /** * 从完整 OSS URL 提取对象路径(去掉 ?Expires=...&OSSAccessKeyId=...&Signature=... 部分) * 例如:https://bucket.oss-cn-shanghai.aliyuncs.com/laser-card/x.png?Expires=... → laser-card/x.png * @param {String} url * @returns {String} */ function extractOssObjectPathFromUrl(url) { try { // 去掉 query string const pathOnly = url.split('?')[0] // 提取 aliyuncs.com/ 之后的路径 const marker = 'aliyuncs.com/' const idx = pathOnly.indexOf(marker) if (idx !== -1) { return pathOnly.substring(idx + marker.length) } // 兜底:去掉协议 + 域名 const protoEnd = pathOnly.indexOf('://') const pathStart = pathOnly.indexOf('/', protoEnd + 3) if (pathStart !== -1) { return pathOnly.substring(pathStart + 1) } return pathOnly } catch (e) { return url } } /** * 同步获取「立即可用的 cover URL」,绝不发起网络请求。 * 用于:先用这个 URL 立即渲染列表,再异步调 getAssetCoverRealUrl 拿到精确的预签名 URL 并替换。 * * 优先级: * 1) /static/... 或空 → 兜底占位图 * 2) 命中缓存 → 缓存内的预签名 URL * 3) 完整 URL 未过期 → 原样返回 * 4) 其它(相对路径 / 过期 URL) → 占位图(等异步预签名补上) * * @param {String} coverUrl * @param {String} [placeholder='/static/nft/collection.png'] * @returns {String} */ export function getInstantAssetCoverUrl(coverUrl, placeholder = '/static/nft/collection.png') { if (!coverUrl || coverUrl.startsWith('/static/')) { return coverUrl || placeholder } const cached = getCoverCache(coverUrl) if (cached) return cached if (coverUrl.includes('://') && !isExpiredPresignedUrl(coverUrl)) { return coverUrl } return placeholder } /** * 获取藏品封面的真实URL - 使用 OSS 预签名 URL * 处理 3 种 coverUrl 形态: * 1) /static/... 本地静态资源 → 原样返回 * 2) 相对路径 asset/1/87/xxx.jpg → 调 getOssPresignedUrlApi 重签 * 3) 完整 OSS 预签名 URL(可能已过期) → 检测 Expires,过期则提取对象路径重签 * @param {String} coverUrl - 后端返回的cover_url * @returns {Promise} 真实可访问的URL */ export async function getAssetCoverRealUrl(coverUrl) { // 默认图片路径 const DEFAULT_IMAGE = '/static/nft/collection.png'; // 1. 本地静态资源或空 → 直接返回 if (!coverUrl || coverUrl.startsWith('/static/')) { return coverUrl || DEFAULT_IMAGE; } // 命中缓存:直接返回(已在 getCoverCache 内做过期检查) const cached = getCoverCache(coverUrl) if (cached) return cached // 2. 完整 URL(带 ://) if (coverUrl.includes('://')) { // 检查是否带 Expires 且已过期 → 提取对象路径重签 if (isExpiredPresignedUrl(coverUrl)) { const objectPath = extractOssObjectPathFromUrl(coverUrl) try { const res = await getOssPresignedUrlApi(objectPath, 3600, 'asset') if (res && res.data && res.data.url) { setCoverCache(coverUrl, res.data.url) return res.data.url } } catch (e) { console.error('[assetImageHelper] re-sign expired url failed:', e) } return objectPath // 兜底:返回相对路径 } // 未过期:原样返回 + 顺手缓存 setCoverCache(coverUrl, coverUrl) return coverUrl } // 3. 相对路径:调预签名接口 try { const res = await getOssPresignedUrlApi(coverUrl, 3600, 'asset'); if (res && res.data && res.data.url) { setCoverCache(coverUrl, res.data.url) return res.data.url; } return coverUrl; } catch (e) { console.error('[assetImageHelper] get presigned url failed:', e); return coverUrl; } } /** * 获取好友头像的真实URL - 使用 OSS 预签名 URL * @param {String} avatarUrl - 后端返回的avatar_url * @returns {Promise} 真实可访问的URL,失败返回空字符串 */ export async function getFriendAvatarRealUrl(avatarUrl) { // 如果是本地静态资源或为空,直接返回 if (!avatarUrl || avatarUrl.startsWith('/static/')) { return avatarUrl || ''; } // 如果是完整的绝对URL(包含 :://),直接返回 if (avatarUrl.includes('://')) { return avatarUrl; } // 相对路径:调用预签名接口获取可访问的URL try { const res = await getOssPresignedUrlApi(avatarUrl, 3600, 'avatar'); if (res && res.data && res.data.url) { return res.data.url; } // 预签名失败,返回原始路径 return avatarUrl; } catch (e) { console.error('[assetImageHelper] get avatar presigned url failed:', e); return avatarUrl; } }