diff --git a/package.json b/package.json index 65be38d0..24a98e57 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "ofetch": "^1.5.1", "react": "19.2.4", "react-dom": "19.2.4", + "unstorage": "^1.17.5", "xstate": "^5", "zod": "^4.4.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64a71fa4..3dd612ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + unstorage: + specifier: ^1.17.5 + version: 1.17.5 xstate: specifier: ^5 version: 5.32.0 @@ -1055,6 +1058,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1187,6 +1194,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} @@ -1216,10 +1227,16 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -1277,6 +1294,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -1617,6 +1637,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -1681,6 +1704,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2075,10 +2101,17 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + node-releases@2.0.47: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2220,6 +2253,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -2232,6 +2268,10 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -2531,6 +2571,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -2541,6 +2584,68 @@ packages: unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -3576,6 +3681,11 @@ snapshots: dependencies: color-convert: 2.0.1 + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + argparse@2.0.1: {} aria-query@5.3.2: {} @@ -3738,6 +3848,10 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + classnames@2.5.1: {} client-only@0.0.1: {} @@ -3764,12 +3878,18 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@1.2.3: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -3828,6 +3948,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.7: {} + destr@2.0.5: {} detect-libc@2.1.2: {} @@ -4308,6 +4430,18 @@ snapshots: graceful-fs@4.2.11: {} + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -4363,6 +4497,8 @@ snapshots: hasown: 2.0.4 side-channel: 1.1.0 + iron-webcrypto@1.2.1: {} + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.9 @@ -4744,8 +4880,12 @@ snapshots: node-fetch-native@1.6.7: {} + node-mock-http@1.0.4: {} + node-releases@2.0.47: {} + normalize-path@3.0.0: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -4891,6 +5031,8 @@ snapshots: queue-microtask@1.2.3: {} + radix3@1.1.2: {} + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -4900,6 +5042,8 @@ snapshots: react@19.2.4: {} + readdirp@5.0.0: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -5298,6 +5442,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + uncrypto@0.1.3: {} + undici-types@6.21.0: {} undici@7.27.2: {} @@ -5329,6 +5475,17 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + unstorage@1.17.5: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.1 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 diff --git a/src/data/repositories/auth_repository.ts b/src/data/repositories/auth_repository.ts index cd783bdc..4e47847a 100644 --- a/src/data/repositories/auth_repository.ts +++ b/src/data/repositories/auth_repository.ts @@ -12,7 +12,7 @@ * * 原始 Dart: lib/data/repositories/auth_repository_impl.dart */ -import { AuthApi, authApi } from "@/data/api"; +import { AuthApi, authApi } from "@/data/services/api"; import { AppleLoginRequest } from "@/data/dto/auth/apple_login_request"; import { FacebookLoginRequest } from "@/data/dto/auth/facebook_login_request"; import { FbIdLoginRequest } from "@/data/dto/auth/fb_id_login_request"; diff --git a/src/data/services/api/interceptor/auth_refresh_interceptor.ts b/src/data/services/api/interceptor/auth_refresh_interceptor.ts index 835f3425..a54a17d4 100644 --- a/src/data/services/api/interceptor/auth_refresh_interceptor.ts +++ b/src/data/services/api/interceptor/auth_refresh_interceptor.ts @@ -7,8 +7,9 @@ import type { FetchHook } from "ofetch"; import { ApiPath } from "../api_path"; import { ApiError, ErrorCode } from "../api_result"; -import { authStorage } from "../../storage/auth_storage"; +import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { deviceIdentifier } from "@/utils/device_identifier"; +import { Result } from "@/utils/result"; /** * 不需要 auth token 刷新的路径 @@ -32,6 +33,22 @@ let isRefreshing = false; */ let refreshPromise: Promise | null = null; +/** + * 获取当前可用的 auth token(登录 token 优先,其次游客 token) + */ +async function getAuthTokenAsync(): Promise { + const storage = AuthStorage.getInstance(); + const loginResult = await storage.getLoginToken(); + if (Result.isOk(loginResult) && loginResult.data && loginResult.data.length > 0) { + return loginResult.data; + } + const guestResult = await storage.getGuestToken(); + if (Result.isOk(guestResult) && guestResult.data && guestResult.data.length > 0) { + return guestResult.data; + } + return null; +} + /** * 实际刷新 token(被拦截器调用) */ @@ -50,15 +67,16 @@ async function refreshToken( * 刷新登录 token */ async function refreshLoginToken(baseUrl: string): Promise { - const refreshToken = authStorage.getRefreshToken(); - if (!refreshToken) { + const storage = AuthStorage.getInstance(); + const refreshResult = await storage.getRefreshToken(); + if (!Result.isOk(refreshResult) || !refreshResult.data) { throw new Error("No refresh token available"); } const response = await fetch(`${baseUrl}${ApiPath.refresh}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken }), + body: JSON.stringify({ refreshToken: refreshResult.data }), }); if (!response.ok) { @@ -71,9 +89,8 @@ async function refreshLoginToken(baseUrl: string): Promise { }; if (json.success && json.data) { - if (json.data.token) authStorage.setLoginToken(json.data.token); - if (json.data.refreshToken) - authStorage.setRefreshToken(json.data.refreshToken); + if (json.data.token) await storage.setLoginToken(json.data.token); + if (json.data.refreshToken) await storage.setRefreshToken(json.data.refreshToken); } else { throw new Error("Refresh response invalid"); } @@ -83,6 +100,7 @@ async function refreshLoginToken(baseUrl: string): Promise { * 刷新游客 token */ async function refreshGuestToken(baseUrl: string): Promise { + const storage = AuthStorage.getInstance(); const deviceId = await deviceIdentifier.getDeviceId(); const response = await fetch(`${baseUrl}${ApiPath.guestLogin}`, { @@ -101,8 +119,8 @@ async function refreshGuestToken(baseUrl: string): Promise { }; if (json.success && json.data) { - if (json.data.token) authStorage.setGuestToken(json.data.token); - if (json.data.deviceId) authStorage.setDeviceId(json.data.deviceId); + if (json.data.token) await storage.setGuestToken(json.data.token); + if (json.data.deviceId) await storage.setDeviceId(json.data.deviceId); } else { throw new Error("Guest refresh response invalid"); } @@ -124,7 +142,7 @@ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => { if (NO_AUTH_REFRESH_PATHS.some((p) => path.includes(p))) return; // 已登录则刷新登录 token,否则刷新游客 token - const isGuestMode = !authStorage.hasLoginToken(); + const isGuestMode = !AuthStorage.getInstance().hasLoginToken(); // 并发刷新:等待或启动 if (isRefreshing && refreshPromise) { @@ -144,9 +162,9 @@ export const onResponseErrorAuthRefresh: FetchHook = async (ctx) => { } // 刷新失败 → 清除数据并抛出 - const newToken = authStorage.getAuthToken(); + const newToken = await getAuthTokenAsync(); if (!newToken) { - authStorage.clearAuthData(); + AuthStorage.getInstance().clearAuthData(); throw new ApiError( ErrorCode.unknownError, "Token refresh failed", diff --git a/src/data/storage/app/app_storage.ts b/src/data/storage/app/app_storage.ts index 475b4492..f823eae8 100644 --- a/src/data/storage/app/app_storage.ts +++ b/src/data/storage/app/app_storage.ts @@ -1,14 +1,14 @@ "use client"; /** - * AppStorage 完整实现(静态类风格 / localStorage) + * AppStorage 完整实现(基于 unstorage 抽象) * * 对齐 Dart 端 `AppStorage`(lib/data/services/storage/app_storage.dart): * - PWA 对话框每日展示 / PWA 事件每日上报 / app-info 每日上报 * - 内部用 `yyyy-MM-dd` 字符串比对实现"每日一次"语义 * * 注意:Dart 端是 `static class` 没有接口,本 TS 版本同样保持静态方法风格。 - * 通过 `LocalStorage` 单例 + 懒加载的私有 getter 访问底层存储。 + * 通过 SpAsyncUtil 静态类访问底层存储(浏览器 localStorage / SSR memory 自动切换)。 * * 语义: * - `canXxx(today)` 返回 `true` 当且仅当"自上次记录以来日期变了" @@ -17,23 +17,10 @@ */ import { Result, type Result as ResultT } from "@/utils/result"; +import { SpAsyncUtil } from "@/utils/storage"; import { StorageKeys } from "../storage_keys"; -import { LocalStorage } from "@/utils/local-storage"; export class AppStorage { - private static _ls: LocalStorage | null = null; - - /** 懒加载 LocalStorage 单例,避免模块加载期触发 SSR 副作用。 */ - private static get ls(): LocalStorage { - if (!AppStorage._ls) AppStorage._ls = LocalStorage.getInstance(); - return AppStorage._ls; - } - - /** @internal */ - static _resetForTests(): void { - AppStorage._ls = null; - } - // ---- PWA install dialog ---- static async canShowPwaDialog(todayString: string): Promise> { @@ -41,7 +28,7 @@ export class AppStorage { } static recordPwaDialogShown(todayString: string): Promise> { - return AppStorage.ls.setString(StorageKeys.lastPwaDialogShown, todayString); + return SpAsyncUtil.setString(StorageKeys.lastPwaDialogShown, todayString); } // ---- PWA event reporting ---- @@ -51,7 +38,7 @@ export class AppStorage { } static recordPwaEventReported(todayString: string): Promise> { - return AppStorage.ls.setString(StorageKeys.lastPwaEventReported, todayString); + return SpAsyncUtil.setString(StorageKeys.lastPwaEventReported, todayString); } // ---- app info reporting ---- @@ -61,7 +48,7 @@ export class AppStorage { } static recordAppInfoReported(todayString: string): Promise> { - return AppStorage.ls.setString(StorageKeys.lastAppInfoReported, todayString); + return SpAsyncUtil.setString(StorageKeys.lastAppInfoReported, todayString); } // ---- internal helpers ---- @@ -77,7 +64,7 @@ export class AppStorage { key: string, todayString: string, ): Promise> { - const r = await AppStorage.ls.getString(key); + const r = await SpAsyncUtil.getString(key); if (!r.success) return r; return Result.ok(r.data === null || r.data !== todayString); } diff --git a/src/data/storage/auth/auth_storage.ts b/src/data/storage/auth/auth_storage.ts index b8e7b95a..acd125ff 100644 --- a/src/data/storage/auth/auth_storage.ts +++ b/src/data/storage/auth/auth_storage.ts @@ -1,32 +1,29 @@ "use client"; /** - * AuthStorage 完整实现(示范 1:KV / localStorage) + * AuthStorage 完整实现(基于 unstorage 抽象) + * + * 原始 Dart: lib/data/services/storage/auth_storage.dart * * SECURITY: 当前与 Dart 端行为一致,refresh_token 以明文存于 localStorage。 * 后续轮次将升级到 HttpOnly Cookie 方案,参见 `clearAuthData` 处的 TODO 标记。 * * 设计说明: - * - 组合而非继承 `LocalStorage`(避免多重继承层级) + * - 内部使用 `SpAsyncUtil` 静态类(@/utils/storage) + * - 浏览器 → `localStorage` driver;SSR → memory driver(unstorage 自动) * - 单例挂在 class 静态字段,规避 HMR 复用污染 - * - 构造函数接受可选 `LocalStorage` 注入,便于测试时使用 jsdom 的 storage * - `hasXxx` = `token != null && token.length > 0`,对齐 Dart 端 `isNotEmpty` 语义 * - `clearAuthData` 顺序清理:loginToken → guestToken → refreshToken,任一失败立即返回 */ import { Result, type Result as ResultT } from "@/utils/result"; +import { SpAsyncUtil } from "@/utils/storage"; import { StorageKeys } from "../storage_keys"; import type { IAuthStorage } from "./iauth_storage"; -import { LocalStorage } from "@/utils/local-storage"; export class AuthStorage implements IAuthStorage { - private readonly ls: LocalStorage; private static _instance: AuthStorage | null = null; - constructor(localStorage?: LocalStorage) { - this.ls = localStorage ?? LocalStorage.getInstance(); - } - static getInstance(): AuthStorage { if (!AuthStorage._instance) AuthStorage._instance = new AuthStorage(); return AuthStorage._instance; @@ -40,10 +37,10 @@ export class AuthStorage implements IAuthStorage { // ---- login token ---- getLoginToken(): Promise> { - return this.ls.getString(StorageKeys.loginToken); + return SpAsyncUtil.getString(StorageKeys.loginToken); } setLoginToken(token: string): Promise> { - return this.ls.setString(StorageKeys.loginToken, token); + return SpAsyncUtil.setString(StorageKeys.loginToken, token); } async hasLoginToken(): Promise> { const r = await this.getLoginToken(); @@ -51,16 +48,16 @@ export class AuthStorage implements IAuthStorage { return Result.ok(r.data !== null && r.data.length > 0); } clearLoginToken(): Promise> { - return this.ls.remove(StorageKeys.loginToken); + return SpAsyncUtil.remove(StorageKeys.loginToken); } // ---- guest token ---- getGuestToken(): Promise> { - return this.ls.getString(StorageKeys.guestToken); + return SpAsyncUtil.getString(StorageKeys.guestToken); } setGuestToken(token: string): Promise> { - return this.ls.setString(StorageKeys.guestToken, token); + return SpAsyncUtil.setString(StorageKeys.guestToken, token); } async hasGuestToken(): Promise> { const r = await this.getGuestToken(); @@ -68,16 +65,16 @@ export class AuthStorage implements IAuthStorage { return Result.ok(r.data !== null && r.data.length > 0); } clearGuestToken(): Promise> { - return this.ls.remove(StorageKeys.guestToken); + return SpAsyncUtil.remove(StorageKeys.guestToken); } // ---- device id ---- getDeviceId(): Promise> { - return this.ls.getString(StorageKeys.deviceId); + return SpAsyncUtil.getString(StorageKeys.deviceId); } setDeviceId(deviceId: string): Promise> { - return this.ls.setString(StorageKeys.deviceId, deviceId); + return SpAsyncUtil.setString(StorageKeys.deviceId, deviceId); } // ---- refresh token ---- @@ -85,22 +82,22 @@ export class AuthStorage implements IAuthStorage { // SECURITY: 明文存储,仅供本轮与 Dart 行为对齐 getRefreshToken(): Promise> { - return this.ls.getString(StorageKeys.refreshToken); + return SpAsyncUtil.getString(StorageKeys.refreshToken); } setRefreshToken(token: string): Promise> { - return this.ls.setString(StorageKeys.refreshToken, token); + return SpAsyncUtil.setString(StorageKeys.refreshToken, token); } clearRefreshToken(): Promise> { - return this.ls.remove(StorageKeys.refreshToken); + return SpAsyncUtil.remove(StorageKeys.refreshToken); } // ---- facebook id ---- getFacebookId(): Promise> { - return this.ls.getString(StorageKeys.facebookId); + return SpAsyncUtil.getString(StorageKeys.facebookId); } setFacebookId(id: string): Promise> { - return this.ls.setString(StorageKeys.facebookId, id); + return SpAsyncUtil.setString(StorageKeys.facebookId, id); } // ---- bulk clear ---- diff --git a/src/data/storage/chat/chat_storage.ts b/src/data/storage/chat/chat_storage.ts index fe50375d..b68b4df2 100644 --- a/src/data/storage/chat/chat_storage.ts +++ b/src/data/storage/chat/chat_storage.ts @@ -1,7 +1,7 @@ "use client"; /** - * ChatStorage 完整实现(KV / localStorage) + * ChatStorage 完整实现(基于 unstorage 抽象) * * 对齐 Dart 端 `ChatStorage`(lib/data/services/storage/chat/chat_storage.dart): * - 游客每日聊天配额(GuestChatQuota:{ remaining, date }) @@ -11,8 +11,7 @@ * - ChatStorage = 游客配额等元数据(KV) * - LocalChatStorage = 聊天记录本身(IndexedDB) * - * 设计沿用 AuthStorage 模式:组合 `LocalStorage` + 单例 + Zod 校验。 - * 跨天重置判断(`needsReset`)保留在 `GuestChatQuota` 类上,存储层只做读写。 + * 设计:内部使用 SpAsyncUtil 静态类 + Zod 校验;保持对外 API 完全兼容。 */ import { @@ -22,18 +21,13 @@ import { import { z } from "zod"; import { type Result as ResultT } from "@/utils/result"; +import { SpAsyncUtil } from "@/utils/storage"; import { StorageKeys } from "../storage_keys"; import type { IChatStorage } from "./ichat_storage"; -import { LocalStorage } from "@/utils/local-storage"; export class ChatStorage implements IChatStorage { - private readonly ls: LocalStorage; private static _instance: ChatStorage | null = null; - constructor(localStorage?: LocalStorage) { - this.ls = localStorage ?? LocalStorage.getInstance(); - } - static getInstance(): ChatStorage { if (!ChatStorage._instance) ChatStorage._instance = new ChatStorage(); return ChatStorage._instance; @@ -47,29 +41,29 @@ export class ChatStorage implements IChatStorage { // ---- guest daily chat quota ---- getGuestDailyChatQuota(): Promise> { - return this.ls.getJson(StorageKeys.guestChatQuota, GuestChatQuotaSchema); + return SpAsyncUtil.getJson(StorageKeys.guestChatQuota, GuestChatQuotaSchema); } setGuestDailyChatQuota(remaining: number, date: string): Promise> { const value: GuestChatQuotaData = { remaining, date }; - return this.ls.setJson(StorageKeys.guestChatQuota, value, GuestChatQuotaSchema); + return SpAsyncUtil.setJson(StorageKeys.guestChatQuota, value, GuestChatQuotaSchema); } clearGuestDailyChatQuota(): Promise> { - return this.ls.remove(StorageKeys.guestChatQuota); + return SpAsyncUtil.remove(StorageKeys.guestChatQuota); } // ---- guest total quota ---- getGuestTotalQuota(): Promise> { - return this.ls.getJson(StorageKeys.guestTotalQuotaRemaining, z.number()); + return SpAsyncUtil.getJson(StorageKeys.guestTotalQuotaRemaining, z.number()); } setGuestTotalQuota(remaining: number): Promise> { - return this.ls.setJson(StorageKeys.guestTotalQuotaRemaining, remaining, z.number()); + return SpAsyncUtil.setJson(StorageKeys.guestTotalQuotaRemaining, remaining, z.number()); } clearGuestTotalQuota(): Promise> { - return this.ls.remove(StorageKeys.guestTotalQuotaRemaining); + return SpAsyncUtil.remove(StorageKeys.guestTotalQuotaRemaining); } } diff --git a/src/data/storage/index.ts b/src/data/storage/index.ts index 1a333dc5..9e356693 100644 --- a/src/data/storage/index.ts +++ b/src/data/storage/index.ts @@ -1,8 +1,10 @@ /** - * @file Automatically generated by barrelsby. + * @file Storage 公共导出 + * + * unstorage 重构后: + * - 删除 `./local_storage`(原自定义 LocalStorage class 已被 unstorage 取代) + * - 业务层(AuthStorage / ChatStorage / UserStorage / AppStorage)继续走原接口 */ - -export * from "./local_storage"; export * from "@/utils/result"; export * from "./storage_keys"; export * from "./app/app_storage"; diff --git a/src/data/storage/user/user_storage.ts b/src/data/storage/user/user_storage.ts index 09362788..eb9280ac 100644 --- a/src/data/storage/user/user_storage.ts +++ b/src/data/storage/user/user_storage.ts @@ -1,13 +1,13 @@ "use client"; /** - * UserStorage 完整实现(KV / localStorage) + * UserStorage 完整实现(基于 unstorage 抽象) * * 对齐 Dart 端 `UserStorage`(lib/data/services/storage/user_storage.dart): * - 三个独立字段:User 对象 / userId / avatarUrl * - `clearUserData()` 批量清空 * - * 设计沿用 AuthStorage 模式:组合 `LocalStorage`(非继承)+ 单例 + Zod 校验。 + * 设计:内部使用 SpAsyncUtil 静态类 + Zod 校验;保持对外 API 完全兼容。 * * 边界: * - `getUser` 走 `UserSchema` 解析:缺字段时 Zod `.default()` 自动兜底 @@ -17,18 +17,13 @@ import { UserSchema, type UserData } from "@/data/schemas/user/user"; import { type Result as ResultT } from "@/utils/result"; +import { SpAsyncUtil } from "@/utils/storage"; import { StorageKeys } from "../storage_keys"; import type { IUserStorage } from "./iuser_storage"; -import { LocalStorage } from "@/utils/local-storage"; export class UserStorage implements IUserStorage { - private readonly ls: LocalStorage; private static _instance: UserStorage | null = null; - constructor(localStorage?: LocalStorage) { - this.ls = localStorage ?? LocalStorage.getInstance(); - } - static getInstance(): UserStorage { if (!UserStorage._instance) UserStorage._instance = new UserStorage(); return UserStorage._instance; @@ -42,43 +37,43 @@ export class UserStorage implements IUserStorage { // ---- user object ---- getUser(): Promise> { - return this.ls.getJson(StorageKeys.user, UserSchema); + return SpAsyncUtil.getJson(StorageKeys.user, UserSchema); } setUser(user: UserData): Promise> { - return this.ls.setJson(StorageKeys.user, user, UserSchema); + return SpAsyncUtil.setJson(StorageKeys.user, user, UserSchema); } clearUser(): Promise> { - return this.ls.remove(StorageKeys.user); + return SpAsyncUtil.remove(StorageKeys.user); } // ---- userId ---- getUserId(): Promise> { - return this.ls.getString(StorageKeys.userId); + return SpAsyncUtil.getString(StorageKeys.userId); } setUserId(id: string): Promise> { - return this.ls.setString(StorageKeys.userId, id); + return SpAsyncUtil.setString(StorageKeys.userId, id); } clearUserId(): Promise> { - return this.ls.remove(StorageKeys.userId); + return SpAsyncUtil.remove(StorageKeys.userId); } // ---- avatarUrl ---- getAvatarUrl(): Promise> { - return this.ls.getString(StorageKeys.userAvatar); + return SpAsyncUtil.getString(StorageKeys.userAvatar); } setAvatarUrl(url: string): Promise> { - return this.ls.setString(StorageKeys.userAvatar, url); + return SpAsyncUtil.setString(StorageKeys.userAvatar, url); } clearAvatarUrl(): Promise> { - return this.ls.remove(StorageKeys.userAvatar); + return SpAsyncUtil.remove(StorageKeys.userAvatar); } // ---- bulk clear ---- diff --git a/src/utils/local-storage.ts b/src/utils/local-storage.ts deleted file mode 100644 index ab0d400a..00000000 --- a/src/utils/local-storage.ts +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -/** - * localStorage 通用包装 - * - * 设计要点: - * 1. 全部方法返回 `Promise>`,与 `LocalChatStorage`(Dexie/异步)保持签名一致, - * 调用方可以统一 `await` 而不必关心底层是同步还是异步。 - * 2. 三重 SSR 保护: - * - 文件顶 `"use client"` 指令 - * - 构造时探测 `typeof window`,服务端拿不到 `window.localStorage` 时存 `null` - * - 每次调用入口再做可用性检查,避免运行时炸 - * 3. 测试友好:构造函数接受可选 `Storage` 注入,单例可通过 `_resetForTests` 重置。 - * 4. `getString` 把空串视作 `null`(对齐 Dart `SpAsyncUtil.getString` 行为)。 - * 5. `getJson` / `setJson` 在边界做 Zod 校验,读写都走 schema。 - */ - -import { type ZodType } from "zod"; -import { Result, type Result as ResultT } from "@/utils/result"; - -const SSR_ERROR_MSG = - "localStorage is not available in this environment (SSR or non-browser)"; - -export class LocalStorage { - private readonly storage: Storage | null; - private static _instance: LocalStorage | null = null; - - constructor(storage?: Storage | null) { - this.storage = - storage !== undefined - ? storage - : typeof window !== "undefined" - ? window.localStorage - : null; - } - - /** 全局单例(仅在浏览器环境有效)。 */ - static getInstance(): LocalStorage { - if (!LocalStorage._instance) { - LocalStorage._instance = new LocalStorage(); - } - return LocalStorage._instance; - } - - /** @internal 测试用:重置单例以便每个测试用例拿到独立实例。 */ - static _resetForTests(): void { - LocalStorage._instance = null; - } - - private isAvailable(): boolean { - return this.storage !== null; - } - - async getString(key: string): Promise> { - if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG)); - try { - const v = this.storage!.getItem(key); - if (v === null || v === "") return Result.ok(null); - return Result.ok(v); - } catch (e) { - return Result.err(e); - } - } - - async setString(key: string, value: string): Promise> { - if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG)); - try { - this.storage!.setItem(key, value); - return Result.ok(undefined); - } catch (e) { - return Result.err(e); - } - } - - async getJson(key: string, schema: ZodType): Promise> { - const r = await this.getString(key); - if (!r.success) return Result.err(r.error); - if (r.data === null) { - const value: T | null = null; - return Result.ok(value); - } - try { - const parsed = schema.parse(JSON.parse(r.data)); - return Result.ok(parsed); - } catch (e) { - return Result.err(e); - } - } - - async setJson( - key: string, - value: T, - schema?: ZodType, - ): Promise> { - if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG)); - try { - const validated = schema ? schema.parse(value) : value; - this.storage!.setItem(key, JSON.stringify(validated)); - return Result.ok(undefined); - } catch (e) { - return Result.err(e); - } - } - - async remove(key: string): Promise> { - if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG)); - try { - this.storage!.removeItem(key); - return Result.ok(undefined); - } catch (e) { - return Result.err(e); - } - } - - async has(key: string): Promise> { - if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG)); - try { - return Result.ok(this.storage!.getItem(key) !== null); - } catch (e) { - return Result.err(e); - } - } -} diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 00000000..fe578ea5 --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,226 @@ +/** + * SpAsyncUtil — 键值对持久化工具类(基于 unstorage) + * + * 原始 Dart: `lib/utils/src/sp_async_util.dart`(`SpAsyncUtil` 静态类) + * + * 设计目标: + * - **完全静态类化**:所有状态/方法/函数均封装在 SpAsyncUtil 类内 + * - 底层基于 unstorage 抽象(浏览器 localStorage / SSR memory 自动切换) + * - 保留 `Promise>` API 兼容(state machines、interceptor、repositories 零修改) + * + * 用法: + * ```ts + * import { SpAsyncUtil } from "@/utils/storage"; + * + * const r = await SpAsyncUtil.getString("auth_token"); + * if (r.success && r.data) { ... } + * ``` + */ +import { createStorage, type Storage } from "unstorage"; +import localStorageDriver from "unstorage/drivers/localstorage"; +import type { ZodType } from "zod"; + +import { Result, type Result as ResultT } from "@/utils/result"; + +export class SpAsyncUtil { + // ===== 私有静态状态 ===== + private static _storage: Storage = createStorage({ + driver: localStorageDriver({ base: "cozsweet:" }), + }); + + // 私有构造函数:禁止实例化(对齐 Dart `SpAsyncUtil._()`) + private constructor() {} + + // ===== 底层 storage 访问 ===== + + /** + * 替换底层 storage 实例(仅测试/Storybook 用) + * @internal + */ + static setStorage(instance: Storage): void { + SpAsyncUtil._storage = instance; + } + + /** 获取底层 storage 实例(高级用法) */ + static getRawStorage(): Storage { + return SpAsyncUtil._storage; + } + + // ===== string ===== + static async getString(key: string): Promise> { + try { + const value = await SpAsyncUtil._storage.getItem(key); + if (value === null || value === undefined) return Result.ok(null); + if (typeof value !== "string") return Result.ok(null); + if (value.length === 0) return Result.ok(null); + return Result.ok(value); + } catch (e) { + return Result.err(toError(e)); + } + } + + static async setString( + key: string, + value: string, + ): Promise> { + try { + await SpAsyncUtil._storage.setItem(key, value); + return Result.ok(undefined); + } catch (e) { + return Result.err(toError(e)); + } + } + + // ===== int ===== + static async getInt(key: string): Promise> { + try { + const value = await SpAsyncUtil._storage.getItem(key); + if (value === null || value === undefined) return Result.ok(null); + if (typeof value !== "number" || !Number.isFinite(value)) return Result.ok(null); + return Result.ok(Math.trunc(value)); + } catch (e) { + return Result.err(toError(e)); + } + } + + static async setInt(key: string, value: number): Promise> { + try { + await SpAsyncUtil._storage.setItem(key, value); + return Result.ok(undefined); + } catch (e) { + return Result.err(toError(e)); + } + } + + // ===== double ===== + static async getDouble(key: string): Promise> { + try { + const value = await SpAsyncUtil._storage.getItem(key); + if (value === null || value === undefined) return Result.ok(null); + if (typeof value !== "number" || !Number.isFinite(value)) return Result.ok(null); + return Result.ok(value); + } catch (e) { + return Result.err(toError(e)); + } + } + + static async setDouble(key: string, value: number): Promise> { + try { + await SpAsyncUtil._storage.setItem(key, value); + return Result.ok(undefined); + } catch (e) { + return Result.err(toError(e)); + } + } + + // ===== bool ===== + static async getBool(key: string): Promise> { + try { + const value = await SpAsyncUtil._storage.getItem(key); + if (value === null || value === undefined) return Result.ok(null); + if (typeof value !== "boolean") return Result.ok(null); + return Result.ok(value); + } catch (e) { + return Result.err(toError(e)); + } + } + + static async setBool(key: string, value: boolean): Promise> { + try { + await SpAsyncUtil._storage.setItem(key, value); + return Result.ok(undefined); + } catch (e) { + return Result.err(toError(e)); + } + } + + // ===== string list ===== + static async getStringList(key: string): Promise> { + try { + const value = await SpAsyncUtil._storage.getItem(key); + if (value === null || value === undefined) return Result.ok(null); + if (!Array.isArray(value)) return Result.ok(null); + if (!value.every((v) => typeof v === "string")) return Result.ok(null); + return Result.ok(value); + } catch (e) { + return Result.err(toError(e)); + } + } + + static async setStringList( + key: string, + value: string[], + ): Promise> { + try { + await SpAsyncUtil._storage.setItem(key, value); + return Result.ok(undefined); + } catch (e) { + return Result.err(toError(e)); + } + } + + // ===== JSON (with optional Zod validation) ===== + static async getJson( + key: string, + schema: ZodType, + ): Promise> { + const r = await SpAsyncUtil.getString(key); + if (!r.success) return Result.err(r.error); + if (r.data === null) return Result.ok(null); + try { + const parsed = schema.parse(JSON.parse(r.data)); + return Result.ok(parsed); + } catch (e) { + return Result.err(toError(e)); + } + } + + static async setJson( + key: string, + value: T, + schema?: ZodType, + ): Promise> { + try { + const validated = schema ? schema.parse(value) : value; + await SpAsyncUtil._storage.setItem(key, JSON.stringify(validated)); + return Result.ok(undefined); + } catch (e) { + return Result.err(toError(e)); + } + } + + // ===== generic operations ===== + static async remove(key: string): Promise> { + try { + await SpAsyncUtil._storage.removeItem(key); + return Result.ok(undefined); + } catch (e) { + return Result.err(toError(e)); + } + } + + static async clear(): Promise> { + try { + await SpAsyncUtil._storage.clear(); + return Result.ok(undefined); + } catch (e) { + return Result.err(toError(e)); + } + } + + static async has(key: string): Promise> { + try { + const exists = await SpAsyncUtil._storage.hasItem(key); + return Result.ok(exists); + } catch (e) { + return Result.err(toError(e)); + } + } +} + +// ============================================================ +// 内部辅助 +// ============================================================ +function toError(e: unknown): Error { + return e instanceof Error ? e : new Error(String(e)); +}