452 lines
12 KiB
TypeScript
452 lines
12 KiB
TypeScript
/**
|
||
* 通用缓存管理器
|
||
* 支持多种数据类型的本地缓存(JSON、文件、图片等)
|
||
* 开发环境存储在项目目录,打包后存储在exe同级目录
|
||
*/
|
||
import { app } from 'electron'
|
||
import { promises as fs } from 'fs'
|
||
import path from 'path'
|
||
import crypto from 'crypto'
|
||
|
||
/**
|
||
* 缓存项元数据
|
||
*/
|
||
interface CacheMetadata {
|
||
key: string
|
||
type: 'json' | 'text' | 'binary' | 'image'
|
||
createdAt: number
|
||
updatedAt: number
|
||
size: number
|
||
ttl?: number // Time to live in milliseconds (可选)
|
||
}
|
||
|
||
/**
|
||
* 缓存配置
|
||
*/
|
||
interface CacheConfig {
|
||
maxAge?: number // 最大缓存时间(毫秒), 默认不过期
|
||
namespace?: string // 缓存命名空间,用于隔离不同模块的缓存
|
||
}
|
||
|
||
/**
|
||
* 缓存管理器类
|
||
*/
|
||
class CacheManager {
|
||
private cacheDir: string = ''
|
||
private metadataMap: Map<string, CacheMetadata> = new Map()
|
||
private initialized: boolean = false
|
||
|
||
/**
|
||
* 初始化缓存管理器
|
||
* 必须在 app.whenReady() 之后调用
|
||
*/
|
||
async init(): Promise<void> {
|
||
if (this.initialized) {
|
||
console.warn('CacheManager 已经初始化过了')
|
||
return
|
||
}
|
||
|
||
try {
|
||
// 判断是否为开发环境
|
||
const isDev = !app.isPackaged
|
||
|
||
if (isDev) {
|
||
// 开发环境: 存储在项目根目录的 .cache 文件夹
|
||
this.cacheDir = path.join(process.cwd(), '.cache')
|
||
} else {
|
||
// 生产环境: 存储在 exe 同级目录的 cache 文件夹
|
||
const exePath = process.execPath
|
||
const exeDir = path.dirname(exePath)
|
||
this.cacheDir = path.join(exeDir, 'cache')
|
||
}
|
||
|
||
// 确保缓存目录存在
|
||
await fs.mkdir(this.cacheDir, { recursive: true })
|
||
|
||
// 加载元数据
|
||
await this.loadMetadata()
|
||
|
||
this.initialized = true
|
||
console.log('CacheManager 初始化完成')
|
||
console.log('缓存目录:', this.cacheDir)
|
||
console.log('当前环境:', isDev ? '开发环境' : '生产环境')
|
||
} catch (error) {
|
||
console.error('CacheManager 初始化失败:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 确保缓存管理器已初始化
|
||
*/
|
||
private ensureInitialized(): void {
|
||
if (!this.initialized) {
|
||
throw new Error('CacheManager 未初始化,请先调用 init()')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成缓存键的哈希值(用于文件名)
|
||
*/
|
||
private hashKey(key: string): string {
|
||
return crypto.createHash('md5').update(key).digest('hex')
|
||
}
|
||
|
||
/**
|
||
* 获取缓存文件路径
|
||
*/
|
||
private getCacheFilePath(key: string, namespace?: string): string {
|
||
const hash = this.hashKey(key)
|
||
if (namespace) {
|
||
const namespaceDir = path.join(this.cacheDir, namespace)
|
||
return path.join(namespaceDir, hash)
|
||
}
|
||
return path.join(this.cacheDir, hash)
|
||
}
|
||
|
||
/**
|
||
* 获取元数据文件路径
|
||
*/
|
||
private getMetadataPath(): string {
|
||
return path.join(this.cacheDir, '_metadata.json')
|
||
}
|
||
|
||
/**
|
||
* 加载元数据
|
||
*/
|
||
private async loadMetadata(): Promise<void> {
|
||
try {
|
||
const metadataPath = this.getMetadataPath()
|
||
const exists = await fs.access(metadataPath).then(() => true).catch(() => false)
|
||
|
||
if (exists) {
|
||
const content = await fs.readFile(metadataPath, 'utf-8')
|
||
const data = JSON.parse(content)
|
||
this.metadataMap = new Map(Object.entries(data))
|
||
console.log(`加载了 ${this.metadataMap.size} 条缓存元数据`)
|
||
}
|
||
} catch (error) {
|
||
console.warn('加载缓存元数据失败:', error)
|
||
this.metadataMap = new Map()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 保存元数据
|
||
*/
|
||
private async saveMetadata(): Promise<void> {
|
||
try {
|
||
const metadataPath = this.getMetadataPath()
|
||
const data = Object.fromEntries(this.metadataMap)
|
||
await fs.writeFile(metadataPath, JSON.stringify(data, null, 2), 'utf-8')
|
||
} catch (error) {
|
||
console.error('保存缓存元数据失败:', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查缓存是否过期
|
||
*/
|
||
private isExpired(metadata: CacheMetadata): boolean {
|
||
if (!metadata.ttl) return false
|
||
const now = Date.now()
|
||
return now - metadata.updatedAt > metadata.ttl
|
||
}
|
||
|
||
/**
|
||
* 设置缓存 (JSON数据)
|
||
*/
|
||
async setJSON<T = any>(key: string, data: T, config?: CacheConfig): Promise<void> {
|
||
this.ensureInitialized()
|
||
|
||
try {
|
||
const namespace = config?.namespace
|
||
const filePath = this.getCacheFilePath(key, namespace)
|
||
|
||
// 确保命名空间目录存在
|
||
if (namespace) {
|
||
const namespaceDir = path.join(this.cacheDir, namespace)
|
||
await fs.mkdir(namespaceDir, { recursive: true })
|
||
}
|
||
|
||
// 写入JSON数据
|
||
const jsonString = JSON.stringify(data, null, 2)
|
||
await fs.writeFile(filePath, jsonString, 'utf-8')
|
||
|
||
// 更新元数据
|
||
const stats = await fs.stat(filePath)
|
||
const metadata: CacheMetadata = {
|
||
key,
|
||
type: 'json',
|
||
createdAt: this.metadataMap.get(key)?.createdAt || Date.now(),
|
||
updatedAt: Date.now(),
|
||
size: stats.size,
|
||
ttl: config?.maxAge
|
||
}
|
||
this.metadataMap.set(key, metadata)
|
||
await this.saveMetadata()
|
||
|
||
console.log(`缓存已设置: ${key} (${stats.size} bytes)`)
|
||
} catch (error) {
|
||
console.error(`设置缓存失败 [${key}]:`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取缓存 (JSON数据)
|
||
*/
|
||
async getJSON<T = any>(key: string, config?: CacheConfig): Promise<T | null> {
|
||
this.ensureInitialized()
|
||
|
||
try {
|
||
const metadata = this.metadataMap.get(key)
|
||
|
||
// 检查元数据是否存在
|
||
if (!metadata || metadata.type !== 'json') {
|
||
console.log(`缓存不存在或类型不匹配: ${key}`)
|
||
return null
|
||
}
|
||
|
||
// 检查是否过期
|
||
if (this.isExpired(metadata)) {
|
||
console.log(`缓存已过期: ${key}`)
|
||
await this.delete(key, config?.namespace)
|
||
return null
|
||
}
|
||
|
||
// 读取缓存文件
|
||
const namespace = config?.namespace
|
||
const filePath = this.getCacheFilePath(key, namespace)
|
||
const content = await fs.readFile(filePath, 'utf-8')
|
||
const data = JSON.parse(content)
|
||
|
||
console.log(`读取缓存: ${key}`)
|
||
return data
|
||
} catch (error) {
|
||
console.error(`读取缓存失败 [${key}]:`, error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 设置缓存 (二进制数据 - 用于图片等)
|
||
*/
|
||
async setBinary(key: string, buffer: Buffer, type: 'binary' | 'image' = 'binary', config?: CacheConfig): Promise<void> {
|
||
this.ensureInitialized()
|
||
|
||
try {
|
||
const namespace = config?.namespace
|
||
const filePath = this.getCacheFilePath(key, namespace)
|
||
|
||
// 确保命名空间目录存在
|
||
if (namespace) {
|
||
const namespaceDir = path.join(this.cacheDir, namespace)
|
||
await fs.mkdir(namespaceDir, { recursive: true })
|
||
}
|
||
|
||
// 写入二进制数据
|
||
await fs.writeFile(filePath, buffer)
|
||
|
||
// 更新元数据
|
||
const stats = await fs.stat(filePath)
|
||
const metadata: CacheMetadata = {
|
||
key,
|
||
type,
|
||
createdAt: this.metadataMap.get(key)?.createdAt || Date.now(),
|
||
updatedAt: Date.now(),
|
||
size: stats.size,
|
||
ttl: config?.maxAge
|
||
}
|
||
this.metadataMap.set(key, metadata)
|
||
await this.saveMetadata()
|
||
|
||
console.log(`二进制缓存已设置: ${key} (${stats.size} bytes)`)
|
||
} catch (error) {
|
||
console.error(`设置二进制缓存失败 [${key}]:`, error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取缓存 (二进制数据)
|
||
*/
|
||
async getBinary(key: string, config?: CacheConfig): Promise<Buffer | null> {
|
||
this.ensureInitialized()
|
||
|
||
try {
|
||
const metadata = this.metadataMap.get(key)
|
||
|
||
// 检查元数据是否存在
|
||
if (!metadata || (metadata.type !== 'binary' && metadata.type !== 'image')) {
|
||
console.log(`二进制缓存不存在或类型不匹配: ${key}`)
|
||
return null
|
||
}
|
||
|
||
// 检查是否过期
|
||
if (this.isExpired(metadata)) {
|
||
console.log(`二进制缓存已过期: ${key}`)
|
||
await this.delete(key, config?.namespace)
|
||
return null
|
||
}
|
||
|
||
// 读取缓存文件
|
||
const namespace = config?.namespace
|
||
const filePath = this.getCacheFilePath(key, namespace)
|
||
const buffer = await fs.readFile(filePath)
|
||
|
||
console.log(`读取二进制缓存: ${key}`)
|
||
return buffer
|
||
} catch (error) {
|
||
console.error(`读取二进制缓存失败 [${key}]:`, error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查缓存是否存在
|
||
*/
|
||
async has(key: string, config?: CacheConfig): Promise<boolean> {
|
||
this.ensureInitialized()
|
||
|
||
try {
|
||
const metadata = this.metadataMap.get(key)
|
||
if (!metadata) return false
|
||
|
||
// 检查是否过期
|
||
if (this.isExpired(metadata)) {
|
||
await this.delete(key, config?.namespace)
|
||
return false
|
||
}
|
||
|
||
// 检查文件是否真实存在
|
||
const namespace = config?.namespace
|
||
const filePath = this.getCacheFilePath(key, namespace)
|
||
const exists = await fs.access(filePath).then(() => true).catch(() => false)
|
||
|
||
return exists
|
||
} catch (error) {
|
||
console.error(`检查缓存失败 [${key}]:`, error)
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除单个缓存
|
||
*/
|
||
async delete(key: string, namespace?: string): Promise<void> {
|
||
this.ensureInitialized()
|
||
|
||
try {
|
||
const filePath = this.getCacheFilePath(key, namespace)
|
||
|
||
// 删除缓存文件
|
||
await fs.unlink(filePath).catch(() => {})
|
||
|
||
// 删除元数据
|
||
this.metadataMap.delete(key)
|
||
await this.saveMetadata()
|
||
|
||
console.log(`缓存已删除: ${key}`)
|
||
} catch (error) {
|
||
console.error(`删除缓存失败 [${key}]:`, error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清空指定命名空间的所有缓存
|
||
*/
|
||
async clearNamespace(namespace: string): Promise<void> {
|
||
this.ensureInitialized()
|
||
|
||
try {
|
||
const namespaceDir = path.join(this.cacheDir, namespace)
|
||
|
||
// 删除命名空间目录
|
||
await fs.rm(namespaceDir, { recursive: true, force: true })
|
||
|
||
// 删除相关元数据
|
||
const keysToDelete: string[] = []
|
||
this.metadataMap.forEach((metadata, key) => {
|
||
const filePath = this.getCacheFilePath(key, namespace)
|
||
if (filePath.startsWith(namespaceDir)) {
|
||
keysToDelete.push(key)
|
||
}
|
||
})
|
||
|
||
keysToDelete.forEach(key => this.metadataMap.delete(key))
|
||
await this.saveMetadata()
|
||
|
||
console.log(`命名空间缓存已清空: ${namespace} (${keysToDelete.length} 项)`)
|
||
} catch (error) {
|
||
console.error(`清空命名空间缓存失败 [${namespace}]:`, error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清空所有缓存
|
||
*/
|
||
async clearAll(): Promise<void> {
|
||
this.ensureInitialized()
|
||
|
||
try {
|
||
// 删除所有缓存文件(保留元数据文件)
|
||
const files = await fs.readdir(this.cacheDir, { withFileTypes: true })
|
||
|
||
for (const file of files) {
|
||
const filePath = path.join(this.cacheDir, file.name)
|
||
|
||
// 跳过元数据文件
|
||
if (file.name === '_metadata.json') continue
|
||
|
||
if (file.isDirectory()) {
|
||
await fs.rm(filePath, { recursive: true, force: true })
|
||
} else {
|
||
await fs.unlink(filePath)
|
||
}
|
||
}
|
||
|
||
// 清空元数据
|
||
this.metadataMap.clear()
|
||
await this.saveMetadata()
|
||
|
||
console.log('所有缓存已清空')
|
||
} catch (error) {
|
||
console.error('清空所有缓存失败:', error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取缓存统计信息
|
||
*/
|
||
async getStats(): Promise<{
|
||
totalItems: number
|
||
totalSize: number
|
||
cacheDir: string
|
||
}> {
|
||
this.ensureInitialized()
|
||
|
||
let totalSize = 0
|
||
this.metadataMap.forEach(metadata => {
|
||
totalSize += metadata.size
|
||
})
|
||
|
||
return {
|
||
totalItems: this.metadataMap.size,
|
||
totalSize,
|
||
cacheDir: this.cacheDir
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取所有缓存键
|
||
*/
|
||
async getAllKeys(): Promise<string[]> {
|
||
this.ensureInitialized()
|
||
return Array.from(this.metadataMap.keys())
|
||
}
|
||
}
|
||
|
||
// 导出单例
|
||
export const cacheManager = new CacheManager()
|