feat(chat): add history unlock api

This commit is contained in:
2026-06-26 19:27:56 +08:00
parent 1b73c3ac10
commit 455204e1e4
10 changed files with 227 additions and 61 deletions
+93
View File
@@ -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<unknown> {
return AppStorage.recordExternalBrowserDialogShown(today);
}
export async function openChatInExternalBrowser(): Promise<void> {
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;
}
+24 -61
View File
@@ -5,13 +5,7 @@ import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuthState } from "@/stores/auth/auth-context"; 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 { 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"; import { MobileShell } from "@/app/_components/core";
@@ -24,16 +18,17 @@ import {
ExternalBrowserDialog, ExternalBrowserDialog,
PwaInstallOverlay, PwaInstallOverlay,
} from "./components"; } from "./components";
import {
deriveIsGuest,
getChatPaywallNavigationUrl,
isChatDevelopmentEnvironment,
openChatInExternalBrowser,
recordExternalBrowserPromptShown,
resolveExternalBrowserPromptEligibility,
shouldStartExternalBrowserPrompt,
} from "./chat-screen.helpers";
import styles from "./components/chat-screen.module.css"; import styles from "./components/chat-screen.module.css";
/**
* 派生 isGuest —— 单一来源:`auth.loginStatus`
*/
function deriveIsGuest(loginStatus: LoginStatus): boolean {
return loginStatus === "guest";
}
export function ChatScreen() { export function ChatScreen() {
const state = useChatState(); const state = useChatState();
const authState = useAuthState(); const authState = useAuthState();
@@ -50,17 +45,17 @@ export function ChatScreen() {
const messageLimitTitle = "The limit for free chat times\nhas been reached"; const messageLimitTitle = "The limit for free chat times\nhas been reached";
const externalBrowserPromptShownRef = useRef(false); const externalBrowserPromptShownRef = useRef(false);
const chatPaywallSubscriptionUrl = ROUTE_BUILDERS.subscription("vip", {
returnTo: "chat",
});
useEffect(() => { useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return; if (
const isDev = AppEnvUtil.isDevelopment(); !shouldStartExternalBrowserPrompt({
const canTriggerPrompt = hasInitialized: authState.hasInitialized,
isDev || isLoading: authState.isLoading,
(authState.loginStatus === "facebook" && BrowserDetector.isInAppBrowser()); loginStatus: authState.loginStatus,
if (!canTriggerPrompt) return; })
) {
return;
}
let mounted = true; let mounted = true;
let timer: number | undefined; let timer: number | undefined;
@@ -68,15 +63,14 @@ export function ChatScreen() {
const init = async () => { const init = async () => {
if (externalBrowserPromptShownRef.current) return; if (externalBrowserPromptShownRef.current) return;
const today = todayString(); const eligibility = await resolveExternalBrowserPromptEligibility();
const canShowResult = await AppStorage.canShowExternalBrowserDialog(today); if (!mounted || !eligibility.canShow) return;
if (!mounted || !canShowResult.success || !canShowResult.data) return;
externalBrowserPromptShownRef.current = true; externalBrowserPromptShownRef.current = true;
timer = window.setTimeout(() => { timer = window.setTimeout(() => {
if (!mounted) return; if (!mounted) return;
setShowExternalBrowserDialog(true); setShowExternalBrowserDialog(true);
void AppStorage.recordExternalBrowserDialogShown(today); void recordExternalBrowserPromptShown(eligibility.today);
}, 3000); }, 3000);
}; };
@@ -94,41 +88,11 @@ export function ChatScreen() {
async function handleOpenExternalBrowser(): Promise<void> { async function handleOpenExternalBrowser(): Promise<void> {
setShowExternalBrowserDialog(false); setShowExternalBrowserDialog(false);
await openChatInExternalBrowser();
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);
} }
function openChatPaywallSubscription(): void { function openChatPaywallSubscription(): void {
if (isGuest) { router.push(getChatPaywallNavigationUrl(authState.loginStatus));
router.push(ROUTE_BUILDERS.authWithRedirect(chatPaywallSubscriptionUrl));
return;
}
router.push(chatPaywallSubscriptionUrl);
} }
function handleUnlockPrivateMessage(): void { function handleUnlockPrivateMessage(): void {
@@ -182,11 +146,10 @@ export function ChatScreen() {
) : ( ) : (
<ChatInputBar disabled={!state.historyLoaded} /> <ChatInputBar disabled={!state.historyLoaded} />
)} )}
</div> </div>
{/* 浏览器提示(应用内浏览器检测) */} {/* 浏览器提示(应用内浏览器检测) */}
<BrowserHintOverlay forceShow={AppEnvUtil.isDevelopment()} /> <BrowserHintOverlay forceShow={isChatDevelopmentEnvironment()} />
{/* PWA 安装提示触发器(无可见 UI3.5s 后弹 dialog */} {/* PWA 安装提示触发器(无可见 UI3.5s 后弹 dialog */}
<PwaInstallOverlay /> <PwaInstallOverlay />
+1
View File
@@ -13,5 +13,6 @@ export * from "./send_message_request";
export * from "./stt_data"; export * from "./stt_data";
export * from "./sync_message"; export * from "./sync_message";
export * from "./ui_message"; export * from "./ui_message";
export * from "./unlock_history_response";
export * from "./unlock_private_request"; export * from "./unlock_private_request";
export * from "./unlock_private_response"; export * from "./unlock_private_response";
@@ -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<string, number>;
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);
}
}
+6
View File
@@ -21,6 +21,7 @@ import {
ChatMessage, ChatMessage,
ChatSendResponse, ChatSendResponse,
SendMessageRequest, SendMessageRequest,
UnlockHistoryResponse,
UnlockPrivateRequest, UnlockPrivateRequest,
UnlockPrivateResponse, UnlockPrivateResponse,
} from "@/data/dto/chat"; } from "@/data/dto/chat";
@@ -68,6 +69,11 @@ export class ChatRepository implements IChatRepository {
); );
} }
/** 一键解锁历史锁定消息。 */
async unlockHistory(): Promise<Result<UnlockHistoryResponse>> {
return Result.wrap(() => this.api.unlockHistory());
}
/** 把本地缓存中的私密消息标记为已解锁。 */ /** 把本地缓存中的私密消息标记为已解锁。 */
async markPrivateMessageUnlockedInLocal( async markPrivateMessageUnlockedInLocal(
messageId: string, messageId: string,
@@ -16,6 +16,7 @@ import type {
ChatHistoryResponse, ChatHistoryResponse,
ChatMessage, ChatMessage,
ChatSendResponse, ChatSendResponse,
UnlockHistoryResponse,
UnlockPrivateResponse, UnlockPrivateResponse,
} from "@/data/dto/chat"; } from "@/data/dto/chat";
@@ -32,6 +33,9 @@ export interface IChatRepository {
/** 解锁私密消息。 */ /** 解锁私密消息。 */
unlockPrivateMessage(messageId: string): Promise<Result<UnlockPrivateResponse>>; unlockPrivateMessage(messageId: string): Promise<Result<UnlockPrivateResponse>>;
/** 一键解锁历史锁定消息。 */
unlockHistory(): Promise<Result<UnlockHistoryResponse>>;
/** 把本地缓存中的私密消息标记为已解锁。 */ /** 把本地缓存中的私密消息标记为已解锁。 */
markPrivateMessageUnlockedInLocal( markPrivateMessageUnlockedInLocal(
messageId: string, messageId: string,
+1
View File
@@ -12,5 +12,6 @@ export * from "./image_upload_response";
export * from "./send_message_request"; export * from "./send_message_request";
export * from "./stt_data"; export * from "./stt_data";
export * from "./sync_message"; export * from "./sync_message";
export * from "./unlock_history_response";
export * from "./unlock_private_request"; export * from "./unlock_private_request";
export * from "./unlock_private_response"; export * from "./unlock_private_response";
@@ -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<typeof UnlockHistoryReasonSchema>;
export type UnlockHistoryResponseInput = z.input<
typeof UnlockHistoryResponseSchema
>;
export type UnlockHistoryResponseData = z.output<
typeof UnlockHistoryResponseSchema
>;
+3
View File
@@ -112,6 +112,9 @@ export class ApiPath {
/** 解锁私密消息 */ /** 解锁私密消息 */
static readonly chatUnlockPrivate = `${ApiPath._chat}/unlock-private`; static readonly chatUnlockPrivate = `${ApiPath._chat}/unlock-private`;
/** 一键解锁历史锁定消息 */
static readonly chatUnlockHistory = `${ApiPath._chat}/unlock-history`;
// ============ 数据看板相关 ============ // ============ 数据看板相关 ============
private static readonly _metrics = `${ApiPath._baseUrl}/metrics`; private static readonly _metrics = `${ApiPath._baseUrl}/metrics`;
+16
View File
@@ -16,6 +16,7 @@ import {
SendMessageRequest, SendMessageRequest,
SttData, SttData,
SyncMessage, SyncMessage,
UnlockHistoryResponse,
UnlockPrivateRequest, UnlockPrivateRequest,
UnlockPrivateResponse, UnlockPrivateResponse,
} from "@/data/dto/chat"; } from "@/data/dto/chat";
@@ -101,6 +102,21 @@ export class ChatApi {
unwrap(env) as Record<string, unknown>, unwrap(env) as Record<string, unknown>,
); );
} }
/**
* 一键解锁历史锁定消息
*/
async unlockHistory(): Promise<UnlockHistoryResponse> {
const env = await httpClient<ApiEnvelope<unknown>>(
ApiPath.chatUnlockHistory,
{
method: "POST",
},
);
return UnlockHistoryResponse.fromJson(
unwrap(env) as Record<string, unknown>,
);
}
} }
/** /**