chore(deps): add unstorage and migrate ChatStorage to use SpAsyncUtil

This commit is contained in:
2026-06-09 18:13:55 +08:00
parent 5e645b003e
commit 6e64ed35f4
11 changed files with 467 additions and 213 deletions
+1
View File
@@ -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"
},
+157
View File
@@ -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
+1 -1
View File
@@ -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";
@@ -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<void> | null = null;
/**
* 获取当前可用的 auth token(登录 token 优先,其次游客 token
*/
async function getAuthTokenAsync(): Promise<string | null> {
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<void> {
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<void> {
};
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<void> {
* 刷新游客 token
*/
async function refreshGuestToken(baseUrl: string): Promise<void> {
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<void> {
};
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",
+7 -20
View File
@@ -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<ResultT<boolean>> {
@@ -41,7 +28,7 @@ export class AppStorage {
}
static recordPwaDialogShown(todayString: string): Promise<ResultT<void>> {
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<ResultT<void>> {
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<ResultT<void>> {
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<ResultT<boolean>> {
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);
}
+19 -22
View File
@@ -1,32 +1,29 @@
"use client";
/**
* AuthStorage 完整实现(示范 1KV / 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` driverSSR → memory driverunstorage 自动)
* - 单例挂在 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<ResultT<string | null>> {
return this.ls.getString(StorageKeys.loginToken);
return SpAsyncUtil.getString(StorageKeys.loginToken);
}
setLoginToken(token: string): Promise<ResultT<void>> {
return this.ls.setString(StorageKeys.loginToken, token);
return SpAsyncUtil.setString(StorageKeys.loginToken, token);
}
async hasLoginToken(): Promise<ResultT<boolean>> {
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<ResultT<void>> {
return this.ls.remove(StorageKeys.loginToken);
return SpAsyncUtil.remove(StorageKeys.loginToken);
}
// ---- guest token ----
getGuestToken(): Promise<ResultT<string | null>> {
return this.ls.getString(StorageKeys.guestToken);
return SpAsyncUtil.getString(StorageKeys.guestToken);
}
setGuestToken(token: string): Promise<ResultT<void>> {
return this.ls.setString(StorageKeys.guestToken, token);
return SpAsyncUtil.setString(StorageKeys.guestToken, token);
}
async hasGuestToken(): Promise<ResultT<boolean>> {
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<ResultT<void>> {
return this.ls.remove(StorageKeys.guestToken);
return SpAsyncUtil.remove(StorageKeys.guestToken);
}
// ---- device id ----
getDeviceId(): Promise<ResultT<string | null>> {
return this.ls.getString(StorageKeys.deviceId);
return SpAsyncUtil.getString(StorageKeys.deviceId);
}
setDeviceId(deviceId: string): Promise<ResultT<void>> {
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<ResultT<string | null>> {
return this.ls.getString(StorageKeys.refreshToken);
return SpAsyncUtil.getString(StorageKeys.refreshToken);
}
setRefreshToken(token: string): Promise<ResultT<void>> {
return this.ls.setString(StorageKeys.refreshToken, token);
return SpAsyncUtil.setString(StorageKeys.refreshToken, token);
}
clearRefreshToken(): Promise<ResultT<void>> {
return this.ls.remove(StorageKeys.refreshToken);
return SpAsyncUtil.remove(StorageKeys.refreshToken);
}
// ---- facebook id ----
getFacebookId(): Promise<ResultT<string | null>> {
return this.ls.getString(StorageKeys.facebookId);
return SpAsyncUtil.getString(StorageKeys.facebookId);
}
setFacebookId(id: string): Promise<ResultT<void>> {
return this.ls.setString(StorageKeys.facebookId, id);
return SpAsyncUtil.setString(StorageKeys.facebookId, id);
}
// ---- bulk clear ----
+9 -15
View File
@@ -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<ResultT<GuestChatQuotaData | null>> {
return this.ls.getJson(StorageKeys.guestChatQuota, GuestChatQuotaSchema);
return SpAsyncUtil.getJson(StorageKeys.guestChatQuota, GuestChatQuotaSchema);
}
setGuestDailyChatQuota(remaining: number, date: string): Promise<ResultT<void>> {
const value: GuestChatQuotaData = { remaining, date };
return this.ls.setJson(StorageKeys.guestChatQuota, value, GuestChatQuotaSchema);
return SpAsyncUtil.setJson(StorageKeys.guestChatQuota, value, GuestChatQuotaSchema);
}
clearGuestDailyChatQuota(): Promise<ResultT<void>> {
return this.ls.remove(StorageKeys.guestChatQuota);
return SpAsyncUtil.remove(StorageKeys.guestChatQuota);
}
// ---- guest total quota ----
getGuestTotalQuota(): Promise<ResultT<number | null>> {
return this.ls.getJson(StorageKeys.guestTotalQuotaRemaining, z.number());
return SpAsyncUtil.getJson(StorageKeys.guestTotalQuotaRemaining, z.number());
}
setGuestTotalQuota(remaining: number): Promise<ResultT<void>> {
return this.ls.setJson(StorageKeys.guestTotalQuotaRemaining, remaining, z.number());
return SpAsyncUtil.setJson(StorageKeys.guestTotalQuotaRemaining, remaining, z.number());
}
clearGuestTotalQuota(): Promise<ResultT<void>> {
return this.ls.remove(StorageKeys.guestTotalQuotaRemaining);
return SpAsyncUtil.remove(StorageKeys.guestTotalQuotaRemaining);
}
}
+5 -3
View File
@@ -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";
+12 -17
View File
@@ -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<ResultT<UserData | null>> {
return this.ls.getJson(StorageKeys.user, UserSchema);
return SpAsyncUtil.getJson(StorageKeys.user, UserSchema);
}
setUser(user: UserData): Promise<ResultT<void>> {
return this.ls.setJson(StorageKeys.user, user, UserSchema);
return SpAsyncUtil.setJson(StorageKeys.user, user, UserSchema);
}
clearUser(): Promise<ResultT<void>> {
return this.ls.remove(StorageKeys.user);
return SpAsyncUtil.remove(StorageKeys.user);
}
// ---- userId ----
getUserId(): Promise<ResultT<string | null>> {
return this.ls.getString(StorageKeys.userId);
return SpAsyncUtil.getString(StorageKeys.userId);
}
setUserId(id: string): Promise<ResultT<void>> {
return this.ls.setString(StorageKeys.userId, id);
return SpAsyncUtil.setString(StorageKeys.userId, id);
}
clearUserId(): Promise<ResultT<void>> {
return this.ls.remove(StorageKeys.userId);
return SpAsyncUtil.remove(StorageKeys.userId);
}
// ---- avatarUrl ----
getAvatarUrl(): Promise<ResultT<string | null>> {
return this.ls.getString(StorageKeys.userAvatar);
return SpAsyncUtil.getString(StorageKeys.userAvatar);
}
setAvatarUrl(url: string): Promise<ResultT<void>> {
return this.ls.setString(StorageKeys.userAvatar, url);
return SpAsyncUtil.setString(StorageKeys.userAvatar, url);
}
clearAvatarUrl(): Promise<ResultT<void>> {
return this.ls.remove(StorageKeys.userAvatar);
return SpAsyncUtil.remove(StorageKeys.userAvatar);
}
// ---- bulk clear ----
-123
View File
@@ -1,123 +0,0 @@
"use client";
/**
* localStorage 通用包装
*
* 设计要点:
* 1. 全部方法返回 `Promise<Result<T>>`,与 `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<ResultT<string | null>> {
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<ResultT<void>> {
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<T>(key: string, schema: ZodType<T>): Promise<ResultT<T | null>> {
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<T>(
key: string,
value: T,
schema?: ZodType<T>,
): Promise<ResultT<void>> {
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<ResultT<void>> {
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<ResultT<boolean>> {
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);
}
}
}
+226
View File
@@ -0,0 +1,226 @@
/**
* SpAsyncUtil — 键值对持久化工具类(基于 unstorage)
*
* 原始 Dart: `lib/utils/src/sp_async_util.dart``SpAsyncUtil` 静态类)
*
* 设计目标:
* - **完全静态类化**:所有状态/方法/函数均封装在 SpAsyncUtil 类内
* - 底层基于 unstorage 抽象(浏览器 localStorage / SSR memory 自动切换)
* - 保留 `Promise<Result<T>>` 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<ResultT<string | null>> {
try {
const value = await SpAsyncUtil._storage.getItem<string>(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<ResultT<void>> {
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<ResultT<number | null>> {
try {
const value = await SpAsyncUtil._storage.getItem<number>(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<ResultT<void>> {
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<ResultT<number | null>> {
try {
const value = await SpAsyncUtil._storage.getItem<number>(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<ResultT<void>> {
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<ResultT<boolean | null>> {
try {
const value = await SpAsyncUtil._storage.getItem<boolean>(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<ResultT<void>> {
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<ResultT<string[] | null>> {
try {
const value = await SpAsyncUtil._storage.getItem<string[]>(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<ResultT<void>> {
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<T>(
key: string,
schema: ZodType<T>,
): Promise<ResultT<T | null>> {
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<T>(
key: string,
value: T,
schema?: ZodType<T>,
): Promise<ResultT<void>> {
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<ResultT<void>> {
try {
await SpAsyncUtil._storage.removeItem(key);
return Result.ok(undefined);
} catch (e) {
return Result.err(toError(e));
}
}
static async clear(): Promise<ResultT<void>> {
try {
await SpAsyncUtil._storage.clear();
return Result.ok(undefined);
} catch (e) {
return Result.err(toError(e));
}
}
static async has(key: string): Promise<ResultT<boolean>> {
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));
}