refactor(router): introduce app navigation manager

This commit is contained in:
2026-07-03 14:09:13 +08:00
parent 8a586e4471
commit 91dde42f92
23 changed files with 669 additions and 277 deletions
-35
View File
@@ -1,8 +1,6 @@
"use client"; "use client";
import type { LoginStatus } from "@/data/dto/auth"; import type { LoginStatus } from "@/data/dto/auth";
import type { PayChannel } from "@/data/dto/payment";
import { ROUTE_BUILDERS } from "@/router/routes";
import { AppEnvUtil, BrowserDetector } from "@/utils"; import { AppEnvUtil, BrowserDetector } from "@/utils";
export interface ExternalBrowserPromptState { export interface ExternalBrowserPromptState {
@@ -31,41 +29,8 @@ export function shouldStartExternalBrowserPrompt({
); );
} }
export function getChatPaywallSubscriptionUrl(
payChannel: PayChannel = "stripe",
): string {
return ROUTE_BUILDERS.subscription("vip", {
payChannel,
returnTo: "chat",
});
}
export function getChatCreditsTopUpSubscriptionUrl(
payChannel: PayChannel = "stripe",
): string {
return ROUTE_BUILDERS.subscription("topup", {
payChannel,
returnTo: "chat",
});
}
export function getInsufficientCreditsSubscriptionType( export function getInsufficientCreditsSubscriptionType(
isVip: boolean, isVip: boolean,
): "vip" | "topup" { ): "vip" | "topup" {
return isVip ? "topup" : "vip"; return isVip ? "topup" : "vip";
} }
export function getChatPaywallNavigationUrl(
loginStatus: LoginStatus,
type: "vip" | "topup" = "vip",
payChannel: PayChannel = "stripe",
): string {
const subscriptionUrl =
type === "topup"
? getChatCreditsTopUpSubscriptionUrl(payChannel)
: getChatPaywallSubscriptionUrl(payChannel);
if (deriveIsGuest(loginStatus)) {
return ROUTE_BUILDERS.authWithRedirect(subscriptionUrl);
}
return subscriptionUrl;
}
+6 -14
View File
@@ -2,18 +2,17 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation";
import { useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { useUserState } from "@/stores/user/user-context"; import { useUserState } from "@/stores/user/user-context";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import { import {
openChatInExternalBrowser, openChatInExternalBrowser,
recordExternalBrowserPromptShown, recordExternalBrowserPromptShown,
resolveExternalBrowserPromptEligibility, resolveExternalBrowserPromptEligibility,
} from "@/lib/chat/chat_external_browser"; } from "@/lib/chat/chat_external_browser";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
@@ -31,7 +30,6 @@ import {
} from "./components"; } from "./components";
import { import {
deriveIsGuest, deriveIsGuest,
getChatPaywallNavigationUrl,
getInsufficientCreditsSubscriptionType, getInsufficientCreditsSubscriptionType,
isChatDevelopmentEnvironment, isChatDevelopmentEnvironment,
shouldStartExternalBrowserPrompt, shouldStartExternalBrowserPrompt,
@@ -45,7 +43,7 @@ export function ChatScreen() {
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
const authState = useAuthState(); const authState = useAuthState();
const userState = useUserState(); const userState = useUserState();
const router = useRouter(); const navigator = useAppNavigator();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] = const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false); useState(false);
const { const {
@@ -122,16 +120,10 @@ export function ChatScreen() {
} }
function handleMessageLimitUnlock(): void { function handleMessageLimitUnlock(): void {
const defaultPayChannel = getDefaultPayChannelForCountryCode( navigator.openSubscription({
userState.currentUser?.countryCode, type: getInsufficientCreditsSubscriptionType(userState.isVip),
); returnTo: "chat",
router.push( });
getChatPaywallNavigationUrl(
authState.loginStatus,
getInsufficientCreditsSubscriptionType(userState.isVip),
defaultPayChannel,
),
);
} }
function handleUnlockVoiceMessage(messageId: string): void { function handleUnlockVoiceMessage(messageId: string): void {
+4 -4
View File
@@ -7,9 +7,9 @@
*/ */
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Lock, Menu } from "lucide-react"; import { Lock, Menu } from "lucide-react";
import { useRouter } from "next/navigation";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import styles from "./chat-header.module.css"; import styles from "./chat-header.module.css";
@@ -19,7 +19,7 @@ export interface ChatHeaderProps {
} }
export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) { export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
const router = useRouter(); const navigator = useAppNavigator();
if (isGuest) { if (isGuest) {
return ( return (
@@ -27,7 +27,7 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
<button <button
type="button" type="button"
className={styles.guestBanner} className={styles.guestBanner}
onClick={() => router.push(ROUTES.auth)} onClick={() => navigator.openAuth(ROUTES.chat)}
aria-label="Sign up to unlock more features" aria-label="Sign up to unlock more features"
> >
<Lock className={styles.guestBannerIcon} size={16} aria-hidden="true" /> <Lock className={styles.guestBannerIcon} size={16} aria-hidden="true" />
@@ -46,7 +46,7 @@ export function ChatHeader({ isGuest, offerBanner }: ChatHeaderProps) {
<button <button
type="button" type="button"
className={styles.menuButton} className={styles.menuButton}
onClick={() => router.push(ROUTES.sidebar)} onClick={() => navigator.push(ROUTES.sidebar)}
aria-label="Menu" aria-label="Menu"
> >
<Menu className={styles.menuIcon} size={24} aria-hidden="true" /> <Menu className={styles.menuIcon} size={24} aria-hidden="true" />
@@ -16,9 +16,7 @@
* 不做的事: * 不做的事:
* - 不直接管理 state machinechat 机器不感知 UI 层) * - 不直接管理 state machinechat 机器不感知 UI 层)
*/ */
import { useRouter } from "next/navigation"; import { useAppNavigator } from "@/router/use-app-navigator";
import { ROUTE_BUILDERS } from "@/router/routes";
import styles from "./chat-insufficient-credits-banner.module.css"; import styles from "./chat-insufficient-credits-banner.module.css";
@@ -26,7 +24,7 @@ export interface ChatInsufficientCreditsBannerProps {
title?: string; title?: string;
ctaLabel?: string; ctaLabel?: string;
/** /**
* 自定义点击回调(不传则默认 router.push(topup subscription) * 自定义点击回调(不传则默认走统一订阅导航
* - 测试时可传 mock * - 测试时可传 mock
* - 嵌入其他场景时(如 nested overlay)可传自定义 * - 嵌入其他场景时(如 nested overlay)可传自定义
*/ */
@@ -38,7 +36,7 @@ export function ChatInsufficientCreditsBanner({
ctaLabel = "Top up credits to continue", ctaLabel = "Top up credits to continue",
onUnlock, onUnlock,
}: ChatInsufficientCreditsBannerProps) { }: ChatInsufficientCreditsBannerProps) {
const router = useRouter(); const navigator = useAppNavigator();
const titleLines = title.split("\n"); const titleLines = title.split("\n");
const handleClick = () => { const handleClick = () => {
@@ -46,7 +44,7 @@ export function ChatInsufficientCreditsBanner({
onUnlock(); onUnlock();
return; return;
} }
router.push(ROUTE_BUILDERS.subscription("topup")); navigator.openSubscription({ type: "topup", returnTo: "chat" });
}; };
return ( return (
@@ -1,21 +1,17 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { ROUTE_BUILDERS } from "@/router/routes";
import { useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
import { useUserState } from "@/stores/user/user-context";
import { import {
consumePendingChatUnlock, consumePendingChatUnlock,
peekPendingChatUnlock, peekPendingChatUnlock,
savePendingChatUnlock,
type PendingChatUnlock, type PendingChatUnlock,
type PendingChatUnlockKind, type PendingChatUnlockKind,
} from "@/lib/navigation/chat_unlock_session"; } from "@/lib/navigation/chat_unlock_session";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel"; import { useAppNavigator } from "@/router/use-app-navigator";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
import { useUserState } from "@/stores/user/user-context";
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers"; import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
@@ -40,14 +36,10 @@ export function useChatUnlockNavigationFlow({
expectedKind, expectedKind,
expectedMessageId, expectedMessageId,
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput { }: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
const router = useRouter(); const navigator = useAppNavigator();
const authState = useAuthState();
const userState = useUserState(); const userState = useUserState();
const chatState = useChatState(); const chatState = useChatState();
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
const isAuthenticatedUser =
authState.loginStatus !== "notLoggedIn" &&
authState.loginStatus !== "guest";
const unlockPaywallRequest = getScopedUnlockPaywallRequest({ const unlockPaywallRequest = getScopedUnlockPaywallRequest({
request: chatState.unlockPaywallRequest, request: chatState.unlockPaywallRequest,
expectedKind, expectedKind,
@@ -55,7 +47,7 @@ export function useChatUnlockNavigationFlow({
}); });
useEffect(() => { useEffect(() => {
if (!chatState.historyLoaded || !isAuthenticatedUser) return; if (!chatState.historyLoaded || !navigator.isAuthenticatedUser) return;
let cancelled = false; let cancelled = false;
@@ -93,7 +85,7 @@ export function useChatUnlockNavigationFlow({
chatState.historyLoaded, chatState.historyLoaded,
expectedKind, expectedKind,
expectedMessageId, expectedMessageId,
isAuthenticatedUser, navigator.isAuthenticatedUser,
returnUrl, returnUrl,
]); ]);
@@ -101,23 +93,17 @@ export function useChatUnlockNavigationFlow({
messageId: string, messageId: string,
kind: PendingChatUnlockKind, kind: PendingChatUnlockKind,
): void { ): void {
if (!isAuthenticatedUser) { navigator.startMessageUnlock({
void (async () => {
await savePendingChatUnlock({
messageId,
kind,
returnUrl,
stage: "auth",
});
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
})();
return;
}
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId, messageId,
kind, kind,
returnUrl,
onAuthenticated: () => {
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId,
kind,
});
},
}); });
} }
@@ -128,24 +114,13 @@ export function useChatUnlockNavigationFlow({
function confirmInsufficientCreditsDialog(): void { function confirmInsufficientCreditsDialog(): void {
if (!unlockPaywallRequest) return; if (!unlockPaywallRequest) return;
void (async () => { chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
await savePendingChatUnlock({ navigator.openSubscriptionForPendingUnlock({
messageId: unlockPaywallRequest.messageId, messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind, kind: unlockPaywallRequest.kind,
returnUrl, returnUrl,
stage: "payment", type: getInsufficientCreditsSubscriptionType(userState.isVip),
}); });
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
const defaultPayChannel = getDefaultPayChannelForCountryCode(
userState.currentUser?.countryCode,
);
router.push(
ROUTE_BUILDERS.subscription(
getInsufficientCreditsSubscriptionType(userState.isVip),
{ payChannel: defaultPayChannel, returnTo: "chat" },
),
);
})();
} }
return { return {
@@ -1,10 +1,9 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { LoginStatus } from "@/data/dto/auth"; import type { LoginStatus } from "@/data/dto/auth";
import { ROUTE_BUILDERS } from "@/router/routes"; import { useAppNavigator } from "@/router/use-app-navigator";
import { usePaymentDispatch, usePaymentState } from "@/stores/payment"; import { usePaymentDispatch, usePaymentState } from "@/stores/payment";
import { useUserState } from "@/stores/user/user-context"; import { useUserState } from "@/stores/user/user-context";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel"; import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
@@ -55,7 +54,7 @@ export function useFirstRechargeOfferBanner({
historyLoaded, historyLoaded,
loginStatus, loginStatus,
}: UseFirstRechargeOfferBannerInput): UseFirstRechargeOfferBannerOutput { }: UseFirstRechargeOfferBannerInput): UseFirstRechargeOfferBannerOutput {
const router = useRouter(); const navigator = useAppNavigator();
const payment = usePaymentState(); const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch(); const paymentDispatch = usePaymentDispatch();
const userState = useUserState(); const userState = useUserState();
@@ -127,15 +126,7 @@ export function useFirstRechargeOfferBanner({
} }
function claim(): void { function claim(): void {
const defaultPayChannel = getDefaultPayChannelForCountryCode( navigator.openSubscription({ type: "vip", returnTo: "chat" });
userState.currentUser?.countryCode,
);
router.push(
ROUTE_BUILDERS.subscription("vip", {
payChannel: defaultPayChannel,
returnTo: "chat",
}),
);
} }
return { return {
+7 -18
View File
@@ -3,12 +3,11 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { BackButton } from "@/app/_components"; import { BackButton } from "@/app/_components";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel"; import { ROUTES } from "@/router/routes";
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes"; import { useAppNavigator } from "@/router/use-app-navigator";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context"; import { useUserDispatch, useUserState } from "@/stores/user/user-context";
@@ -23,7 +22,7 @@ import styles from "./components/sidebar-screen.module.css";
const FALLBACK_USERNAME = "User name"; const FALLBACK_USERNAME = "User name";
export function SidebarScreen() { export function SidebarScreen() {
const router = useRouter(); const navigator = useAppNavigator();
const user = useUserState(); const user = useUserState();
const userDispatch = useUserDispatch(); const userDispatch = useUserDispatch();
const auth = useAuthState(); const auth = useAuthState();
@@ -60,16 +59,6 @@ export function SidebarScreen() {
const name = user.currentUser?.username ?? FALLBACK_USERNAME; const name = user.currentUser?.username ?? FALLBACK_USERNAME;
const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null; const avatarUrl = user.avatarUrl ?? user.currentUser?.avatarUrl ?? null;
const defaultPayChannel = getDefaultPayChannelForCountryCode(
user.currentUser?.countryCode,
);
const vipSubscriptionUrl = ROUTE_BUILDERS.subscription("vip", {
payChannel: defaultPayChannel,
});
const topUpSubscriptionUrl = ROUTE_BUILDERS.subscription("topup", {
payChannel: defaultPayChannel,
});
return ( return (
<MobileShell background="#fcf3f4"> <MobileShell background="#fcf3f4">
<div className={styles.shell}> <div className={styles.shell}>
@@ -86,7 +75,7 @@ export function SidebarScreen() {
name={name} name={name}
avatarUrl={avatarUrl} avatarUrl={avatarUrl}
isLoading={isInitializingUser} isLoading={isInitializingUser}
onLoginClick={() => router.push(ROUTES.auth)} onLoginClick={() => navigator.openAuth(ROUTES.sidebar)}
/> />
</section> </section>
@@ -96,10 +85,10 @@ export function SidebarScreen() {
onActivateVip={ onActivateVip={
sidebarState === "vip" sidebarState === "vip"
? undefined ? undefined
: () => router.push(vipSubscriptionUrl) : () => navigator.openSubscription({ type: "vip" })
} }
onTopUp={() => router.push(topUpSubscriptionUrl)} onTopUp={() => navigator.openSubscription({ type: "topup" })}
onRulesClick={() => router.push(ROUTES.coinsRules)} onRulesClick={() => navigator.push(ROUTES.coinsRules)}
/> />
</section> </section>
+1
View File
@@ -17,6 +17,7 @@ export * from "./chat/chat_storage";
export * from "./chat/local_chat_db"; export * from "./chat/local_chat_db";
export * from "./chat/local_chat_storage"; export * from "./chat/local_chat_storage";
export * from "./chat/local_message"; export * from "./chat/local_message";
export * from "./navigation/navigation_storage";
export * from "./payment/pending_payment_order_storage"; export * from "./payment/pending_payment_order_storage";
export * from "./user/iuser_storage"; export * from "./user/iuser_storage";
export * from "./user/user_storage"; export * from "./user/user_storage";
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStorage } from "unstorage";
import memoryDriver from "unstorage/drivers/memory";
import { SessionAsyncUtil } from "@/utils";
import { NavigationStorage } from "../navigation_storage";
describe("NavigationStorage", () => {
beforeEach(() => {
SessionAsyncUtil.setStorage(createStorage({ driver: memoryDriver() }));
vi.useRealTimers();
});
it("saves, peeks, and consumes pending chat unlock sessions", async () => {
await NavigationStorage.savePendingChatUnlock({
messageId: "msg_1",
kind: "image",
returnUrl: "/chat/image/msg_1",
stage: "payment",
});
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toMatchObject({
messageId: "msg_1",
kind: "image",
returnUrl: "/chat/image/msg_1",
stage: "payment",
});
await expect(
NavigationStorage.consumePendingChatUnlock(),
).resolves.toMatchObject({
messageId: "msg_1",
kind: "image",
});
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull();
});
it("drops expired pending chat unlock sessions", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-03T00:00:00.000Z"));
await NavigationStorage.savePendingChatUnlock({
messageId: "msg_1",
kind: "private",
returnUrl: "/chat",
stage: "auth",
});
vi.setSystemTime(new Date("2026-07-03T00:31:00.000Z"));
await expect(NavigationStorage.peekPendingChatUnlock()).resolves.toBeNull();
});
it("saves and consumes pending chat image return sessions", async () => {
await NavigationStorage.savePendingChatImageReturn({
messageId: "msg_1",
returnUrl: "/chat/image/msg_1",
});
await expect(
NavigationStorage.consumePendingChatImageReturn(),
).resolves.toMatchObject({
messageId: "msg_1",
returnUrl: "/chat/image/msg_1",
});
await expect(
NavigationStorage.consumePendingChatImageReturn(),
).resolves.toBeNull();
});
});
+1
View File
@@ -0,0 +1 @@
export * from "./navigation_storage";
@@ -0,0 +1,150 @@
"use client";
import { z } from "zod";
import { StorageKeys } from "@/data/storage/storage_keys";
import { Result, SessionAsyncUtil } from "@/utils";
const MAX_AGE_MS = 30 * 60 * 1000;
export type PendingChatUnlockKind = "private" | "voice" | "image";
export type PendingChatUnlockStage = "auth" | "payment";
const PendingChatUnlockSchema = z.object({
reason: z.literal("single_message_unlock"),
messageId: z.string().min(1),
kind: z.enum(["private", "voice", "image"]),
returnUrl: z
.string()
.min(1)
.refine(
(value) => value.startsWith("/") && !value.startsWith("//"),
"returnUrl must be an internal route",
),
stage: z.enum(["auth", "payment"]),
createdAt: z.number(),
});
const PendingChatImageReturnSchema = z.object({
reason: z.literal("image_paywall"),
messageId: z.string().min(1),
returnUrl: z
.string()
.min(1)
.refine(
(value) => value.startsWith("/") && !value.startsWith("//"),
"returnUrl must be an internal route",
),
createdAt: z.number(),
});
export type PendingChatUnlock = z.output<typeof PendingChatUnlockSchema>;
export type PendingChatImageReturn = z.output<
typeof PendingChatImageReturnSchema
>;
/**
* NavigationStorage owns short-lived cross-route sessions.
*
* UI / router code should not touch the raw session storage utility directly.
* Keeping these payloads here makes auth, payment, and chat return flows
* testable without spreading storage keys across screens.
*/
export class NavigationStorage {
private constructor() {}
static async savePendingChatUnlock(input: {
messageId: string;
kind: PendingChatUnlockKind;
returnUrl: string;
stage: PendingChatUnlockStage;
}): Promise<void> {
const payload: PendingChatUnlock = {
reason: "single_message_unlock",
messageId: input.messageId,
kind: input.kind,
returnUrl: input.returnUrl,
stage: input.stage,
createdAt: Date.now(),
};
await SessionAsyncUtil.setJson(
StorageKeys.pendingChatUnlock,
payload,
PendingChatUnlockSchema,
);
}
static async consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
const result = await SessionAsyncUtil.getJson(
StorageKeys.pendingChatUnlock,
PendingChatUnlockSchema,
);
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
if (Result.isErr(result)) return null;
return NavigationStorage.parsePendingChatUnlock(result.data);
}
static async peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
const result = await SessionAsyncUtil.getJson(
StorageKeys.pendingChatUnlock,
PendingChatUnlockSchema,
);
if (Result.isErr(result)) return null;
const value = NavigationStorage.parsePendingChatUnlock(result.data);
if (!value) {
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
}
return value;
}
static async hasPendingChatUnlock(): Promise<boolean> {
return (await NavigationStorage.peekPendingChatUnlock()) !== null;
}
static async clearPendingChatUnlock(): Promise<void> {
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
}
static async savePendingChatImageReturn(input: {
messageId: string;
returnUrl: string;
}): Promise<void> {
const payload: PendingChatImageReturn = {
reason: "image_paywall",
messageId: input.messageId,
returnUrl: input.returnUrl,
createdAt: Date.now(),
};
await SessionAsyncUtil.setJson(
StorageKeys.pendingChatImageReturn,
payload,
PendingChatImageReturnSchema,
);
}
static async consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
const result = await SessionAsyncUtil.getJson(
StorageKeys.pendingChatImageReturn,
PendingChatImageReturnSchema,
);
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
if (Result.isErr(result)) return null;
return NavigationStorage.parsePendingChatImageReturn(result.data);
}
private static parsePendingChatUnlock(
value: PendingChatUnlock | null,
): PendingChatUnlock | null {
if (!value) return null;
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
return value;
}
private static parsePendingChatImageReturn(
value: PendingChatImageReturn | null,
): PendingChatImageReturn | null {
if (!value) return null;
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
return value;
}
}
@@ -1,54 +1,19 @@
"use client"; "use client";
import { z } from "zod"; import {
NavigationStorage,
type PendingChatImageReturn,
} from "@/data/storage/navigation";
import { StorageKeys } from "@/data/storage/storage_keys"; export type { PendingChatImageReturn };
import { Result, SessionAsyncUtil } from "@/utils";
const MAX_AGE_MS = 30 * 60 * 1000;
const PendingChatImageReturnSchema = z.object({
reason: z.literal("image_paywall"),
messageId: z.string().min(1),
returnUrl: z.string().min(1),
createdAt: z.number(),
});
export type PendingChatImageReturn = z.output<
typeof PendingChatImageReturnSchema
>;
export async function savePendingChatImageReturn(input: { export async function savePendingChatImageReturn(input: {
messageId: string; messageId: string;
returnUrl: string; returnUrl: string;
}): Promise<void> { }): Promise<void> {
const payload: PendingChatImageReturn = { await NavigationStorage.savePendingChatImageReturn(input);
reason: "image_paywall",
messageId: input.messageId,
returnUrl: input.returnUrl,
createdAt: Date.now(),
};
await SessionAsyncUtil.setJson(
StorageKeys.pendingChatImageReturn,
payload,
PendingChatImageReturnSchema,
);
} }
export async function consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> { export async function consumePendingChatImageReturn(): Promise<PendingChatImageReturn | null> {
const result = await SessionAsyncUtil.getJson( return NavigationStorage.consumePendingChatImageReturn();
StorageKeys.pendingChatImageReturn,
PendingChatImageReturnSchema,
);
await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn);
if (Result.isErr(result)) return null;
return parsePendingChatImageReturn(result.data);
}
function parsePendingChatImageReturn(
value: PendingChatImageReturn | null,
): PendingChatImageReturn | null {
if (!value) return null;
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
return value;
} }
+16 -65
View File
@@ -1,31 +1,17 @@
"use client"; "use client";
import { z } from "zod"; import {
NavigationStorage,
type PendingChatUnlock,
type PendingChatUnlockKind,
type PendingChatUnlockStage,
} from "@/data/storage/navigation";
import { StorageKeys } from "@/data/storage/storage_keys"; export type {
import { Result, SessionAsyncUtil } from "@/utils"; PendingChatUnlock,
PendingChatUnlockKind,
const MAX_AGE_MS = 30 * 60 * 1000; PendingChatUnlockStage,
};
export type PendingChatUnlockKind = "private" | "voice" | "image";
export type PendingChatUnlockStage = "auth" | "payment";
const PendingChatUnlockSchema = z.object({
reason: z.literal("single_message_unlock"),
messageId: z.string().min(1),
kind: z.enum(["private", "voice", "image"]),
returnUrl: z
.string()
.min(1)
.refine(
(value) => value.startsWith("/") && !value.startsWith("//"),
"returnUrl must be an internal route",
),
stage: z.enum(["auth", "payment"]),
createdAt: z.number(),
});
export type PendingChatUnlock = z.output<typeof PendingChatUnlockSchema>;
export async function savePendingChatUnlock(input: { export async function savePendingChatUnlock(input: {
messageId: string; messageId: string;
@@ -33,56 +19,21 @@ export async function savePendingChatUnlock(input: {
returnUrl: string; returnUrl: string;
stage: PendingChatUnlockStage; stage: PendingChatUnlockStage;
}): Promise<void> { }): Promise<void> {
const payload: PendingChatUnlock = { await NavigationStorage.savePendingChatUnlock(input);
reason: "single_message_unlock",
messageId: input.messageId,
kind: input.kind,
returnUrl: input.returnUrl,
stage: input.stage,
createdAt: Date.now(),
};
await SessionAsyncUtil.setJson(
StorageKeys.pendingChatUnlock,
payload,
PendingChatUnlockSchema,
);
} }
export async function consumePendingChatUnlock(): Promise<PendingChatUnlock | null> { export async function consumePendingChatUnlock(): Promise<PendingChatUnlock | null> {
const result = await SessionAsyncUtil.getJson( return NavigationStorage.consumePendingChatUnlock();
StorageKeys.pendingChatUnlock,
PendingChatUnlockSchema,
);
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
if (Result.isErr(result)) return null;
return parsePendingChatUnlock(result.data);
} }
export async function peekPendingChatUnlock(): Promise<PendingChatUnlock | null> { export async function peekPendingChatUnlock(): Promise<PendingChatUnlock | null> {
const result = await SessionAsyncUtil.getJson( return NavigationStorage.peekPendingChatUnlock();
StorageKeys.pendingChatUnlock,
PendingChatUnlockSchema,
);
if (Result.isErr(result)) return null;
const value = parsePendingChatUnlock(result.data);
if (!value) {
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock);
}
return value;
} }
export async function hasPendingChatUnlock(): Promise<boolean> { export async function hasPendingChatUnlock(): Promise<boolean> {
return (await peekPendingChatUnlock()) !== null; return NavigationStorage.hasPendingChatUnlock();
} }
export async function clearPendingChatUnlock(): Promise<void> { export async function clearPendingChatUnlock(): Promise<void> {
await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); await NavigationStorage.clearPendingChatUnlock();
}
function parsePendingChatUnlock(
value: PendingChatUnlock | null,
): PendingChatUnlock | null {
if (!value) return null;
if (Date.now() - value.createdAt > MAX_AGE_MS) return null;
return value;
} }
+2
View File
@@ -17,6 +17,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider"; import { ViewportCssVarsProvider } from "@/providers/viewport-css-vars-provider";
import { AppNavigationGuard } from "@/router/app-navigation-guard";
import { AuthProvider } from "@/stores/auth/auth-context"; import { AuthProvider } from "@/stores/auth/auth-context";
import { AuthStatusChecker } from "@/stores/auth/auth-status-checker"; import { AuthStatusChecker } from "@/stores/auth/auth-status-checker";
import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync"; import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync";
@@ -42,6 +43,7 @@ export function RootProviders({ children }: RootProvidersProps) {
<AuthStatusChecker /> <AuthStatusChecker />
{/* OAuthSessionSync 同样依赖 AuthProvider 上下文 */} {/* OAuthSessionSync 同样依赖 AuthProvider 上下文 */}
<OAuthSessionSync /> <OAuthSessionSync />
<AppNavigationGuard />
<UserProvider> <UserProvider>
<UserAuthSync /> <UserAuthSync />
<PaymentProvider> <PaymentProvider>
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
isAuthenticatedUser,
resolveAuthenticatedNavigation,
resolveRouteGuardRedirect,
} from "../navigation-resolver";
import { ROUTE_BUILDERS, ROUTES } from "../routes";
describe("navigation resolver", () => {
it("treats only real login providers as authenticated users", () => {
expect(isAuthenticatedUser("notLoggedIn")).toBe(false);
expect(isAuthenticatedUser("guest")).toBe(false);
expect(isAuthenticatedUser("email")).toBe(true);
expect(isAuthenticatedUser("facebook")).toBe(true);
});
it("wraps protected navigation with auth redirect for guests", () => {
expect(
resolveAuthenticatedNavigation({
loginStatus: "guest",
targetUrl: "/subscription?type=vip",
}),
).toBe(ROUTE_BUILDERS.authWithRedirect("/subscription?type=vip"));
});
it("keeps protected navigation targets for authenticated users", () => {
expect(
resolveAuthenticatedNavigation({
loginStatus: "google",
targetUrl: "/subscription?type=topup",
}),
).toBe("/subscription?type=topup");
});
it("redirects not logged in session routes to splash", () => {
expect(
resolveRouteGuardRedirect({
loginStatus: "notLoggedIn",
pathname: ROUTES.chat,
}),
).toBe(ROUTES.splash);
});
it("redirects non-real users on subscription routes to auth", () => {
expect(
resolveRouteGuardRedirect({
loginStatus: "guest",
pathname: ROUTES.subscription,
searchParams: "type=vip&returnTo=chat",
}),
).toBe(
ROUTE_BUILDERS.authWithRedirect(
"/subscription?type=vip&returnTo=chat",
),
);
});
it("allows public and authorized routes", () => {
expect(
resolveRouteGuardRedirect({
loginStatus: "guest",
pathname: ROUTES.coinsRules,
}),
).toBeNull();
expect(
resolveRouteGuardRedirect({
loginStatus: "email",
pathname: ROUTES.subscription,
searchParams: "type=vip",
}),
).toBeNull();
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { getRouteAccess, isInternalRoute } from "../route-meta";
import { ROUTES } from "../routes";
describe("route meta", () => {
it("classifies static routes by access level", () => {
expect(getRouteAccess(ROUTES.splash)).toBe("authOnly");
expect(getRouteAccess(ROUTES.auth)).toBe("authOnly");
expect(getRouteAccess(ROUTES.chat)).toBe("session");
expect(getRouteAccess(ROUTES.sidebar)).toBe("session");
expect(getRouteAccess(ROUTES.subscription)).toBe("realUser");
expect(getRouteAccess(ROUTES.coinsRules)).toBe("public");
});
it("classifies known dynamic chat routes", () => {
expect(getRouteAccess("/chat/image/msg_1")).toBe("session");
expect(getRouteAccess("/chat/deviceid/device_1")).toBe("public");
});
it("treats unknown routes as public by default", () => {
expect(getRouteAccess("/unknown")).toBe("public");
});
it("accepts only internal redirect targets", () => {
expect(isInternalRoute("/chat")).toBe(true);
expect(isInternalRoute("/subscription?type=vip")).toBe(true);
expect(isInternalRoute("https://example.com/chat")).toBe(false);
expect(isInternalRoute("//example.com/chat")).toBe(false);
expect(isInternalRoute(null)).toBe(false);
});
});
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuthState } from "@/stores/auth/auth-context";
import { resolveRouteGuardRedirect } from "./navigation-resolver";
export function AppNavigationGuard() {
const authState = useAuthState();
const pathname = usePathname();
const router = useRouter();
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
const redirectTo = resolveRouteGuardRedirect({
loginStatus: authState.loginStatus,
pathname,
searchParams: window.location.search.replace(/^\?/, ""),
});
if (redirectTo) router.replace(redirectTo);
}, [
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
pathname,
router,
]);
return null;
}
+41
View File
@@ -0,0 +1,41 @@
import type { LoginStatus } from "@/data/dto/auth";
import { getRouteAccess } from "./route-meta";
import { ROUTE_BUILDERS, ROUTES } from "./routes";
export function isAuthenticatedUser(loginStatus: LoginStatus): boolean {
return loginStatus !== "notLoggedIn" && loginStatus !== "guest";
}
export function resolveAuthenticatedNavigation(input: {
loginStatus: LoginStatus;
targetUrl: string;
}): string {
if (isAuthenticatedUser(input.loginStatus)) return input.targetUrl;
return ROUTE_BUILDERS.authWithRedirect(input.targetUrl);
}
export function resolveRouteGuardRedirect(input: {
loginStatus: LoginStatus;
pathname: string;
searchParams?: string;
}): string | null {
const access = getRouteAccess(input.pathname);
if (access === "session" && input.loginStatus === "notLoggedIn") {
return ROUTES.splash;
}
if (access === "realUser" && !isAuthenticatedUser(input.loginStatus)) {
return ROUTE_BUILDERS.authWithRedirect(
buildCurrentUrl(input.pathname, input.searchParams),
);
}
return null;
}
function buildCurrentUrl(pathname: string, searchParams?: string): string {
if (!searchParams) return pathname;
return `${pathname}?${searchParams}`;
}
+31
View File
@@ -0,0 +1,31 @@
import type { PayChannel } from "@/data/dto/payment";
import type {
PendingChatUnlockKind,
PendingChatUnlockStage,
} from "@/data/storage/navigation";
export type AppSubscriptionType = "vip" | "topup";
export type AppSubscriptionReturnTo = "chat" | null;
export interface OpenSubscriptionInput {
type: AppSubscriptionType;
payChannel?: PayChannel;
returnTo?: AppSubscriptionReturnTo;
replace?: boolean;
}
export interface StartMessageUnlockInput {
messageId: string;
kind: PendingChatUnlockKind;
returnUrl: string;
stage?: PendingChatUnlockStage;
onAuthenticated: () => void;
}
export interface OpenSubscriptionForPendingUnlockInput {
messageId: string;
kind: PendingChatUnlockKind;
returnUrl: string;
type: AppSubscriptionType;
payChannel?: PayChannel;
}
+23
View File
@@ -0,0 +1,23 @@
import { ROUTES } from "./routes";
export type RouteAccess = "public" | "session" | "realUser" | "authOnly";
const STATIC_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
[ROUTES.root]: "public",
[ROUTES.splash]: "authOnly",
[ROUTES.auth]: "authOnly",
[ROUTES.chat]: "session",
[ROUTES.sidebar]: "session",
[ROUTES.subscription]: "realUser",
[ROUTES.coinsRules]: "public",
};
export function getRouteAccess(pathname: string): RouteAccess {
if (pathname.startsWith("/chat/image/")) return "session";
if (pathname.startsWith("/chat/deviceid/")) return "public";
return STATIC_ROUTE_ACCESS[pathname] ?? "public";
}
export function isInternalRoute(value: string | null | undefined): boolean {
return Boolean(value && value.startsWith("/") && !value.startsWith("//"));
}
-20
View File
@@ -48,26 +48,6 @@ export const ROUTE_BUILDERS = {
`${ROUTES.auth}?redirect=${encodeURIComponent(redirectTo)}` as const, `${ROUTES.auth}?redirect=${encodeURIComponent(redirectTo)}` as const,
} as const; } as const;
/** 仅未登录态可见的路由(命中后已登录用户应跳走) */
export const AUTH_ONLY_ROUTES: readonly StaticRoute[] = [
ROUTES.splash,
ROUTES.auth,
] as const;
/** 仅登录态可见的路由(命中后未登录用户应跳走) */
export const PROTECTED_ROUTES: readonly StaticRoute[] = [
ROUTES.chat,
ROUTES.sidebar,
ROUTES.subscription,
] as const;
/** 公开路由(游客态也允许访问) */
export const PUBLIC_ROUTES: readonly StaticRoute[] = [
ROUTES.chat,
ROUTES.sidebar,
ROUTES.coinsRules,
] as const;
/** 所有静态路由,供 `proxy.ts` matcher 与未来 sitemap 使用 */ /** 所有静态路由,供 `proxy.ts` matcher 与未来 sitemap 使用 */
export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [ export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
ROUTES.splash, ROUTES.splash,
+140
View File
@@ -0,0 +1,140 @@
"use client";
import { useRouter } from "next/navigation";
import { NavigationStorage } from "@/data/storage/navigation";
import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel";
import { useAuthState } from "@/stores/auth/auth-context";
import { useUserState } from "@/stores/user/user-context";
import {
ROUTE_BUILDERS,
ROUTES,
type Route,
} from "./routes";
import {
isAuthenticatedUser as resolveIsAuthenticatedUser,
resolveAuthenticatedNavigation,
} from "./navigation-resolver";
import type {
OpenSubscriptionForPendingUnlockInput,
OpenSubscriptionInput,
StartMessageUnlockInput,
} from "./navigation-types";
export interface AppNavigator {
push: (href: string, options?: { scroll?: boolean }) => void;
replace: (href: string, options?: { scroll?: boolean }) => void;
openAuth: (redirectTo?: string) => void;
openSubscription: (input: OpenSubscriptionInput) => void;
startMessageUnlock: (input: StartMessageUnlockInput) => void;
openSubscriptionForPendingUnlock: (
input: OpenSubscriptionForPendingUnlockInput,
) => void;
getDefaultPayChannel: () => ReturnType<typeof getDefaultPayChannelForCountryCode>;
isAuthenticatedUser: boolean;
}
export function useAppNavigator(): AppNavigator {
const router = useRouter();
const authState = useAuthState();
const userState = useUserState();
const isAuthenticatedUser = resolveIsAuthenticatedUser(authState.loginStatus);
function push(href: string, options?: { scroll?: boolean }): void {
router.push(href, options);
}
function replace(href: string, options?: { scroll?: boolean }): void {
router.replace(href, options);
}
function openAuth(redirectTo: string = ROUTES.chat): void {
router.push(ROUTE_BUILDERS.authWithRedirect(redirectTo));
}
function getDefaultPayChannel() {
return getDefaultPayChannelForCountryCode(userState.currentUser?.countryCode);
}
function openSubscription({
type,
payChannel = getDefaultPayChannel(),
returnTo = null,
replace: shouldReplace = false,
}: OpenSubscriptionInput): void {
const target = ROUTE_BUILDERS.subscription(type, {
payChannel,
returnTo: returnTo ?? undefined,
});
const nextUrl = resolveAuthenticatedNavigation({
loginStatus: authState.loginStatus,
targetUrl: target,
});
if (shouldReplace) {
router.replace(nextUrl);
return;
}
router.push(nextUrl);
}
function startMessageUnlock({
messageId,
kind,
returnUrl,
stage = "auth",
onAuthenticated,
}: StartMessageUnlockInput): void {
if (isAuthenticatedUser) {
onAuthenticated();
return;
}
void (async () => {
await NavigationStorage.savePendingChatUnlock({
messageId,
kind,
returnUrl,
stage,
});
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
})();
}
function openSubscriptionForPendingUnlock({
messageId,
kind,
returnUrl,
type,
payChannel = getDefaultPayChannel(),
}: OpenSubscriptionForPendingUnlockInput): void {
void (async () => {
await NavigationStorage.savePendingChatUnlock({
messageId,
kind,
returnUrl,
stage: "payment",
});
openSubscription({
type,
payChannel,
returnTo: "chat",
});
})();
}
return {
push,
replace,
openAuth,
openSubscription,
startMessageUnlock,
openSubscriptionForPendingUnlock,
getDefaultPayChannel,
isAuthenticatedUser,
};
}
export type { Route };
-13
View File
@@ -8,22 +8,14 @@
* 不再承担全局状态同步职责。 * 不再承担全局状态同步职责。
*/ */
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { usePathname, useRouter } from "next/navigation";
import { AuthStorage } from "@/data/storage/auth"; import { AuthStorage } from "@/data/storage/auth";
import { useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch } from "@/stores/chat/chat-context"; import { useChatDispatch } from "@/stores/chat/chat-context";
import { PROTECTED_ROUTES, ROUTES, type StaticRoute } from "@/router/routes";
function isProtectedPath(pathname: string): boolean {
return PROTECTED_ROUTES.some((route: StaticRoute) => pathname === route);
}
export function ChatAuthSync() { export function ChatAuthSync() {
const authState = useAuthState(); const authState = useAuthState();
const chatDispatch = useChatDispatch(); const chatDispatch = useChatDispatch();
const pathname = usePathname();
const router = useRouter();
const prevSessionKeyRef = useRef<string | null>(null); const prevSessionKeyRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -35,9 +27,6 @@ export function ChatAuthSync() {
if (authState.loginStatus === "notLoggedIn") { if (authState.loginStatus === "notLoggedIn") {
chatDispatch({ type: "ChatLogout" }); chatDispatch({ type: "ChatLogout" });
if (isProtectedPath(pathname)) {
router.replace(ROUTES.splash);
}
return; return;
} }
@@ -65,8 +54,6 @@ export function ChatAuthSync() {
authState.isLoading, authState.isLoading,
authState.loginStatus, authState.loginStatus,
chatDispatch, chatDispatch,
pathname,
router,
]); ]);
return null; return null;