Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -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=

Expand All @@ -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",可以复制以下所有变量。
# 推荐连接字符串
Expand Down Expand Up @@ -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=?
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/content-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,15 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: "pnpm"

# Verify pnpm version matches package.json packageManager field
- name: Check pnpm version
run: node scripts/check-pnpm-version.mjs

- run: pnpm install --frozen-lockfile

# Verify lockfile wasn't modified by install
- name: Check lockfile consistency
run: |
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/sync-uuid.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
9 changes: 7 additions & 2 deletions app/api/analytics/route.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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 }),
},
});

Expand Down
2 changes: 0 additions & 2 deletions app/api/auth/[...nextauth]/route.ts

This file was deleted.

80 changes: 74 additions & 6 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Comment on lines +39 to +61
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the Java-backend proxy attempt, the timeout is only cleared on the success path. If fetch() throws (network error/abort), timeoutId isn’t cleared, leaving a pending timer per request. Use a finally block (or try { ... } finally { clearTimeout(timeoutId) }) to always clear the timeout.

Copilot uses AI. Check for mistakes.
// 如果 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,
Expand All @@ -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(
Expand Down Expand Up @@ -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. 保存用户消息 (取最后一条)
Expand Down
20 changes: 15 additions & 5 deletions app/api/upload/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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}`;
Expand Down
24 changes: 15 additions & 9 deletions app/components/AuthNav.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="size-9 rounded-full bg-muted animate-pulse" />;
}

const user = session?.user;
const provider =
session && "provider" in session
? (session.provider as string | undefined)
: undefined;

return user ? <UserMenu user={user} provider={provider} /> : <SignInButton />;
return user ? (
<UserMenu
user={{
name: user.displayName,
email: user.email ?? null,
image: user.avatarUrl ?? null,
}}
provider="github"
logout={logout}
/>
) : (
<SignInButton />
);
}
Loading
Loading