chore(deps): add unstorage and migrate ChatStorage to use SpAsyncUtil
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<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 ----
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user