diff --git a/.env.sample b/.env.sample index bb942fd9..f63cdb0d 100644 --- a/.env.sample +++ b/.env.sample @@ -1,13 +1,21 @@ # 本文件提供运行本项目所需的环境变量示例。 # 提交代码时请提交本文件而不是实际的 .env,真实密钥请存放在个人或 CI 配置中。 -# NextAuth 基本配置 -AUTH_URL=http://localhost:3000 #https://involutionhell.com -# 生成 32 字节以上的随机字符串,可用 openssl: `openssl rand -base64 32` -AUTH_SECRET= -# GitHub OAuth App 的 Client ID / Secret,可在 GitHub Developer settings 中创建 +# 后端地址(Spring Boot,认证已迁移到后端,NextAuth 已移除) +# 服务端 API Route 调用后端时使用(不暴露给浏览器) +BACKEND_URL=http://localhost:8080 +# 客户端组件(如登录按钮)直接跳转后端时使用 +NEXT_PUBLIC_BACKEND_URL=http://localhost:8080 + +# GitHub OAuth 配置 (由后端处理,此处仅作为示例配置参考) AUTH_GITHUB_ID= AUTH_GITHUB_SECRET= +AUTH_SECRET= +AUTH_GITHUB_ID_DEV= +AUTH_GITHUB_SECRET_DEV= +AUTH_TRUST_HOST=true +AUTH_URL=http://localhost:3000 + # 可选:用于访问 GitHub API(例如同步仓库) GITHUB_TOKEN= @@ -16,6 +24,10 @@ INDEXNOW_API_TOKEN= #Open的Key INDEXNOW_KEY=5b6ef14a7406496b8a2ce8ab17820b34 NEXT_PUBLIC_SITE_URL=https://involutionhell.com +# 内部识别/认证 Key +INTERN_KEY= +# Neon 项目 ID +NEON_PROJECT_ID= # Neon 提供的 Postgres 连接。 # 登录 Neon 控制台 → 数据库 → "Connect" → "Connection details",可以复制以下所有变量。 # 推荐连接字符串 @@ -44,6 +56,8 @@ POSTGRES_PRISMA_URL= NEXT_PUBLIC_STACK_PROJECT_ID= NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY= STACK_SECRET_SERVER_KEY= +# Vercel OIDC Token +VERCEL_OIDC_TOKEN= # R2的存储桶,用于提供图片自动上传服务 R2_ACCOUNT_ID=? diff --git a/.github/workflows/content-check.yml b/.github/workflows/content-check.yml index f0286bad..a74f30af 100644 --- a/.github/workflows/content-check.yml +++ b/.github/workflows/content-check.yml @@ -39,7 +39,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: "pnpm" # Verify pnpm version matches package.json packageManager field @@ -47,7 +47,7 @@ jobs: run: node scripts/check-pnpm-version.mjs - run: pnpm install --frozen-lockfile - + # Verify lockfile wasn't modified by install - name: Check lockfile consistency run: | @@ -66,7 +66,7 @@ jobs: exit 1 fi echo "✅ Lockfile is consistent" - + - name: Run tests run: pnpm test # Non-blocking image migration + lint (visibility only) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ae218a4c..13809157 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -28,7 +28,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm # Verify pnpm version matches package.json packageManager field diff --git a/.github/workflows/sync-uuid.yml b/.github/workflows/sync-uuid.yml index 960b04c4..343b9555 100644 --- a/.github/workflows/sync-uuid.yml +++ b/.github/workflows/sync-uuid.yml @@ -44,7 +44,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: "pnpm" # 顺便启用 pnpm 缓存,加速 # Verify pnpm version matches package.json packageManager field diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22 diff --git a/app/api/analytics/route.ts b/app/api/analytics/route.ts index 3cc785bb..3bc2070b 100644 --- a/app/api/analytics/route.ts +++ b/app/api/analytics/route.ts @@ -1,8 +1,9 @@ import { prisma } from "@/lib/db"; +import { resolveUserId } from "@/lib/server-auth"; export async function POST(req: Request) { try { - const { eventType, eventData, userId } = await req.json(); + const { eventType, eventData } = await req.json(); if (!eventType) { return Response.json( @@ -11,11 +12,15 @@ export async function POST(req: Request) { ); } + // 服务端验证身份,不信任客户端传入的 userId + const userId = await resolveUserId(req); + await prisma.analyticsEvent.create({ data: { eventType, eventData: eventData ?? {}, - userId: userId ? parseInt(String(userId)) : null, + // userId 对应 user_accounts.id(BigInt);匿名访问为 null + ...(userId != null && { userId }), }, }); diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 7c62e2db..00000000 --- a/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,2 +0,0 @@ -import { handlers } from "@/auth"; -export const { GET, POST } = handlers; diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 734cc9e1..0864dbe1 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -26,8 +26,65 @@ interface ChatRequest { chatId?: string; } +import { resolveUserId } from "@/lib/server-auth"; + export async function POST(req: Request) { + // 1. 克隆请求,因为如果代理失败,后面的代码还需要读取 req.json() + const proxyReq = req.clone(); + + // ====== 尝试优雅降级代理到 Java 后端 ====== + try { + const backendUrl = process.env.BACKEND_URL; + if (!backendUrl) throw new Error("BACKEND_URL is not configured."); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时 + + // 原封不动把前端的参数丢给 Java + let proxyRes: Response; + try { + proxyRes = await fetch(`${backendUrl}/openai/responses/stream`, { + method: "POST", + headers: { + "Content-Type": "application/json", + // 浏览器侧用 x-satoken 传递 token,转发给后端时改回后端期望的 satoken + ...(req.headers.get("x-satoken") + ? { satoken: req.headers.get("x-satoken")! } + : {}), + }, + body: await proxyReq.text(), + signal: controller.signal, + }); + } finally { + // 无论成功还是抛出(网络错误/超时中断),都清除定时器 + clearTimeout(timeoutId); + } + + // 如果 Java 后端返回成功,则直接把它的流传回浏览器,提前结束 + if (proxyRes.ok && proxyRes.body) { + console.log( + "[Chat Fallback Proxy] 🚀 Java Backend responded successfully. Piping stream...", + ); + return new Response(proxyRes.body, { + headers: { + "Content-Type": + proxyRes.headers.get("Content-Type") || "text/plain; charset=utf-8", + }, + }); + } else { + console.warn( + `[Chat Fallback Proxy] ⚠️ Java Backend returned status: ${proxyRes.status}, fallback to local Next.js inference.`, + ); + } + } catch (error) { + console.warn( + `[Chat Fallback Proxy] ❌ Java Backend unavailable or timed out, fallback to local Next.js inference. Error:`, + error, + ); + } + // ====== 代理失败,继续往下走,启用备选方案(本地直连 AI)====== + try { + // 先把 body 消费掉,再并行验证用户身份 const { messages, system, @@ -37,6 +94,9 @@ export async function POST(req: Request) { chatId, }: ChatRequest = await req.json(); + // 并行解析用户身份(不阻塞主流程,失败静默降级为匿名) + const userIdPromise = resolveUserId(req); + // 对指定Provider验证key是否存在 if (requiresApiKey(provider) && (!apiKey || apiKey.trim() === "")) { return Response.json( @@ -90,22 +150,30 @@ export async function POST(req: Request) { // 根据Provider获取 AI 模型实例 const model = getModel(provider, apiKey); - // 确保有 chatId (如果前端没传,就生成一个临时的,虽然这会导致每次请求都是新会话) - // 理想情况是前端应该维护 chatId const effectiveChatId = chatId || crypto.randomUUID(); // 生成流式响应 const result = streamText({ model: model, system: systemMessage, - messages: convertToModelMessages(messages || []), + messages: await convertToModelMessages(messages || []), onFinish: async ({ text }) => { try { - // 1. 保存/更新会话 + // 等待用户身份解析(与流式传输并行运行,此时大概率已完成) + const userId = await userIdPromise; + + // 1. 保存/更新会话,绑定用户 ID + // update 也写入 userId:覆盖此前匿名创建的记录(用户登录后继续同一 chatId) await prisma.chat.upsert({ where: { id: effectiveChatId }, - update: { updatedAt: new Date() }, - create: { id: effectiveChatId }, + update: { + updatedAt: new Date(), + ...(userId != null && { userId }), + }, + create: { + id: effectiveChatId, + ...(userId != null && { userId }), + }, }); // 2. 保存用户消息 (取最后一条) diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index f0e74c52..397df25b 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -1,5 +1,5 @@ -import { auth } from "@/auth"; import { NextRequest, NextResponse } from "next/server"; +import type { UserView } from "@/lib/use-auth"; import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { sanitizeDocumentSlug, sanitizeResourceKey } from "@/lib/sanitizer"; @@ -36,12 +36,22 @@ interface UploadRequest { */ export async function POST(request: NextRequest) { try { - // 验证用户身份 - const session = await auth(); + // 从请求头读取 x-satoken(客户端侧统一约定),转发后端时改为 satoken + const token = request.headers.get("x-satoken"); + if (!token) { + return NextResponse.json({ error: "未授权访问" }, { status: 401 }); + } - if (!session?.user?.id) { + // 调用后端 /auth/me 验证 token(服务端直连后端,走 BACKEND_URL 环境变量) + const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8080"; + const meRes = await fetch(`${backendUrl}/auth/me`, { + headers: { satoken: token }, + }); + if (!meRes.ok) { return NextResponse.json({ error: "未授权访问" }, { status: 401 }); } + const meBody = (await meRes.json()) as { data: UserView }; + const currentUser = meBody.data; // 验证环境变量 if ( @@ -81,7 +91,7 @@ export async function POST(request: NextRequest) { // 生成唯一的对象键 // 格式:users/{userId}/{article-slug}/{timestamp}-{filename} const timestamp = Date.now(); - const userId = session.user.id; + const userId = String(currentUser.id); const sanitizedSlug = sanitizeDocumentSlug(articleSlug); const sanitizedFilename = sanitizeResourceKey(filename); const key = `users/${userId}/${sanitizedSlug}/${timestamp}-${sanitizedFilename}`; diff --git a/app/components/AuthNav.tsx b/app/components/AuthNav.tsx index e258007f..5bf3f779 100644 --- a/app/components/AuthNav.tsx +++ b/app/components/AuthNav.tsx @@ -1,21 +1,27 @@ "use client"; -import { useSession } from "next-auth/react"; +import { useAuth } from "@/lib/use-auth"; import { SignInButton } from "@/app/components/SignInButton"; import { UserMenu } from "@/app/components/UserMenu"; export function AuthNav() { - const { data: session, status } = useSession(); + const { user, status, logout } = useAuth(); if (status === "loading") { return
; } - const user = session?.user; - const provider = - session && "provider" in session - ? (session.provider as string | undefined) - : undefined; - - return user ? : ; + return user ? ( + + ) : ( + + ); } diff --git a/app/components/DocsAssistant.tsx b/app/components/DocsAssistant.tsx index 9a9bf6d0..49d07a92 100644 --- a/app/components/DocsAssistant.tsx +++ b/app/components/DocsAssistant.tsx @@ -2,7 +2,10 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; -import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { + AssistantRuntimeProvider, + type AssistantRuntime, +} from "@assistant-ui/react"; import { useAISDKRuntime } from "@assistant-ui/react-ai-sdk"; import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; @@ -58,10 +61,21 @@ function DocsAssistantInner({ pageContext }: DocsAssistantProps) { ? geminiApiKey : ""; - // 生成唯一的会话 ID - const [chatId] = useState( - () => `chat-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); + // 按 slug 从 localStorage 读取或生成持久化会话 ID + // 同一文档页关闭后再打开依然复用同一 chatId,保持会话连续性 + const [chatId] = useState(() => { + // SSR 阶段无法访问 localStorage,生成占位 ID(不影响 DOM,不产生 hydration 警告) + if (typeof window === "undefined") { + return `chat-ssr-${Math.random().toString(36).slice(2)}`; + } + const key = `chat_id:${pageContext.slug ?? "__global__"}`; + const stored = localStorage.getItem(key); + if (stored) return stored; + const newId = `chat-${Date.now()}-${Math.random().toString(36).slice(2)}`; + localStorage.setItem(key, newId); + return newId; + }); + const chatRuntimeId = useMemo( () => `${chatId}:${provider}:${hashTransportConfig(apiKey)}`, [chatId, provider, apiKey], @@ -77,6 +91,16 @@ function DocsAssistantInner({ pageContext }: DocsAssistantProps) { apiKey, chatId, }, + // 在每次请求时动态读取 satoken,避免用户登录前创建 transport 导致 token 为空 + fetch: async (url, init) => { + const token = + typeof window !== "undefined" + ? localStorage.getItem("satoken") + : null; + const headers = new Headers(init?.headers); + if (token) headers.set("x-satoken", token); + return fetch(url, { ...init, headers }); + }, }), [pageContext, provider, apiKey, chatId], ); @@ -97,12 +121,22 @@ function DocsAssistantInner({ pageContext }: DocsAssistantProps) { const fetchedWelcomeRef = useRef(false); // 埋点上报函数 + // x-satoken 由服务端验证身份,不在 body 里传 userId(服务端自己解析) const logAnalyticsEvent = useCallback( async (eventType: string, eventData?: Record) => { try { + const token = + typeof window !== "undefined" + ? localStorage.getItem("satoken") + : null; + const headers: Record = { + "Content-Type": "application/json", + }; + if (token) headers["x-satoken"] = token; + await fetch("/api/analytics", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify({ eventType, eventData: { @@ -312,7 +346,8 @@ function DocsAssistantInner({ pageContext }: DocsAssistantProps) { clearChatError(); }, [clearChatError]); - const runtime = useAISDKRuntime(chat); + // @assistant-ui/react-ai-sdk 与 @assistant-ui/react 内部 AssistantRuntime 类型因版本分叉而不兼容,运行时行为一致 + const runtime = useAISDKRuntime(chat) as unknown as AssistantRuntime; return ( diff --git a/app/components/Header.tsx b/app/components/Header.tsx index 8bb7cd8d..695895f6 100644 --- a/app/components/Header.tsx +++ b/app/components/Header.tsx @@ -2,19 +2,12 @@ import { ThemeToggle } from "./ThemeToggle"; import { Button } from "@/components/ui/button"; import { MessageCircle } from "lucide-react"; import { Github as GithubIcon } from "./icons/Github"; -import { SignInButton } from "./SignInButton"; -import { auth } from "@/auth"; -import { UserMenu } from "./UserMenu"; +import { AuthNav } from "./AuthNav"; import { BrandMark } from "./BrandMark"; import { LiveEditionLabel } from "./LiveEditionLabel"; -export async function Header() { - const session = await auth(); - const user = session?.user; - const provider = - session && "provider" in session - ? (session.provider as string | undefined) - : undefined; +// 改为普通服务端组件,登录状态由客户端 AuthNav 处理 +export function Header() { const now = new Date(); const editionTimestampMs = now.getTime(); const formattedDate = now.toLocaleDateString("en-US", { @@ -22,7 +15,6 @@ export async function Header() { day: "numeric", year: "numeric", }); - console.log("session", session); return (
@@ -103,11 +95,8 @@ export async function Header() { - {user ? ( - - ) : ( - - )} + {/* AuthNav 是客户端组件,内部通过 useAuth() 自动处理登录/未登录状态 */} +
diff --git a/app/components/SignInButton.tsx b/app/components/SignInButton.tsx index a1afef51..cdeb19de 100644 --- a/app/components/SignInButton.tsx +++ b/app/components/SignInButton.tsx @@ -1,24 +1,24 @@ "use client"; -import { signIn } from "next-auth/react"; import { Button } from "@/app/components/ui/button"; interface SignInButtonProps { className?: string; - redirectTo?: string; - /** - * @deprecated Use `redirectTo` (NextAuth v5). Kept for backward compatibility. - */ - callbackUrl?: string; } -export function SignInButton({ className, redirectTo }: SignInButtonProps) { - const targetUrl = redirectTo ?? "/"; +export function SignInButton({ className }: SignInButtonProps) { + // 直接跳转到后端 GitHub OAuth 授权入口(NEXT_PUBLIC_BACKEND_URL) + // 后端完成授权后带着 token 重定向回前端首页 /?token=xxx + const handleSignIn = () => { + const backendUrl = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:8080"; + window.location.href = `${backendUrl}/oauth/render/github`; + }; return (