diff --git a/src/data/storage/__tests__/storage_migration.test.ts b/src/data/storage/__tests__/storage_migration.test.ts new file mode 100644 index 00000000..fcc357cc --- /dev/null +++ b/src/data/storage/__tests__/storage_migration.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { createStorage } from "unstorage"; +import memoryDriver from "unstorage/drivers/memory"; + +import { StorageKeys } from "@/data/storage/storage_keys"; +import { runStorageMigrations } from "@/data/storage/storage_migration"; +import { SpAsyncUtil } from "@/utils"; + +describe("runStorageMigrations", () => { + beforeEach(() => { + SpAsyncUtil.setStorage(createStorage({ driver: memoryDriver() })); + }); + + it("moves legacy facebook id into asid", async () => { + await SpAsyncUtil.setString("facebook_id", "asid-legacy"); + + const result = await runStorageMigrations(); + + expect(result.success).toBe(true); + await expectString(StorageKeys.asid, "asid-legacy"); + await expectString("facebook_id", null); + }); + + it("does not overwrite existing asid", async () => { + await SpAsyncUtil.setString(StorageKeys.asid, "asid-current"); + await SpAsyncUtil.setString("facebook_id", "asid-legacy"); + + const result = await runStorageMigrations(); + + expect(result.success).toBe(true); + await expectString(StorageKeys.asid, "asid-current"); + await expectString("facebook_id", null); + }); + + it("migrates legacy pwa dialog markers and removes old keys", async () => { + await SpAsyncUtil.setString("last_pwa_dialog_shown", "2026-01-01"); + + const result = await runStorageMigrations(); + + expect(result.success).toBe(true); + const shown = await SpAsyncUtil.getBool(StorageKeys.pwaDialogShown); + expect(shown).toEqual({ success: true, data: true }); + await expectString("last_pwa_dialog_shown", null); + }); + + it("records migration version and skips repeated migrations", async () => { + await SpAsyncUtil.setString("facebook_id", "asid-legacy"); + await expectSuccess(runStorageMigrations()); + await SpAsyncUtil.setString("facebook_id", "asid-new-legacy"); + + await expectSuccess(runStorageMigrations()); + + await expectString(StorageKeys.asid, "asid-legacy"); + await expectString("facebook_id", "asid-new-legacy"); + const version = await SpAsyncUtil.getInt(StorageKeys.storageMigrationVersion); + expect(version).toEqual({ success: true, data: 1 }); + }); +}); + +async function expectString( + key: string, + expected: string | null, +): Promise { + const value = await SpAsyncUtil.getString(key); + expect(value).toEqual({ success: true, data: expected }); +} + +async function expectSuccess( + resultPromise: Promise<{ success: boolean }>, +): Promise { + const result = await resultPromise; + expect(result.success).toBe(true); +} diff --git a/src/data/storage/app/app_storage.ts b/src/data/storage/app/app_storage.ts index d953e41d..6bbea25c 100644 --- a/src/data/storage/app/app_storage.ts +++ b/src/data/storage/app/app_storage.ts @@ -15,11 +15,6 @@ import { Result, type Result as ResultT, SpAsyncUtil } from "@/utils"; import { StorageKeys } from "../storage_keys"; -const LEGACY_PWA_DIALOG_SHOWN_KEYS = [ - "cozsweet:lastPwaDialogShown", - "last_pwa_dialog_shown", -]; - export class AppStorage { // ---- PWA install dialog ---- @@ -27,13 +22,6 @@ export class AppStorage { const shownResult = await SpAsyncUtil.getBool(StorageKeys.pwaDialogShown); if (!shownResult.success) return shownResult; if (shownResult.data === true) return Result.ok(false); - - for (const key of LEGACY_PWA_DIALOG_SHOWN_KEYS) { - const legacyResult = await SpAsyncUtil.getString(key); - if (!legacyResult.success) return legacyResult; - if (legacyResult.data !== null) return Result.ok(false); - } - return Result.ok(true); } diff --git a/src/data/storage/auth/auth_storage.ts b/src/data/storage/auth/auth_storage.ts index 4549efa8..e9fc724b 100644 --- a/src/data/storage/auth/auth_storage.ts +++ b/src/data/storage/auth/auth_storage.ts @@ -92,29 +92,23 @@ export class AuthStorage implements IAuthStorage { // ---- ASID / PSID ---- async getAsid(): Promise> { - return getStringWithLegacyFallback( - StorageKeys.asid, - StorageKeys.legacyFacebookId, - ); + return SpAsyncUtil.getString(StorageKeys.asid); } setAsid(id: string): Promise> { return SpAsyncUtil.setString(StorageKeys.asid, id); } clearAsid(): Promise> { - return removeKeys(StorageKeys.asid, StorageKeys.legacyFacebookId); + return SpAsyncUtil.remove(StorageKeys.asid); } async getPsid(): Promise> { - return getStringWithLegacyFallback( - StorageKeys.psid, - StorageKeys.legacyFacebookAppId, - ); + return SpAsyncUtil.getString(StorageKeys.psid); } setPsid(id: string): Promise> { return SpAsyncUtil.setString(StorageKeys.psid, id); } clearPsid(): Promise> { - return removeKeys(StorageKeys.psid, StorageKeys.legacyFacebookAppId); + return SpAsyncUtil.remove(StorageKeys.psid); } // ---- bulk clear ---- @@ -146,24 +140,6 @@ export class AuthStorage implements IAuthStorage { } } -async function getStringWithLegacyFallback( - key: string, - legacyKey: string, -): Promise> { - const value = await SpAsyncUtil.getString(key); - if (!value.success) return value; - if (value.data !== null && value.data.length > 0) return value; - return SpAsyncUtil.getString(legacyKey); -} - -async function removeKeys(...keys: string[]): Promise> { - for (const key of keys) { - const result = await SpAsyncUtil.remove(key); - if (!result.success) return result; - } - return Result.ok(undefined); -} - function isLoginStatus(value: string): value is LoginStatusT { return Object.values(LoginStatus).includes(value as LoginStatusT); } diff --git a/src/data/storage/index.ts b/src/data/storage/index.ts index b90d537e..d669a7b7 100644 --- a/src/data/storage/index.ts +++ b/src/data/storage/index.ts @@ -7,6 +7,7 @@ */ export * from "@/utils/result"; export * from "./storage_keys"; +export * from "./storage_migration"; export * from "./app/app_storage"; // NOTE: top-level `auth_storage.ts` is intentionally not re-exported here — // it conflicts with `auth/auth_storage.ts` (both define `AuthStorage`). diff --git a/src/data/storage/storage_keys.ts b/src/data/storage/storage_keys.ts index f38566d7..0b331a20 100644 --- a/src/data/storage/storage_keys.ts +++ b/src/data/storage/storage_keys.ts @@ -15,8 +15,7 @@ export const StorageKeys = { refreshToken: "refresh_token", asid: "asid", psid: "psid", - legacyFacebookId: "facebook_id", - legacyFacebookAppId: "facebook_app_id", + storageMigrationVersion: "storage_migration_version", // user user: "user", diff --git a/src/data/storage/storage_migration.ts b/src/data/storage/storage_migration.ts new file mode 100644 index 00000000..c0a19cb4 --- /dev/null +++ b/src/data/storage/storage_migration.ts @@ -0,0 +1,129 @@ +"use client"; + +import { Result, type Result as ResultT, SpAsyncUtil } from "@/utils"; + +import { StorageKeys } from "./storage_keys"; + +const CURRENT_STORAGE_MIGRATION_VERSION = 1; + +const LEGACY_FACEBOOK_ID_KEY = "facebook_id"; +const LEGACY_PWA_DIALOG_SHOWN_KEYS = [ + "last_pwa_dialog_shown", + "cozsweet:lastPwaDialogShown", +]; + +/** + * Runs app startup migrations for persistent key-value storage. + * + * Migrations are intentionally idempotent: copying only fills missing new keys, + * and legacy keys are removed after each attempt. + */ +export async function runStorageMigrations(): Promise> { + const versionResult = await SpAsyncUtil.getInt( + StorageKeys.storageMigrationVersion, + ); + if (!versionResult.success) return versionResult; + if ((versionResult.data ?? 0) >= CURRENT_STORAGE_MIGRATION_VERSION) { + return Result.ok(undefined); + } + + const migrationResult = await migrateToV1(); + if (!migrationResult.success) return migrationResult; + + return SpAsyncUtil.setInt( + StorageKeys.storageMigrationVersion, + CURRENT_STORAGE_MIGRATION_VERSION, + ); +} + +async function migrateToV1(): Promise> { + const asidResult = await migrateStringKey({ + legacyKey: LEGACY_FACEBOOK_ID_KEY, + newKey: StorageKeys.asid, + }); + if (!asidResult.success) return asidResult; + + return migratePwaDialogShown(); +} + +async function migrateStringKey(input: { + legacyKey: string; + newKey: string; +}): Promise> { + const current = await SpAsyncUtil.getString(input.newKey); + if (!current.success) return current; + + const legacy = await readLegacyString(input.legacyKey); + if (!legacy.success) return legacy; + + if (!current.data && legacy.data) { + const writeResult = await SpAsyncUtil.setString(input.newKey, legacy.data); + if (!writeResult.success) return writeResult; + } + + return removeLegacyKey(input.legacyKey); +} + +async function migratePwaDialogShown(): Promise> { + const current = await SpAsyncUtil.getBool(StorageKeys.pwaDialogShown); + if (!current.success) return current; + + let wasShown = current.data === true; + for (const legacyKey of LEGACY_PWA_DIALOG_SHOWN_KEYS) { + const legacy = await readLegacyString(legacyKey); + if (!legacy.success) return legacy; + wasShown = wasShown || legacy.data !== null; + } + + if (wasShown && current.data !== true) { + const writeResult = await SpAsyncUtil.setBool( + StorageKeys.pwaDialogShown, + true, + ); + if (!writeResult.success) return writeResult; + } + + for (const legacyKey of LEGACY_PWA_DIALOG_SHOWN_KEYS) { + const removeResult = await removeLegacyKey(legacyKey); + if (!removeResult.success) return removeResult; + } + + return Result.ok(undefined); +} + +async function readLegacyString(key: string): Promise> { + const storageResult = await SpAsyncUtil.getString(key); + if (!storageResult.success) return storageResult; + if (storageResult.data) return storageResult; + + const browserValue = readBrowserLocalStorage(key); + if (browserValue !== null && browserValue.length > 0) { + return Result.ok(browserValue); + } + return Result.ok(null); +} + +async function removeLegacyKey(key: string): Promise> { + const removeResult = await SpAsyncUtil.remove(key); + if (!removeResult.success) return removeResult; + removeBrowserLocalStorage(key); + return Result.ok(undefined); +} + +function readBrowserLocalStorage(key: string): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} + +function removeBrowserLocalStorage(key: string): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(key); + } catch { + // Best-effort cleanup only. + } +} diff --git a/src/stores/auth/auth-status-checker.tsx b/src/stores/auth/auth-status-checker.tsx index b3bd65d6..71308e61 100644 --- a/src/stores/auth/auth-status-checker.tsx +++ b/src/stores/auth/auth-status-checker.tsx @@ -5,11 +5,12 @@ * 派发时机:仅 mount 时一次(`useEffect` deps = `[dispatch]`,永远不重跑)。 * * 与 平级: - * - AuthStatusChecker: 启动时一次性 init,恢复登录态、绑定 ASID/PSID、尝试 PSID 直登 + * - AuthStatusChecker: 启动时先跑 storage migration,再恢复登录态、绑定 ASID/PSID、尝试 PSID 直登 * - OAuthSessionSync: 持续监听 NextAuth session("别人在写") */ import { useEffect } from "react"; +import { runStorageMigrations } from "@/data/storage"; import { useAuthDispatch } from "./auth-context"; import { Logger } from "@/utils"; @@ -19,8 +20,26 @@ export function AuthStatusChecker() { const dispatch = useAuthDispatch(); useEffect(() => { - log.debug("[auth-init] mount → dispatch AuthInit"); - dispatch({ type: "AuthInit" }); + let cancelled = false; + + async function runStartupAuthInit() { + const migrationResult = await runStorageMigrations(); + if (!migrationResult.success) { + log.warn( + { err: migrationResult.error.message }, + "[auth-init] storage migration failed; continuing AuthInit", + ); + } + if (cancelled) return; + log.debug("[auth-init] mount → dispatch AuthInit"); + dispatch({ type: "AuthInit" }); + } + + void runStartupAuthInit(); + + return () => { + cancelled = true; + }; }, [dispatch]); // 无 UI