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
@@ -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,
} 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 使用 */
export const ALL_STATIC_ROUTES: readonly StaticRoute[] = [
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 };