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
+1
View File
@@ -17,6 +17,7 @@ export * from "./chat/chat_storage";
export * from "./chat/local_chat_db";
export * from "./chat/local_chat_storage";
export * from "./chat/local_message";
export * from "./navigation/navigation_storage";
export * from "./payment/pending_payment_order_storage";
export * from "./user/iuser_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;
}
}