From 90fe8929950f0955a6b12d97f06cee7653d25b8a Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 8 Jun 2026 17:26:09 +0800 Subject: [PATCH] feat(assets): add chat typing indicator lottie animation --- barrelsby.json | 7 +- package.json | 3 +- pnpm-lock.yaml | 8 + src/data/storage/app/app_storage.ts | 84 ++++++++++ src/data/storage/app/index.ts | 5 + src/data/storage/auth/auth_storage.ts | 115 ++++++++++++++ src/data/storage/auth/iauth_storage.ts | 36 +++++ src/data/storage/auth/index.ts | 6 + src/data/storage/chat/chat_storage.ts | 75 +++++++++ src/data/storage/chat/ichat_storage.ts | 28 ++++ src/data/storage/chat/index.ts | 9 ++ src/data/storage/chat/local_chat_db.ts | 36 +++++ src/data/storage/chat/local_chat_storage.ts | 163 ++++++++++++++++++++ src/data/storage/chat/local_message.ts | 77 +++++++++ src/data/storage/index.ts | 17 ++ src/data/storage/local_storage.ts | 123 +++++++++++++++ src/data/storage/result.ts | 35 +++++ src/data/storage/storage_keys.ts | 33 ++++ src/data/storage/user/index.ts | 6 + src/data/storage/user/iuser_storage.ts | 31 ++++ src/data/storage/user/user_storage.ts | 93 +++++++++++ tsconfig.json | 1 - 22 files changed, 988 insertions(+), 3 deletions(-) create mode 100644 src/data/storage/app/app_storage.ts create mode 100644 src/data/storage/app/index.ts create mode 100644 src/data/storage/auth/auth_storage.ts create mode 100644 src/data/storage/auth/iauth_storage.ts create mode 100644 src/data/storage/auth/index.ts create mode 100644 src/data/storage/chat/chat_storage.ts create mode 100644 src/data/storage/chat/ichat_storage.ts create mode 100644 src/data/storage/chat/index.ts create mode 100644 src/data/storage/chat/local_chat_db.ts create mode 100644 src/data/storage/chat/local_chat_storage.ts create mode 100644 src/data/storage/chat/local_message.ts create mode 100644 src/data/storage/index.ts create mode 100644 src/data/storage/local_storage.ts create mode 100644 src/data/storage/result.ts create mode 100644 src/data/storage/storage_keys.ts create mode 100644 src/data/storage/user/index.ts create mode 100644 src/data/storage/user/iuser_storage.ts create mode 100644 src/data/storage/user/user_storage.ts diff --git a/barrelsby.json b/barrelsby.json index bc876d75..f83c1131 100644 --- a/barrelsby.json +++ b/barrelsby.json @@ -5,6 +5,11 @@ "./src/data/models/chat", "./src/data/models/metrics", "./src/data/models/user", - "./src/data/api" + "./src/data/api", + "./src/data/storage", + "./src/data/storage/auth", + "./src/data/storage/user", + "./src/data/storage/chat", + "./src/data/storage/app" ] } diff --git a/package.json b/package.json index d3c4cf3a..02e152ef 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,11 @@ "start": "next start", "lint": "eslint", "lint:fix": "eslint --fix", - "generate-barrels": "barrelsby --delete" + "generate-barrels": "barrelsby --delete -c barrelsby.json" }, "dependencies": { "@fingerprintjs/fingerprintjs": "^5.2.0", + "dexie": "^4.4.3", "next": "16.2.7", "ofetch": "^1.5.1", "react": "19.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c35f351d..27a145f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@fingerprintjs/fingerprintjs': specifier: ^5.2.0 version: 5.2.0 + dexie: + specifier: ^4.4.3 + version: 4.4.3 next: specifier: 16.2.7 version: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -979,6 +982,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dexie@4.4.3: + resolution: {integrity: sha512-N+3IGQ3HPlyO2YAkntGAwitm42BpBGV86MttzUMiRzWLa4NGh0pltVRcUVF4ybL/OnXjCrr9k7SDPIKkFYP2Lg==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -3008,6 +3014,8 @@ snapshots: detect-libc@2.1.2: {} + dexie@4.4.3: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 diff --git a/src/data/storage/app/app_storage.ts b/src/data/storage/app/app_storage.ts new file mode 100644 index 00000000..c919a8c9 --- /dev/null +++ b/src/data/storage/app/app_storage.ts @@ -0,0 +1,84 @@ +"use client"; + +/** + * AppStorage 完整实现(静态类风格 / localStorage) + * + * 对齐 Dart 端 `AppStorage`(lib/data/services/storage/app_storage.dart): + * - PWA 对话框每日展示 / PWA 事件每日上报 / app-info 每日上报 + * - 内部用 `yyyy-MM-dd` 字符串比对实现"每日一次"语义 + * + * 注意:Dart 端是 `static class` 没有接口,本 TS 版本同样保持静态方法风格。 + * 通过 `LocalStorage` 单例 + 懒加载的私有 getter 访问底层存储。 + * + * 语义: + * - `canXxx(today)` 返回 `true` 当且仅当"自上次记录以来日期变了" + * (即 stored !== today)。首次调用(key 不存在)也返回 `true`。 + * - `recordXxx(today)` 写入 todayString,后续 `canXxx` 在同一天返回 `false`。 + */ + +import { LocalStorage } from "../local_storage"; +import { Result, type Result as ResultT } from "../result"; +import { StorageKeys } from "../storage_keys"; + +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> { + return AppStorage.canReportKey(StorageKeys.lastPwaDialogShown, todayString); + } + + static recordPwaDialogShown(todayString: string): Promise> { + return AppStorage.ls.setString(StorageKeys.lastPwaDialogShown, todayString); + } + + // ---- PWA event reporting ---- + + static canReportPwaEvent(todayString: string): Promise> { + return AppStorage.canReportKey(StorageKeys.lastPwaEventReported, todayString); + } + + static recordPwaEventReported(todayString: string): Promise> { + return AppStorage.ls.setString(StorageKeys.lastPwaEventReported, todayString); + } + + // ---- app info reporting ---- + + static canReportAppInfo(todayString: string): Promise> { + return AppStorage.canReportKey(StorageKeys.lastAppInfoReported, todayString); + } + + static recordAppInfoReported(todayString: string): Promise> { + return AppStorage.ls.setString(StorageKeys.lastAppInfoReported, todayString); + } + + // ---- internal helpers ---- + + /** + * 通用"每日一次"判断: + * - 取已记录的日期字符串 + * - 缺失 → 允许(true) + * - 等于 today → 拒绝(false) + * - 不等于 today → 允许(true) + */ + private static async canReportKey( + key: string, + todayString: string, + ): Promise> { + const r = await AppStorage.ls.getString(key); + if (r.kind === "failure") return r; + return Result.success(r.value === null || r.value !== todayString); + } +} diff --git a/src/data/storage/app/index.ts b/src/data/storage/app/index.ts new file mode 100644 index 00000000..4372f168 --- /dev/null +++ b/src/data/storage/app/index.ts @@ -0,0 +1,5 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./app_storage"; diff --git a/src/data/storage/auth/auth_storage.ts b/src/data/storage/auth/auth_storage.ts new file mode 100644 index 00000000..d66f442d --- /dev/null +++ b/src/data/storage/auth/auth_storage.ts @@ -0,0 +1,115 @@ +"use client"; + +/** + * AuthStorage 完整实现(示范 1:KV / localStorage) + * + * SECURITY: 当前与 Dart 端行为一致,refresh_token 以明文存于 localStorage。 + * 后续轮次将升级到 HttpOnly Cookie 方案,参见 `clearAuthData` 处的 TODO 标记。 + * + * 设计说明: + * - 组合而非继承 `LocalStorage`(避免多重继承层级) + * - 单例挂在 class 静态字段,规避 HMR 复用污染 + * - 构造函数接受可选 `LocalStorage` 注入,便于测试时使用 jsdom 的 storage + * - `hasXxx` = `token != null && token.length > 0`,对齐 Dart 端 `isNotEmpty` 语义 + * - `clearAuthData` 顺序清理:loginToken → guestToken → refreshToken,任一失败立即返回 + */ + +import { LocalStorage } from "../local_storage"; +import { Result, type Result as ResultT } from "../result"; +import { StorageKeys } from "../storage_keys"; +import type { IAuthStorage } from "./iauth_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; + } + + /** @internal */ + static _resetForTests(): void { + AuthStorage._instance = null; + } + + // ---- login token ---- + + getLoginToken(): Promise> { + return this.ls.getString(StorageKeys.loginToken); + } + setLoginToken(token: string): Promise> { + return this.ls.setString(StorageKeys.loginToken, token); + } + async hasLoginToken(): Promise> { + const r = await this.getLoginToken(); + if (r.kind === "failure") return Result.failure(r.error); + return Result.success(r.value !== null && r.value.length > 0); + } + clearLoginToken(): Promise> { + return this.ls.remove(StorageKeys.loginToken); + } + + // ---- guest token ---- + + getGuestToken(): Promise> { + return this.ls.getString(StorageKeys.guestToken); + } + setGuestToken(token: string): Promise> { + return this.ls.setString(StorageKeys.guestToken, token); + } + async hasGuestToken(): Promise> { + const r = await this.getGuestToken(); + if (r.kind === "failure") return Result.failure(r.error); + return Result.success(r.value !== null && r.value.length > 0); + } + clearGuestToken(): Promise> { + return this.ls.remove(StorageKeys.guestToken); + } + + // ---- device id ---- + + getDeviceId(): Promise> { + return this.ls.getString(StorageKeys.deviceId); + } + setDeviceId(deviceId: string): Promise> { + return this.ls.setString(StorageKeys.deviceId, deviceId); + } + + // ---- refresh token ---- + // TODO(security): 升级到 HttpOnly Cookie 方案 + // SECURITY: 明文存储,仅供本轮与 Dart 行为对齐 + + getRefreshToken(): Promise> { + return this.ls.getString(StorageKeys.refreshToken); + } + setRefreshToken(token: string): Promise> { + return this.ls.setString(StorageKeys.refreshToken, token); + } + clearRefreshToken(): Promise> { + return this.ls.remove(StorageKeys.refreshToken); + } + + // ---- facebook id ---- + + getFacebookId(): Promise> { + return this.ls.getString(StorageKeys.facebookId); + } + setFacebookId(id: string): Promise> { + return this.ls.setString(StorageKeys.facebookId, id); + } + + // ---- bulk clear ---- + + async clearAuthData(): Promise> { + const r1 = await this.clearLoginToken(); + if (r1.kind === "failure") return r1; + const r2 = await this.clearGuestToken(); + if (r2.kind === "failure") return r2; + return this.clearRefreshToken(); + } +} diff --git a/src/data/storage/auth/iauth_storage.ts b/src/data/storage/auth/iauth_storage.ts new file mode 100644 index 00000000..5d0aa6fb --- /dev/null +++ b/src/data/storage/auth/iauth_storage.ts @@ -0,0 +1,36 @@ +"use client"; + +/** + * IAuthStorage 接口 + * + * 对齐 Dart 端 `IAuthStorage`(lib/data/services/storage/iauth_storage.dart): + * - loginToken / guestToken / deviceId / refreshToken / facebookId 五个字段的 CRUD + * - `clearAuthData()` 批量清空登录态 + * - 所有方法返回 `Promise>`,与 Dart `Future>` 对齐 + */ + +import type { Result } from "../result"; + +export interface IAuthStorage { + getLoginToken(): Promise>; + setLoginToken(token: string): Promise>; + hasLoginToken(): Promise>; + clearLoginToken(): Promise>; + + getGuestToken(): Promise>; + setGuestToken(token: string): Promise>; + hasGuestToken(): Promise>; + clearGuestToken(): Promise>; + + getDeviceId(): Promise>; + setDeviceId(deviceId: string): Promise>; + + getRefreshToken(): Promise>; + setRefreshToken(token: string): Promise>; + clearRefreshToken(): Promise>; + + getFacebookId(): Promise>; + setFacebookId(id: string): Promise>; + + clearAuthData(): Promise>; +} diff --git a/src/data/storage/auth/index.ts b/src/data/storage/auth/index.ts new file mode 100644 index 00000000..02f33516 --- /dev/null +++ b/src/data/storage/auth/index.ts @@ -0,0 +1,6 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./auth_storage"; +export * from "./iauth_storage"; diff --git a/src/data/storage/chat/chat_storage.ts b/src/data/storage/chat/chat_storage.ts new file mode 100644 index 00000000..b908c960 --- /dev/null +++ b/src/data/storage/chat/chat_storage.ts @@ -0,0 +1,75 @@ +"use client"; + +/** + * ChatStorage 完整实现(KV / localStorage) + * + * 对齐 Dart 端 `ChatStorage`(lib/data/services/storage/chat/chat_storage.dart): + * - 游客每日聊天配额(GuestChatQuota:{ remaining, date }) + * - 游客总配额(int) + * + * 注意:本类与 `LocalChatStorage`(IndexedDB / Dexie)是不同类: + * - ChatStorage = 游客配额等元数据(KV) + * - LocalChatStorage = 聊天记录本身(IndexedDB) + * + * 设计沿用 AuthStorage 模式:组合 `LocalStorage` + 单例 + Zod 校验。 + * 跨天重置判断(`needsReset`)保留在 `GuestChatQuota` 类上,存储层只做读写。 + */ + +import { + GuestChatQuotaSchema, + type GuestChatQuotaData, +} from "@/data/models"; +import { z } from "zod"; + +import { LocalStorage } from "../local_storage"; +import { type Result as ResultT } from "../result"; +import { StorageKeys } from "../storage_keys"; +import type { IChatStorage } from "./ichat_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; + } + + /** @internal */ + static _resetForTests(): void { + ChatStorage._instance = null; + } + + // ---- guest daily chat quota ---- + + getGuestDailyChatQuota(): Promise> { + return this.ls.getJson(StorageKeys.guestChatQuota, GuestChatQuotaSchema); + } + + setGuestDailyChatQuota(remaining: number, date: string): Promise> { + const value: GuestChatQuotaData = { remaining, date }; + return this.ls.setJson(StorageKeys.guestChatQuota, value, GuestChatQuotaSchema); + } + + clearGuestDailyChatQuota(): Promise> { + return this.ls.remove(StorageKeys.guestChatQuota); + } + + // ---- guest total quota ---- + + getGuestTotalQuota(): Promise> { + return this.ls.getJson(StorageKeys.guestTotalQuotaRemaining, z.number()); + } + + setGuestTotalQuota(remaining: number): Promise> { + return this.ls.setJson(StorageKeys.guestTotalQuotaRemaining, remaining, z.number()); + } + + clearGuestTotalQuota(): Promise> { + return this.ls.remove(StorageKeys.guestTotalQuotaRemaining); + } +} diff --git a/src/data/storage/chat/ichat_storage.ts b/src/data/storage/chat/ichat_storage.ts new file mode 100644 index 00000000..322bb906 --- /dev/null +++ b/src/data/storage/chat/ichat_storage.ts @@ -0,0 +1,28 @@ +"use client"; + +/** + * IChatStorage 接口 + * + * 对齐 Dart 端 `IChatStorage`(lib/data/services/storage/chat/ichat_storage.dart): + * - 游客每日聊天配额 + 游客总配额 两个独立字段的 CRUD + * + * 注:`GuestChatQuota` 模型见 `src/data/models/chat/guest_chat_quota.ts`, + * 数据形态 `{ remaining: number, date: string }`。 + * 跨天重置逻辑(`needsReset`)保留在 `GuestChatQuota` 类上,存储层只做读写。 + */ + +import type { Result } from "../result"; +import type { GuestChatQuotaData } from "@/data/models"; + +export interface IChatStorage { + getGuestDailyChatQuota(): Promise>; + setGuestDailyChatQuota( + remaining: number, + date: string, + ): Promise>; + clearGuestDailyChatQuota(): Promise>; + + getGuestTotalQuota(): Promise>; + setGuestTotalQuota(remaining: number): Promise>; + clearGuestTotalQuota(): Promise>; +} diff --git a/src/data/storage/chat/index.ts b/src/data/storage/chat/index.ts new file mode 100644 index 00000000..f278bc90 --- /dev/null +++ b/src/data/storage/chat/index.ts @@ -0,0 +1,9 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./chat_storage"; +export * from "./ichat_storage"; +export * from "./local_chat_db"; +export * from "./local_chat_storage"; +export * from "./local_message"; diff --git a/src/data/storage/chat/local_chat_db.ts b/src/data/storage/chat/local_chat_db.ts new file mode 100644 index 00000000..53edcdea --- /dev/null +++ b/src/data/storage/chat/local_chat_db.ts @@ -0,0 +1,36 @@ +"use client"; + +/** + * Dexie 数据库定义 + * + * 唯一表 `messages`,只有 `++dbId` 自增主键,无次级索引(按本轮需求"最简表")。 + * 未来要按 session 查询可升级到: + * this.version(2).stores({ messages: "++dbId, sessionId, createdAt" }) + * .upgrade(async (tx) => { ... }); + * + * 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。 + */ + +import Dexie, { type Table } from "dexie"; + +export interface LocalMessageRow { + /** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */ + dbId?: number; + /** 消息 id(来自 ChatMessage.id)。 */ + id: string; + role: string; + content: string; + createdAt: string; + sessionId: string; +} + +export class LocalChatDB extends Dexie { + messages!: Table; + + constructor(dbName: string = "cozsweet-chat") { + super(dbName); + this.version(1).stores({ + messages: "++dbId", + }); + } +} diff --git a/src/data/storage/chat/local_chat_storage.ts b/src/data/storage/chat/local_chat_storage.ts new file mode 100644 index 00000000..f71947ae --- /dev/null +++ b/src/data/storage/chat/local_chat_storage.ts @@ -0,0 +1,163 @@ +"use client"; + +/** + * LocalChatStorage 完整实现(示范 2:IndexedDB via Dexie) + * + * 对齐 Dart 端 `LocalChatStorage`(lib/data/services/storage/chat/local_chat_storage.dart): + * - init / saveMessage / saveMessages / getAllMessages / clearAll / deleteMessage / close + * - 额外提供 `getMessageCount`(异步)替代 Dart 的同步 `messageCount` getter + * - 额外提供 `getAllMessagesBySession(sessionId)`(filter 模拟,等下一轮加索引后再用 where) + * + * 单例挂在 class 静态字段。 + * Dexie 实例可注入,便于测试用 fake-indexeddb 跑真 Dexie。 + * + * 注意:当前表无次级索引,所有过滤(sessionId)都是 `toArray().then(filter)`,O(n)。 + * 数据量大时考虑升级 schema 加索引。 + */ + +import { Result, type Result as ResultT } from "../result"; +import { LocalChatDB } from "./local_chat_db"; +import { LocalMessage } from "./local_message"; + +export class LocalChatStorage { + private readonly db: LocalChatDB; + private static _instance: LocalChatStorage | null = null; + + constructor(db?: LocalChatDB) { + this.db = db ?? new LocalChatDB(); + } + + static getInstance(): LocalChatStorage { + if (!LocalChatStorage._instance) { + LocalChatStorage._instance = new LocalChatStorage(); + } + return LocalChatStorage._instance; + } + + /** @internal */ + static _resetForTests(): void { + LocalChatStorage._instance = null; + } + + /** + * 触发 Dexie 打开数据库。 + * 幂等:Dexie.open() 多次调用无副作用。 + * 在 bootstrap 中 await 此方法可保证后续操作不会因连接未开而延迟。 + */ + async init(): Promise> { + try { + await this.db.open(); + return Result.success(undefined); + } catch (e) { + return Result.failure(e); + } + } + + // ---- create / append ---- + + async saveMessage(message: LocalMessage): Promise> { + try { + await this.db.messages.add(message.toRow()); + return Result.success(undefined); + } catch (e) { + return Result.failure(e); + } + } + + async saveMessages(messages: readonly LocalMessage[]): Promise> { + try { + await this.db.transaction( + "rw", + this.db.messages, + async () => { + for (const m of messages) { + await this.db.messages.add(m.toRow()); + } + }, + ); + return Result.success(undefined); + } catch (e) { + return Result.failure(e); + } + } + + // ---- read ---- + + async getAllMessages(): Promise> { + try { + const rows = await this.db.messages.toArray(); + // 按 dbId 升序(与 Dart `box.values.toList()` 插入序语义一致) + rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0)); + return Result.success(rows.map((r) => LocalMessage.fromRow(r))); + } catch (e) { + return Result.failure(e); + } + } + + async getMessageCount(): Promise> { + try { + return Result.success(await this.db.messages.count()); + } catch (e) { + return Result.failure(e); + } + } + + async getAllMessagesBySession( + sessionId: string, + ): Promise> { + try { + const rows = await this.db.messages + .filter((r) => r.sessionId === sessionId) + .toArray(); + rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0)); + return Result.success(rows.map((r) => LocalMessage.fromRow(r))); + } catch (e) { + return Result.failure(e); + } + } + + // ---- delete ---- + + async clearAll(): Promise> { + try { + await this.db.messages.clear(); + return Result.success(undefined); + } catch (e) { + return Result.failure(e); + } + } + + /** + * 按数组下标删除(保持 Dart 端语义:`box.values.toList()[index].delete()`)。 + * O(n) 取下标后单条 delete,与 Dart 行为一致。 + */ + async deleteMessage(index: number): Promise> { + try { + if (!Number.isInteger(index) || index < 0) { + return Result.failure( + new RangeError(`deleteMessage: index ${index} out of range`), + ); + } + const rows = await this.db.messages.toArray(); + if (index >= rows.length) { + return Result.failure( + new RangeError(`deleteMessage: index ${index} out of range`), + ); + } + const target = rows[index]!; + await this.db.messages.delete(target.dbId!); + return Result.success(undefined); + } catch (e) { + return Result.failure(e); + } + } + + async close(): Promise> { + try { + this.db.close(); + return Result.success(undefined); + } catch (e) { + return Result.failure(e); + } + } +} diff --git a/src/data/storage/chat/local_message.ts b/src/data/storage/chat/local_message.ts new file mode 100644 index 00000000..72f1e5d7 --- /dev/null +++ b/src/data/storage/chat/local_message.ts @@ -0,0 +1,77 @@ +"use client"; + +/** + * LocalMessage 模型(存储层副本) + * + * 与 `src/data/models/chat/chat_message.ts` 的 `ChatMessage` 解耦: + * 存储层维护自己的数据形态(独立 id/role/content/createdAt + 内部 sessionId 分组), + * 不耦合 wire 模型,方便后续 schema 独立演进。 + * + * 对齐项目内其他 Zod 模型的范式: + * - `*Schema` 是验证与默认值的单一来源 + * - `*Input` 是构造输入(字段可缺),`*Data` 是解析后形态 + * - 不可变(private constructor + Object.freeze) + * - 桥接方法 `toRow` / `fromRow` 处理 Dexie 行 ↔ 类实例的转换 + */ + +import { z } from "zod"; +import type { LocalMessageRow } from "./local_chat_db"; + +export const LocalMessageSchema = z.object({ + id: z.string(), + role: z.string(), + content: z.string(), + createdAt: z.string().default(""), + sessionId: z.string().default(""), +}); + +export type LocalMessageInput = z.input; +export type LocalMessageData = z.output; + +export class LocalMessage { + declare readonly id: string; + declare readonly role: string; + declare readonly content: string; + declare readonly createdAt: string; + declare readonly sessionId: string; + + private constructor(input: LocalMessageInput) { + const data = LocalMessageSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: LocalMessageInput): LocalMessage { + return new LocalMessage(input); + } + + static fromJson(json: unknown): LocalMessage { + return LocalMessage.from(json as LocalMessageInput); + } + + toJson(): LocalMessageData { + return LocalMessageSchema.parse(this); + } + + /** 转成 Dexie 行(不含 dbId,让 Dexie 自增)。 */ + toRow(): Omit { + return { + id: this.id, + role: this.role, + content: this.content, + createdAt: this.createdAt, + sessionId: this.sessionId, + }; + } + + /** 从 Dexie 行还原(忽略 dbId 内部主键)。 */ + static fromRow(row: LocalMessageRow): LocalMessage { + return LocalMessage.from({ + id: row.id, + role: row.role, + content: row.content, + createdAt: row.createdAt, + sessionId: row.sessionId, + }); + } +} diff --git a/src/data/storage/index.ts b/src/data/storage/index.ts new file mode 100644 index 00000000..a18a4e31 --- /dev/null +++ b/src/data/storage/index.ts @@ -0,0 +1,17 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./local_storage"; +export * from "./result"; +export * from "./storage_keys"; +export * from "./app/app_storage"; +export * from "./auth/auth_storage"; +export * from "./auth/iauth_storage"; +export * from "./chat/chat_storage"; +export * from "./chat/ichat_storage"; +export * from "./chat/local_chat_db"; +export * from "./chat/local_chat_storage"; +export * from "./chat/local_message"; +export * from "./user/iuser_storage"; +export * from "./user/user_storage"; diff --git a/src/data/storage/local_storage.ts b/src/data/storage/local_storage.ts new file mode 100644 index 00000000..7fa80a66 --- /dev/null +++ b/src/data/storage/local_storage.ts @@ -0,0 +1,123 @@ +"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 "./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.failure(new Error(SSR_ERROR_MSG)); + try { + const v = this.storage!.getItem(key); + if (v === null || v === "") return Result.success(null); + return Result.success(v); + } catch (e) { + return Result.failure(e); + } + } + + async setString(key: string, value: string): Promise> { + if (!this.isAvailable()) return Result.failure(new Error(SSR_ERROR_MSG)); + try { + this.storage!.setItem(key, value); + return Result.success(undefined); + } catch (e) { + return Result.failure(e); + } + } + + async getJson(key: string, schema: ZodType): Promise> { + const r = await this.getString(key); + if (r.kind === "failure") return Result.failure(r.error); + if (r.value === null) { + const value: T | null = null; + return Result.success(value); + } + try { + const parsed = schema.parse(JSON.parse(r.value)); + return Result.success(parsed); + } catch (e) { + return Result.failure(e); + } + } + + async setJson( + key: string, + value: T, + schema?: ZodType, + ): Promise> { + if (!this.isAvailable()) return Result.failure(new Error(SSR_ERROR_MSG)); + try { + const validated = schema ? schema.parse(value) : value; + this.storage!.setItem(key, JSON.stringify(validated)); + return Result.success(undefined); + } catch (e) { + return Result.failure(e); + } + } + + async remove(key: string): Promise> { + if (!this.isAvailable()) return Result.failure(new Error(SSR_ERROR_MSG)); + try { + this.storage!.removeItem(key); + return Result.success(undefined); + } catch (e) { + return Result.failure(e); + } + } + + async has(key: string): Promise> { + if (!this.isAvailable()) return Result.failure(new Error(SSR_ERROR_MSG)); + try { + return Result.success(this.storage!.getItem(key) !== null); + } catch (e) { + return Result.failure(e); + } + } +} diff --git a/src/data/storage/result.ts b/src/data/storage/result.ts new file mode 100644 index 00000000..09d25786 --- /dev/null +++ b/src/data/storage/result.ts @@ -0,0 +1,35 @@ +/** + * 通用 Result 类型 + * + * 对齐 Dart 项目中的 `sealed class Result` / `Success` / `Failure` 模式。 + * TypeScript 没有 sealed class,用判别联合(discriminated union)实现等价效果: + * 调用方在 `switch (result.kind)` 上做穷尽匹配,编译器在 strict 模式下能保证完整性。 + * + * `error` 字段保持 `unknown`(不强制 `Error`)以匹配 Dart 端可抛任意对象的能力。 + */ + +export type Result = + | { readonly kind: "success"; readonly value: T } + | { readonly kind: "failure"; readonly error: unknown }; + +export const Result = { + success(value: T): Result { + return { kind: "success", value }; + }, + + failure(error: unknown): Result { + return { kind: "failure", error }; + }, + + isSuccess(r: Result): r is { kind: "success"; value: T } { + return r.kind === "success"; + }, + + isFailure(r: Result): r is { kind: "failure"; error: unknown } { + return r.kind === "failure"; + }, + + map(r: Result, fn: (v: T) => U): Result { + return r.kind === "success" ? Result.success(fn(r.value)) : r; + }, +} as const; diff --git a/src/data/storage/storage_keys.ts b/src/data/storage/storage_keys.ts new file mode 100644 index 00000000..c633fa55 --- /dev/null +++ b/src/data/storage/storage_keys.ts @@ -0,0 +1,33 @@ +/** + * 存储键常量 + * + * 键名沿用 Dart `lib/data/services/storage/storage_keys.dart` 的下划线风格, + * 便于跨端调试时直接对位。`as const` 让每个值都是字符串字面量类型, + * 未来若需要可用 `z.literal(StorageKeys.loginToken)` 做编译期键名校验。 + */ + +export const StorageKeys = { + // auth + loginToken: "login_token", + guestToken: "guest_token", + deviceId: "device_id", + refreshToken: "refresh_token", + facebookId: "facebook_id", + + // user + user: "user", + userId: "user_id", + userAvatar: "user_avatar", + + // chat + chatHistory: "chat_history", + guestChatQuota: "guest_chat_quota", + guestTotalQuotaRemaining: "guest_total_quota_remaining", + + // pwa / app info + lastPwaDialogShown: "last_pwa_dialog_shown", + lastPwaEventReported: "last_pwa_event_reported", + lastAppInfoReported: "last_app_info_reported", +} as const; + +export type StorageKey = (typeof StorageKeys)[keyof typeof StorageKeys]; diff --git a/src/data/storage/user/index.ts b/src/data/storage/user/index.ts new file mode 100644 index 00000000..56ccf429 --- /dev/null +++ b/src/data/storage/user/index.ts @@ -0,0 +1,6 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./iuser_storage"; +export * from "./user_storage"; diff --git a/src/data/storage/user/iuser_storage.ts b/src/data/storage/user/iuser_storage.ts new file mode 100644 index 00000000..a80ac719 --- /dev/null +++ b/src/data/storage/user/iuser_storage.ts @@ -0,0 +1,31 @@ +"use client"; + +/** + * IUserStorage 接口 + * + * 对齐 Dart 端 `IUserStorage`(lib/data/services/storage/iuser_storage.dart): + * - User 对象 / userId / avatarUrl 三个独立字段的 CRUD + * - `clearUserData()` 批量清空 + * + * 注:完整 User 模型见 `src/data/models/user/user.ts`,本接口允许存 JSON 字符串, + * 具体序列化由实现层通过 Zod schema 校验后转换为 `User` 类实例。 + */ + +import type { Result } from "../result"; +import type { UserData } from "@/data/models"; + +export interface IUserStorage { + getUser(): Promise>; + setUser(user: UserData): Promise>; + clearUser(): Promise>; + + getUserId(): Promise>; + setUserId(id: string): Promise>; + clearUserId(): Promise>; + + getAvatarUrl(): Promise>; + setAvatarUrl(url: string): Promise>; + clearAvatarUrl(): Promise>; + + clearUserData(): Promise>; +} diff --git a/src/data/storage/user/user_storage.ts b/src/data/storage/user/user_storage.ts new file mode 100644 index 00000000..431c94e6 --- /dev/null +++ b/src/data/storage/user/user_storage.ts @@ -0,0 +1,93 @@ +"use client"; + +/** + * UserStorage 完整实现(KV / localStorage) + * + * 对齐 Dart 端 `UserStorage`(lib/data/services/storage/user_storage.dart): + * - 三个独立字段:User 对象 / userId / avatarUrl + * - `clearUserData()` 批量清空 + * + * 设计沿用 AuthStorage 模式:组合 `LocalStorage`(非继承)+ 单例 + Zod 校验。 + * + * 边界: + * - `getUser` 走 `UserSchema` 解析:缺字段时 Zod `.default()` 自动兜底 + * - `setUser` 走 `UserSchema` 校验写入:保证存储的总是完整 UserData + * - 单例挂在 class 静态字段,HMR 复用安全 + */ + +import { UserSchema, type UserData } from "@/data/models"; +import { LocalStorage } from "../local_storage"; +import { type Result as ResultT } from "../result"; +import { StorageKeys } from "../storage_keys"; +import type { IUserStorage } from "./iuser_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; + } + + /** @internal */ + static _resetForTests(): void { + UserStorage._instance = null; + } + + // ---- user object ---- + + getUser(): Promise> { + return this.ls.getJson(StorageKeys.user, UserSchema); + } + + setUser(user: UserData): Promise> { + return this.ls.setJson(StorageKeys.user, user, UserSchema); + } + + clearUser(): Promise> { + return this.ls.remove(StorageKeys.user); + } + + // ---- userId ---- + + getUserId(): Promise> { + return this.ls.getString(StorageKeys.userId); + } + + setUserId(id: string): Promise> { + return this.ls.setString(StorageKeys.userId, id); + } + + clearUserId(): Promise> { + return this.ls.remove(StorageKeys.userId); + } + + // ---- avatarUrl ---- + + getAvatarUrl(): Promise> { + return this.ls.getString(StorageKeys.userAvatar); + } + + setAvatarUrl(url: string): Promise> { + return this.ls.setString(StorageKeys.userAvatar, url); + } + + clearAvatarUrl(): Promise> { + return this.ls.remove(StorageKeys.userAvatar); + } + + // ---- bulk clear ---- + + async clearUserData(): Promise> { + const r1 = await this.clearUser(); + if (r1.kind === "failure") return r1; + const r2 = await this.clearUserId(); + if (r2.kind === "failure") return r2; + return this.clearAvatarUrl(); + } +} diff --git a/tsconfig.json b/tsconfig.json index b1c9b9e3..3a13f90a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,6 @@ "isolatedModules": true, "jsx": "react-jsx", "incremental": true, - "baseUrl": "src/", "plugins": [ { "name": "next"