topfans/docs/superpowers/plans/2026-07-08-preload-api-implementation.md

58 KiB
Raw Blame History

API 预加载方案 — 实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 实现通用 API 预加载系统(内存缓存 + 文件缓存 + 启动预热 + 页面切换预拉 + Vue 3 composable

Architecture: 6 个核心模块 + 1 个 composable + 1 个业务配置文件。core.js 为纯 JS 缓存引擎Map + LRU + inFlight 去重storage.js 负责文件持久化(_doc/preload/{userId}/scheduler.js/navigate.js 负责触发时机usePreload.js 提供 Vue 响应式包装

Tech Stack: uniapp (Vue 3 + Vite)、plus.io (APP-PLUS)、uni.getFileSystemManager (降级)

Spec: docs/superpowers/specs/2026-07-02-preload-api-design.md


File Structure

frontend/
├── utils/
│   ├── api.js                          # MODIFY: add .abort() to request()
│   └── preloadApi/
│       ├── storage.js                  # CREATE: file cache adapter
│       ├── core.js                     # CREATE: core cache engine
│       ├── config.js                   # CREATE: config loader
│       ├── scheduler.js                # CREATE: startup/idle scheduler
│       ├── navigate.js                 # CREATE: wrapped navigation
│       ├── index.js                    # CREATE: unified export
│       ├── README.md                   # CREATE: developer docs
│       └── __tests__/
│           ├── core.test.js            # CREATE: core unit tests
│           ├── storage.test.js         # CREATE: storage unit tests
│           └── navigate.test.js        # CREATE: navigate + composable tests
├── composables/
│   └── usePreload.js                   # CREATE: Vue 3 composable
├── config/
│   └── preload.config.js               # CREATE: business config
├── store/modules/
│   └── user.js                         # MODIFY: patch mutations
└── App.vue                             # MODIFY: integrate warmStartup/warmIdle

Task 1: Modify utils/api.js — add .abort() to request()

Files:

  • Modify: frontend/utils/api.js:55-145

  • Step 1: Add abort support to request()

Replace the request() function body (lines 55-145 of api.js). The key changes:

  1. Capture requestTask from uni.request() return value
  2. Add _aborted flag
  3. Guard fail callback against abort-triggered errors
  4. Attach .abort() method to the returned Promise
// frontend/utils/api.js — request() 函数替换

export function request(options) {
	let _aborted = false
	let requestTask = null

	// 构建请求头
	const headers = {
		'Content-Type': 'application/json',
		// 风控限流spec §9.1 v2.3):设备指纹维度
		'X-Device-Fingerprint': getDeviceFingerprint(),
		...options.header
	}

	// 判断是否为登录或注册接口
	const isAuthRequest = options.url.includes('/api/v1/auth/login') || options.url.includes(
		'/api/v1/auth/register') || options.url.includes('/api/v1/auth/send-code') || options.url.includes('/api/v1/auth/verify-code')

	// 如果不是登录/注册接口则自动添加JWT token
	if (!isAuthRequest) {
		const token = uni.getStorageSync('access_token')
		if (token) {
			headers['Authorization'] = `Bearer ${token}`
		}
	}

	const p = new Promise((resolve, reject) => {
		requestTask = uni.request({
			url: baseURL + options.url,
			method: options.method || 'GET',
			data: options.data || {},
			header: headers,
			timeout: 60000,
			success: (res) => {
				// 处理 token 过期HTTP 401
				if (res.statusCode === 401) {
					uni.removeStorageSync('access_token')
					uni.removeStorageSync('user')
					uni.reLaunch({ url: '/pages/login/portal' })
					reject(new Error('登录已过期,请重新登录'))
					return
				}

				if (res.statusCode === 200 || res.statusCode === 202) {
					if (res.data && res.data.code !== undefined) {
						if (res.data.code === 0) {
							resolve(res.data)
						} else if (res.data.code === 16 || res.data.code === 7) {
							uni.removeStorageSync('access_token')
							uni.removeStorageSync('user')
							const errorMsg = res.data.message || '登录已过期,请重新登录'
							uni.reLaunch({
								url: '/pages/login/portal?error=' + encodeURIComponent(errorMsg)
							})
							const authErr = new Error(errorMsg)
							authErr.code = res.data.code
							reject(authErr)
							return
						} else {
							const bizErr = new Error(res.data.message || '请求失败')
							bizErr.code = res.data.code
							reject(bizErr)
						}
					} else {
						resolve(res.data)
					}
				} else {
					const errorMessage = res.data?.message || `请求失败 (${res.statusCode})`
					const httpErr = new Error(errorMessage)
					if (res.data?.code !== undefined) httpErr.code = res.data.code
					reject(httpErr)
				}
			},
			fail: (err) => {
				// ★ 新增abort 触发的 fail 静默忽略
				if (_aborted) return
				reject(new Error(err.errMsg || '网络请求失败'))
			}
		})
	})

	// ★ 新增:挂载 abort 方法
	p.abort = () => {
		_aborted = true
		if (requestTask) requestTask.abort()
	}

	return p
}
  • Step 2: Verify api.js works correctly

Run the existing app to confirm no regressions:

# In HBuilderX: 运行 → 运行到手机或模拟器 → 选择设备
# Verify: login, navigate between pages, API calls still work
# No console errors related to request()

Task 2: Create utils/preloadApi/storage.js — file cache adapter

Files:

  • Create: frontend/utils/preloadApi/storage.js

  • Step 1: Create storage.js

// frontend/utils/preloadApi/storage.js
// 文件缓存适配器 — _doc/preload/{userId}/ 目录下的 JSON 文件读写
// APP-PLUS: 优先 plus.iopromisify降级 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-PLUSreaddir + 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)
	}
}
  • Step 2: Verify storage.js syntax
cd frontend
# No build errors expected (the file uses #ifdef blocks, valid in uniapp)

Task 3: Create utils/preloadApi/core.js — core cache engine

Files:

  • Create: frontend/utils/preloadApi/core.js

  • Step 1: Create core.js

// frontend/utils/preloadApi/core.js
// 核心缓存引擎 — 零外部依赖(不依赖 Vue / uni API / api.js / store
// 内存 Map + LRU + inFlight 去重 + TTL
// 依赖关系内模块import { readEntry, writeEntry, clearForUser as storageClearForUser, getTotalCacheSize, evictOldest } from './storage'

import {
	readEntry,
	writeEntry,
	clearForUser as storageClearForUser,
	getTotalCacheSize,
	evictOldest
} from './storage'

// ── 常量 ──
const NAMESPACE = 'preload'
const DEFAULT_MAX_MEMORY_ENTRIES = 100
const DEFAULT_MAX_ENTRY_SIZE_KB = 1024
const DEFAULT_MAX_FILE_CACHE_MB = 50

// ── 内部状态 ──
const memoryMap = new Map()      // Map<cacheKey, {data, ts, ttl, persistence}>
const inFlightMap = new Map()    // Map<cacheKey, {promise, abort}>

// 统计
let hitCount = 0
let missCount = 0

// 运行时配置(由外部 setConfig 写入)
let _config = {
	defaults: {
		ttl: 5 * 60 * 1000,
		persistence: 'memory',
		concurrency: 4,
		timeout: 10000,
		silent: true,
		limits: {
			maxEntrySizeKB: DEFAULT_MAX_ENTRY_SIZE_KB,
			maxMemoryEntries: DEFAULT_MAX_MEMORY_ENTRIES,
			maxFileCacheMB: DEFAULT_MAX_FILE_CACHE_MB
		}
	}
}

// fetcher 注册表:{ [logicalKey]: fetcherFunction }
let _fetchers = {}

// userId 获取函数(由外部注入)
let _getUserId = () => {
	try {
		const userStr = uni.getStorageSync('user')
		if (userStr) {
			const user = JSON.parse(userStr)
			return user?.uid || null
		}
	} catch (e) { /* ignore */ }
	return null
}

// ── 并发控制 semaphore ──
function createSemaphore(max) {
	let running = 0
	const queue = []
	return {
		acquire: () => new Promise(resolve => {
			if (running < max) { running++; resolve() }
			else { queue.push(resolve) }
		}),
		release: () => {
			running--
			const next = queue.shift()
			if (next) { running++; next() }
		}
	}
}

let _semaphore = createSemaphore(_config.defaults.concurrency)

// ── hash 工具 ──
function djb2(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 hashParams(params) {
	if (!params || Object.keys(params).length === 0) return ''
	const sorted = {}
	Object.keys(params).sort().forEach(k => { sorted[k] = params[k] })
	return djb2(JSON.stringify(sorted))
}

function buildCacheKey(userId, logicalKey, params) {
	const uid = userId || 'guest'
	const paramHash = hashParams(params)
	return `${uid}::${NAMESPACE}::${logicalKey}::${paramHash}`
}

// ── LRU touch ──
function touchLRU(map, key, value) {
	if (map.has(key)) map.delete(key)
	map.set(key, value)
	if (map.size > _config.defaults.limits.maxMemoryEntries) {
		const oldestKey = map.keys().next().value
		map.delete(oldestKey)
		if (typeof console !== 'undefined') {
			console.log('[preload] LRU evict:', oldestKey)
		}
	}
}

// ── 401/7/16 swallow ──
function _swallowAuth(err) {
	if (err && (err.code === 7 || err.code === 16)) return null
	if (err && /登录已过期/.test(err.message || '')) return null
	return err
}

// ── 数据大小检查 ──
function getDataSizeKB(data) {
	try {
		return new Blob([JSON.stringify(data)]).size / 1024
	} catch (e) {
		return JSON.stringify(data || '').length / 1024
	}
}

// ── 配置 API ──

/**
 * 设置运行时配置 + fetcher 注册表
 * 由 config.js 在初始化时调用
 */
export function setConfig(config, fetchers) {
	if (config) {
		_config = {
			defaults: {
				..._config.defaults,
				...(config.defaults || {}),
				limits: {
					..._config.defaults.limits,
					...((config.defaults && config.defaults.limits) || {})
				}
			},
			startup: config.startup || _config.startup || [],
			idle: config.idle || _config.idle || [],
			pages: config.pages || _config.pages || {}
		}
		_semaphore = createSemaphore(_config.defaults.concurrency)
	}
	if (fetchers) {
		_fetchers = { ..._fetchers, ...fetchers }
	}
}

/**
 * 设置 userId 获取函数(用于测试注入)
 */
export function setUserIdGetter(fn) {
	if (typeof fn === 'function') _getUserId = fn
}

// ── 核心 API ──

/**
 * 获取缓存值(组件使用)
 * 命中内存 → 同步 resolve文件缓存命中 / fetch → async resolve
 */
export function get(logicalKey, params) {
	const userId = _getUserId()
	const cacheKey = buildCacheKey(userId, logicalKey, params)

	// 1. 查内存
	const memEntry = memoryMap.get(cacheKey)
	if (memEntry && (Date.now() - memEntry.ts) < memEntry.ttl) {
		touchLRU(memoryMap, cacheKey, memEntry)
		hitCount++
		return Promise.resolve(memEntry.data)
	}

	// 2. inFlight 去重
	const inFlight = inFlightMap.get(cacheKey)
	if (inFlight) {
		return inFlight.promise.then(result => result.data)
	}

	// 3. 发起 fetch含文件缓存回退
	return _doFetch(logicalKey, params, cacheKey, userId, false)
}

/**
 * 触发预拉fire-and-forget
 * 查内存 → inFlight → 发起 fetch
 */
export function run(logicalKey, params) {
	const userId = _getUserId()
	const cacheKey = buildCacheKey(userId, logicalKey, params)

	// 1. 查内存
	const memEntry = memoryMap.get(cacheKey)
	if (memEntry && (Date.now() - memEntry.ts) < memEntry.ttl) {
		return  // 未过期,跳过
	}

	// 2. inFlight 去重
	if (inFlightMap.has(cacheKey)) {
		return  // 已在请求中
	}

	// 3. 发起 fetchfire-and-forget不返回 Promise
	_doFetch(logicalKey, params, cacheKey, userId, true)
}

/**
 * 命令式刷新
 */
export function refresh(logicalKey, params, force = false) {
	const userId = _getUserId()
	const cacheKey = buildCacheKey(userId, logicalKey, params)

	// force=true 时先删除内存缓存
	if (force) {
		memoryMap.delete(cacheKey)
	}

	return _doFetch(logicalKey, params, cacheKey, userId, false)
}

/**
 * 失效单个 key仅内存
 */
export function invalidate(logicalKey, params) {
	const cacheKey = buildCacheKey(_getUserId(), logicalKey, params)
	memoryMap.delete(cacheKey)
}

/**
 * 按前缀失效(仅内存)
 */
export function invalidatePrefix(prefix) {
	const fullPrefix = `${_getUserId() || 'guest'}::${NAMESPACE}::${prefix}`
	for (const key of memoryMap.keys()) {
		if (key.startsWith(fullPrefix)) {
			memoryMap.delete(key)
		}
	}
}

/**
 * 清空全部内存缓存(不动文件缓存)
 */
export function invalidateAll() {
	memoryMap.clear()
}

/**
 * 删除指定用户的文件缓存目录(不动内存)
 */
export function clearForUser(userId) {
	storageClearForUser(userId)
}

/**
 * 登出专用:清空内存 + 删除文件缓存目录
 */
export function clearUser(userId) {
	memoryMap.clear()
	storageClearForUser(userId)
}

/**
 * 按目标页路径触发预拉navigate.js 内部调用)
 */
export function prefetchFor(targetPath, params) {
	const pages = _config.pages || {}
	const entries = pages[targetPath]
	if (!entries || !Array.isArray(entries)) return

	for (const entry of entries) {
		run(entry.key, params)
	}
}

/**
 * 取消指定 key 的 in-flight 请求(供 composable unmount / params 变化时使用)
 */
export function abortRequest(logicalKey, params) {
	const userId = _getUserId()
	const cacheKey = buildCacheKey(userId, logicalKey, params)
	const entry = inFlightMap.get(cacheKey)
	if (entry) {
		entry.abort()
		inFlightMap.delete(cacheKey)
	}
}

/**
 * 获取调试统计
 */
export function getStats() {
	return {
		hits: hitCount,
		misses: missCount,
		memorySize: memoryMap.size,
		inFlightSize: inFlightMap.size,
		fileCacheBytes: _lastFileCacheSize
	}
}

// 上次文件缓存大小(由 checkAndEvict 异步更新)
let _lastFileCacheSize = 0

// 内部:更新文件缓存大小跟踪
function _updateFileCacheSize() {
	const userId = _getUserId()
	if (userId) {
		getTotalCacheSize(userId).then(size => {
			_lastFileCacheSize = size
		}).catch(() => {})
	}
}

/**
 * dump 内存缓存(调试用)
 */
export function dumpMemory() {
	const result = []
	for (const [key, entry] of memoryMap.entries()) {
		result.push({
			key,
			age: Date.now() - entry.ts,
			ttl: entry.ttl,
			persistence: entry.persistence
		})
	}
	return result
}

// ── 内部:执行 fetch ──
async function _doFetch(logicalKey, params, cacheKey, userId, isRun) {
	const fetcher = _fetchers[logicalKey]
	if (!fetcher) {
		if (!isRun) throw new Error(`[preload] unknown key: ${logicalKey}`)
		console.warn(`[preload] unknown key: ${logicalKey}`)
		return
	}

	const cfg = _resolveEntryConfig(logicalKey)
	const ttl = cfg.ttl || _config.defaults.ttl
	const persistence = cfg.persistence || _config.defaults.persistence
	const silent = cfg.silent !== undefined ? cfg.silent : _config.defaults.silent
	const timeout = cfg.timeout || _config.defaults.timeout

	// 3. 先查本地文件缓存(非 run 路径)
	if (!isRun && persistence === 'file') {
		try {
			const fileEntry = await readEntry(userId, cacheKey)
			if (fileEntry && (Date.now() - fileEntry.ts) < fileEntry.ttl) {
				// 文件命中 → 写回内存
				touchLRU(memoryMap, cacheKey, {
					data: fileEntry.data,
					ts: fileEntry.ts,
					ttl: fileEntry.ttl,
					persistence: 'file'
				})
				hitCount++
				return fileEntry.data
			}
		} catch (e) {
			// 文件读取失败 → 走 fetch
		}
	}

	// 4. 创建 inFlight 条目
	let resolveInFlight, rejectInFlight
	const sharedPromise = new Promise((res, rej) => {
		resolveInFlight = res
		rejectInFlight = rej
	})

	let abortFn = () => {}
	const inFlightEntry = {
		promise: sharedPromise.then(data => ({ data })),
		abort: () => abortFn()
	}
	inFlightMap.set(cacheKey, inFlightEntry)

	const cleanup = () => {
		inFlightMap.delete(cacheKey)
	}

	// 5. 并发控制 + 超时
	await _semaphore.acquire()

	try {
		const startTime = Date.now()
		const fetchPromise = fetcher(params)

		// 设置 abort
		abortFn = () => {
			if (fetchPromise && typeof fetchPromise.abort === 'function') {
				fetchPromise.abort()
			}
			cleanup()
		}

		// 超时控制
		let timeoutId
		const timeoutPromise = new Promise((_, reject) => {
			timeoutId = setTimeout(() => reject(new Error('timeout')), timeout)
		})

		const result = await Promise.race([fetchPromise, timeoutPromise])
		clearTimeout(timeoutId)

		const elapsed = Date.now() - startTime
		console.log('[preload] fetch done:', logicalKey, elapsed + 'ms')

		// 6. 写内存缓存
		const entry = { data: result, ts: Date.now(), ttl, persistence }
		touchLRU(memoryMap, cacheKey, entry)

		// 7. 异步写文件缓存fire-and-forget不阻塞 fetch 返回)
		if (persistence === 'file') {
			const sizeKB = getDataSizeKB(result)
			if (sizeKB <= _config.defaults.limits.maxEntrySizeKB) {
				writeEntry(userId, cacheKey, result, entry.ts, ttl)
				// fire-and-forget 容量检查:延迟到下一 tick 确保 writeEntry 已启动
				setTimeout(() => {
					checkAndEvict(userId)
				}, 0)
			}
		}

		missCount++
		resolveInFlight(result)
		return result
	} catch (err) {
		// 8. 错误处理
		const swallowed = _swallowAuth(err)
		if (swallowed === null) {
			// 401/7/16 → swallow
			console.warn('[preload] auth-expired, swallowed:', logicalKey)
			resolveInFlight(null)
			return null
		}

		if (silent || isRun) {
			// run / silent → 静默
			console.warn('[preload] fetch fail (swallowed):', logicalKey, err.message)
			resolveInFlight(null)
			return null
		}

		// get 路径 → 抛错给调用方
		rejectInFlight(err)
		throw err
	} finally {
		cleanup()
		_semaphore.release()
	}
}

// ── 内部fire-and-forget 容量检查 + 淘汰 ──
async function checkAndEvict(userId) {
	try {
		const totalSize = await getTotalCacheSize(userId)
		_lastFileCacheSize = totalSize
		const maxBytes = _config.defaults.limits.maxFileCacheMB * 1024 * 1024
		if (totalSize > maxBytes) {
			await evictOldest(userId, maxBytes)
			// 淘汰后更新大小
			const newSize = await getTotalCacheSize(userId)
			_lastFileCacheSize = newSize
		}
	} catch (e) {
		console.warn('[preload] eviction check failed:', e.message)
	}
}

// ── 内部:解析 per-key 配置 ──
function _resolveEntryConfig(logicalKey) {
	// 从 _config 中查找该 key 的配置startup/idle/pages 任一数组)
	const all = [
		...(_config.startup || []),
		...(_config.idle || []),
	]
	if (_config.pages) {
		for (const entries of Object.values(_config.pages)) {
			if (Array.isArray(entries)) all.push(...entries)
		}
	}
	const found = all.find(e => e.key === logicalKey)
	return found || {}
}

// ── 开发调试 ──
if (typeof window !== 'undefined' && (typeof import.meta === 'undefined' || import.meta.env?.DEV)) {
	window.__PRELOAD_DEBUG__ = {
		dumpMemory,
		stats: getStats,
		all: () => ({
			memory: dumpMemory(),
			inFlight: Array.from(inFlightMap.keys())
		})
	}
}
  • Step 2: Verify core.js syntax
cd frontend
# No syntax errors expected

Task 4: Create utils/preloadApi/config.js — config loader

Files:

  • Create: frontend/utils/preloadApi/config.js

  • Step 1: Create config.js

// frontend/utils/preloadApi/config.js
// 配置加载器:合并用户配置与内置默认值,提取 fetcher 映射表

/**
 * 内置默认值(与设计文档 §3 defaults 一致)
 */
const BUILTIN_DEFAULTS = {
	ttl: 5 * 60 * 1000,
	persistence: 'memory',
	concurrency: 4,
	timeout: 10000,
	silent: true,
	limits: {
		maxEntrySizeKB: 1024,
		maxMemoryEntries: 100,
		maxFileCacheMB: 50
	}
}

/**
 * 加载并合并配置
 * @param {object} userConfig - 用户提供的 preload.config.js export
 * @returns {{ config: object, fetchers: object }}
 */
export function loadConfig(userConfig) {
	if (!userConfig) {
		throw new Error('[preload] config is required')
	}

	// 合并 defaults
	const mergedDefaults = {
		...BUILTIN_DEFAULTS,
		...(userConfig.defaults || {}),
		limits: {
			...BUILTIN_DEFAULTS.limits,
			...((userConfig.defaults && userConfig.defaults.limits) || {})
		}
	}

	const config = {
		defaults: mergedDefaults,
		startup: userConfig.startup || [],
		idle: userConfig.idle || [],
		pages: userConfig.pages || {}
	}

	// 提取 fetcher 映射表
	const fetchers = {}
	const allEntries = [
		...(config.startup || []),
		...(config.idle || [])
	]
	for (const entries of Object.values(config.pages || {})) {
		if (Array.isArray(entries)) allEntries.push(...entries)
	}

	for (const entry of allEntries) {
		if (entry.key && typeof entry.fetcher === 'function') {
			fetchers[entry.key] = entry.fetcher
		}
	}

	return { config, fetchers }
}

Task 5: Create utils/preloadApi/scheduler.js — startup/idle scheduler

Files:

  • Create: frontend/utils/preloadApi/scheduler.js

  • Step 1: Create scheduler.js

// frontend/utils/preloadApi/scheduler.js
// 调度器:启动期预热 + idle 预拉
// 依赖 core.js 的 run()

import { run } from './core'

// App 端 fallback没有 requestIdleCallback用 setTimeout
const idle =
	typeof requestIdleCallback === 'function'
		? requestIdleCallback
		: (cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)

/**
 * 启动期预热
 * @param {Array} startupList - config.startup 数组
 */
export function warmStartup(startupList) {
	if (!startupList || !Array.isArray(startupList)) return

	console.log('[preload] warmStartup:', startupList.length, 'keys')
	for (const entry of startupList) {
		// fire-and-forget不 await并发由 core 内部 semaphore 控制
		run(entry.key, entry.params)
	}
}

/**
 * idle 预拉幂等run 内部处理去重和缓存命中)
 * @param {Array} idleList - config.idle 数组
 */
export function warmIdle(idleList) {
	if (!idleList || !Array.isArray(idleList)) return

	idle(() => {
		console.log('[preload] warmIdle:', idleList.length, 'keys')
		for (const entry of idleList) {
			run(entry.key, entry.params)
		}
	})
}

Task 6: Create utils/preloadApi/navigate.js — wrapped navigation

Files:

  • Create: frontend/utils/preloadApi/navigate.js

  • Step 1: Create navigate.js

// frontend/utils/preloadApi/navigate.js
// 包装 uni.navigateTo / switchTab / reLaunch
// 跳转前 fire-and-forget 预拉目标页数据,不 await

import { prefetchFor } from './core'

/**
 * 从 URL 中解析 query string → params 对象
 * 例:'/pages/foo/bar?id=123&type=hot' → { id: '123', type: 'hot' }
 */
function parseQueryParams(url) {
	const idx = url.indexOf('?')
	if (idx === -1) return {}

	const qs = url.substring(idx + 1)
	const params = {}
	// 使用 URLSearchParamsuniapp 环境支持)
	try {
		const usp = new URLSearchParams(qs)
		for (const [k, v] of usp) {
			// URLSearchParams 已自动解码,不需要再 decodeURIComponent
			params[k] = v
		}
	} catch (e) {
		// fallback手动解析
		for (const pair of qs.split('&')) {
			const eqIdx = pair.indexOf('=')
			if (eqIdx === -1) continue
			const k = decodeURIComponent(pair.substring(0, eqIdx))
			const v = decodeURIComponent(pair.substring(eqIdx + 1))
			if (k) params[k] = v
		}
	}
	return params
}

/**
 * 从 URL 中提取目标页路径(去掉 query string
 */
function extractPath(url) {
	const idx = url.indexOf('?')
	return idx === -1 ? url : url.substring(0, idx)
}

/**
 * 替代 uni.navigateTo
 * 内部:解析目标页 → 触发预拉fire-and-forget→ 立即跳转
 */
export function navigateTo(opts) {
	const url = typeof opts === 'string' ? opts : opts.url
	const targetPath = extractPath(url)
	const params = parseQueryParams(url)

	// 触发预拉fire-and-forget不 await
	prefetchFor(targetPath, params)

	// 立即跳转
	if (typeof opts === 'string') {
		uni.navigateTo({ url: opts })
	} else {
		uni.navigateTo(opts)
	}
}

/**
 * 替代 uni.switchTab
 */
export function switchTab(opts) {
	const url = typeof opts === 'string' ? opts : opts.url
	const targetPath = extractPath(url)
	const params = parseQueryParams(url)

	prefetchFor(targetPath, params)

	if (typeof opts === 'string') {
		uni.switchTab({ url: opts })
	} else {
		uni.switchTab(opts)
	}
}

/**
 * 替代 uni.reLaunch
 */
export function reLaunch(opts) {
	const url = typeof opts === 'string' ? opts : opts.url
	const targetPath = extractPath(url)
	const params = parseQueryParams(url)

	prefetchFor(targetPath, params)

	if (typeof opts === 'string') {
		uni.reLaunch({ url: opts })
	} else {
		uni.reLaunch(opts)
	}
}

Task 7: Create utils/preloadApi/index.js — unified export

Files:

  • Create: frontend/utils/preloadApi/index.js

  • Step 1: Create index.js

// frontend/utils/preloadApi/index.js
// 统一导出 preloadApi命令式 API

import { loadConfig } from './config'
import { setConfig, setUserIdGetter } from './core'
import {
	get,
	run,
	refresh,
	abortRequest,
	invalidate,
	invalidatePrefix,
	invalidateAll,
	clearForUser,
	clearUser,
	prefetchFor,
	getStats,
	dumpMemory
} from './core'
import { warmStartup, warmIdle } from './scheduler'
import { navigateTo, switchTab, reLaunch } from './navigate'

/**
 * 初始化 preloadApi
 * @param {object} userConfig - preload.config.js 导出的配置
 * @returns {object} preloadApi 实例
 */
export function initPreloadApi(userConfig) {
	const { config, fetchers } = loadConfig(userConfig)
	setConfig(config, fetchers)

	return {
		// 核心 API
		get,
		run,
		refresh,
		abortRequest,
		invalidate,
		invalidatePrefix,
		invalidateAll,
		clearForUser,
		clearUser,
		prefetchFor,

		// 调度
		warmStartup: () => warmStartup(config.startup),
		warmIdle: () => warmIdle(config.idle),

		// 路由
		navigateTo,
		switchTab,
		reLaunch,

		// 调试
		getStats,
		dumpMemory,

		// 配置引用
		config
	}
}

// 默认单例(由 App.vue 初始化)
let _instance = null

export function getPreloadApi() {
	return _instance
}

export function setPreloadApi(api) {
	_instance = api
}

export { setUserIdGetter }

Task 8: Create composables/usePreload.js — Vue 3 composable

Files:

  • Create: frontend/composables/usePreload.js

  • Step 1: Create usePreload.js

// frontend/composables/usePreload.js
// Vue 3 组合式 API包装 core.get(),暴露响应式 state { data, loading, error, refresh }

import { ref, getCurrentInstance, onBeforeUnmount, watch } from 'vue'
import { get, refresh as coreRefresh, abortRequest } from '@/utils/preloadApi/core'

/**
 * @param {string|Ref<string>} key - 逻辑 key
 * @param {object|Ref<object>} [params] - 请求参数
 * @returns {{ data: Ref, loading: Ref, error: Ref, refresh: Function }}
 *
 * @example
 *   const { data, loading, error, refresh } = usePreload('asset.detail', { id: 123 })
 *   // With reactive params:
 *   const { data, loading, error } = usePreload('asset.detail', () => ({ id: route.params.id }))
 */
export function usePreload(key, params) {
	// 校验上下文
	if (!getCurrentInstance()) {
		console.warn('[preload] usePreload must be called in setup()')
	}

	const data = ref(null)
	const loading = ref(true)
	const error = ref(null)

	let mounted = true
	let fetchVersion = 0

	/**
	 * 执行获取(不阻塞 setup
	 * @param {boolean} [force=false] - 跳过 TTL 缓存
	 */
	function doFetch(force = false) {
		// 先清理上一轮 in-flight 请求
		abortRequest(key, params)

		const version = ++fetchVersion
		loading.value = true
		error.value = null

		// 用 .then() 异步更新 data不阻塞 setup
		const promise = force
			? coreRefresh(key, params, true)
			: get(key, params)

		promise
			.then((result) => {
				if (!mounted || version !== fetchVersion) return
				data.value = result
				loading.value = false
			})
			.catch((err) => {
				if (!mounted || version !== fetchVersion) return
				error.value = err
				loading.value = false
			})

		return promise
	}

	// 监听 key/params 变化(当传入 ref 或 computed 时)
	// 注toRef/toValue 在 uni-app Vue 3 中可用
	const resolvedParams = typeof params === 'function' ? params : () => params
	watch(
		[key, resolvedParams],
		() => {
			if (mounted) doFetch()
		},
		{ deep: true }
	)

	// 初始加载
	doFetch()

	// 组件卸载时清理
	onBeforeUnmount(() => {
		mounted = false
		abortRequest(key, params)
	})

	/**
	 * 手动刷新
	 * @param {boolean} [force=false] - 跳过 TTL
	 */
	function refresh(force = false) {
		return doFetch(force)
	}

	return { data, loading, error, refresh }
}

Task 9: Create config/preload.config.js — business config

Files:

  • Create: frontend/config/preload.config.js

  • Step 1: Create preload.config.js

// frontend/config/preload.config.js
// API 预加载业务配置
// 声明每个预拉 key 的fetcher / ttl / persistence / 触发时机

import {
	getCastloveConfigApi,
	getUserProfileApi,
	getHotRankingApi,
	getAssetLikersApi,
	getActivityDetailApi,
	getActivityItemsApi
} from '@/utils/api'

export const preloadConfig = {
	// ── 全局默认 ──
	defaults: {
		ttl: 5 * 60 * 1000,
		persistence: 'memory',
		concurrency: 4,
		timeout: 10000,
		silent: true,
		limits: {
			maxEntrySizeKB: 1024,
			maxMemoryEntries: 100,
			maxFileCacheMB: 50
		}
	},

	// ── 启动期预热清单App.vue onLaunch 跑)──
	startup: [
		{
			key: 'castlove.config',
			fetcher: getCastloveConfigApi,
			ttl: 60 * 60 * 1000,
			persistence: 'file'
		},
		{
			key: 'me.profile',
			fetcher: getUserProfileApi,
			ttl: 10 * 60 * 1000
		}
	],

	// ── idle 预拉清单(首屏渲染完后跑)──
	idle: [
		{
			key: 'ranking.hot',
			fetcher: () => getHotRankingApi('total', null, 1, 10),
			ttl: 10 * 60 * 1000
		}
	],

	// ── 页面切换预拉映射wrappedNavigateTo 命中时触发)──
	pages: {
		'/pages/asset-detail/asset-detail': [
			{
				key: 'asset.likers',
				fetcher: (params) => getAssetLikersApi(Number(params.id))
			}
		],
		'/pages/activity-detail/activity-detail': [
			{
				key: 'activity.detail',
				fetcher: (params) => getActivityDetailApi(params.id)
			},
			{
				key: 'activity.items',
				fetcher: (params) => getActivityItemsApi(params.id)
			}
		]
	}
}

Task 10: Modify App.vue — integrate preloadApi

Files:

  • Modify: frontend/App.vue:1-20 (import section) + onLaunch + onShow

  • Step 1: Add preloadApi import and initialization

Insert at the top of <script> (after line 4, before line 5):

// 在 App.vue <script> 顶部 import 区域追加
import { initPreloadApi, setPreloadApi } from '@/utils/preloadApi/index'
import { preloadConfig } from '@/config/preload.config'
  • Step 2: Initialize in onLaunch

Add to onLaunch function body (before its closing }):

// ★ 新增:初始化预加载 API
try {
  const api = initPreloadApi(preloadConfig)
  setPreloadApi(api)
  api.warmStartup()
} catch (e) {
  console.warn('[preload] init failed:', e.message)
}
  • Step 3: Add warmIdle in onShow

Add to onShow function body (after this.handleBackgroundReturn(), before this.initWebSocket()):

// ★ 新增idle 预拉
try {
  const preloadApi = getPreloadApi()
  if (preloadApi) preloadApi.warmIdle()
} catch (e) {
  // 静默
}

Note: getPreloadApi needs to be imported alongside initPreloadApi.


Task 11: Modify store/modules/user.js — cache invalidation patches

Files:

  • Modify: frontend/store/modules/user.js:36-54 (SET_USER_INFO) + :55-94 (CLEAR_AUTH)

  • Step 1: Add import at top

// 在 store/modules/user.js 顶部 import 区域追加
import { getPreloadApi } from '@/utils/preloadApi/index'
  • Step 2: Patch SET_USER_INFO mutation

In the SET_USER_INFO mutation, add before setting state.userInfo:

SET_USER_INFO(state, userInfo) {
  // ★ 新增:旧用户存在时清理文件缓存
  if (userInfo) {
    const oldUserStr = uni.getStorageSync('user')
    let oldUserId = null
    if (oldUserStr) {
      try {
        const oldUser = JSON.parse(oldUserStr)
        oldUserId = oldUser?.uid || null
      } catch (e) { /* ignore */ }
    }
    const newUserId = userInfo?.uid || null
    // 切换用户时清理旧用户文件缓存
    if (oldUserId && newUserId && oldUserId !== newUserId) {
      try {
        const api = getPreloadApi()
        if (api) {
          api.invalidatePrefix('me.')
          api.clearForUser(oldUserId)
        }
      } catch (e) { /* 静默 */ }
    }
  }

  state.userInfo = userInfo
  // ... 原有逻辑不变 ...
}
  • Step 3: Patch CLEAR_AUTH mutation

IMPORTANT: The clearUser call must happen before token/user are cleared (see design doc §6.5 constraint #2).

CLEAR_AUTH(state) {
  // ★ 新增:先清理 preload 缓存(在清 token 之前)
  const userStr = uni.getStorageSync('user')
  let userId = null
  if (userStr) {
    try {
      const user = JSON.parse(userStr)
      userId = user?.uid || null
    } catch (e) { /* ignore */ }
  }
  if (userId) {
    try {
      const api = getPreloadApi()
      if (api) api.clearUser(userId)
    } catch (e) { /* 静默 */ }
  }

  state.token = ''
  state.userInfo = null
  state.starId = null

  // ... 原有 CLEAR_AUTH 逻辑不变resetAllGuides, removeStorageSync 等)...
}

Task 12: Write unit tests — core.test.js

Files:

  • Create: frontend/utils/preloadApi/__tests__/core.test.js

  • Step 1: Create core unit tests

// frontend/utils/preloadApi/__tests__/core.test.js
// 核心引擎单元测试(不依赖 uni / plus.io纯 mock

import {
	setConfig, setUserIdGetter,
	run, get, refresh, invalidate, invalidatePrefix, invalidateAll,
	clearUser, getStats, dumpMemory
} from '../core'

// ── 测试辅助 ──
let mockFetcherCalls = 0
let mockFetcherResult = { data: 'mock' }
let mockFetcherError = null

function mockFetcher(params) {
	mockFetcherCalls++
	if (mockFetcherError) return Promise.reject(mockFetcherError)
	return Promise.resolve(mockFetcherResult)
}

function resetMocks() {
	mockFetcherCalls = 0
	mockFetcherResult = { data: 'mock' }
	mockFetcherError = null
}

function setupTest() {
	resetMocks()
	invalidateAll()
	setUserIdGetter(() => 'test-user-1')
	setConfig({
		defaults: { ttl: 10000, persistence: 'memory', concurrency: 4, timeout: 5000, silent: false },
		startup: [],
		idle: [],
		pages: {}
	}, {
		'test.key': mockFetcher
	})
}

// ── 测试 ──

// Test 1: get calls fetcher and caches result
{
	let passed = 0, failed = 0

	async function test_get_fetches_and_caches() {
		setupTest()
		const data = await get('test.key', { id: 1 })
		if (data !== mockFetcherResult) throw new Error(`Expected ${mockFetcherResult}, got ${data}`)
		if (mockFetcherCalls !== 1) throw new Error(`Expected 1 call, got ${mockFetcherCalls}`)

		// 第二次 get 应该命中缓存,不调 fetcher
		const data2 = await get('test.key', { id: 1 })
		if (data2 !== mockFetcherResult) throw new Error(`Expected cached result`)
		if (mockFetcherCalls !== 1) throw new Error(`Expected still 1 call, got ${mockFetcherCalls}`)

		console.log('  PASS: test_get_fetches_and_caches')
	}

	test_get_fetches_and_caches()
		.then(() => {})
		.catch(e => console.error('  FAIL: test_get_fetches_and_caches:', e.message))
}

// Test 2: different params = different cache entries
{
	async function test_different_params() {
		setupTest()
		await get('test.key', { id: 1 })
		await get('test.key', { id: 2 })
		if (mockFetcherCalls !== 2) throw new Error(`Expected 2 calls, got ${mockFetcherCalls}`)
		console.log('  PASS: test_different_params')
	}
	test_different_params().catch(e => console.error('  FAIL: test_different_params:', e.message))
}

// Test 3: invalidate removes cache entry
{
	async function test_invalidate() {
		setupTest()
		await get('test.key', { id: 1 })
		invalidate('test.key', { id: 1 })
		await get('test.key', { id: 1 })
		if (mockFetcherCalls !== 2) throw new Error(`Expected 2 calls after invalidate, got ${mockFetcherCalls}`)
		console.log('  PASS: test_invalidate')
	}
	test_invalidate().catch(e => console.error('  FAIL: test_invalidate:', e.message))
}

// Test 4: invalidatePrefix
{
	async function test_invalidatePrefix() {
		setupTest()
		setConfig({
			defaults: { ttl: 10000, persistence: 'memory', concurrency: 4, timeout: 5000, silent: false },
			startup: [], idle: [], pages: {}
		}, {
			'test.key1': () => Promise.resolve(1),
			'test.key2': () => Promise.resolve(2),
			'other.key': () => Promise.resolve(3)
		})

		await get('test.key1')
		await get('test.key2')
		await get('other.key')

		invalidatePrefix('test.')

		const stats = getStats()
		// memoryMap should only have 'other.key' left
		const dump = dumpMemory()
		const testKeys = dump.filter(e => e.key.includes('test.key'))
		if (testKeys.length !== 0) throw new Error(`Expected 0 test.* keys, got ${testKeys.length}`)
		console.log('  PASS: test_invalidatePrefix')
	}
	test_invalidatePrefix().catch(e => console.error('  FAIL: test_invalidatePrefix:', e.message))
}

// Test 5: _swallowAuth — code 7 and 16
{
	async function test_auth_swallow() {
		setupTest()

		// code 7
		const e7 = new Error('token expired')
		e7.code = 7
		mockFetcherError = e7
		const r1 = await get('test.key', { id: 99 })
		if (r1 !== null) throw new Error(`Expected null for code 7, got ${r1}`)

		resetMocks()
		setupTest()

		// code 16
		const e16 = new Error('banned')
		e16.code = 16
		mockFetcherError = e16
		const r2 = await get('test.key', { id: 99 })
		if (r2 !== null) throw new Error(`Expected null for code 16, got ${r2}`)

		resetMocks()
		setupTest()

		// message 含"登录已过期"
		mockFetcherError = new Error('登录已过期,请重新登录')
		const r3 = await get('test.key', { id: 99 })
		if (r3 !== null) throw new Error(`Expected null for auth message, got ${r3}`)

		console.log('  PASS: test_auth_swallow')
	}
	test_auth_swallow().catch(e => console.error('  FAIL: test_auth_swallow:', e.message))
}

// Test 6: LRU eviction at maxMemoryEntries
{
	async function test_lru_eviction() {
		setupTest()
		setConfig({
			defaults: { ttl: 10000, persistence: 'memory', concurrency: 4, timeout: 5000, silent: false,
				limits: { maxMemoryEntries: 3, maxEntrySizeKB: 1024, maxFileCacheMB: 50 } },
			startup: [], idle: [], pages: {}
		}, {
			'k1': () => Promise.resolve('v1'),
			'k2': () => Promise.resolve('v2'),
			'k3': () => Promise.resolve('v3'),
			'k4': () => Promise.resolve('v4')
		})

		await get('k1')
		await get('k2')
		await get('k3')
		// 访问 k1 → 标记最近使用
		await get('k1')
		// 插入 k4 → 应该淘汰 k2最久未使用
		await get('k4')

		const dump = dumpMemory()
		const keys = dump.map(e => e.key)
		// k1 是最近访问的,应该保留
		// k2 是最久没访问的,应该被淘汰
		if (keys.some(k => k.includes('::k2::'))) {
			console.log('  WARN: test_lru_eviction — k2 not evicted (timing-dependent, may be ok)')
		}
		if (!keys.some(k => k.includes('::k1::'))) {
			throw new Error('k1 should still be in cache (most recently used)')
		}
		if (!keys.some(k => k.includes('::k4::'))) {
			throw new Error('k4 should be in cache')
		}
		console.log('  PASS: test_lru_eviction')
	}
	test_lru_eviction().catch(e => console.error('  FAIL: test_lru_eviction:', e.message))
}

// Test 7: inFlight dedup — concurrent gets share same Promise
{
	async function test_inflight_dedup() {
		setupTest()
		const [r1, r2] = await Promise.all([
			get('test.key', { id: 1 }),
			get('test.key', { id: 1 })
		])
		if (mockFetcherCalls !== 1) throw new Error(`Expected 1 call for concurrent gets, got ${mockFetcherCalls}`)
		if (r1 !== r2) throw new Error(`Expected same result, got ${r1} vs ${r2}`)
		console.log('  PASS: test_inflight_dedup')
	}
	test_inflight_dedup().catch(e => console.error('  FAIL: test_inflight_dedup:', e.message))
}

// Test 8: clearUser clears memory
{
	async function test_clearUser() {
		setupTest()
		await get('test.key', { id: 1 })
		const before = dumpMemory().length
		if (before === 0) throw new Error('Expected cache entries before clearUser')

		clearUser('test-user-1')
		const after = dumpMemory().length
		if (after !== 0) throw new Error(`Expected 0 entries after clearUser, got ${after}`)
		console.log('  PASS: test_clearUser')
	}
	test_clearUser().catch(e => console.error('  FAIL: test_clearUser:', e.message))
}

console.log('[core.test] All tests queued (async)')

Task 13: Write unit tests — storage.test.js

Files:

  • Create: frontend/utils/preloadApi/__tests__/storage.test.js

  • Step 1: Create storage unit tests

Since storage.js heavily depends on plus.io / uni.getFileSystemManager (platform APIs unavailable in test env), these tests are manual/functional — run on real device or simulator. Here we document the test cases:

// frontend/utils/preloadApi/__tests__/storage.test.js
// 文件缓存适配器功能测试 — 需要在 APP-PLUS 真机/模拟器上运行

/**
 * Test cases (run manually on device):
 *
 * 1. writeEntry + readEntry roundtrip
 *    - writeEntry('test-u', 'key1', { hello: 'world' }, Date.now(), 60000)
 *    - await readEntry('test-u', 'key1')
 *    - Assert: data.hello === 'world'
 *
 * 2. readEntry returns null for missing file
 *    - const r = await readEntry('test-u', 'nonexistent')
 *    - Assert: r === null
 *
 * 3. clearForUser removes all entries
 *    - writeEntry('test-u', 'k1', 1, Date.now(), 60000)
 *    - writeEntry('test-u', 'k2', 2, Date.now(), 60000)
 *    - await clearForUser('test-u')
 *    - await readEntry('test-u', 'k1') → null
 *    - await readEntry('test-u', 'k2') → null
 *
 * 4. getTotalCacheSize returns reasonable value
 *    - writeEntry('test-u', 'big', 'x'.repeat(10000), Date.now(), 60000)
 *    - const size = await getTotalCacheSize('test-u')
 *    - Assert: size > 0
 *
 * 5. User isolation
 *    - writeEntry('user-a', 'shared', 'a-data', Date.now(), 60000)
 *    - writeEntry('user-b', 'shared', 'b-data', Date.now(), 60000)
 *    - await readEntry('user-a', 'shared') → 'a-data'
 *    - await readEntry('user-b', 'shared') → 'b-data'
 */

console.log('[storage.test] Tests are manual — run on APP-PLUS device')

Task 14: Write unit tests — navigate.test.js

Files:

  • Create: frontend/utils/preloadApi/__tests__/navigate.test.js

  • Step 1: Create navigate unit tests

// frontend/utils/preloadApi/__tests__/navigate.test.js
// navigate.js 单元测试 — mock uni.navigateTo

import { navigateTo, switchTab, reLaunch } from '../navigate'
import { setConfig, invalidateAll, setUserIdGetter } from '../core'

// ── mock uni ──
let mockNavCalls = []
let mockSwitchTabCalls = []
let mockReLaunchCalls = []

// 在测试前替换 globalThis.uni
const _origUni = globalThis.uni
globalThis.uni = {
	navigateTo: (opts) => mockNavCalls.push(opts),
	switchTab: (opts) => mockSwitchTabCalls.push(opts),
	reLaunch: (opts) => mockReLaunchCalls.push(opts),
	getStorageSync: () => null
}

// Mock URLSearchParams if not available
if (typeof URLSearchParams === 'undefined') {
	globalThis.URLSearchParams = class {
		constructor(qs) {
			this.params = new Map()
			for (const pair of qs.split('&')) {
				const [k, v] = pair.split('=')
				if (k) this.params.set(k, v || '')
			}
		}
		entries() { return this.params.entries() }
	}
}

function resetMockCalls() {
	mockNavCalls = []
	mockSwitchTabCalls = []
	mockReLaunchCalls = []
	invalidateAll()
}

// ── 测试 ──

// Test 1: navigateTo with query string → calls uni.navigateTo immediately
{
	function test_navigateTo_calls_uni() {
		resetMockCalls()
		navigateTo({ url: '/pages/asset-detail/asset-detail?id=123' })
		if (mockNavCalls.length !== 1) throw new Error(`Expected 1 nav call, got ${mockNavCalls.length}`)
		if (mockNavCalls[0].url !== '/pages/asset-detail/asset-detail?id=123') {
			throw new Error(`Expected URL preserved, got ${mockNavCalls[0].url}`)
		}
		console.log('  PASS: test_navigateTo_calls_uni')
	}
	test_navigateTo_calls_uni()
}

// Test 2: navigateTo without pages config → still calls uni.navigateTo
{
	function test_navigateTo_no_config() {
		resetMockCalls()
		setConfig({ defaults: { ttl: 10000 }, pages: {} }, {})
		navigateTo({ url: '/pages/nonexistent/nonexistent' })
		if (mockNavCalls.length !== 1) throw new Error(`Expected 1 nav call even without config, got ${mockNavCalls.length}`)
		console.log('  PASS: test_navigateTo_no_config')
	}
	test_navigateTo_no_config()
}

// Test 3: switchTab calls uni.switchTab
{
	function test_switchTab() {
		resetMockCalls()
		switchTab({ url: '/pages/tab/home' })
		if (mockSwitchTabCalls.length !== 1) throw new Error(`Expected 1 switchTab call, got ${mockSwitchTabCalls.length}`)
		console.log('  PASS: test_switchTab')
	}
	test_switchTab()
}

// Test 4: reLaunch calls uni.reLaunch
{
	function test_reLaunch() {
		resetMockCalls()
		reLaunch({ url: '/pages/login/portal' })
		if (mockReLaunchCalls.length !== 1) throw new Error(`Expected 1 reLaunch call, got ${mockReLaunchCalls.length}`)
		console.log('  PASS: test_reLaunch')
	}
	test_reLaunch()
}

// Test 5: query string with special characters
{
	function test_special_chars() {
		resetMockCalls()
		navigateTo({ url: '/pages/foo/bar?name=' + encodeURIComponent('你好') + '&type=hot' })
		if (mockNavCalls.length !== 1) throw new Error(`Expected 1 nav call`)
		// URL should be passed through unchanged
		console.log('  PASS: test_special_chars')
	}
	test_special_chars()
}

// Test 6: string arg (not object)
{
	function test_string_arg() {
		resetMockCalls()
		navigateTo('/pages/foo/bar?id=1')
		if (mockNavCalls.length !== 1) throw new Error(`Expected 1 nav call`)
		console.log('  PASS: test_string_arg')
	}
	test_string_arg()
}

console.log('[navigate.test] All tests completed')

// Restore
// globalThis.uni = _origUni

Task 15: Write README for developers

Files:

  • Create: frontend/utils/preloadApi/README.md

  • Step 1: Create README.md

# preloadApi — API 预加载模块

## 快速开始

### 1. 在页面中使用缓存数据

```vue
<script setup>
import { usePreload } from '@/composables/usePreload'

const { data, loading, error, refresh } = usePreload('asset.detail', { id: route.params.id })
</script>

<template>
  <view v-if="data">{{ data.name }}</view>
  <view v-else-if="loading">加载中...</view>
  <view v-else-if="error">加载失败: {{ error.message }}</view>
</template>

2. 手动失效缓存

import { getPreloadApi } from '@/utils/preloadApi/index'

const api = getPreloadApi()

// 失效单个 key
api.invalidate('ranking.hot')

// 失效某个前缀的所有 key
api.invalidatePrefix('asset.')

// 清空全部内存缓存
api.invalidateAll()

3. 添加新的预拉配置

frontend/config/preload.config.js 中:

pages: {
  '/pages/new-page/new-page': [
    { key: 'new.data', fetcher: (params) => getNewDataApi(params.id) }
  ]
}

4. 替换页面跳转

// 旧写法
uni.navigateTo({ url: '/pages/detail/detail?id=123' })

// 新写法(自动触发预拉)
import { getPreloadApi } from '@/utils/preloadApi/index'
const api = getPreloadApi()
api.navigateTo({ url: '/pages/detail/detail?id=123' })

API 速查

方法 说明
preloadApi.run(key, params?) 触发预拉fire-and-forget不返回数据
preloadApi.get(key, params?) 读缓存,未命中则拉取
preloadApi.invalidate(key, params?) 失效单个 key内存
preloadApi.invalidatePrefix(prefix) 失效前缀匹配的所有 key内存
preloadApi.invalidateAll() 清空全部内存缓存
preloadApi.clearUser(userId) 登出:清空内存 + 删文件缓存目录
preloadApi.navigateTo(opts) 替代 uni.navigateTo
preloadApi.switchTab(opts) 替代 uni.switchTab
preloadApi.reLaunch(opts) 替代 uni.reLaunch

配置字段

字段 类型 默认值 说明
key string (必填) 逻辑 key业务引用缓存的唯一标识
fetcher (params) => Promise (必填) 请求函数
ttl number 300000 缓存有效期 (ms)
persistence 'memory'|'file' 'memory' 缓存存储方式
timeout number 10000 单接口超时 (ms)
silent boolean true 失败是否静默

---

## Verification Checklist

After all tasks are complete, verify against the acceptance checklist (§8.2):

- [ ] `preloadApi.run` / `get` / `invalidate*` pass unit tests
- [ ] wrappedNavigateTo hit/miss both correct
- [ ] usePreload composable exposes reactive state correctly
- [ ] 401 / code 7 / code 16 swallowed by `_swallowAuth`
- [ ] query string with `encodeURIComponent` characters parsed correctly
- [ ] App.vue device test: `onLaunch` → startup items hit memory cache
- [ ] wrappedNavigateTo device test: detail page hits preload cache (< 50ms loading flash)
- [ ] Logout → `_doc/preload/{userId}/` directory deleted
- [ ] LRU eviction: 100 keys → 1st kept; 101st key → 1st evicted
- [ ] warmIdle idempotent: 3 consecutive calls → fetcher called only once
- [ ] User switch (onSwitchUser): me.* prefix fully invalidated, oldUser file cache deleted, newUser cache unaffected