// frontend/utils/preloadApi/storage.js // 文件缓存适配器 — _doc/preload/{userId}/ 目录下的 JSON 文件读写 // APP-PLUS: 优先 plus.io(promisify),降级 uni.getFileSystemManager // H5/小程序: uni.getFileSystemManager const BASE_DIR = '_doc/preload' const NAMESPACE = 'preload' // ── djb2 hash(与 core.js 共用逻辑,此处独立一份避免循环依赖)── function hashStr(str) { let hash = 5381 for (let i = 0; i < str.length; i++) { hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0 } return (hash >>> 0).toString(16) } // ── 路径工具 ── function getUserDir(userId) { return `${BASE_DIR}/${userId || 'guest'}` } function getFilePath(userId, cacheKey) { return `${getUserDir(userId)}/${hashStr(cacheKey)}.json` } // ── plus.io promisify 工具 ── function promisifyPlusIO(fn) { return new Promise((resolve, reject) => { try { fn(resolve, reject) } catch (e) { reject(e) } }) } // ── 确保目录存在 ── async function ensureDir(dirPath) { // #ifdef APP-PLUS return promisifyPlusIO((resolve, reject) => { plus.io.resolveLocalFileSystemURL( `_doc/`, (docEntry) => { // 逐级创建 preload/{userId} const parts = dirPath.replace('_doc/', '').split('/') let currentEntry = docEntry const createNext = (idx) => { if (idx >= parts.length) return resolve() currentEntry.getDirectory( parts[idx], { create: true }, (dirEntry) => { currentEntry = dirEntry createNext(idx + 1) }, (err) => reject(err) ) } createNext(0) }, (err) => reject(err) ) }) // #endif // #ifndef APP-PLUS try { const fs = uni.getFileSystemManager() // uni.getFileSystemManager 的 mkdir 需要父目录已存在,逐级创建 const parts = dirPath.replace('_doc/', '').split('/') let current = '_doc' for (const part of parts) { current += '/' + part try { fs.accessSync(current) } catch (e) { fs.mkdirSync(current) } } } catch (e) { // 目录已存在或创建失败,静默 } // #endif } // ── 公共 API ── /** * 读文件缓存条目 * @returns {Promise<{data, ts, ttl}|null>} null = 未命中 */ export async function readEntry(userId, cacheKey) { const filePath = getFilePath(userId, cacheKey) try { // #ifdef APP-PLUS const content = await promisifyPlusIO((resolve, reject) => { plus.io.resolveLocalFileSystemURL( filePath, (fileEntry) => { fileEntry.file( (file) => { const reader = new plus.io.FileReader() reader.onloadend = (e) => resolve(e.target.result) reader.onerror = (e) => reject(e) reader.readAsText(file, 'utf-8') }, (err) => reject(err) ) }, (err) => reject(err) // 文件不存在 = 未命中 ) }) return JSON.parse(content) // #endif // #ifndef APP-PLUS const fs = uni.getFileSystemManager() const raw = fs.readFileSync(filePath, 'utf-8') return JSON.parse(raw) // #endif } catch (e) { return null // 文件不存在 / 损坏 → 未命中 } } /** * 写文件缓存条目(fire-and-forget,调用方不 await) * 内部自建 .catch 防止 unhandled rejection */ export function writeEntry(userId, cacheKey, data, ts, ttl) { const dirPath = getUserDir(userId) const filePath = getFilePath(userId, cacheKey) const content = JSON.stringify({ data, ts, ttl }) ensureDir(dirPath).then(() => { // #ifdef APP-PLUS return promisifyPlusIO((resolve, reject) => { plus.io.resolveLocalFileSystemURL( dirPath, (dirEntry) => { dirEntry.getFile( hashStr(cacheKey) + '.json', { create: true }, (fileEntry) => { fileEntry.createWriter( (writer) => { writer.onwriteend = () => resolve() writer.onerror = (e) => reject(e) writer.write(content) }, (err) => reject(err) ) }, (err) => reject(err) ) }, (err) => reject(err) ) }) // #endif // #ifndef APP-PLUS const fs = uni.getFileSystemManager() fs.writeFileSync(filePath, content, 'utf-8') // #endif }).catch((err) => { console.warn('[preload] storage write failed:', filePath, err.message) }) } /** * 删除指定用户的文件缓存目录 */ export async function clearForUser(userId) { const dirPath = getUserDir(userId) try { // #ifdef APP-PLUS await promisifyPlusIO((resolve, reject) => { plus.io.resolveLocalFileSystemURL( dirPath, (dirEntry) => { dirEntry.removeRecursively( () => resolve(), (err) => reject(err) ) }, // 目录不存在不算错误 () => resolve() ) }) // #endif // #ifndef APP-PLUS const fs = uni.getFileSystemManager() try { fs.rmdirSync(dirPath, true) } catch (e) { /* absent = ok */ } // #endif } catch (e) { console.warn('[preload] clearForUser failed:', userId, e.message) } } /** * 获取用户缓存目录总大小(字节) * 用于 FIFO 容量检查 * 注意:累加所有文件的 file.size,异步回调全部完成后才 resolve */ export async function getTotalCacheSize(userId) { const dirPath = getUserDir(userId) let totalSize = 0 try { // #ifdef APP-PLUS await promisifyPlusIO((resolve, reject) => { plus.io.resolveLocalFileSystemURL( dirPath, (dirEntry) => { const reader = dirEntry.createReader() let pending = 0 let done = false const readAll = () => { reader.readEntries( (entries) => { if (entries.length === 0) { done = true if (pending === 0) resolve() return } for (const entry of entries) { if (entry.isFile) { pending++ entry.file( (f) => { totalSize += (f.size || 0) pending-- if (done && pending === 0) resolve() }, () => { pending-- if (done && pending === 0) resolve() } ) } } readAll() // 递归读下一批 }, (err) => reject(err) ) } readAll() }, () => resolve() // 目录不存在 → size = 0 ) }) // #endif // #ifndef APP-PLUS const fs = uni.getFileSystemManager() try { const files = fs.readdirSync(dirPath) for (const f of files) { try { const stat = fs.statSync(dirPath + '/' + f) totalSize += stat.size || 0 } catch (e) { /* skip */ } } } catch (e) { /* absent = ok */ } // #endif } catch (e) { // ignore } return totalSize } /** * FIFO 淘汰最旧文件,直到总大小 < maxSize 字节 * APP-PLUS:递归 readEntries 收集所有文件 → 按 mtime 排序 → 从最旧的开始删除 * 非 APP-PLUS:readdir + stat → 按 mtime 排序 → 删除最旧的 * 注:getTotalCacheSize 的异步回调方式不适用于"删除后重算"循环, * 此处改为一次 scan 出文件列表 → 排序 → 按需删除 */ export async function evictOldest(userId, maxSize) { const dirPath = getUserDir(userId) try { // #ifdef APP-PLUS // 1. 收集所有文件及其 mtime 和 size const files = await promisifyPlusIO((resolve, reject) => { plus.io.resolveLocalFileSystemURL( dirPath, (dirEntry) => { const reader = dirEntry.createReader() const collected = [] let pending = 0 let done = false const readAll = () => { reader.readEntries( (entries) => { if (entries.length === 0) { done = true if (pending === 0) resolve(collected) return } for (const entry of entries) { if (entry.isFile) { pending++ entry.file( (f) => { // plus.io File 的 modificationTime 或直接用 lastModified const mtime = f.lastModified || f.lastModifiedDate?.getTime?.() || 0 collected.push({ name: entry.name, entry, size: f.size || 0, mtime }) pending-- if (done && pending === 0) resolve(collected) }, () => { pending-- if (done && pending === 0) resolve(collected) } ) } } readAll() }, (err) => reject(err) ) } readAll() }, () => resolve([]) // 目录不存在 → 空列表 ) }) // 2. 按 mtime 升序排列(最旧的在前) files.sort((a, b) => a.mtime - b.mtime) // 3. 计算当前总大小,按 FIFO 删除直到 < maxSize * 0.8 let totalSize = files.reduce((sum, f) => sum + f.size, 0) for (const f of files) { if (totalSize <= maxSize * 0.8) break f.entry.remove(() => {}, () => {}) totalSize -= f.size } // #endif // #ifndef APP-PLUS const fs = uni.getFileSystemManager() try { const fileNames = fs.readdirSync(dirPath) const files = fileNames.map(name => { try { const stat = fs.statSync(dirPath + '/' + name) return { name, size: stat.size || 0, mtime: stat.lastModified || stat.lastModifiedTime || 0 } } catch (e) { return { name, size: 0, mtime: 0 } } }) // 按 mtime 升序(最旧的在前) files.sort((a, b) => a.mtime - b.mtime) let totalSize = files.reduce((sum, f) => sum + f.size, 0) for (const f of files) { if (totalSize <= maxSize * 0.8) break try { fs.unlinkSync(dirPath + '/' + f.name) } catch (e) { /* skip */ } totalSize -= f.size } } catch (e) { /* absent = ok */ } // #endif } catch (e) { console.warn('[preload] evictOldest failed:', userId, e.message) } }