diff --git a/README.md b/README.md index ae0c6a8..2cca7d2 100644 --- a/README.md +++ b/README.md @@ -446,6 +446,7 @@ dockge/komodo 等 docker compose UI 也有自动更新功能 | DANMAKU_API_BASE | 弹幕 API 地址 | URL | http://localhost:9321 | | DANMAKU_API_TOKEN | 弹幕 API Token | 任意字符串 | 87654321 | | DATA_MIGRATION_CHUNK_SIZE | 数据迁移批处理大小(控制导入导出时每批处理的用户数量和数据条数) | 正整数 | 10 | +| QR_LOGIN_STORE_MODE | 电视端扫码登录状态存储模式;serverless环境下多节点内存状态不可靠。 | auto、memory、hybrid、shared | auto | NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释: diff --git a/src/lib/qr-login/store.ts b/src/lib/qr-login/store.ts index 5ab5a32..a9b73e9 100644 --- a/src/lib/qr-login/store.ts +++ b/src/lib/qr-login/store.ts @@ -11,6 +11,16 @@ export interface QrLoginSession { userAgent?: string; } +export type QrLoginStoreMode = 'auto' | 'memory' | 'hybrid' | 'shared'; +type ResolvedQrLoginStoreMode = Exclude; +type QrLoginStorageAdapter = { + hSet?: (key: string, field: string, value: string) => Promise; + hGet?: (key: string, field: string) => Promise; + hGetAll?: (key: string) => Promise>; + hDel?: (key: string, field: string) => Promise; +}; +type QrLoginStorage = { adapter?: QrLoginStorageAdapter }; + type GlobalWithQr = typeof globalThis & { __moonTvQrLoginStore?: Map }; const g = globalThis as GlobalWithQr; @@ -18,38 +28,104 @@ export const qrLoginStore = g.__moonTvQrLoginStore || new Map(['auto', 'memory', 'hybrid', 'shared']); -let getStorage: (() => any) | null = null; +let getStorage: (() => unknown) | null = null; -async function loadStorage() { +function resolveQrLoginStoreMode(): ResolvedQrLoginStoreMode { + const configuredMode = (process.env.QR_LOGIN_STORE_MODE || 'auto').toLowerCase() as QrLoginStoreMode; + const mode = VALID_STORE_MODES.has(configuredMode) ? configuredMode : 'auto'; + + if (mode !== 'auto') return mode; + + const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; + const isCloudflare = + process.env.CF_PAGES === '1' || process.env.BUILD_TARGET === 'cloudflare'; + + if (storageType === 'upstash' || isCloudflare) return 'hybrid'; + return 'memory'; +} + +async function loadStorage(required = false) { try { if (!getStorage) { const db = await import('@/lib/db'); getStorage = db.getStorage; } - const storage = getStorage(); - return storage && typeof (storage as any).adapter?.hGet === 'function' ? storage : null; - } catch { + const storage = getStorage() as QrLoginStorage | null; + if (storage && typeof storage.adapter?.hGet === 'function') return storage; + } catch (error) { + if (required) throw error; + } + + if (required) { + throw new Error('QR login shared storage is unavailable'); + } + + return null; +} + +async function readSharedQrLoginSession(token: string, required = false) { + const storage = await loadStorage(required); + if (!storage || typeof storage.adapter?.hGet !== 'function') return null; + + try { + const raw = await storage.adapter.hGet(QR_LOGIN_HASH_KEY, token); + if (!raw) return null; + return JSON.parse(raw) as QrLoginSession; + } catch (error) { + if (required) throw error; + try { + await storage.adapter.hDel?.(QR_LOGIN_HASH_KEY, token); + } catch { + // ignore best-effort cleanup failures in hybrid mode + } return null; } } async function persistQrLoginSession(session: QrLoginSession) { - qrLoginStore.set(session.token, session); + const mode = resolveQrLoginStoreMode(); - const storage = await loadStorage(); - if (!storage || typeof (storage as any).adapter?.hSet !== 'function') return; + if (mode !== 'shared') { + qrLoginStore.set(session.token, session); + } - await (storage as any).adapter.hSet(QR_LOGIN_HASH_KEY, session.token, JSON.stringify(session)); + if (mode === 'memory') return; + + const storage = await loadStorage(mode === 'shared'); + if (!storage || typeof storage.adapter?.hSet !== 'function') { + if (mode === 'shared') throw new Error('QR login shared storage does not support hSet'); + return; + } + + try { + await storage.adapter.hSet(QR_LOGIN_HASH_KEY, session.token, JSON.stringify(session)); + } catch (error) { + if (mode === 'shared') throw error; + } } async function deletePersistedQrLoginSession(token: string) { - qrLoginStore.delete(token); + const mode = resolveQrLoginStoreMode(); - const storage = await loadStorage(); - if (!storage || typeof (storage as any).adapter?.hDel !== 'function') return; + if (mode !== 'shared') { + qrLoginStore.delete(token); + } - await (storage as any).adapter.hDel(QR_LOGIN_HASH_KEY, token); + if (mode === 'memory') return; + + const storage = await loadStorage(mode === 'shared'); + if (!storage || typeof storage.adapter?.hDel !== 'function') { + if (mode === 'shared') throw new Error('QR login shared storage does not support hDel'); + return; + } + + try { + await storage.adapter.hDel(QR_LOGIN_HASH_KEY, token); + } catch (error) { + if (mode === 'shared') throw error; + } } export async function createQrLoginSession(ttlMs = 120_000) { @@ -68,21 +144,17 @@ export async function createQrLoginSession(ttlMs = 120_000) { export async function getQrLoginSession(token?: string | null) { if (!token) return null; - let session = qrLoginStore.get(token) || null; + const mode = resolveQrLoginStoreMode(); + let session: QrLoginSession | null = null; - if (!session) { - const storage = await loadStorage(); - if (storage && typeof (storage as any).adapter?.hGet === 'function') { - const raw = await (storage as any).adapter.hGet(QR_LOGIN_HASH_KEY, token); - if (raw) { - try { - session = JSON.parse(raw) as QrLoginSession; - qrLoginStore.set(token, session); - } catch { - await deletePersistedQrLoginSession(token); - return null; - } - } + if (mode === 'memory') { + session = qrLoginStore.get(token) || null; + } else { + session = await readSharedQrLoginSession(token, mode === 'shared'); + if (session && mode === 'hybrid') { + qrLoginStore.set(token, session); + } else if (!session && mode === 'hybrid') { + session = qrLoginStore.get(token) || null; } } @@ -98,17 +170,33 @@ export async function saveQrLoginSession(session: QrLoginSession) { } export async function cleanupQrLoginSessions() { + const mode = resolveQrLoginStoreMode(); const now = Date.now(); - for (const [token, session] of Array.from(qrLoginStore.entries())) { - if (session.expiresAt + 300_000 < now || session.status === 'used') { - await deletePersistedQrLoginSession(token); + + if (mode !== 'shared') { + for (const [token, session] of Array.from(qrLoginStore.entries())) { + if (session.expiresAt + 300_000 < now || session.status === 'used') { + await deletePersistedQrLoginSession(token); + } } } - const storage = await loadStorage(); - if (!storage || typeof (storage as any).adapter?.hGetAll !== 'function') return; + if (mode === 'memory') return; + + const storage = await loadStorage(mode === 'shared'); + if (!storage || typeof storage.adapter?.hGetAll !== 'function') { + if (mode === 'shared') throw new Error('QR login shared storage does not support hGetAll'); + return; + } + + let sessions: Record; + try { + sessions = await storage.adapter.hGetAll(QR_LOGIN_HASH_KEY); + } catch (error) { + if (mode === 'shared') throw error; + return; + } - const sessions = await (storage as any).adapter.hGetAll(QR_LOGIN_HASH_KEY); for (const [token, raw] of Object.entries(sessions)) { try { const session = JSON.parse(raw as string) as QrLoginSession;