Merge branch 'dev' into test

This commit is contained in:
2026-06-18 14:31:18 +08:00
32 changed files with 366 additions and 233 deletions
@@ -19,6 +19,9 @@ import { SUBSCRIPTION_PLANS } from "@/data/constants/subscription-plans";
import type { SubscriptionPlanId } from "@/data/constants/subscription-plans";
import { getPlanPriceId } from "@/lib/stripe/stripe-products";
import { getStripeClient } from "@/lib/stripe/stripe-client";
import { Logger } from "@/utils";
const log = new Logger("AppApiPaymentCreateCheckoutSessionRoute");
interface CreateCheckoutRequestBody {
planId: SubscriptionPlanId;
@@ -86,7 +89,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ url: session.url });
} catch (e) {
const err = e as { message?: string; type?: string; code?: string };
console.error("[api/create-checkout-session] Stripe error", {
log.error("[api/create-checkout-session] Stripe error", {
message: err.message,
type: err.type,
code: err.code,
+4 -1
View File
@@ -14,6 +14,9 @@
import { NextRequest, NextResponse } from "next/server";
import { getStripeClient } from "@/lib/stripe/stripe-client";
import { Logger } from "@/utils";
const log = new Logger("AppApiPaymentCustomerPortalRoute");
interface CreatePortalRequestBody {
customerId: string | null;
@@ -56,7 +59,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ url: session.url });
} catch (e) {
const err = e as { message?: string; type?: string; code?: string };
console.error("[api/customer-portal] Stripe error", {
log.error("[api/customer-portal] Stripe error", {
customerId: body.customerId,
message: err.message,
type: err.type,
+9 -6
View File
@@ -27,11 +27,14 @@ import type Stripe from "stripe";
import { dispatchStripeEvent } from "@/lib/stripe/stripe-events";
import { getStripeClient } from "@/lib/stripe/stripe-client";
import { Logger } from "@/utils";
const log = new Logger("AppApiPaymentWebhookRoute");
export async function POST(req: NextRequest) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
console.error("[api/webhook] STRIPE_WEBHOOK_SECRET is not set");
log.error("[api/webhook] STRIPE_WEBHOOK_SECRET is not set");
return NextResponse.json(
{ error: "Webhook secret not configured" },
{ status: 500 },
@@ -42,7 +45,7 @@ export async function POST(req: NextRequest) {
const rawBody = await req.text();
const sigHeader = req.headers.get("stripe-signature");
if (!sigHeader) {
console.error("[api/webhook] Missing stripe-signature header");
log.error("[api/webhook] Missing stripe-signature header");
return NextResponse.json(
{ error: "Missing stripe-signature header" },
{ status: 400 },
@@ -56,7 +59,7 @@ export async function POST(req: NextRequest) {
event = stripe.webhooks.constructEvent(rawBody, sigHeader, webhookSecret);
} catch (e) {
const err = e as Error;
console.error("[api/webhook] Signature verification failed", {
log.error("[api/webhook] Signature verification failed", {
message: err.message,
});
return NextResponse.json(
@@ -66,7 +69,7 @@ export async function POST(req: NextRequest) {
}
// 分发到 handler
console.log("[api/webhook] Received event", {
log.debug("[api/webhook] Received event", {
eventId: event.id,
type: event.type,
created: new Date(event.created * 1000).toISOString(),
@@ -75,11 +78,11 @@ export async function POST(req: NextRequest) {
try {
const handled = await dispatchStripeEvent(event);
if (!handled) {
console.log(`[api/webhook] Ignored event type: ${event.type}`);
log.debug(`[api/webhook] Ignored event type: ${event.type}`);
}
} catch (e) {
const err = e as Error;
console.error("[api/webhook] Handler error", {
log.error("[api/webhook] Handler error", {
eventId: event.id,
type: event.type,
message: err.message,
+5 -2
View File
@@ -13,6 +13,9 @@ import { ROUTES } from "@/router/routes";
import { AuthBackground, AuthPanel } from "./components";
import styles from "./components/auth-screen.module.css";
import { Logger } from "@/utils";
const log = new Logger("AppAuthAuthScreen");
export function AuthScreen() {
const state = useAuthState();
@@ -23,7 +26,7 @@ export function AuthScreen() {
// 用 pendingRedirect flag(不是 useRef transition detection)——
// re-mount 时 flag 在 auth context 里持久,区分"刚按了"和"re-visit"准确无误。
useEffect(() => {
console.log("[auth-screen] useEffect (loginStatus + pendingRedirect)", {
log.debug("[auth-screen] useEffect (loginStatus + pendingRedirect)", {
pending: state.pendingRedirect,
loginStatus: state.loginStatus,
willRedirect:
@@ -31,7 +34,7 @@ export function AuthScreen() {
});
if (state.pendingRedirect && state.loginStatus !== "notLoggedIn") {
console.log(
log.debug(
"[auth-screen] useEffect → router.replace(/chat) [via pendingRedirect flag]",
);
authDispatch({ type: "AuthClearPendingRedirect" });
+6 -3
View File
@@ -14,6 +14,9 @@ import { AuthTextField } from "./auth-text-field";
import { AuthPrimaryButton } from "./auth-primary-button";
import { AuthErrorMessage } from "./auth-error-message";
import styles from "./email-form.module.css";
import { Logger } from "@/utils";
const log = new Logger("AppAuthComponentsEmailLoginForm");
export interface EmailLoginFormProps {
globalError?: string | null;
@@ -44,7 +47,7 @@ export function EmailLoginForm({
};
const submit = () => {
console.log("[email-login-form] submit ENTRY", {
log.debug("[email-login-form] submit ENTRY", {
emailLength: email.length,
emailPreview: email.slice(0, 8),
passwordLength: password.length,
@@ -53,7 +56,7 @@ export function EmailLoginForm({
const passwordErr = validatePassword(password);
if (emailErr || passwordErr) {
const firstErr = emailErr ?? passwordErr ?? null;
console.warn(
log.warn(
"[email-login-form] submit VALIDATION FAILED (silent return)",
{ emailErr, passwordErr, surfaced: firstErr },
);
@@ -61,7 +64,7 @@ export function EmailLoginForm({
return;
}
setValidationError(null);
console.log("[email-login-form] submit dispatching AuthEmailLoginSubmitted");
log.debug("[email-login-form] submit dispatching AuthEmailLoginSubmitted");
dispatch({ type: "AuthEmailLoginSubmitted", email, password });
};
@@ -25,6 +25,9 @@ import { AuthTextField } from "./auth-text-field";
import { AuthPrimaryButton } from "./auth-primary-button";
import { AuthErrorMessage } from "./auth-error-message";
import styles from "./email-form.module.css";
import { Logger } from "@/utils";
const log = new Logger("AppAuthComponentsEmailRegisterForm");
export interface EmailRegisterFormProps {
globalError?: string | null;
@@ -66,7 +69,7 @@ export function EmailRegisterForm({
};
const submit = () => {
console.log("[email-register-form] submit ENTRY", {
log.debug("[email-register-form] submit ENTRY", {
emailLength: email.length,
emailPreview: email.slice(0, 8),
passwordLength: password.length,
@@ -81,7 +84,7 @@ export function EmailRegisterForm({
// 按 username → email → password → confirm 顺序挑第一个错(与字段顺序一致)
const firstErr =
usernameErr ?? emailErr ?? passwordErr ?? confirmErr ?? null;
console.warn(
log.warn(
"[email-register-form] submit VALIDATION FAILED (silent return)",
{
emailErr,
@@ -95,7 +98,7 @@ export function EmailRegisterForm({
return;
}
setValidationError(null);
console.log(
log.debug(
"[email-register-form] submit dispatching AuthEmailRegisterSubmitted",
);
dispatch({
+26 -17
View File
@@ -1,28 +1,17 @@
"use client";
/**
* ChatScreen 编排层
*
* 子组件职责:
* - ChatHeader:顶部栏(游客 banner / 菜单按钮)
* - ChatArea:消息列表
* - ChatInputBar:输入栏(文字 + 发送 + More)
* - PwaInstallOverlayPWA 安装提示触发器
* - BrowserHintOverlay:浏览器提示
*
* 鉴权解耦(事件驱动):
* - chat 机器不感知鉴权 / 不管 WebSocket
* - auth → chat 的生命周期同步由根级 <ChatAuthSync /> 负责
* - ChatScreen 只派生 UI 展示所需的 isGuest
*/
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
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, UrlLauncherUtil } from "@/utils";
import { AppEnvUtil, BrowserDetector, todayString, UrlLauncherUtil } from "@/utils";
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
import { MobileShell } from "@/app/_components/core";
@@ -68,16 +57,36 @@ export function ChatScreen() {
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
if (authState.loginStatus !== "facebook") return;
const isDev = AppEnvUtil.isDevelopment();
const canTriggerPrompt =
isDev ||
(authState.loginStatus === "facebook" && BrowserDetector.isInAppBrowser());
if (!canTriggerPrompt) return;
let mounted = true;
let timer: number | undefined;
const init = async () => {
if (externalBrowserPromptShownRef.current) return;
if (!BrowserDetector.isInAppBrowser()) return;
const today = todayString();
const canShowResult = await AppStorage.canShowExternalBrowserDialog(today);
if (!mounted || !canShowResult.success || !canShowResult.data) return;
externalBrowserPromptShownRef.current = true;
const timer = window.setTimeout(() => {
timer = window.setTimeout(() => {
if (!mounted) return;
setShowExternalBrowserDialog(true);
void AppStorage.recordExternalBrowserDialogShown(today);
}, 3000);
};
return () => window.clearTimeout(timer);
void init();
return () => {
mounted = false;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [
authState.hasInitialized,
authState.isLoading,
+4 -1
View File
@@ -27,6 +27,9 @@ import { useChatDispatch } from "@/stores/chat/chat-context";
import { ChatInputTextField } from "./chat-input-text-field";
import { ChatSendButton } from "./chat-send-button";
import styles from "./chat-input-bar.module.css";
import { Logger } from "@/utils";
const log = new Logger("AppChatComponentsChatInputBar");
export interface ChatInputBarProps {
disabled?: boolean;
@@ -42,7 +45,7 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
const handleSend = () => {
if (!hasContent) return;
console.log("[chat-input-bar] handleSend", {
log.debug("[chat-input-bar] handleSend", {
contentLength: input.length,
contentPreview: input.slice(0, 50),
hasContent,
@@ -5,18 +5,17 @@
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md, 16px);
background: rgba(0, 0, 0, 0.5);
}
.dialog {
width: 100%;
max-width: 360px;
padding: 24px 18px 18px;
width: calc(100% - 40px);
max-width: var(--pwa-install-dialog-max-width, 360px);
padding: 24px 16px 16px;
text-align: center;
background: var(--color-page-background, #ffffff);
border-radius: 32px;
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.22);
border-radius: 40px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
.title {
@@ -28,35 +27,40 @@
}
.content {
margin: 0 12px 22px;
font-size: var(--font-size-md, 15px);
margin: 0 24px var(--spacing-xxl, 24px);
font-size: var(--font-size-lg, 16px);
line-height: 1.5;
color: var(--color-text-secondary, #555555);
text-align: left;
color: #393939;
white-space: pre-line;
}
.actions {
display: flex;
gap: var(--spacing-md, 12px);
width: 100%;
}
.button {
flex: 1 1 auto;
height: 44px;
height: var(--pwa-button-height, 44px);
border: 0;
border-radius: 999px;
font-size: var(--font-size-md, 15px);
font-weight: 700;
border-radius: var(--radius-bottom-sheet, 28px);
font-size: var(--font-size-lg, 16px);
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.secondary {
color: var(--color-text-secondary, #555555);
background: #efefef;
color: var(--color-page-background, #ffffff);
background: var(--color-text-secondary, #9e9e9e);
}
.primary {
color: #ffffff;
color: var(--color-page-background, #ffffff);
background: linear-gradient(
var(--color-button-gradient-start, #ff67e0),
var(--color-button-gradient-end, #ff52a2)
@@ -27,8 +27,7 @@ export function ExternalBrowserDialog({
Open in browser
</h2>
<p className={styles.content}>
Sync your Facebook profile to an external browser{"\n"}
for the best chat experience.
Sync your Facebook profile to an external browser for the best chat experience.
</p>
<div className={styles.actions}>
<button
@@ -16,6 +16,9 @@ import { useRouter } from "next/navigation";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { UserStorage } from "@/data/storage/user/user_storage";
import { ROUTES } from "@/router/routes";
import { Logger } from "@/utils";
const log = new Logger("AppChatDeviceidDeviceIdDeepLinkPersist");
interface DeepLinkPersistProps {
deviceId: string;
@@ -43,7 +46,7 @@ export default function DeepLinkPersist({
}
} catch (err) {
// 写不进 localStorage 时(例如隐私模式)也不阻塞跳转;记录到 console 即可。
console.warn("[DeepLinkPersist] failed to persist", err);
log.warn("[DeepLinkPersist] failed to persist", err);
} finally {
setDone(true);
router.replace(ROUTES.chat);
+4 -1
View File
@@ -14,6 +14,9 @@ import { useEffect } from "react";
import Link from "next/link";
import { ROUTES } from "@/router/routes";
import { Logger } from "@/utils";
const log = new Logger("AppError");
interface ErrorProps {
error: Error & { digest?: string };
@@ -23,7 +26,7 @@ interface ErrorProps {
export default function GlobalError({ error, unstable_retry }: ErrorProps) {
useEffect(() => {
// 把错误送到 console;后续接 Sentry/Datadog 时改这里。
console.error("[app/error.tsx]", error);
log.error("[app/error.tsx]", error);
}, [error]);
return (
+5 -3
View File
@@ -7,7 +7,7 @@ import { MobileShell } from "@/app/_components/core";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { ROUTES } from "@/router/routes";
import { pwaUtil } from "@/utils";
import { pwaUtil, Logger } from "@/utils";
import {
SplashBackground,
@@ -17,6 +17,8 @@ import {
} from "./components";
import styles from "./components/splash-screen.module.css";
const log = new Logger("AppSplashSplashScreen");
// 尽量早地缓存 beforeinstallprompt,避免用户停留 splash 时浏览器事件已触发并丢失。
if (typeof window !== "undefined") {
pwaUtil.prepareInstallPrompt();
@@ -67,7 +69,7 @@ export function SplashScreen() {
// ─────────────────────────────────────────────────────────────
useEffect(() => {
// 调试日志(保留,不删 —— 用于排查"re-mount 误跳 /chat"问题)
console.log("[splash] useEffect ② (loginStatus + pendingRedirect)", {
log.debug("[splash] useEffect ② (loginStatus + pendingRedirect)", {
pending: state.pendingRedirect,
loginStatus: state.loginStatus,
willRedirect:
@@ -75,7 +77,7 @@ export function SplashScreen() {
});
if (state.pendingRedirect && state.loginStatus !== "notLoggedIn") {
console.log(
log.debug(
"[splash] useEffect ② → router.replace(/chat) [via pendingRedirect flag]",
);
authDispatch({ type: "AuthClearPendingRedirect" });
@@ -18,6 +18,9 @@ import { ROUTES } from "@/router/routes";
import { SubscriptionCtaButton } from "./subscription-cta-button";
import styles from "./subscription-cta-button.module.css";
import { Logger } from "@/utils";
const log = new Logger("AppSubscriptionComponentsSubscriptionCheckoutButton");
export interface SubscriptionCheckoutButtonProps {
planId: SubscriptionPlanId;
@@ -64,7 +67,7 @@ export function SubscriptionCheckoutButton({
window.location.href = url;
} catch (e) {
const err = e as Error;
console.error("[subscription-checkout-button] error", err);
log.error("[subscription-checkout-button] error", err);
setError(err.message);
setIsLoading(false);
}
@@ -15,6 +15,9 @@ import { ROUTES } from "@/router/routes";
import { SubscriptionCtaButton } from "./subscription-cta-button";
import styles from "./subscription-cta-button.module.css";
import { Logger } from "@/utils";
const log = new Logger("AppSubscriptionComponentsSubscriptionManageButton");
export interface SubscriptionManageButtonProps {
/** 从 User.stripeCustomerId 取(webhook 设置) */
@@ -63,7 +66,7 @@ export function SubscriptionManageButton({ customerId }: SubscriptionManageButto
window.location.href = url;
} catch (e) {
const err = e as Error;
console.error("[subscription-manage-button] error", err);
log.error("[subscription-manage-button] error", err);
setError(err.message);
setIsLoading(false);
}
+13 -37
View File
@@ -6,11 +6,9 @@
*/
import type { FetchHook } from "ofetch";
const TAG = "[API]";
import { AppEnvUtil, Logger } from "@/utils";
function isDev(): boolean {
return process.env.NODE_ENV !== "production";
}
const log = new Logger("ApiLoggingInterceptor");
function getRequestUrl(request: Request | string): string {
return typeof request === "string" ? request : request.url;
@@ -23,54 +21,32 @@ function getRequestMethod(
return request instanceof Request ? request.method : options.method ?? "GET";
}
function formatBody(body: unknown): string {
if (body === undefined || body === null) return "";
try {
if (body instanceof FormData) {
const entries: string[] = [];
body.forEach((value, key) => {
if (value instanceof File) {
entries.push(`${key}=File(${value.name})`);
} else {
entries.push(`${key}=${String(value)}`);
}
});
return `FormData{${entries.join(", ")}}`;
}
return JSON.stringify(body);
} catch {
return String(body);
}
}
export const onRequestLogging: FetchHook = async (ctx) => {
if (!isDev()) return;
if (AppEnvUtil.isProduction()) return;
const method = getRequestMethod(ctx.request, ctx.options);
const url = getRequestUrl(ctx.request);
console.log(
`${TAG} ${method} ${url}`,
ctx.options.body !== undefined ? formatBody(ctx.options.body) : ""
);
const body = Logger.formatValue(ctx.options.body);
log.debug(`${method} ${url}${body ? `\nrequest body:\n${body}` : ""}`);
};
export const onResponseLogging: FetchHook = async (ctx) => {
if (!isDev() || !ctx.response) return;
if (AppEnvUtil.isProduction() || !ctx.response) return;
const method = getRequestMethod(ctx.request, ctx.options);
const url = getRequestUrl(ctx.request);
console.log(
`${TAG}${ctx.response.status} ${method} ${url}`,
formatBody(ctx.response._data)
const body = Logger.formatValue(ctx.response._data);
log.debug(
`${ctx.response.status} ${method} ${url}${body ? `\nresponse body:\n${body}` : ""}`,
);
};
export const onErrorLogging: FetchHook = async (ctx) => {
if (!isDev()) return;
if (AppEnvUtil.isProduction()) return;
const status = ctx.response?.status ?? "N/A";
const method = getRequestMethod(ctx.request, ctx.options);
const url = getRequestUrl(ctx.request);
console.error(
`${TAG}${status} ${method} ${url}`,
ctx.error?.message ?? "Unknown error"
const body = Logger.formatValue(ctx.response?._data);
log.error(
`${status} ${method} ${url}\nerror: ${ctx.error?.message ?? "Unknown error"}${body ? `\nresponse body:\n${body}` : ""}`,
);
};
+15 -12
View File
@@ -6,9 +6,12 @@
* 原始 Dart: lib/core/net/interceptor/token_interceptor.dart + token_paths.dart
*/
import type { FetchHook } from "ofetch";
import { Logger } from "@/utils";
import { ApiPath } from "../../../data/services/api/api_path";
import { AuthStorage } from "../../../data/storage/auth/auth_storage";
const log = new Logger("TokenInterceptor");
/**
* 不需要 token 的路径
*/
@@ -47,41 +50,41 @@ async function getAuthToken(): Promise<string | null> {
const storage = AuthStorage.getInstance();
const loginR = await storage.getLoginToken();
if (loginR.success && loginR.data && loginR.data.length > 0) {
console.log("[token-interceptor] getAuthToken: using LOGIN token", {
log.debug({
length: loginR.data.length,
prefix: loginR.data.slice(0, 8),
});
}, "getAuthToken: using LOGIN token");
return loginR.data;
}
console.log("[token-interceptor] getAuthToken: no login token", {
log.debug({
success: loginR.success,
hasData: loginR.success ? !!loginR.data : false,
});
}, "getAuthToken: no login token");
const guestR = await storage.getGuestToken();
if (guestR.success && guestR.data && guestR.data.length > 0) {
console.log(
"[token-interceptor] getAuthToken: using GUEST token (login priority, no login available)",
log.debug(
{
length: guestR.data.length,
prefix: guestR.data.slice(0, 8),
},
"getAuthToken: using GUEST token (login priority, no login available)",
);
return guestR.data;
}
console.warn("[token-interceptor] getAuthToken: NO token available (will skip Authorization)", {
log.warn({
loginAttempted: true,
guestAttempted: true,
loginSuccess: loginR.success,
guestSuccess: guestR.success,
});
}, "getAuthToken: NO token available (will skip Authorization)");
return null;
}
export const onRequestToken: FetchHook = async (ctx) => {
const path = getRequestPath(ctx.request);
if (!needsToken(path)) {
console.log("[token-interceptor] onRequestToken SKIP (no-token path)", { path });
log.debug({ path }, "onRequestToken SKIP (no-token path)");
return;
}
@@ -91,13 +94,13 @@ export const onRequestToken: FetchHook = async (ctx) => {
const headers = new Headers(ctx.options.headers);
headers.set("Authorization", `Bearer ${token}`);
ctx.options.headers = headers;
console.log("[token-interceptor] onRequestToken SET Authorization header (via ctx.options.headers)", {
log.debug({
path,
length: token.length,
prefix: token.slice(0, 8),
});
}, "onRequestToken SET Authorization header (via ctx.options.headers)");
} else {
console.warn("[token-interceptor] onRequestToken NO token to set", { path });
log.warn({ path }, "onRequestToken NO token to set");
}
};
+5 -1
View File
@@ -8,8 +8,12 @@
* - 消费者可声明每条消息是否成功消费;不成功则重新入队
* - 队列可在任意时刻清空(dispose)
*/
import { Logger } from "@/utils";
export type MessageConsumer = (content: string) => boolean | Promise<boolean>;
const log = new Logger("MessageQueue");
export class MessageQueue {
private queue: string[] = [];
private timer: ReturnType<typeof setTimeout> | null = null;
@@ -38,7 +42,7 @@ export class MessageQueue {
try {
ok = await this.consumer(next);
} catch (e) {
console.error("[MessageQueue] consumer error", e);
log.error({ err: e }, "consumer error");
ok = false;
}
if (!ok) {
+15 -13
View File
@@ -8,7 +8,7 @@
* - 登录成功后调 `_saveLoginData` 持久化 token + User
* - 登出先调 API,再强制清本地态(无论 API 成败)
* - `getCurrentUser` 成功后尽力写本地 User 缓存(不因缓存失败影响主流程)
* - 写 storage 失败用 `console.warn` 记录,不让本地缓存失败影响主流程
* - 写 storage 失败用 `Logger.warn` 记录,不让本地缓存失败影响主流程
*
* 原始 Dart: lib/data/repositories/auth_repository_impl.dart
*/
@@ -28,11 +28,13 @@ import {
SendCodeRequest,
} from "@/data/dto/auth";
import { User } from "@/data/dto/user";
import { Result } from "@/utils";
import { Result, Logger } from "@/utils";
import type { IAuthRepository } from "@/data/repositories/interfaces";
import { AuthStorage, type IAuthStorage } from "@/data/storage/auth";
import { UserStorage, type IUserStorage } from "@/data/storage/user";
const log = new Logger("DataRepositoriesAuthRepository");
/** 硬编码平台名,对齐 Dart `PlatformUtil.platformName.toLowerCase()`Web 平台)。 */
const WEB_PLATFORM = "web";
@@ -106,7 +108,7 @@ export class AuthRepository implements IAuthRepository {
await this.api.logout();
} catch (e) {
// API 失败也要继续清本地态
console.warn("[AuthRepository] logout API failed, clearing local anyway", e);
log.warn("[AuthRepository] logout API failed, clearing local anyway", e);
}
try {
await this.storage.clearAuthData();
@@ -123,7 +125,7 @@ export class AuthRepository implements IAuthRepository {
* 成功后写 guest token + deviceId + userId。
*/
async guestLogin(deviceId: string): Promise<Result<GuestLoginResponse>> {
console.log("[AuthRepository.guestLogin] API call START", {
log.debug("[AuthRepository.guestLogin] API call START", {
deviceIdLength: deviceId.length,
deviceIdPrefix: deviceId.slice(0, 8),
});
@@ -131,7 +133,7 @@ export class AuthRepository implements IAuthRepository {
const response = await this.api.guestLogin(
GuestLoginRequest.from({ deviceId }),
);
console.log("[AuthRepository.guestLogin] API call SUCCESS (Zod parsed)", {
log.debug("[AuthRepository.guestLogin] API call SUCCESS (Zod parsed)", {
hasToken: !!response.token,
hasUser: !!response.user,
userLastMessageAt: response.user?.lastMessageAt,
@@ -143,7 +145,7 @@ export class AuthRepository implements IAuthRepository {
}
return response;
}).catch((e) => {
console.error("[AuthRepository.guestLogin] API call FAILED", {
log.error("[AuthRepository.guestLogin] API call FAILED", {
errorName: e?.name,
errorMessage: e?.message,
});
@@ -238,14 +240,14 @@ export class AuthRepository implements IAuthRepository {
/**
* 获取当前登录用户。成功后尽力写本地 User 缓存(offline hydrate),
* 缓存失败用 `console.warn` 记录,不影响主流程。
* 缓存失败用 `Logger.warn` 记录,不影响主流程。
*/
async getCurrentUser(): Promise<Result<User>> {
return Result.wrap(async () => {
const user = await this.api.getCurrentUser();
const writeResult = await this.userStorage.setUser(user.toJson());
if (!writeResult.success) {
console.warn(
log.warn(
"[AuthRepository] failed to cache current user",
writeResult.error,
);
@@ -272,27 +274,27 @@ export class AuthRepository implements IAuthRepository {
/**
* 持久化登录态:login token → refresh token → User 对象 → userId。
* 全部 best-effort,错误用 `console.warn` 记录但不让登录「失败」——
* 全部 best-effort,错误用 `Logger.warn` 记录但不让登录「失败」——
* 调用方已经从 API 拿到了 LoginResponse,缓存只是离线加速。
*/
private async _saveLoginData(data: LoginResponse): Promise<void> {
const r1 = await this.storage.setLoginToken(data.token);
if (!r1.success) {
console.warn("[AuthRepository] setLoginToken failed", r1.error);
log.warn("[AuthRepository] setLoginToken failed", r1.error);
}
if (data.refreshToken) {
const r2 = await this.storage.setRefreshToken(data.refreshToken);
if (!r2.success) {
console.warn("[AuthRepository] setRefreshToken failed", r2.error);
log.warn("[AuthRepository] setRefreshToken failed", r2.error);
}
}
const r3 = await this.userStorage.setUser(data.user.toJson());
if (!r3.success) {
console.warn("[AuthRepository] setUser failed", r3.error);
log.warn("[AuthRepository] setUser failed", r3.error);
}
const r4 = await this.userStorage.setUserId(data.user.id);
if (!r4.success) {
console.warn("[AuthRepository] setUserId failed", r4.error);
log.warn("[AuthRepository] setUserId failed", r4.error);
}
}
}
+22 -17
View File
@@ -9,20 +9,25 @@ import { z } from "zod";
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import { ApiEnvelope, unwrap } from "./response_helper";
import { AppleLoginRequest } from "@/data/dto/auth/apple_login_request";
import { FacebookLoginRequest } from "@/data/dto/auth/facebook_login_request";
import { FbIdLoginRequest } from "@/data/dto/auth/fb_id_login_request";
import { GoogleLoginRequest } from "@/data/dto/auth/google_login_request";
import { GuestLoginRequest } from "@/data/dto/auth/guest_login_request";
import { GuestLoginResponse } from "@/data/dto/auth/guest_login_response";
import { LoginRequest } from "@/data/dto/auth/login_request";
import { LoginResponse } from "@/data/dto/auth/login_response";
import { RefreshTokenRequest } from "@/data/dto/auth/refresh_token_request";
import { RefreshTokenResponse } from "@/data/dto/auth/refresh_token_response";
import { RegisterRequest } from "@/data/dto/auth/register_request";
import { SendCodeRequest } from "@/data/dto/auth/send_code_request";
import { User } from "@/data/dto/user/user";
import {
AppleLoginRequest,
FacebookLoginRequest,
FbIdLoginRequest,
GoogleLoginRequest,
GuestLoginRequest,
GuestLoginResponse,
LoginRequest,
LoginResponse,
RefreshTokenRequest,
RefreshTokenResponse,
RegisterRequest,
SendCodeRequest,
} from "@/data/dto/auth";
import { User } from "@/data/dto/user";
import type { UserData } from "@/data/schemas/user/user";
import { Logger } from "@/utils";
const log = new Logger("DataServicesApiAuthApi");
export class AuthApi {
/**
@@ -123,10 +128,10 @@ export class AuthApi {
method: "POST",
body: body.toJson(),
});
console.log("[AuthApi.guestLogin] raw envelope from backend", env);
log.debug("[AuthApi.guestLogin] raw envelope from backend", env);
const unwrapped = unwrap(env) as Record<string, unknown>;
const userObj = unwrapped.user as { lastMessageAt?: unknown } | undefined;
console.log("[AuthApi.guestLogin] unwrapped data (pre-parse)", {
log.debug("[AuthApi.guestLogin] unwrapped data (pre-parse)", {
hasUser: "user" in unwrapped,
userKeys: userObj && typeof userObj === "object" ? Object.keys(userObj) : null,
lastMessageAt: userObj?.lastMessageAt,
@@ -136,13 +141,13 @@ export class AuthApi {
return GuestLoginResponse.fromJson(unwrapped);
} catch (e) {
if (e instanceof z.ZodError) {
console.error("[AuthApi.guestLogin] ZodError parsing response", {
log.error("[AuthApi.guestLogin] ZodError parsing response", {
issueCount: e.issues.length,
firstIssue: e.issues[0],
allPaths: e.issues.map((i) => i.path),
});
} else {
console.error("[AuthApi.guestLogin] unexpected error", e);
log.error("[AuthApi.guestLogin] unexpected error", e);
}
throw e;
}
+10 -8
View File
@@ -7,14 +7,16 @@
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import { ApiEnvelope, unwrap } from "./response_helper";
import { ChatHistoryResponse } from "@/data/dto/chat/chat_history_response";
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
import { ChatSyncData } from "@/data/dto/chat/chat_sync_data";
import { ChatSyncRequest } from "@/data/dto/chat/chat_sync_request";
import { ImageUploadResponse } from "@/data/dto/chat/image_upload_response";
import { SendMessageRequest } from "@/data/dto/chat/send_message_request";
import { SttData } from "@/data/dto/chat/stt_data";
import { SyncMessage } from "@/data/dto/chat/sync_message";
import {
ChatHistoryResponse,
ChatSendResponse,
ChatSyncData,
ChatSyncRequest,
ImageUploadResponse,
SendMessageRequest,
SttData,
SyncMessage,
} from "@/data/dto/chat";
export class ChatApi {
/**
+1 -2
View File
@@ -7,8 +7,7 @@
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import { ApiEnvelope, unwrapOptional } from "./response_helper";
import { AppEvent } from "@/data/dto/metrics/app_event";
import { PwaEvent } from "@/data/dto/metrics/pwa_event";
import { AppEvent, PwaEvent } from "@/data/dto/metrics";
export class MetricsApi {
/**
+7 -5
View File
@@ -7,11 +7,13 @@
import { httpClient } from "./http_client";
import { ApiPath } from "./api_path";
import { ApiEnvelope, unwrap } from "./response_helper";
import { CreditsData } from "@/data/dto/user/credits_data";
import { CreditsHistoryData } from "@/data/dto/user/credits_history_data";
import { UpdateProfileRequest } from "@/data/dto/user/update_profile_request";
import { User } from "@/data/dto/user/user";
import { UserStatsResponse } from "@/data/dto/user/user_stats_response";
import {
CreditsData,
CreditsHistoryData,
UpdateProfileRequest,
User,
UserStatsResponse,
} from "@/data/dto/user";
import type { UserData } from "@/data/schemas/user/user";
export class UserApi {
+20
View File
@@ -30,6 +30,26 @@ export class AppStorage {
return SpAsyncUtil.setString(StorageKeys.lastPwaDialogShown, todayString);
}
// ---- external browser dialog ----
static async canShowExternalBrowserDialog(
todayString: string,
): Promise<ResultT<boolean>> {
return AppStorage.canReportKey(
StorageKeys.lastExternalBrowserDialogShown,
todayString,
);
}
static recordExternalBrowserDialogShown(
todayString: string,
): Promise<ResultT<void>> {
return SpAsyncUtil.setString(
StorageKeys.lastExternalBrowserDialogShown,
todayString,
);
}
// ---- PWA event reporting ----
static canReportPwaEvent(todayString: string): Promise<ResultT<boolean>> {
+1
View File
@@ -26,6 +26,7 @@ export const StorageKeys = {
// pwa / app info
lastPwaDialogShown: "last_pwa_dialog_shown",
lastExternalBrowserDialogShown: "last_external_browser_dialog_shown",
lastPwaEventReported: "last_pwa_event_reported",
lastAppInfoReported: "last_app_info_reported",
} as const;
+8 -5
View File
@@ -15,6 +15,9 @@
*/
import type Stripe from "stripe";
import { getStripeClient } from "./stripe-client";
import { Logger } from "@/utils";
const log = new Logger("LibStripeStripeEvents");
/**
* 4 个事件的主分发器
@@ -65,7 +68,7 @@ async function handleCheckoutCompleted(
? session.subscription
: session.subscription?.id;
console.log("[stripe-events] checkout.session.completed", {
log.debug("[stripe-events] checkout.session.completed", {
eventId: event.id,
customerId,
subscriptionId,
@@ -99,7 +102,7 @@ async function handleSubscriptionDeleted(
? subscription.customer
: subscription.customer.id;
console.log("[stripe-events] customer.subscription.deleted", {
log.debug("[stripe-events] customer.subscription.deleted", {
eventId: event.id,
customerId,
subscriptionId: subscription.id,
@@ -130,7 +133,7 @@ async function handleSubscriptionUpdated(
? subscription.customer
: subscription.customer.id;
console.log(`[stripe-events] customer.subscription.${event.type}`, {
log.debug(`[stripe-events] customer.subscription.${event.type}`, {
eventId: event.id,
customerId,
subscriptionId: subscription.id,
@@ -164,14 +167,14 @@ async function handleInvoicePaymentSucceeded(
// 只关心周期续费(不对首次购买重复补充)
if (invoice.billing_reason !== "subscription_cycle") {
console.log(
log.debug(
`[stripe-events] invoice.payment_succeeded (skipped, billing_reason=${invoice.billing_reason})`,
{ eventId: event.id, invoiceId: invoice.id },
);
return;
}
console.log("[stripe-events] invoice.payment_succeeded (subscription_cycle)", {
log.debug("[stripe-events] invoice.payment_succeeded (subscription_cycle)", {
eventId: event.id,
invoiceId: invoice.id,
customerId,
+14 -14
View File
@@ -20,17 +20,17 @@ const authRepo: IAuthRepository = authRepository;
export const emailLoginActor = fromPromise<LoginStatus, { email: string; password: string }>(
async ({ input }) => {
console.log("[auth-machine] emailLoginActor ENTRY", {
log.debug("[auth-machine] emailLoginActor ENTRY", {
emailLength: input.email.length,
emailPreview: input.email.slice(0, 8),
passwordLength: input.password.length,
});
const guestId = await readGuestId();
console.log("[auth-machine] emailLoginActor calling authRepo.emailLogin", {
log.debug("[auth-machine] emailLoginActor calling authRepo.emailLogin", {
hasGuestId: !!guestId,
});
const result = await authRepo.emailLogin({ ...input, guestId });
console.log("[auth-machine] emailLoginActor authRepo.emailLogin DONE", {
log.debug("[auth-machine] emailLoginActor authRepo.emailLogin DONE", {
success: result.success,
hasData: result.success ? !!result.data : null,
errorName: result.success ? null : result.error?.name,
@@ -45,7 +45,7 @@ export const emailRegisterThenLoginActor = fromPromise<
LoginStatus,
{ email: string; password: string; username: string; confirmPassword: string }
>(async ({ input }) => {
console.log("[auth-machine] emailRegisterThenLoginActor ENTRY", {
log.debug("[auth-machine] emailRegisterThenLoginActor ENTRY", {
emailLength: input.email.length,
usernameLength: input.username.length,
passwordLength: input.password.length,
@@ -53,9 +53,9 @@ export const emailRegisterThenLoginActor = fromPromise<
});
const guestId = await readGuestId();
console.log("[auth-machine] emailRegisterThenLoginActor calling authRepo.register");
log.debug("[auth-machine] emailRegisterThenLoginActor calling authRepo.register");
const registerResult = await authRepo.register({ ...input, guestId });
console.log("[auth-machine] emailRegisterThenLoginActor authRepo.register DONE", {
log.debug("[auth-machine] emailRegisterThenLoginActor authRepo.register DONE", {
success: registerResult.success,
errorName: registerResult.success ? null : registerResult.error?.name,
errorMessage: registerResult.success ? null : registerResult.error?.message,
@@ -63,7 +63,7 @@ export const emailRegisterThenLoginActor = fromPromise<
if (Result.isErr(registerResult)) throw registerResult.error;
// 注册后自动登录(对齐 Dart 行为)
console.log(
log.debug(
"[auth-machine] emailRegisterThenLoginActor calling authRepo.emailLogin (post-register)",
);
const loginResult = await authRepo.emailLogin({
@@ -71,7 +71,7 @@ export const emailRegisterThenLoginActor = fromPromise<
password: input.password,
guestId,
});
console.log(
log.debug(
"[auth-machine] emailRegisterThenLoginActor authRepo.emailLogin DONE",
{
success: loginResult.success,
@@ -201,30 +201,30 @@ async function persistFacebookProfile(accessToken: string): Promise<void> {
// 受保护 API 401 会由 HTTP 层清掉 token 回到 splash,无需在此处主动校验。
export const guestLoginActor = fromPromise<LoginStatus, void>(async () => {
console.log("[auth-machine] guestLoginActor ENTRY");
log.debug("[auth-machine] guestLoginActor ENTRY");
// 1. 先查本地 guestToken —— 已有就直接进 idle(带 pendingRedirect
const hasTokenR = await AuthStorage.getInstance().hasGuestToken();
console.log("[auth-machine] guestLoginActor hasGuestToken", {
log.debug("[auth-machine] guestLoginActor hasGuestToken", {
success: hasTokenR.success,
hasData: hasTokenR.success ? !!hasTokenR.data : null,
});
if (hasTokenR.success && hasTokenR.data) {
console.log("[auth-machine] guestLoginActor SHORT-CIRCUIT (has local guestToken) → return guest");
log.debug("[auth-machine] guestLoginActor SHORT-CIRCUIT (has local guestToken) → return guest");
return "guest" as LoginStatus;
}
// Result.err(_) 时不抛 —— 落到 API 路径让真正的错误信号出现
// 2. 没有(或读不出)才走完整流程:拿 deviceId → 调后端
const deviceId = await deviceIdentifier.getDeviceId();
console.log("[auth-machine] guestLoginActor deviceId", {
log.debug("[auth-machine] guestLoginActor deviceId", {
length: deviceId.length,
prefix: deviceId.slice(0, 8),
});
console.log("[auth-machine] guestLoginActor calling authRepository.guestLogin");
log.debug("[auth-machine] guestLoginActor calling authRepository.guestLogin");
const result = await authRepository.guestLogin(deviceId);
console.log("[auth-machine] guestLoginActor authRepository.guestLogin DONE", {
log.debug("[auth-machine] guestLoginActor authRepository.guestLogin DONE", {
success: result.success,
hasData: result.success ? !!result.data : null,
errorName: result.success ? null : result.error?.name,
+4 -1
View File
@@ -11,12 +11,15 @@
import { useEffect } from "react";
import { useAuthDispatch } from "./auth-context";
import { Logger } from "@/utils";
const log = new Logger("StoresAuthAuthStatusChecker");
export function AuthStatusChecker() {
const dispatch = useAuthDispatch();
useEffect(() => {
console.log("[auth-init] mount → dispatch AuthInit");
log.debug("[auth-init] mount → dispatch AuthInit");
dispatch({ type: "AuthInit" });
}, [dispatch]);
+36 -34
View File
@@ -5,7 +5,7 @@
import { fromPromise, fromCallback } from "xstate";
import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket";
import { Result } from "@/utils";
import { Result, Logger } from "@/utils";
import {
PAGE_SIZE,
@@ -17,6 +17,8 @@ import {
} from "./chat-machine.helpers";
import type { ChatEvent } from "./chat-events";
const log = new Logger("StoresChatChatMachineActors");
// ============================================================
// 共享 WS 引用(chatWebSocketActor ↔ sendMessageWsActor
// ============================================================
@@ -51,9 +53,9 @@ export const loadQuotaActor = fromPromise<{
remaining: number;
total: number;
}>(async () => {
console.log("[chat-machine] loadQuotaActor ENTRY");
log.debug("[chat-machine] loadQuotaActor ENTRY");
const result = await readGuestQuota();
console.log("[chat-machine] loadQuotaActor DONE", result);
log.debug("[chat-machine] loadQuotaActor DONE", result);
return result;
});
@@ -74,9 +76,9 @@ export const loadHistoryActor = fromPromise<{
localCount: number;
networkCount: number;
}>(async () => {
console.log("[chat-machine] loadHistoryActor ENTRY");
log.debug("[chat-machine] loadHistoryActor ENTRY");
const result = await readAndSyncHistory();
console.log("[chat-machine] loadHistoryActor DONE", {
log.debug("[chat-machine] loadHistoryActor DONE", {
finalCount: result.messages.length,
localCount: result.localCount,
networkCount: result.networkCount,
@@ -99,25 +101,25 @@ export const sendMessageHttpActor = fromPromise<
{ reply: UiMessage },
{ content: string }
>(async ({ input, self }) => {
console.log("[chat-machine] sendMessageHttpActor ENTRY", {
log.debug("[chat-machine] sendMessageHttpActor ENTRY", {
contentLength: input.content.length,
contentPreview: input.content.slice(0, 50),
selfId: self.id,
selfPath: "<actor path>",
});
console.log("[chat-machine] sendMessageHttpActor calling chatRepo.sendMessage");
log.debug("[chat-machine] sendMessageHttpActor calling chatRepo.sendMessage");
const result = await chatRepo.sendMessage(input.content);
console.log("[chat-machine] sendMessageHttpActor chatRepo.sendMessage DONE", {
log.debug("[chat-machine] sendMessageHttpActor chatRepo.sendMessage DONE", {
success: result.success,
error: result.success ? null : Result.isErr(result) ? result.error : null,
});
if (Result.isErr(result)) {
console.error("[chat-machine] sendMessageHttpActor result isErr, throwing");
log.error("[chat-machine] sendMessageHttpActor result isErr, throwing");
throw result.error;
}
// 一次发送 = 一次返回 —— 直接转 reply → UiMessage
console.log("[chat-machine] sendMessageHttpActor converting reply to UiMessage", {
log.debug("[chat-machine] sendMessageHttpActor converting reply to UiMessage", {
replyLength: result.data.reply.length,
replyPreview: result.data.reply.slice(0, 50),
messageId: result.data.messageId,
@@ -125,7 +127,7 @@ export const sendMessageHttpActor = fromPromise<
});
const reply = sendResponseToUiMessage(result.data);
console.log("[chat-machine] sendMessageHttpActor done", {
log.debug("[chat-machine] sendMessageHttpActor done", {
replyContentLength: reply.content.length,
});
return { reply };
@@ -136,37 +138,37 @@ export const loadMoreHistoryActor = fromPromise<
{ messages: UiMessage[]; hasMore: boolean; newOffset: number },
{ offset: number }
>(async ({ input, self }) => {
console.log("[chat-machine] loadMoreHistoryActor ENTRY", {
log.debug("[chat-machine] loadMoreHistoryActor ENTRY", {
inputOffset: input.offset,
pageSize: PAGE_SIZE,
selfPath: "<actor path>",
});
console.log("[chat-machine] loadMoreHistoryActor calling chatRepo.getHistory", {
log.debug("[chat-machine] loadMoreHistoryActor calling chatRepo.getHistory", {
pageSize: PAGE_SIZE,
offset: input.offset,
});
const result = await chatRepo.getHistory(PAGE_SIZE, input.offset);
console.log("[chat-machine] loadMoreHistoryActor chatRepo.getHistory DONE", {
log.debug("[chat-machine] loadMoreHistoryActor chatRepo.getHistory DONE", {
success: result.success,
error: result.success ? null : Result.isErr(result) ? result.error : null,
responseType: result.success ? typeof result.data : "err",
});
if (Result.isErr(result)) {
console.error("[chat-machine] loadMoreHistoryActor result isErr, throwing", {
log.error("[chat-machine] loadMoreHistoryActor result isErr, throwing", {
error: result.error,
});
throw result.error;
}
console.log("[chat-machine] loadMoreHistoryActor result data", {
log.debug("[chat-machine] loadMoreHistoryActor result data", {
rawMessagesCount: result.data.messages.length,
});
const page = localMessagesToUi(result.data.messages);
console.log("[chat-machine] loadMoreHistoryActor AFTER UiMessage mapping", {
log.debug("[chat-machine] loadMoreHistoryActor AFTER UiMessage mapping", {
uiMessagesCount: page.length,
});
const hasMore = page.length >= PAGE_SIZE;
const newOffset = input.offset + page.length;
console.log("[chat-machine] loadMoreHistoryActor result", {
log.debug("[chat-machine] loadMoreHistoryActor result", {
pageSize: page.length,
hasMore,
newOffset,
@@ -194,33 +196,33 @@ export const loadMoreHistoryActor = fromPromise<
*/
export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
({ sendBack, input }) => {
console.log("[chat-machine] chatWebSocketActor ENTRY", {
log.debug("[chat-machine] chatWebSocketActor ENTRY", {
hasToken: !!input.token,
tokenLength: input.token?.length ?? 0,
tokenPrefix: input.token?.slice(0, 10) ?? "EMPTY",
selfPath: "<actor path>",
});
console.log("[chat-machine] chatWebSocketActor creating ChatWebSocket instance");
log.debug("[chat-machine] chatWebSocketActor creating ChatWebSocket instance");
const ws = createChatWebSocket(input.token);
console.log("[chat-machine] chatWebSocketActor createChatWebSocket DONE", {
log.debug("[chat-machine] chatWebSocketActor createChatWebSocket DONE", {
wsType: ws.constructor.name,
});
// 写入 module-level 共享引用 —— sendMessageWsActor 借此调 ws.sendMessage()
activeChatWebSocket = ws;
console.log("[chat-machine] chatWebSocketActor activeChatWebSocket SET", {
log.debug("[chat-machine] chatWebSocketActor activeChatWebSocket SET", {
isConnected: ws.isConnected,
});
ws.onConnected = (userId) => {
console.log("[chat-machine] WS onConnected", {
log.debug("[chat-machine] WS onConnected", {
userId,
userIdPrefix: userId?.slice(0, 8) ?? "EMPTY",
});
sendBack({ type: "ChatWebSocketConnected", userId });
};
ws.onSentence = (p) => {
console.log("[chat-machine] WS onSentence", {
log.debug("[chat-machine] WS onSentence", {
index: p.index,
total: p.total,
done: p.done,
@@ -237,23 +239,23 @@ export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
});
};
ws.onError = (msg) => {
console.error("[chat-machine] WS onError", {
log.error("[chat-machine] WS onError", {
errorMessage: msg,
errorLength: msg?.length ?? 0,
});
sendBack({ type: "ChatWebSocketError", errorMessage: msg });
};
console.log("[chat-machine] chatWebSocketActor calling ws.connect()");
log.debug("[chat-machine] chatWebSocketActor calling ws.connect()");
ws.connect();
console.log("[chat-machine] chatWebSocketActor ws.connect() called");
log.debug("[chat-machine] chatWebSocketActor ws.connect() called");
return () => {
console.log(
log.debug(
"[chat-machine] chatWebSocketActor CLEANUP (parent state exit / logout / cross-transition / unmount)",
);
// 清空 module-level 引用 —— 严格用 `=== ws` 判等,避免覆盖更新的实例
if (activeChatWebSocket === ws) {
activeChatWebSocket = null;
console.log("[chat-machine] chatWebSocketActor activeChatWebSocket CLEARED");
log.debug("[chat-machine] chatWebSocketActor activeChatWebSocket CLEARED");
}
ws.disconnect();
};
@@ -281,26 +283,26 @@ export const chatWebSocketActor = fromCallback<ChatEvent, { token: string }>(
*/
export const sendMessageWsActor = fromPromise<void, { content: string }>(
async ({ input }) => {
console.log("[chat-machine] sendMessageWsActor ENTRY", {
log.debug("[chat-machine] sendMessageWsActor ENTRY", {
contentLength: input.content.length,
contentPreview: input.content.slice(0, 50),
});
const ws = getActiveChatWebSocket();
if (!ws) {
console.error("[chat-machine] sendMessageWsActor no activeChatWebSocket");
log.error("[chat-machine] sendMessageWsActor no activeChatWebSocket");
throw new Error("[chat-machine] sendMessageWs: no active WebSocket (actor not running)");
}
console.log("[chat-machine] sendMessageWsActor calling ws.sendMessage", {
log.debug("[chat-machine] sendMessageWsActor calling ws.sendMessage", {
isConnected: ws.isConnected,
});
const ok = ws.sendMessage(input.content);
if (!ok) {
console.error(
log.error(
"[chat-machine] sendMessageWsActor ws.sendMessage returned false (not OPEN)",
);
throw new Error("[chat-machine] sendMessageWs: WebSocket not OPEN");
}
console.log(
log.debug(
"[chat-machine] sendMessageWsActor ws.sendMessage OK, awaiting AI sentence stream",
);
// resolve 不返值 —— AI reply 由 chatWebSocketActor 的 ChatAISentenceReceived 流回
+9 -7
View File
@@ -25,7 +25,9 @@ import { chatRepository } from "@/data/repositories/chat_repository";
import type { IChatRepository } from "@/data/repositories/interfaces";
import { ChatStorage } from "@/data/storage/chat/chat_storage";
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
import { formatDate, Result } from "@/utils";
import { formatDate, Result, Logger } from "@/utils";
const log = new Logger("StoresChatChatMachineHelpers");
// ============================================================
// Constants
@@ -161,7 +163,7 @@ export async function readAndSyncHistory(): Promise<{
Result.isOk(localResult) && localResult.data
? localMessagesToUi(localResult.data)
: [];
console.log("[chat-machine] loadHistory LOCAL DONE", {
log.debug("[chat-machine] loadHistory LOCAL DONE", {
count: localMessages.length,
});
@@ -169,7 +171,7 @@ export async function readAndSyncHistory(): Promise<{
const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0);
if (!Result.isOk(networkResult)) {
// network 失败 —— 返空 messages(不让 UI 卡住)
console.error(
log.error(
"[chat-machine] loadHistory NETWORK FAILED",
networkResult.success ? null : (networkResult as { error: unknown }).error,
);
@@ -178,7 +180,7 @@ export async function readAndSyncHistory(): Promise<{
const fallbackMessages =
localMessages.length === 0 ? [greetingMessage] : localMessages;
if (fallbackMessages[0] === greetingMessage) {
console.log(
log.debug(
"[chat-machine] loadHistory EMPTY → prepend greeting (network-failed path)",
);
}
@@ -192,14 +194,14 @@ export async function readAndSyncHistory(): Promise<{
};
}
const networkUi = localMessagesToUi(networkResult.data.messages);
console.log("[chat-machine] loadHistory NETWORK DONE", {
log.debug("[chat-machine] loadHistory NETWORK DONE", {
count: networkUi.length,
});
// 3. 用 network 覆盖 local
const saveResult = await chatRepo.saveMessagesToLocal(networkResult.data.messages);
const localOverwritten = Result.isOk(saveResult);
console.log("[chat-machine] loadHistory SAVE TO LOCAL DONE", {
log.debug("[chat-machine] loadHistory SAVE TO LOCAL DONE", {
localOverwritten,
});
@@ -207,7 +209,7 @@ export async function readAndSyncHistory(): Promise<{
const finalMessages =
networkUi.length === 0 ? [greetingMessage] : networkUi;
if (finalMessages[0] === greetingMessage) {
console.log("[chat-machine] loadHistory EMPTY → prepend greeting");
log.debug("[chat-machine] loadHistory EMPTY → prepend greeting");
}
return {
+6 -4
View File
@@ -33,7 +33,7 @@
import { setup, assign } from "xstate";
import { ChatStorage } from "@/data/storage/chat/chat_storage";
import { formatDate, todayString } from "@/utils";
import { formatDate, todayString, Logger } from "@/utils";
import { ChatState, initialState } from "./chat-state";
@@ -47,6 +47,8 @@ import {
chatWebSocketActor,
} from "./chat-machine.actors";
const log = new Logger("StoresChatChatMachine");
/** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */
export const GuestChatQuota = {
warningThreshold: 5,
@@ -93,7 +95,7 @@ export const chatMachine = setup({
ChatStorage.getInstance().setGuestTotalQuota(newTotal);
console.log("[chat-machine] appendUserMessage", {
log.debug("[chat-machine] appendUserMessage", {
contentLength: event.content.length,
contentPreview: event.content.slice(0, 50),
quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)",
@@ -134,7 +136,7 @@ export const chatMachine = setup({
void ChatStorage.getInstance().setGuestTotalQuota(newTotal);
console.log("[chat-machine] appendUserImage", {
log.debug("[chat-machine] appendUserImage", {
oldMessagesCount: context.messages.length,
wsConnected: context.wsConnected,
quotaMode: context.wsConnected ? "ws (no decrement)" : "http (decrement)",
@@ -177,7 +179,7 @@ export const chatMachine = setup({
};
}
}
console.log("[chat-machine] appendOrUpdateAISentence", {
log.debug("[chat-machine] appendOrUpdateAISentence", {
index: event.index,
total: event.total,
done: event.done,
+70 -10
View File
@@ -2,6 +2,9 @@ import pino, { type Logger as PinoLogger, type LoggerOptions } from "pino";
import { AppEnvUtil } from "./app-env";
type LogArgs = unknown[];
type PinoLogArgs = Parameters<PinoLogger["debug"]>;
/**
* 全局 pino root logger(单例,进程级复用)。
*
@@ -45,20 +48,77 @@ export class Logger {
this.logger = rootLogger.child({ component });
}
debug(...args: Parameters<PinoLogger["debug"]>): void {
this.logger.debug(...args);
debug(...args: LogArgs): void {
this.logger.debug(...Logger.normalizeLogArgs(args));
}
info(...args: Parameters<PinoLogger["info"]>): void {
this.logger.info(...args);
info(...args: LogArgs): void {
this.logger.info(...Logger.normalizeLogArgs(args));
}
warn(...args: Parameters<PinoLogger["warn"]>): void {
this.logger.warn(...args);
warn(...args: LogArgs): void {
this.logger.warn(...Logger.normalizeLogArgs(args));
}
error(...args: Parameters<PinoLogger["error"]>): void {
this.logger.error(...args);
error(...args: LogArgs): void {
this.logger.error(...Logger.normalizeLogArgs(args));
}
fatal(...args: Parameters<PinoLogger["fatal"]>): void {
this.logger.fatal(...args);
fatal(...args: LogArgs): void {
this.logger.fatal(...Logger.normalizeLogArgs(args));
}
/**
* 把日志中的任意数据转成人眼友好的多行文本。
* - object / array: pretty JSON
* - JSON string: parse 后 pretty JSON
* - FormData / File: 使用轻量描述,避免直接输出二进制
*/
static formatValue(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof FormData !== "undefined" && value instanceof FormData) {
const entries: string[] = [];
value.forEach((entryValue, key) => {
if (typeof File !== "undefined" && entryValue instanceof File) {
entries.push(`${key}=File(${entryValue.name})`);
} else {
entries.push(`${key}=${String(entryValue)}`);
}
});
return `FormData{\n ${entries.join(",\n ")}\n}`;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length === 0) return "";
try {
return JSON.stringify(JSON.parse(trimmed), null, 2);
} catch {
return value;
}
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
private static normalizeLogArgs(args: LogArgs): PinoLogArgs {
const [first, second, ...rest] = args;
if (typeof first !== "string" || second === undefined) return args as PinoLogArgs;
if (second instanceof Error) {
return [{ err: second }, first, ...rest] as PinoLogArgs;
}
if (
typeof second === "object" &&
second !== null &&
!Array.isArray(second)
) {
return [second, first, ...rest] as PinoLogArgs;
}
return args as PinoLogArgs;
}
/** 透传 raw pino logger(高级用法:自定义 binding / child / serializers */