Files
author/app/lib/persistence.js

204 lines
5.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
// ==================== 持久化适配器 ====================
// 统一的存储接口:优先使用服务端文件系统,fallback 到浏览器 IndexedDB/localStorage
// 多用户隔离:首次访问自动生成 userId 并存入 cookie
import { get, set, del } from 'idb-keyval';
// ==================== 用户ID管理 ====================
function getUserId() {
if (typeof document === 'undefined') return null;
const match = document.cookie.match(/author-uid=([a-zA-Z0-9_-]+)/);
return match ? match[1] : null;
}
function ensureUserId() {
let uid = getUserId();
if (!uid) {
uid = 'u-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
// 设置 365 天有效的 cookie(HttpOnly = false,前端可读)
document.cookie = `author-uid=${uid}; path=/; max-age=${365 * 24 * 3600}; SameSite=Lax`;
}
return uid;
}
// ==================== 服务端存储 ====================
let _serverAvailable = null; // null = 未检测, true/false = 检测结果
async function checkServerAvailable() {
if (_serverAvailable !== null) return _serverAvailable;
try {
const res = await fetch('/api/storage?key=__ping', {
method: 'GET',
credentials: 'include',
});
_serverAvailable = res.ok || res.status === 401; // 401 也说明服务端在运行
return _serverAvailable;
} catch {
_serverAvailable = false;
return false;
}
}
async function serverGet(key) {
const res = await fetch(`/api/storage?key=${encodeURIComponent(key)}`, {
method: 'GET',
credentials: 'include',
});
if (!res.ok) throw new Error(`Server GET failed: ${res.status}`);
const { data } = await res.json();
return data;
}
async function serverSet(key, value) {
const res = await fetch('/api/storage', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ key, value }),
});
if (!res.ok) throw new Error(`Server POST failed: ${res.status}`);
}
async function serverDel(key) {
const res = await fetch(`/api/storage?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) throw new Error(`Server DELETE failed: ${res.status}`);
}
// ==================== 统一存储接口 ====================
/**
* 读取数据(服务端优先,fallback 到 IndexedDB)
* @param {string} key - 存储键名
* @returns {Promise<any>} 存储的值,不存在时返回 undefined
*/
export async function persistGet(key) {
if (typeof window === 'undefined') return undefined;
ensureUserId();
try {
if (await checkServerAvailable()) {
const data = await serverGet(key);
if (data !== null && data !== undefined) return data;
// 服务端没有,尝试从浏览器迁移
const localData = await browserGet(key);
if (localData !== null && localData !== undefined) {
// 自动迁移到服务端
await serverSet(key, localData).catch(() => { });
return localData;
}
return undefined;
}
} catch {
// 服务端失败,降级到浏览器
}
return await browserGet(key);
}
/**
* 写入数据(同时写入服务端和浏览器)
* @param {string} key - 存储键名
* @param {any} value - 要存储的值
*/
export async function persistSet(key, value) {
if (typeof window === 'undefined') return;
ensureUserId();
// 先写浏览器(立即可用)
await browserSet(key, value);
// 异步写服务端(不阻塞 UI)
if (await checkServerAvailable()) {
serverSet(key, value).catch(err => {
console.warn('[persist] Server write failed, data saved in browser only:', err.message);
});
}
}
/**
* 删除数据
* @param {string} key - 存储键名
*/
export async function persistDel(key) {
if (typeof window === 'undefined') return;
await browserDel(key);
if (await checkServerAvailable()) {
serverDel(key).catch(() => { });
}
}
// ==================== 浏览器存储桥接 ====================
// 大数据用 IndexedDB,小数据用 localStorage
const LOCALSTORAGE_KEYS = new Set([
'author-project-settings',
'author-active-work',
'author-token-stats',
'author-theme',
'author-lang',
'author-visual',
'author-onboarding-done',
'author-context-selection',
'author-api-profiles',
'author-delete-never-remind',
'author-delete-skip-today',
]);
async function browserGet(key) {
if (LOCALSTORAGE_KEYS.has(key)) {
const raw = localStorage.getItem(key);
if (raw === null) return undefined;
try { return JSON.parse(raw); } catch { return raw; }
}
const val = await get(key);
return val === undefined ? undefined : val;
}
async function browserSet(key, value) {
if (LOCALSTORAGE_KEYS.has(key)) {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
return;
}
await set(key, value);
}
async function browserDel(key) {
if (LOCALSTORAGE_KEYS.has(key)) {
localStorage.removeItem(key);
return;
}
await del(key);
}
// ==================== 便捷方法 ====================
/**
* 同步读取 localStorage(仅用于需要同步值的场景,如初始化 zustand store)
* 不走服务端。
*/
export function persistGetSync(key) {
if (typeof window === 'undefined') return undefined;
const raw = localStorage.getItem(key);
if (raw === null) return undefined;
try { return JSON.parse(raw); } catch { return raw; }
}
/**
* 初始化:确保 userId 存在,并触发服务端可用性检测
* 应在应用启动时调用一次
*/
export async function initPersistence() {
if (typeof window === 'undefined') return;
ensureUserId();
await checkServerAvailable();
}