diff --git a/barrelsby.json b/barrelsby.json index a8be9403..64e7a065 100644 --- a/barrelsby.json +++ b/barrelsby.json @@ -10,7 +10,8 @@ "./src/data/storage/auth", "./src/data/storage/user", "./src/data/storage/chat", - "./src/data/storage/app", + "./src/data/storage/app", + "./src/data/repositories", "./src/design" ] } diff --git a/src/data/api/auth_api.ts b/src/data/api/auth_api.ts index f08522f0..7ceb3fb6 100644 --- a/src/data/api/auth_api.ts +++ b/src/data/api/auth_api.ts @@ -6,6 +6,7 @@ */ import { httpClient } from "./http_client"; import { ApiPath } from "./api_path"; +import { ApiEnvelope, unwrap } from "./response_helper"; 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"; @@ -22,23 +23,6 @@ import { SendCodeRequest } from "@/data/dto/auth/send_code_request"; import { User } from "@/data/dto/user/user"; import type { UserData } from "@/data/schemas/user/user"; -/** - * 后端统一响应包装 - */ -interface ApiEnvelope { - success: boolean; - data?: T; - message?: string; - error?: string; -} - -function unwrap(envelope: ApiEnvelope): T { - if (!envelope.success || envelope.data === undefined) { - throw new Error(envelope.message ?? envelope.error ?? "API call failed"); - } - return envelope.data; -} - export class AuthApi { /** * 发送验证码 diff --git a/src/data/api/chat_api.ts b/src/data/api/chat_api.ts index 2201047d..5fe6873d 100644 --- a/src/data/api/chat_api.ts +++ b/src/data/api/chat_api.ts @@ -6,6 +6,7 @@ */ import { httpClient } from "./http_client"; import { ApiPath } from "./api_path"; +import { ApiEnvelope, unwrap } from "./response_helper"; import { ChatHistoryResponse } from "@/data/dto/chat/chat_history_response"; import { ChatSendResponse } from "@/data/dto/chat/chat_send_response"; import { ChatSyncData } from "@/data/dto/chat/chat_sync_data"; @@ -15,23 +16,6 @@ import { SendMessageRequest } from "@/data/dto/chat/send_message_request"; import { SttData } from "@/data/dto/chat/stt_data"; import { SyncMessage } from "@/data/dto/chat/sync_message"; -/** - * 后端统一响应包装 - */ -interface ApiEnvelope { - success: boolean; - data?: T; - message?: string; - error?: string; -} - -function unwrap(envelope: ApiEnvelope): T { - if (!envelope.success || envelope.data === undefined) { - throw new Error(envelope.message ?? envelope.error ?? "API call failed"); - } - return envelope.data; -} - export class ChatApi { /** * 发送消息 diff --git a/src/data/api/index.ts b/src/data/api/index.ts index ce50758f..9888954a 100644 --- a/src/data/api/index.ts +++ b/src/data/api/index.ts @@ -8,6 +8,7 @@ export * from "./auth_api"; export * from "./chat_api"; export * from "./http_client"; export * from "./metrics_api"; +export * from "./response_helper"; export * from "./user_api"; export * from "./config/api_config"; export * from "./interceptor/auth_refresh_interceptor"; diff --git a/src/data/api/metrics_api.ts b/src/data/api/metrics_api.ts index d669d106..875b5d06 100644 --- a/src/data/api/metrics_api.ts +++ b/src/data/api/metrics_api.ts @@ -6,26 +6,10 @@ */ import { httpClient } from "./http_client"; import { ApiPath } from "./api_path"; +import { ApiEnvelope, unwrapOptional } from "./response_helper"; import { AppEvent } from "@/data/dto/metrics/app_event"; import { PwaEvent } from "@/data/dto/metrics/pwa_event"; -/** - * 后端统一响应包装 - */ -interface ApiEnvelope { - success: boolean; - data?: T; - message?: string; - error?: string; -} - -function unwrap(envelope: ApiEnvelope): T | undefined { - if (!envelope.success) { - throw new Error(envelope.message ?? envelope.error ?? "API call failed"); - } - return envelope.data; -} - export class MetricsApi { /** * 上报 PWA 事件 @@ -35,7 +19,7 @@ export class MetricsApi { ApiPath.metricsPwaEvent, { method: "POST", body: body.toJson() } ); - unwrap(env); + unwrapOptional(env); } /** @@ -46,7 +30,7 @@ export class MetricsApi { ApiPath.reportUserInfo, { method: "POST", body: body.toJson() } ); - unwrap(env); + unwrapOptional(env); } } diff --git a/src/data/api/response_helper.ts b/src/data/api/response_helper.ts new file mode 100644 index 00000000..fb14ba09 --- /dev/null +++ b/src/data/api/response_helper.ts @@ -0,0 +1,49 @@ +/** + * 共享的后端 envelope 解析工具 + * + * 4 个 `*_api.ts` 文件中重复的 `interface ApiEnvelope` 与 `function unwrap` + * 集中到这里。HTTP 拦截器(`http_client.ts`)已经对非 2xx 响应抛 `ApiError`; + * 本工具处理第二种失败:2xx 但 `success: false` 的 envelope。 + * + * 行为升级:失败时抛 `ApiError`(`ApiError extends Error`),而不是裸 `Error`。 + * 这样调用方在 `Result.err` 内能 `instanceof ApiError` 检查 `.code` / `.status`。 + * + * 原始 Dart: lib/data/services/api/api_client.dart 的 `NetResult` 解析。 + */ +import { ApiError, ErrorCode } from "./api_result"; + +/** 后端统一响应包装。 */ +export interface ApiEnvelope { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +/** + * 解包 envelope,失败时抛 `ApiError`。 + * 用于:data 字段必有(API 一定返回业务数据)的端点。 + */ +export function unwrap(envelope: ApiEnvelope): T { + if (!envelope.success || envelope.data === undefined) { + throw new ApiError( + ErrorCode.unknownError, + envelope.message ?? envelope.error ?? "API call failed", + ); + } + return envelope.data; +} + +/** + * 解包 envelope,但允许 `data` 字段为 `undefined`(用于 fire-and-forget 类端点)。 + * 失败时仍抛 `ApiError`。 + */ +export function unwrapOptional(envelope: ApiEnvelope): T | undefined { + if (!envelope.success) { + throw new ApiError( + ErrorCode.unknownError, + envelope.message ?? envelope.error ?? "API call failed", + ); + } + return envelope.data; +} diff --git a/src/data/api/user_api.ts b/src/data/api/user_api.ts index aa53066e..e3ec5e45 100644 --- a/src/data/api/user_api.ts +++ b/src/data/api/user_api.ts @@ -6,6 +6,7 @@ */ import { httpClient } from "./http_client"; import { ApiPath } from "./api_path"; +import { ApiEnvelope, unwrap } from "./response_helper"; import { CreditsData } from "@/data/dto/user/credits_data"; import { CreditsHistoryData } from "@/data/dto/user/credits_history_data"; import { UpdateProfileRequest } from "@/data/dto/user/update_profile_request"; @@ -13,23 +14,6 @@ import { User } from "@/data/dto/user/user"; import { UserStatsResponse } from "@/data/dto/user/user_stats_response"; import type { UserData } from "@/data/schemas/user/user"; -/** - * 后端统一响应包装 - */ -interface ApiEnvelope { - success: boolean; - data?: T; - message?: string; - error?: string; -} - -function unwrap(envelope: ApiEnvelope): T { - if (!envelope.success || envelope.data === undefined) { - throw new Error(envelope.message ?? envelope.error ?? "API call failed"); - } - return envelope.data; -} - export class UserApi { /** * 获取用户统计信息 diff --git a/src/data/repositories/auth_repository.ts b/src/data/repositories/auth_repository.ts new file mode 100644 index 00000000..cbd9b63f --- /dev/null +++ b/src/data/repositories/auth_repository.ts @@ -0,0 +1,289 @@ +"use client"; + +/** + * AuthRepository + * + * 认证仓库:编排远程 API + 本地 AuthStorage + UserStorage。 + * 行为对齐原 Dart `lib/data/repositories/auth_repository_impl.dart`: + * - 登录成功后调 `_saveLoginData` 持久化 token + User + * - 登出先调 API,再**强制**清本地态(无论 API 成败) + * - `getCurrentUser` 成功后**尽力**写本地 User 缓存(不因缓存失败影响主流程) + * - 写 storage 失败用 `console.warn` 记录,不让本地缓存失败影响主流程 + * + * 原始 Dart: lib/data/repositories/auth_repository_impl.dart + */ +import { AuthApi, authApi } from "@/data/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"; +import { GoogleLoginRequest } from "@/data/dto/auth/google_login_request"; +import { GuestLoginRequest } from "@/data/dto/auth/guest_login_request"; +import { GuestLoginResponse } from "@/data/dto/auth/guest_login_response"; +import { LoginRequest } from "@/data/dto/auth/login_request"; +import { LoginResponse } from "@/data/dto/auth/login_response"; +import { RefreshTokenRequest } from "@/data/dto/auth/refresh_token_request"; +import { RefreshTokenResponse } from "@/data/dto/auth/refresh_token_response"; +import { RegisterRequest } from "@/data/dto/auth/register_request"; +import { SendCodeRequest } from "@/data/dto/auth/send_code_request"; +import { User } from "@/data/dto/user/user"; +import { Result } from "@/data/result"; +import { AuthStorage } from "@/data/storage/auth/auth_storage"; +import type { IAuthStorage } from "@/data/storage/auth/iauth_storage"; +import { UserStorage } from "@/data/storage/user/user_storage"; +import type { IUserStorage } from "@/data/storage/user/iuser_storage"; + +/** 硬编码平台名,对齐 Dart `PlatformUtil.platformName.toLowerCase()`(Web 平台)。 */ +const WEB_PLATFORM = "web"; + +export class AuthRepository { + constructor( + private readonly api: AuthApi, + private readonly storage: IAuthStorage, + private readonly userStorage: IUserStorage, + ) {} + + // ============ 公共方法 ============ + + /** + * 用户注册。 + * 注册成功不会自动登录(与 Dart 行为一致),调用方需自行调 `emailLogin`。 + */ + async register(input: { + username: string; + email: string; + password: string; + guestId?: string; + }): Promise> { + return Result.wrap(async () => { + await this.api.register( + RegisterRequest.from({ + username: input.username, + email: input.email, + password: input.password, + platform: WEB_PLATFORM, + guestId: input.guestId ?? "", + }), + ); + }); + } + + /** 邮箱/用户名 + 密码登录。 */ + async emailLogin(input: { + email?: string; + username?: string; + password: string; + guestId?: string; + }): Promise> { + return Result.wrap(async () => { + const response = await this.api.emailLogin( + LoginRequest.from({ + email: input.email ?? "", + username: input.username ?? "", + password: input.password, + platform: WEB_PLATFORM, + guestId: input.guestId ?? "", + }), + ); + await this._saveLoginData(response); + return response; + }); + } + + /** 发送邮箱验证码。 */ + async sendCode(email: string): Promise> { + return Result.wrap(async () => { + await this.api.sendCode(SendCodeRequest.from({ email })); + }); + } + + /** + * 退出登录:先调 API 注销服务端会话,**无论成败**都清本地登录态。 + * 强制清本地的逻辑:用户即使在 API 失败时也想从设备上登出。 + */ + async logout(): Promise> { + try { + await this.api.logout(); + } catch (e) { + // API 失败也要继续清本地态 + console.warn("[AuthRepository] logout API failed, clearing local anyway", e); + } + try { + await this.storage.clearAuthData(); + await this.userStorage.clearUserData(); + return Result.ok(undefined); + } catch (e) { + // 清本地是 best-effort;理论上 LS.remove 不应失败 + return Result.err(e); + } + } + + /** + * 游客登录:使用 deviceId 换取 guest token。 + * 成功后写 guest token + deviceId + userId。 + */ + async guestLogin(deviceId: string): Promise> { + return Result.wrap(async () => { + const response = await this.api.guestLogin( + GuestLoginRequest.from({ deviceId }), + ); + await this.storage.setGuestToken(response.token); + await this.storage.setDeviceId(deviceId); + if (response.userId) { + await this.userStorage.setUserId(response.userId); + } + return response; + }); + } + + /** Google 登录。 */ + async googleLogin(input: { + idToken: string; + guestId?: string; + }): Promise> { + return this._socialLogin(() => + this.api.googleLogin( + GoogleLoginRequest.from({ + idToken: input.idToken, + platform: WEB_PLATFORM, + guestId: input.guestId ?? "", + }), + ), + ); + } + + /** Facebook 登录(accessToken 流程)。 */ + async facebookLogin(input: { + accessToken: string; + guestId?: string; + }): Promise> { + return this._socialLogin(() => + this.api.facebookLogin( + FacebookLoginRequest.from({ + accessToken: input.accessToken, + platform: WEB_PLATFORM, + guestId: input.guestId ?? "", + }), + ), + ); + } + + /** Facebook ID 登录(v7.0 新增,fbId 流程)。 */ + async facebookIdLogin(input: { + fbId: string; + avatarUrl?: string; + }): Promise> { + return this._socialLogin(() => + this.api.facebookIdLogin( + FbIdLoginRequest.from({ + fbId: input.fbId, + avatarUrl: input.avatarUrl ?? "", + }), + ), + ); + } + + /** + * Apple 登录。 + * 注:Dart 端另有一个 `uploadFacebookAvatar` 方法,但当前 TS 端 `AuthApi` + * 尚未实现该端点,故仓库暂不暴露。等 `AuthApi` 补齐后再加。 + */ + async appleLogin(identityToken: string): Promise> { + return this._socialLogin(() => + this.api.appleLogin( + AppleLoginRequest.from({ + identityToken, + platform: WEB_PLATFORM, + }), + ), + ); + } + + /** + * 刷新 token:先读本地的 refresh token,空则直接返回错误; + * 调用 API 成功后写回新的 login token + refresh token。 + */ + async refreshToken(): Promise> { + const existing = await this.storage.getRefreshToken(); + if (existing.kind !== "success" || !existing.value) { + return Result.err(new Error("No refresh token available")); + } + const refreshToken = existing.value; + return Result.wrap(async () => { + const response = await this.api.refreshToken( + RefreshTokenRequest.from({ refreshToken }), + ); + await this.storage.setLoginToken(response.token); + if (response.refreshToken) { + await this.storage.setRefreshToken(response.refreshToken); + } + return response; + }); + } + + /** + * 获取当前登录用户。成功后**尽力**写本地 User 缓存(offline hydrate), + * 缓存失败用 `console.warn` 记录,不影响主流程。 + */ + async getCurrentUser(): Promise> { + return Result.wrap(async () => { + const user = await this.api.getCurrentUser(); + const writeResult = await this.userStorage.setUser(user.toJson()); + if (writeResult.kind === "failure") { + console.warn( + "[AuthRepository] failed to cache current user", + writeResult.error, + ); + } + return user; + }); + } + + // ============ 私有助手 ============ + + /** + * 社交登录的公共包装:调 API → 调 `_saveLoginData` 持久化。 + * `_saveLoginData` 内部全部 best-effort,存储写失败不抛错。 + */ + private async _socialLogin( + call: () => Promise, + ): Promise> { + return Result.wrap(async () => { + const response = await call(); + await this._saveLoginData(response); + return response; + }); + } + + /** + * 持久化登录态:login token → refresh token → User 对象 → userId。 + * 全部 best-effort,错误用 `console.warn` 记录但不让登录「失败」—— + * 调用方已经从 API 拿到了 LoginResponse,缓存只是离线加速。 + */ + private async _saveLoginData(data: LoginResponse): Promise { + const r1 = await this.storage.setLoginToken(data.token); + if (r1.kind === "failure") { + console.warn("[AuthRepository] setLoginToken failed", r1.error); + } + if (data.refreshToken) { + const r2 = await this.storage.setRefreshToken(data.refreshToken); + if (r2.kind === "failure") { + console.warn("[AuthRepository] setRefreshToken failed", r2.error); + } + } + const r3 = await this.userStorage.setUser(data.user.toJson()); + if (r3.kind === "failure") { + console.warn("[AuthRepository] setUser failed", r3.error); + } + const r4 = await this.userStorage.setUserId(data.user.id); + if (r4.kind === "failure") { + console.warn("[AuthRepository] setUserId failed", r4.error); + } + } +} + +/** 全局单例。 */ +export const authRepository = new AuthRepository( + authApi, + AuthStorage.getInstance(), + UserStorage.getInstance(), +); diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts new file mode 100644 index 00000000..2f790534 --- /dev/null +++ b/src/data/repositories/chat_repository.ts @@ -0,0 +1,150 @@ +"use client"; + +/** + * ChatRepository + * + * 聊天仓库:编排远程 ChatApi + 本地 LocalChatStorage(Dexie/IndexedDB)。 + * 行为对齐原 Dart `lib/data/repositories/chat_repository_impl.dart`: + * - `saveMessagesToLocal` 保留「先清后写」语义(Dart `box.clear()` + bulk add) + * - 替代 Dart 同步 `int get localMessageCount`,改为异步 `getLocalMessageCount()` + * (Dexie 异步 API 决定) + * + * ChatMessage ↔ LocalMessage 的字段映射在仓库内 inline 完成(用户决策): + * - 写:ChatMessage 的 id/role/content/createdAt → LocalMessage(sessionId="") + * - 读:LocalMessage 的 id/role/content/createdAt → ChatMessage(丢弃 sessionId) + * + * 原始 Dart: lib/data/repositories/chat_repository_impl.dart + */ +import { ChatApi, chatApi } from "@/data/api"; +import { ChatHistoryResponse } from "@/data/dto/chat/chat_history_response"; +import { ChatMessage } from "@/data/dto/chat/chat_message"; +import { ChatSendResponse } from "@/data/dto/chat/chat_send_response"; +import { SendMessageRequest } from "@/data/dto/chat/send_message_request"; +import { Result } from "@/data/result"; +import { LocalChatStorage } from "@/data/storage/chat/local_chat_storage"; +import { LocalMessage } from "@/data/storage/chat/local_message"; + +export class ChatRepository { + constructor( + private readonly api: ChatApi, + private readonly localStorage: LocalChatStorage, + ) {} + + // ============ 远程操作 ============ + + /** 发送一条消息。 */ + async sendMessage( + message: string, + options?: { image?: string; useWebSocket?: boolean }, + ): Promise> { + return Result.wrap(async () => { + const request = SendMessageRequest.from({ + message, + image: options?.image ?? "", + useWebSocket: options?.useWebSocket ?? false, + }); + return await this.api.sendMessage(request); + }); + } + + /** 获取聊天历史,分页参数默认 limit=50, offset=0。 */ + async getHistory( + limit = 50, + offset = 0, + ): Promise> { + return Result.wrap(() => this.api.getHistory(limit, offset)); + } + + // ============ 本地(Dexie)操作 ============ + + /** 把一条消息写入本地存储。 */ + async saveMessageToLocal(message: ChatMessage): Promise> { + return this._mapLocalStorageResult( + this.localStorage.saveMessage(this._chatToLocal(message)), + ); + } + + /** + * 批量覆盖写入:先清空本地存储,再写入新列表。 + * 保留 Dart 端的「先清后写」语义(用于同步场景的整批刷新)。 + */ + async saveMessagesToLocal( + messages: readonly ChatMessage[], + ): Promise> { + const cleared = await this.localStorage.clearAll(); + if (cleared.kind === "failure") { + return Result.err(cleared.error); + } + return this._mapLocalStorageResult( + this.localStorage.saveMessages(messages.map((m) => this._chatToLocal(m))), + ); + } + + /** 读取所有本地消息,按 dbId 升序。 */ + async getLocalMessages(): Promise> { + const result = await this.localStorage.getAllMessages(); + if (result.kind === "failure") { + return Result.err(result.error); + } + return Result.ok(result.value.map((lm) => this._localToChat(lm))); + } + + /** 清空本地消息。 */ + async clearLocalMessages(): Promise> { + return this._mapLocalStorageResult(this.localStorage.clearAll()); + } + + /** + * 获取本地消息数量。 + * 替代 Dart 的同步 `int get localMessageCount`——Dexie 异步 API 决定必须 async。 + */ + async getLocalMessageCount(): Promise> { + return this._mapLocalStorageResult(this.localStorage.getMessageCount()); + } + + // ============ 私有助手 ============ + + /** ChatMessage → LocalMessage:丢弃 wire 字段,注入 storage 层的 sessionId=""。 */ + private _chatToLocal(message: ChatMessage): LocalMessage { + return LocalMessage.from({ + id: message.id, + role: message.role, + content: message.content, + createdAt: message.createdAt, + sessionId: "", + }); + } + + /** LocalMessage → ChatMessage:丢弃 storage 层的 sessionId。 */ + private _localToChat(local: LocalMessage): ChatMessage { + return ChatMessage.from({ + id: local.id, + role: local.role, + content: local.content, + createdAt: local.createdAt, + }); + } + + /** + * 把 storage 层 `{kind, value/error}` 形态的 `Result` 桥接到仓库层 + * `{success, data/error}` 形态。错误统一用 `Error` 包装。 + */ + private async _mapLocalStorageResult( + promise: Promise< + | { readonly kind: "success"; readonly value: T } + | { readonly kind: "failure"; readonly error: unknown } + >, + ): Promise> { + const r = await promise; + if (r.kind === "success") { + return Result.ok(r.value); + } + return Result.err(r.error); + } +} + +/** 全局单例。 */ +export const chatRepository = new ChatRepository( + chatApi, + LocalChatStorage.getInstance(), +); diff --git a/src/data/repositories/index.ts b/src/data/repositories/index.ts new file mode 100644 index 00000000..6922ab27 --- /dev/null +++ b/src/data/repositories/index.ts @@ -0,0 +1,8 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from "./auth_repository"; +export * from "./chat_repository"; +export * from "./metrics_repository"; +export * from "./user_repository"; diff --git a/src/data/repositories/metrics_repository.ts b/src/data/repositories/metrics_repository.ts new file mode 100644 index 00000000..cc5fde81 --- /dev/null +++ b/src/data/repositories/metrics_repository.ts @@ -0,0 +1,62 @@ +"use client"; + +/** + * MetricsRepository + * + * 指标/埋点仓库:纯远程 fire-and-forget 上报,无本地状态。 + * + * 原始 Dart: lib/data/repositories/metrics_repository_impl.dart + */ +import { MetricsApi, metricsApi } from "@/data/api"; +import { AppEvent } from "@/data/dto/metrics/app_event"; +import { PwaEvent } from "@/data/dto/metrics/pwa_event"; +import { Result } from "@/data/result"; + +export class MetricsRepository { + constructor(private readonly api: MetricsApi) {} + + /** + * 上报 PWA 事件。 + * 自动注入当前秒级时间戳。 + */ + async reportPwaEvent(input: { + deviceId: string; + deviceType: string; + pwaInstalled: boolean; + pwaSupported: boolean; + }): Promise> { + return Result.wrap(async () => { + await this.api.reportPwaEvent( + PwaEvent.from({ + deviceId: input.deviceId, + deviceType: input.deviceType, + timestamp: Math.floor(Date.now() / 1000), + pwaInstalled: input.pwaInstalled, + pwaSupported: input.pwaSupported, + }), + ); + }); + } + + /** + * 上报用户环境信息(浏览器、UA 等)。 + */ + async reportUserInfo(input: { + userId: string; + browser: string; + userAgent: string; + }): Promise> { + return Result.wrap(async () => { + await this.api.reportUserInfo( + AppEvent.from({ + userId: input.userId, + browser: input.browser, + userAgent: input.userAgent, + }), + ); + }); + } +} + +/** 全局单例。 */ +export const metricsRepository = new MetricsRepository(metricsApi); diff --git a/src/data/repositories/user_repository.ts b/src/data/repositories/user_repository.ts new file mode 100644 index 00000000..e3e1d204 --- /dev/null +++ b/src/data/repositories/user_repository.ts @@ -0,0 +1,52 @@ +"use client"; + +/** + * UserRepository + * + * 用户信息仓库:纯远程调用,不直接写入本地 storage + * (用户数据持久化由 AuthRepository 在登录流程中统一处理)。 + * + * 原始 Dart: lib/data/repositories/user_repository_impl.dart + */ +import { UserApi, userApi } from "@/data/api"; +import { CreditsData } from "@/data/dto/user/credits_data"; +import { CreditsHistoryData } from "@/data/dto/user/credits_history_data"; +import { UpdateProfileRequest } from "@/data/dto/user/update_profile_request"; +import { User } from "@/data/dto/user/user"; +import { UserStatsResponse } from "@/data/dto/user/user_stats_response"; +import { Result } from "@/data/result"; + +export class UserRepository { + constructor(private readonly api: UserApi) {} + + /** 获取用户统计信息。 */ + async getUserStats(): Promise> { + return Result.wrap(() => this.api.getUserStats()); + } + + /** 获取当前登录用户信息。 */ + async getCurrentUser(): Promise> { + return Result.wrap(() => this.api.getCurrentUser()); + } + + /** 更新个人资料。 */ + async updateProfile(request: UpdateProfileRequest): Promise> { + return Result.wrap(() => this.api.updateProfile(request)); + } + + /** 查询积分余额。 */ + async getCredits(): Promise> { + return Result.wrap(() => this.api.getCredits()); + } + + /** 查询积分操作历史,分页参数默认 limit=50, offset=0。 */ + async getCreditsHistory( + limit = 50, + offset = 0, + ): Promise> { + return Result.wrap(() => this.api.getCreditsHistory(limit, offset)); + } +} + +/** 全局单例。 */ +export const userRepository = new UserRepository(userApi); diff --git a/src/data/result.ts b/src/data/result.ts new file mode 100644 index 00000000..589908f1 --- /dev/null +++ b/src/data/result.ts @@ -0,0 +1,76 @@ +/** + * 仓库层统一 Result + * + * 与 `src/data/storage/result.ts` 的 `Result`(`{kind, value}` 形态)并存: + * - 本类型供仓库层(`src/data/repositories/*`)使用,`{success, data}` 形态更贴近 + * 原 Dart `Result.success` / `Result.failure` 命名与 API 端 envelope 风格。 + * - 存储层继续使用 `{kind, value}`,因为它的消费者(localStorage、Dexie)已经在 + * 大量代码中 pattern-match `r.kind`。 + * - 两个类型路径不同(`@/data/result` vs `@/data/storage/result`),不冲突; + * 如需在同模块中混用,可用 `import { Result as StorageResult } from + * "@/data/storage/result"` 别名。 + * + * `error` 固定为 `Error`(非 `unknown`):HTTP 拦截器与共享 unwrap 都抛 `ApiError` + * (`ApiError extends Error`),仓库 `Result.wrap` 捕获后用 `toError` 规范化, + * 调用方能可靠地 `instanceof ApiError` / `error.message` 访问错误信息。 + */ +export type Result = + | { readonly success: true; readonly data: T } + | { readonly success: false; readonly error: Error }; + +export const Result = { + /** 构造成功结果。 */ + ok(data: T): Result { + return { success: true, data }; + }, + + /** + * 构造失败结果。`error` 接受任意 thrown 值,内部用 `toError` 归一化为 `Error`。 + */ + err(error: unknown): Result { + return { success: false, error: toError(error) }; + }, + + /** 类型守卫:true 分支。 */ + isOk(r: Result): r is { readonly success: true; readonly data: T } { + return r.success; + }, + + /** 类型守卫:false 分支。 */ + isErr(r: Result): r is { readonly success: false; readonly error: Error } { + return !r.success; + }, + + /** 不可变的链式 map:失败时原样传递。 */ + map(r: Result, fn: (v: T) => U): Result { + return r.success ? Result.ok(fn(r.data)) : r; + }, + + /** + * 捕获异步 thunk 的异常并包装成 `Result`。仓库内主要用这个把 + * `*Api` 调用从「throw 风格」桥接到「Result 风格」。 + */ + async wrap(thunk: () => Promise): Promise> { + try { + return Result.ok(await thunk()); + } catch (e) { + return Result.err(e); + } + }, +} as const; + +/** + * 把任意 thrown 值规范化为 `Error`: + * - 已是 `Error` 子类 → 原样返回(保留 `.code` / `.status` 等自定义字段) + * - string → `new Error(str)` + * - 其他 → `new Error(JSON.stringify(value))`,失败则 `new Error(String(value))` + */ +export function toError(value: unknown): Error { + if (value instanceof Error) return value; + if (typeof value === "string") return new Error(value); + try { + return new Error(JSON.stringify(value)); + } catch { + return new Error(String(value)); + } +}