369 lines
9.4 KiB
JavaScript
369 lines
9.4 KiB
JavaScript
// 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)
|
||
}
|
||
}
|