From 455204e1e40315ef3a4d62c9bc7171b60aaf96a2 Mon Sep 17 00:00:00 2001 From: chenhang Date: Fri, 26 Jun 2026 19:27:56 +0800 Subject: [PATCH] feat(chat): add history unlock api --- src/app/chat/chat-screen.helpers.ts | 93 +++++++++++++++++++ src/app/chat/chat-screen.tsx | 85 +++++------------ src/data/dto/chat/index.ts | 1 + src/data/dto/chat/unlock_history_response.ts | 43 +++++++++ src/data/repositories/chat_repository.ts | 6 ++ .../interfaces/ichat_repository.ts | 4 + src/data/schemas/chat/index.ts | 1 + .../schemas/chat/unlock_history_response.ts | 36 +++++++ src/data/services/api/api_path.ts | 3 + src/data/services/api/chat_api.ts | 16 ++++ 10 files changed, 227 insertions(+), 61 deletions(-) create mode 100644 src/app/chat/chat-screen.helpers.ts create mode 100644 src/data/dto/chat/unlock_history_response.ts create mode 100644 src/data/schemas/chat/unlock_history_response.ts diff --git a/src/app/chat/chat-screen.helpers.ts b/src/app/chat/chat-screen.helpers.ts new file mode 100644 index 00000000..4c65f06c --- /dev/null +++ b/src/app/chat/chat-screen.helpers.ts @@ -0,0 +1,93 @@ +"use client"; + +import type { LoginStatus } from "@/data/dto/auth"; +import { AppStorage } from "@/data/storage/app/app_storage"; +import { AuthStorage } from "@/data/storage/auth/auth_storage"; +import { UserStorage } from "@/data/storage/user/user_storage"; +import { ROUTE_BUILDERS, ROUTES } from "@/router/routes"; +import { AppEnvUtil, BrowserDetector, todayString, UrlLauncherUtil } from "@/utils"; + +export interface ExternalBrowserPromptState { + hasInitialized: boolean; + isLoading: boolean; + loginStatus: LoginStatus; +} + +export function deriveIsGuest(loginStatus: LoginStatus): boolean { + return loginStatus === "guest"; +} + +export function isChatDevelopmentEnvironment(): boolean { + return AppEnvUtil.isDevelopment(); +} + +export function shouldStartExternalBrowserPrompt({ + hasInitialized, + isLoading, + loginStatus, +}: ExternalBrowserPromptState): boolean { + if (!hasInitialized || isLoading) return false; + return ( + isChatDevelopmentEnvironment() || + (loginStatus === "facebook" && BrowserDetector.isInAppBrowser()) + ); +} + +export async function resolveExternalBrowserPromptEligibility(): Promise<{ + canShow: boolean; + today: string; +}> { + const today = todayString(); + const result = await AppStorage.canShowExternalBrowserDialog(today); + return { + canShow: result.success && result.data, + today, + }; +} + +export function recordExternalBrowserPromptShown(today: string): Promise { + return AppStorage.recordExternalBrowserDialogShown(today); +} + +export async function openChatInExternalBrowser(): Promise { + const authStorage = AuthStorage.getInstance(); + const userStorage = UserStorage.getInstance(); + const [deviceIdR, facebookIdR, avatarUrlR] = await Promise.all([ + authStorage.getDeviceId(), + authStorage.getFacebookId(), + userStorage.getAvatarUrl(), + ]); + + const deviceId = deviceIdR.success ? deviceIdR.data : null; + const facebookId = facebookIdR.success ? facebookIdR.data : null; + const avatarUrl = avatarUrlR.success ? avatarUrlR.data : null; + + if (deviceId && facebookId) { + UrlLauncherUtil.openUrlWithExternalBrowser( + ROUTE_BUILDERS.chatDeviceId(deviceId), + { + queryParams: { + fbid: facebookId, + ...(avatarUrl ? { avatarUrl } : {}), + }, + }, + ); + return; + } + + UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash); +} + +export function getChatPaywallSubscriptionUrl(): string { + return ROUTE_BUILDERS.subscription("vip", { + returnTo: "chat", + }); +} + +export function getChatPaywallNavigationUrl(loginStatus: LoginStatus): string { + const subscriptionUrl = getChatPaywallSubscriptionUrl(); + if (deriveIsGuest(loginStatus)) { + return ROUTE_BUILDERS.authWithRedirect(subscriptionUrl); + } + return subscriptionUrl; +} diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 2e11e18b..a17c36e1 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -5,13 +5,7 @@ import Image from "next/image"; import { useRouter } from "next/navigation"; import { useAuthState } from "@/stores/auth/auth-context"; -import type { LoginStatus } from "@/data/dto/auth"; -import { AppStorage } from "@/data/storage/app/app_storage"; -import { AuthStorage } from "@/data/storage/auth/auth_storage"; -import { UserStorage } from "@/data/storage/user/user_storage"; import { useChatState } from "@/stores/chat/chat-context"; -import { AppEnvUtil, BrowserDetector, todayString, UrlLauncherUtil } from "@/utils"; -import { ROUTE_BUILDERS, ROUTES } from "@/router/routes"; import { MobileShell } from "@/app/_components/core"; @@ -24,16 +18,17 @@ import { ExternalBrowserDialog, PwaInstallOverlay, } from "./components"; +import { + deriveIsGuest, + getChatPaywallNavigationUrl, + isChatDevelopmentEnvironment, + openChatInExternalBrowser, + recordExternalBrowserPromptShown, + resolveExternalBrowserPromptEligibility, + shouldStartExternalBrowserPrompt, +} from "./chat-screen.helpers"; import styles from "./components/chat-screen.module.css"; - -/** - * 派生 isGuest —— 单一来源:`auth.loginStatus` - */ -function deriveIsGuest(loginStatus: LoginStatus): boolean { - return loginStatus === "guest"; -} - export function ChatScreen() { const state = useChatState(); const authState = useAuthState(); @@ -50,17 +45,17 @@ export function ChatScreen() { const messageLimitTitle = "The limit for free chat times\nhas been reached"; const externalBrowserPromptShownRef = useRef(false); - const chatPaywallSubscriptionUrl = ROUTE_BUILDERS.subscription("vip", { - returnTo: "chat", - }); useEffect(() => { - if (!authState.hasInitialized || authState.isLoading) return; - const isDev = AppEnvUtil.isDevelopment(); - const canTriggerPrompt = - isDev || - (authState.loginStatus === "facebook" && BrowserDetector.isInAppBrowser()); - if (!canTriggerPrompt) return; + if ( + !shouldStartExternalBrowserPrompt({ + hasInitialized: authState.hasInitialized, + isLoading: authState.isLoading, + loginStatus: authState.loginStatus, + }) + ) { + return; + } let mounted = true; let timer: number | undefined; @@ -68,15 +63,14 @@ export function ChatScreen() { const init = async () => { if (externalBrowserPromptShownRef.current) return; - const today = todayString(); - const canShowResult = await AppStorage.canShowExternalBrowserDialog(today); - if (!mounted || !canShowResult.success || !canShowResult.data) return; + const eligibility = await resolveExternalBrowserPromptEligibility(); + if (!mounted || !eligibility.canShow) return; externalBrowserPromptShownRef.current = true; timer = window.setTimeout(() => { if (!mounted) return; setShowExternalBrowserDialog(true); - void AppStorage.recordExternalBrowserDialogShown(today); + void recordExternalBrowserPromptShown(eligibility.today); }, 3000); }; @@ -94,41 +88,11 @@ export function ChatScreen() { async function handleOpenExternalBrowser(): Promise { setShowExternalBrowserDialog(false); - - const authStorage = AuthStorage.getInstance(); - const userStorage = UserStorage.getInstance(); - const [deviceIdR, facebookIdR, avatarUrlR] = await Promise.all([ - authStorage.getDeviceId(), - authStorage.getFacebookId(), - userStorage.getAvatarUrl(), - ]); - - const deviceId = deviceIdR.success ? deviceIdR.data : null; - const facebookId = facebookIdR.success ? facebookIdR.data : null; - const avatarUrl = avatarUrlR.success ? avatarUrlR.data : null; - - if (deviceId && facebookId) { - UrlLauncherUtil.openUrlWithExternalBrowser( - ROUTE_BUILDERS.chatDeviceId(deviceId), - { - queryParams: { - fbid: facebookId, - ...(avatarUrl ? { avatarUrl } : {}), - }, - }, - ); - return; - } - - UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash); + await openChatInExternalBrowser(); } function openChatPaywallSubscription(): void { - if (isGuest) { - router.push(ROUTE_BUILDERS.authWithRedirect(chatPaywallSubscriptionUrl)); - return; - } - router.push(chatPaywallSubscriptionUrl); + router.push(getChatPaywallNavigationUrl(authState.loginStatus)); } function handleUnlockPrivateMessage(): void { @@ -182,11 +146,10 @@ export function ChatScreen() { ) : ( )} - {/* 浏览器提示(应用内浏览器检测) */} - + {/* PWA 安装提示触发器(无可见 UI,3.5s 后弹 dialog) */} diff --git a/src/data/dto/chat/index.ts b/src/data/dto/chat/index.ts index 6c3678dd..4a7e0f06 100644 --- a/src/data/dto/chat/index.ts +++ b/src/data/dto/chat/index.ts @@ -13,5 +13,6 @@ export * from "./send_message_request"; export * from "./stt_data"; export * from "./sync_message"; export * from "./ui_message"; +export * from "./unlock_history_response"; export * from "./unlock_private_request"; export * from "./unlock_private_response"; diff --git a/src/data/dto/chat/unlock_history_response.ts b/src/data/dto/chat/unlock_history_response.ts new file mode 100644 index 00000000..0db61f7e --- /dev/null +++ b/src/data/dto/chat/unlock_history_response.ts @@ -0,0 +1,43 @@ +/** + * 一键解锁历史锁定消息响应 DTO + */ +import { + UnlockHistoryResponseSchema, + type UnlockHistoryReason, + type UnlockHistoryResponseData, + type UnlockHistoryResponseInput, +} from "@/data/schemas/chat/unlock_history_response"; + +export class UnlockHistoryResponse { + declare readonly unlocked: boolean; + declare readonly reason: UnlockHistoryReason; + declare readonly totalLocked: number; + declare readonly unlockedCount: number; + declare readonly privateCount: number; + declare readonly imageCount: number; + declare readonly voiceCount: number; + declare readonly requiredCredits: number; + declare readonly currentCredits: number; + declare readonly remainingCredits: number; + declare readonly shortfallCredits: number; + declare readonly costsByMessage: Record; + declare readonly messageIds: string[]; + + private constructor(input: UnlockHistoryResponseInput) { + const data = UnlockHistoryResponseSchema.parse(input); + Object.assign(this, data); + Object.freeze(this); + } + + static from(input: UnlockHistoryResponseInput): UnlockHistoryResponse { + return new UnlockHistoryResponse(input); + } + + static fromJson(json: unknown): UnlockHistoryResponse { + return UnlockHistoryResponse.from(json as UnlockHistoryResponseInput); + } + + toJson(): UnlockHistoryResponseData { + return UnlockHistoryResponseSchema.parse(this); + } +} diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index cfff2c38..ed3c98fd 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -21,6 +21,7 @@ import { ChatMessage, ChatSendResponse, SendMessageRequest, + UnlockHistoryResponse, UnlockPrivateRequest, UnlockPrivateResponse, } from "@/data/dto/chat"; @@ -68,6 +69,11 @@ export class ChatRepository implements IChatRepository { ); } + /** 一键解锁历史锁定消息。 */ + async unlockHistory(): Promise> { + return Result.wrap(() => this.api.unlockHistory()); + } + /** 把本地缓存中的私密消息标记为已解锁。 */ async markPrivateMessageUnlockedInLocal( messageId: string, diff --git a/src/data/repositories/interfaces/ichat_repository.ts b/src/data/repositories/interfaces/ichat_repository.ts index f19ba0ec..8c78daa6 100644 --- a/src/data/repositories/interfaces/ichat_repository.ts +++ b/src/data/repositories/interfaces/ichat_repository.ts @@ -16,6 +16,7 @@ import type { ChatHistoryResponse, ChatMessage, ChatSendResponse, + UnlockHistoryResponse, UnlockPrivateResponse, } from "@/data/dto/chat"; @@ -32,6 +33,9 @@ export interface IChatRepository { /** 解锁私密消息。 */ unlockPrivateMessage(messageId: string): Promise>; + /** 一键解锁历史锁定消息。 */ + unlockHistory(): Promise>; + /** 把本地缓存中的私密消息标记为已解锁。 */ markPrivateMessageUnlockedInLocal( messageId: string, diff --git a/src/data/schemas/chat/index.ts b/src/data/schemas/chat/index.ts index 4d6f28c5..a6021785 100644 --- a/src/data/schemas/chat/index.ts +++ b/src/data/schemas/chat/index.ts @@ -12,5 +12,6 @@ export * from "./image_upload_response"; export * from "./send_message_request"; export * from "./stt_data"; export * from "./sync_message"; +export * from "./unlock_history_response"; export * from "./unlock_private_request"; export * from "./unlock_private_response"; diff --git a/src/data/schemas/chat/unlock_history_response.ts b/src/data/schemas/chat/unlock_history_response.ts new file mode 100644 index 00000000..89c8d2ea --- /dev/null +++ b/src/data/schemas/chat/unlock_history_response.ts @@ -0,0 +1,36 @@ +/** + * 一键解锁历史锁定消息响应 + */ +import { z } from "zod"; + +export const UnlockHistoryCostsByMessageSchema = z.record(z.string(), z.number()); + +export const UnlockHistoryReasonSchema = z.enum([ + "ok", + "insufficient_balance", + "no_locked_messages", +]); + +export const UnlockHistoryResponseSchema = z.object({ + unlocked: z.boolean(), + reason: UnlockHistoryReasonSchema, + totalLocked: z.number().default(0), + unlockedCount: z.number().default(0), + privateCount: z.number().default(0), + imageCount: z.number().default(0), + voiceCount: z.number().default(0), + requiredCredits: z.number().default(0), + currentCredits: z.number().default(0), + remainingCredits: z.number().default(0), + shortfallCredits: z.number().default(0), + costsByMessage: UnlockHistoryCostsByMessageSchema.default({}), + messageIds: z.array(z.string()).default([]), +}); + +export type UnlockHistoryReason = z.output; +export type UnlockHistoryResponseInput = z.input< + typeof UnlockHistoryResponseSchema +>; +export type UnlockHistoryResponseData = z.output< + typeof UnlockHistoryResponseSchema +>; diff --git a/src/data/services/api/api_path.ts b/src/data/services/api/api_path.ts index c424e3b1..132e5e38 100644 --- a/src/data/services/api/api_path.ts +++ b/src/data/services/api/api_path.ts @@ -112,6 +112,9 @@ export class ApiPath { /** 解锁私密消息 */ static readonly chatUnlockPrivate = `${ApiPath._chat}/unlock-private`; + /** 一键解锁历史锁定消息 */ + static readonly chatUnlockHistory = `${ApiPath._chat}/unlock-history`; + // ============ 数据看板相关 ============ private static readonly _metrics = `${ApiPath._baseUrl}/metrics`; diff --git a/src/data/services/api/chat_api.ts b/src/data/services/api/chat_api.ts index 11b720f8..d60af6bd 100644 --- a/src/data/services/api/chat_api.ts +++ b/src/data/services/api/chat_api.ts @@ -16,6 +16,7 @@ import { SendMessageRequest, SttData, SyncMessage, + UnlockHistoryResponse, UnlockPrivateRequest, UnlockPrivateResponse, } from "@/data/dto/chat"; @@ -101,6 +102,21 @@ export class ChatApi { unwrap(env) as Record, ); } + + /** + * 一键解锁历史锁定消息 + */ + async unlockHistory(): Promise { + const env = await httpClient>( + ApiPath.chatUnlockHistory, + { + method: "POST", + }, + ); + return UnlockHistoryResponse.fromJson( + unwrap(env) as Record, + ); + } } /**