Skip to content

Commit 21a5a12

Browse files
authored
Merge branch 'main' into feat/route-pinyin-mapping
2 parents 7662556 + 7bc12be commit 21a5a12

53 files changed

Lines changed: 7765 additions & 5274 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.sample

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
# 本文件提供运行本项目所需的环境变量示例。
22
# 提交代码时请提交本文件而不是实际的 .env,真实密钥请存放在个人或 CI 配置中。
33

4-
# NextAuth 基本配置
5-
AUTH_URL=http://localhost:3000 #https://involutionhell.com
6-
# 生成 32 字节以上的随机字符串,可用 openssl: `openssl rand -base64 32`
7-
AUTH_SECRET=
8-
# GitHub OAuth App 的 Client ID / Secret,可在 GitHub Developer settings 中创建
4+
# 后端地址(Spring Boot,认证已迁移到后端,NextAuth 已移除)
5+
# 服务端 API Route 调用后端时使用(不暴露给浏览器)
6+
BACKEND_URL=http://localhost:8080
7+
# 客户端组件(如登录按钮)直接跳转后端时使用
8+
NEXT_PUBLIC_BACKEND_URL=http://localhost:8080
9+
10+
# GitHub OAuth 配置 (由后端处理,此处仅作为示例配置参考)
911
AUTH_GITHUB_ID=
1012
AUTH_GITHUB_SECRET=
13+
AUTH_SECRET=
14+
AUTH_GITHUB_ID_DEV=
15+
AUTH_GITHUB_SECRET_DEV=
16+
AUTH_TRUST_HOST=true
17+
AUTH_URL=http://localhost:3000
18+
1119
# 可选:用于访问 GitHub API(例如同步仓库)
1220
GITHUB_TOKEN=
1321

@@ -16,6 +24,10 @@ INDEXNOW_API_TOKEN=
1624
#Open的Key
1725
INDEXNOW_KEY=5b6ef14a7406496b8a2ce8ab17820b34
1826
NEXT_PUBLIC_SITE_URL=https://involutionhell.com
27+
# 内部识别/认证 Key
28+
INTERN_KEY=
29+
# Neon 项目 ID
30+
NEON_PROJECT_ID=
1931
# Neon 提供的 Postgres 连接。
2032
# 登录 Neon 控制台 → 数据库 → "Connect" → "Connection details",可以复制以下所有变量。
2133
# 推荐连接字符串
@@ -44,6 +56,8 @@ POSTGRES_PRISMA_URL=
4456
NEXT_PUBLIC_STACK_PROJECT_ID=
4557
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=
4658
STACK_SECRET_SERVER_KEY=
59+
# Vercel OIDC Token
60+
VERCEL_OIDC_TOKEN=
4761

4862
# R2的存储桶,用于提供图片自动上传服务
4963
R2_ACCOUNT_ID=?

.github/workflows/content-check.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,15 @@ jobs:
3939

4040
- uses: actions/setup-node@v4
4141
with:
42-
node-version: 20
42+
node-version: 22
4343
cache: "pnpm"
4444

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

4949
- run: pnpm install --frozen-lockfile
50-
50+
5151
# Verify lockfile wasn't modified by install
5252
- name: Check lockfile consistency
5353
run: |
@@ -66,7 +66,7 @@ jobs:
6666
exit 1
6767
fi
6868
echo "✅ Lockfile is consistent"
69-
69+
7070
- name: Run tests
7171
run: pnpm test
7272
# Non-blocking image migration + lint (visibility only)

.github/workflows/deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828

2929
- uses: actions/setup-node@v4
3030
with:
31-
node-version: 20
31+
node-version: 22
3232
cache: pnpm
3333

3434
# Verify pnpm version matches package.json packageManager field

.github/workflows/sync-uuid.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ jobs:
4444

4545
- uses: actions/setup-node@v4
4646
with:
47-
node-version: 20
47+
node-version: 22
4848
cache: "pnpm" # 顺便启用 pnpm 缓存,加速
4949

5050
# Verify pnpm version matches package.json packageManager field

.node-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
22

app/api/analytics/route.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { prisma } from "@/lib/db";
2+
import { resolveUserId } from "@/lib/server-auth";
23

34
export async function POST(req: Request) {
45
try {
5-
const { eventType, eventData, userId } = await req.json();
6+
const { eventType, eventData } = await req.json();
67

78
if (!eventType) {
89
return Response.json(
@@ -11,11 +12,15 @@ export async function POST(req: Request) {
1112
);
1213
}
1314

15+
// 服务端验证身份,不信任客户端传入的 userId
16+
const userId = await resolveUserId(req);
17+
1418
await prisma.analyticsEvent.create({
1519
data: {
1620
eventType,
1721
eventData: eventData ?? {},
18-
userId: userId ? parseInt(String(userId)) : null,
22+
// userId 对应 user_accounts.id(BigInt);匿名访问为 null
23+
...(userId != null && { userId }),
1924
},
2025
});
2126

app/api/auth/[...nextauth]/route.ts

Lines changed: 0 additions & 2 deletions
This file was deleted.

app/api/chat/route.ts

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,65 @@ interface ChatRequest {
2626
chatId?: string;
2727
}
2828

29+
import { resolveUserId } from "@/lib/server-auth";
30+
2931
export async function POST(req: Request) {
32+
// 1. 克隆请求,因为如果代理失败,后面的代码还需要读取 req.json()
33+
const proxyReq = req.clone();
34+
35+
// ====== 尝试优雅降级代理到 Java 后端 ======
36+
try {
37+
const backendUrl = process.env.BACKEND_URL;
38+
if (!backendUrl) throw new Error("BACKEND_URL is not configured.");
39+
const controller = new AbortController();
40+
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时
41+
42+
// 原封不动把前端的参数丢给 Java
43+
let proxyRes: Response;
44+
try {
45+
proxyRes = await fetch(`${backendUrl}/openai/responses/stream`, {
46+
method: "POST",
47+
headers: {
48+
"Content-Type": "application/json",
49+
// 浏览器侧用 x-satoken 传递 token,转发给后端时改回后端期望的 satoken
50+
...(req.headers.get("x-satoken")
51+
? { satoken: req.headers.get("x-satoken")! }
52+
: {}),
53+
},
54+
body: await proxyReq.text(),
55+
signal: controller.signal,
56+
});
57+
} finally {
58+
// 无论成功还是抛出(网络错误/超时中断),都清除定时器
59+
clearTimeout(timeoutId);
60+
}
61+
62+
// 如果 Java 后端返回成功,则直接把它的流传回浏览器,提前结束
63+
if (proxyRes.ok && proxyRes.body) {
64+
console.log(
65+
"[Chat Fallback Proxy] 🚀 Java Backend responded successfully. Piping stream...",
66+
);
67+
return new Response(proxyRes.body, {
68+
headers: {
69+
"Content-Type":
70+
proxyRes.headers.get("Content-Type") || "text/plain; charset=utf-8",
71+
},
72+
});
73+
} else {
74+
console.warn(
75+
`[Chat Fallback Proxy] ⚠️ Java Backend returned status: ${proxyRes.status}, fallback to local Next.js inference.`,
76+
);
77+
}
78+
} catch (error) {
79+
console.warn(
80+
`[Chat Fallback Proxy] ❌ Java Backend unavailable or timed out, fallback to local Next.js inference. Error:`,
81+
error,
82+
);
83+
}
84+
// ====== 代理失败,继续往下走,启用备选方案(本地直连 AI)======
85+
3086
try {
87+
// 先把 body 消费掉,再并行验证用户身份
3188
const {
3289
messages,
3390
system,
@@ -37,6 +94,9 @@ export async function POST(req: Request) {
3794
chatId,
3895
}: ChatRequest = await req.json();
3996

97+
// 并行解析用户身份(不阻塞主流程,失败静默降级为匿名)
98+
const userIdPromise = resolveUserId(req);
99+
40100
// 对指定Provider验证key是否存在
41101
if (requiresApiKey(provider) && (!apiKey || apiKey.trim() === "")) {
42102
return Response.json(
@@ -90,22 +150,30 @@ export async function POST(req: Request) {
90150
// 根据Provider获取 AI 模型实例
91151
const model = getModel(provider, apiKey);
92152

93-
// 确保有 chatId (如果前端没传,就生成一个临时的,虽然这会导致每次请求都是新会话)
94-
// 理想情况是前端应该维护 chatId
95153
const effectiveChatId = chatId || crypto.randomUUID();
96154

97155
// 生成流式响应
98156
const result = streamText({
99157
model: model,
100158
system: systemMessage,
101-
messages: convertToModelMessages(messages || []),
159+
messages: await convertToModelMessages(messages || []),
102160
onFinish: async ({ text }) => {
103161
try {
104-
// 1. 保存/更新会话
162+
// 等待用户身份解析(与流式传输并行运行,此时大概率已完成)
163+
const userId = await userIdPromise;
164+
165+
// 1. 保存/更新会话,绑定用户 ID
166+
// update 也写入 userId:覆盖此前匿名创建的记录(用户登录后继续同一 chatId)
105167
await prisma.chat.upsert({
106168
where: { id: effectiveChatId },
107-
update: { updatedAt: new Date() },
108-
create: { id: effectiveChatId },
169+
update: {
170+
updatedAt: new Date(),
171+
...(userId != null && { userId }),
172+
},
173+
create: {
174+
id: effectiveChatId,
175+
...(userId != null && { userId }),
176+
},
109177
});
110178

111179
// 2. 保存用户消息 (取最后一条)

app/api/docs/history/route.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import type { HistoryItem } from "@/app/types/docs-history";
3+
4+
// 响应缓存 1 小时,GitHub API 每小时限额 5000 次(authenticated)
5+
export const revalidate = 3600;
6+
7+
interface GitHubCommit {
8+
sha: string;
9+
commit: {
10+
author: {
11+
name: string;
12+
date: string;
13+
};
14+
message: string;
15+
};
16+
author: {
17+
login: string;
18+
avatar_url: string;
19+
} | null;
20+
html_url: string;
21+
}
22+
23+
/**
24+
* 规范化前端传入的文档路径为仓库根相对路径(GitHub API 要求)。
25+
*
26+
* 接受的输入形态:
27+
* - `app/docs/ai/...`(仓库根相对)→ 原样返回
28+
* - `docs/ai/...` → 前面补 `app/`
29+
* - `/docs/ai/...`(浏览器 URL 风格)→ 去开头斜杠再补 `app/`
30+
*
31+
* 拒绝:含 `..`、反斜杠、null 字节;最终不落在 `app/docs/` 下的路径一律拒绝,
32+
* 避免用服务端 GITHUB_TOKEN 被动泄露仓库内任意文件的 commit 信息。
33+
*/
34+
function normalizeDocsPath(raw: string): string | null {
35+
if (!raw) return null;
36+
// 路径穿越 / 反斜杠 / null 字节 直接拒
37+
if (raw.includes("..") || raw.includes("\\") || raw.includes("\0")) {
38+
return null;
39+
}
40+
41+
let normalized = raw;
42+
// URL 风格 /docs/... → docs/...
43+
if (normalized.startsWith("/")) {
44+
normalized = normalized.slice(1);
45+
}
46+
// docs/... → app/docs/...
47+
if (normalized.startsWith("docs/")) {
48+
normalized = `app/${normalized}`;
49+
}
50+
// 必须落在 app/docs/ 下才放行
51+
if (!normalized.startsWith("app/docs/")) {
52+
return null;
53+
}
54+
return normalized;
55+
}
56+
57+
export async function GET(req: NextRequest) {
58+
const { searchParams } = new URL(req.url);
59+
const rawPath = searchParams.get("path");
60+
61+
const path = rawPath ? normalizeDocsPath(rawPath) : null;
62+
if (!path) {
63+
return NextResponse.json(
64+
{
65+
success: false,
66+
error: "缺少合法的 path 参数(仅允许 app/docs/ 路径)",
67+
},
68+
{ status: 400 },
69+
);
70+
}
71+
72+
const token = process.env.GITHUB_TOKEN;
73+
if (!token) {
74+
return NextResponse.json(
75+
{ success: false, error: "服务端未配置 GITHUB_TOKEN" },
76+
{ status: 500 },
77+
);
78+
}
79+
80+
const apiUrl = `https://api.github.com/repos/InvolutionHell/involutionhell/commits?path=${encodeURIComponent(path)}&per_page=5`;
81+
82+
let res: Response;
83+
try {
84+
res = await fetch(apiUrl, {
85+
headers: {
86+
Authorization: `Bearer ${token}`,
87+
Accept: "application/vnd.github+json",
88+
"X-GitHub-Api-Version": "2022-11-28",
89+
},
90+
// Next.js fetch 缓存,与 revalidate 配合
91+
next: { revalidate: 3600 },
92+
});
93+
} catch {
94+
return NextResponse.json(
95+
{ success: false, error: "无法连接 GitHub API" },
96+
{ status: 502 },
97+
);
98+
}
99+
100+
// 403 可能是限流、也可能是 token 权限不足 / 仓库不可访问;用 x-ratelimit-remaining 区分
101+
if (res.status === 403) {
102+
const rateRemaining = res.headers.get("x-ratelimit-remaining");
103+
if (rateRemaining === "0") {
104+
return NextResponse.json(
105+
{ success: false, error: "GitHub API 限流,请稍后重试" },
106+
{ status: 429 },
107+
);
108+
}
109+
return NextResponse.json(
110+
{ success: false, error: "GitHub API 403(可能 token 权限不足)" },
111+
{ status: 403 },
112+
);
113+
}
114+
115+
if (res.status === 401) {
116+
return NextResponse.json(
117+
{ success: false, error: "GitHub token 无效或过期" },
118+
{ status: 401 },
119+
);
120+
}
121+
122+
if (!res.ok) {
123+
return NextResponse.json(
124+
{ success: false, error: `GitHub API 返回 ${res.status}` },
125+
{ status: 502 },
126+
);
127+
}
128+
129+
const commits: GitHubCommit[] = await res.json();
130+
131+
const data: HistoryItem[] = commits.map((c) => ({
132+
sha: c.sha,
133+
authorName: c.commit.author.name,
134+
// author 为 null 时(commit 作者邮箱未关联 GitHub 账号),login 退回展示名
135+
authorLogin: c.author?.login ?? c.commit.author.name,
136+
// commit.author.name 是展示名(可能含中文/空格),拼 github.com/<name>.png 容易 404;
137+
// 仅在有真实 author 时用其 avatar_url,否则返回空串让前端用占位资源
138+
avatarUrl: c.author?.avatar_url ?? "",
139+
date: c.commit.author.date,
140+
// 只取 commit message 第一行
141+
message: c.commit.message.split("\n")[0],
142+
htmlUrl: c.html_url,
143+
}));
144+
145+
return NextResponse.json(
146+
{ success: true, data },
147+
{
148+
headers: {
149+
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
150+
},
151+
},
152+
);
153+
}

0 commit comments

Comments
 (0)