Skip to content

Commit 38bbaaa

Browse files
authored
feat(admin): /admin/database 页面嵌入 pgAdmin iframe (#301)
* feat(admin): /admin/database 页面嵌入 pgAdmin iframe 管理员用一个主站入口进 pgAdmin 做备份/恢复/查表/跑 SQL,不再打开 api.involutionhell.com:8082 这种裸页面。pgAdmin 本身的 UI 风格跟主站不搭, 但用户明确说"管理员不配享受好 UI",优先接通能力。 - 新增 app/admin/database/page.tsx:AdminGuard 兜底权限,iframe src 走 https://api.involutionhell.com/admin/pgadmin/(可由 NEXT_PUBLIC_PGADMIN_URL 覆盖) - /admin 首页加"数据库管理"入口卡片 真实的权限/流量控制在后端 compose + Caddy 那边(见 involutionhell-backend#12): Caddy 反向代理 /admin/pgadmin/* 到 127.0.0.1:8082,剥 X-Frame-Options, 下发 CSP frame-ancestors 放行 involutionhell.com 主域。 * feat(chat): onFinish 改 fetch 后端 /api/chat/sessions/save,不再直连 Prisma 背景:Neon → 自建 Docker PG 迁移后,前端 Prisma 还指向 Neon,AI 对话持久 化会写进旧库,和后端读自建 PG 分叉出脏数据。方案 A:把 chat + message 写 入挪到后端统一走,前端 onFinish 只发一次 HTTP。 - 删掉 import { prisma } from "@/lib/db",运行时再无 Prisma 依赖 - onFinish 原来三次 prisma 调用(chat upsert + user 消息 + assistant 消息) 合并成一次 fetch(BACKEND_URL + "/api/chat/sessions/save") - 后端接口匿名允许,登录时通过 satoken header 关联 userId,行为语义和原 Prisma 版完全一致(匿名写 userId=NULL,登录补挂 userId) - BACKEND_URL 未配或后端返回非 2xx 时 console.warn 不抛错,保持 "持久化失败不阻塞对话流式返回"的原语义 Vercel AI SDK 流式路径(streamText / convertToModelMessages 等)完全未动, 前端 UX 无感知。 配套后端 PR:InvolutionHell/involutionhell-backend#13 * refactor(admin): /admin/database 去掉 iframe,改新标签打开 pgAdmin iframe 嵌入两种嵌法都是坑: - 跨域嵌:pgAdmin session/CSRF cookie 走 SameSite=Lax,子域 iframe POST 不带 cookie,登录永远报 "CSRF session token is missing" - 同源代理嵌:pgAdmin 会发绝对 URL 的重定向(host 是容器自己以为的值), 浏览器跟着跳到 http://localhost:8082 变成 ERR_CONNECTION_REFUSED 管理员不高频用数据库,没必要为了 UI 嵌在主站里搭这些管道。改成一个大按钮, target=_blank 打开 pgAdmin 自己的页面——cookie / CSRF 都在它自己域里, 一切正常工作。 同步删掉上一版临时加的 Next.js /admin/pgadmin/:path* rewrite。 * feat(auth): 登录成功同步 satoken 到 .involutionhell.com cookie 配合后端 /api/admin/pgadmin-check 和 Caddy forward_auth 的整条链:用户直连 api.involutionhell.com/admin/pgadmin/* 时浏览器不会主动发 satoken header, 必须靠 cookie 自动携带。 - 新加 syncTokenCookie(token):登录 / 刷新有效 session / 登出全部打点 localhost 域不写 Domain(浏览器默认绑当前 host); 生产写 Domain=.involutionhell.com 让主域 + 所有子域共享 SameSite=Lax 刚好够——顶层导航 / 子资源 GET 都会带;跨站 POST 不带但我们 也不需要(pgAdmin 的 CSRF 有自己的 cookie) Max-Age=2592000 与 sa-token.timeout 保持一致 - token 无效 / 登出时清掉 cookie,避免 stale 身份残留 服务端配套:InvolutionHell/involutionhell-backend#12 * feat(admin/database): hostname=localhost 时按钮自动指本地 pgAdmin 开发时访问 localhost:3010/admin/database 点按钮会直接打 prod api.involutionhell.com,需要 cookie 但 localhost 登录时 cookie 写不到 .involutionhell.com 域,只能卡 401。 改成客户端挂载后读 window.location.hostname: - localhost / 127.0.0.1 → http://localhost:8082/admin/pgadmin/ (要求开发者先 ssh -L 8082:127.0.0.1:8082 server 引端口) - 其他 → 原来的公网 URL(走 Caddy forward_auth 链) NEXT_PUBLIC_PGADMIN_URL 仍然最高优先级,想覆盖任何时候都能覆盖。 useEffect 里 setState 走 Promise.resolve 异步化,绕开 React "cascading renders" lint 规则。
1 parent 6a72d76 commit 38bbaaa

4 files changed

Lines changed: 218 additions & 47 deletions

File tree

app/admin/database/page.tsx

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"use client";
2+
3+
/**
4+
* /admin/database — 数据库管理入口。
5+
*
6+
* 早期版本用 iframe 嵌入 pgAdmin,但跨域 / 同源两种嵌法都各有坑:
7+
* - 跨域(iframe src = api.involutionhell.com/admin/pgadmin/):
8+
* pgAdmin 的 session + CSRF cookie 走 SameSite=Lax,子域 iframe 发 POST
9+
* 时浏览器不带 cookie,登录永远报 "CSRF session token is missing"。
10+
* - 同源(走 Next.js rewrite 把 pgAdmin 代到 localhost:3010 下):
11+
* pgAdmin 自己会发绝对 URL 的重定向(host 用它自己以为的值),
12+
* 浏览器跟着跳到 http://localhost:8082 踩 "拒绝连接"。
13+
*
14+
* 所以改简单方案——这里只放一个跳转按钮,新标签页打开 pgAdmin,让它自己管
15+
* 自己的 session / CSRF,省心。管理员反正不高频用。
16+
*
17+
* 权限:<AdminGuard required="admin"> 兜底(真正的安全由 pgAdmin 登录把守)。
18+
*/
19+
20+
import { useEffect, useState } from "react";
21+
import Link from "next/link";
22+
import { AdminGuard } from "../events/AdminGuard";
23+
24+
// pgAdmin URL 选择逻辑(客户端运行时决定,否则 SSR 拿不到 hostname):
25+
// 1. NEXT_PUBLIC_PGADMIN_URL 显式覆盖(最高优先级)
26+
// 2. 浏览器 hostname 是 localhost / 127.0.0.1 → 走本地 http://localhost:8082/admin/pgadmin/
27+
// (要求开发者先 ssh -L 8082:127.0.0.1:8082 server 把端口引到本机)
28+
// 3. 其他情况 → 公网入口 https://api.involutionhell.com/admin/pgadmin/
29+
// (需要 Caddy forward_auth + cookie,prod 正常使用路径)
30+
const PROD_PGADMIN_URL = "https://api.involutionhell.com/admin/pgadmin/";
31+
const LOCAL_PGADMIN_URL = "http://localhost:8082/admin/pgadmin/";
32+
33+
function pickPgadminUrl(hostname: string | null): string {
34+
if (process.env.NEXT_PUBLIC_PGADMIN_URL) {
35+
return process.env.NEXT_PUBLIC_PGADMIN_URL;
36+
}
37+
if (hostname === "localhost" || hostname === "127.0.0.1") {
38+
return LOCAL_PGADMIN_URL;
39+
}
40+
return PROD_PGADMIN_URL;
41+
}
42+
43+
export default function AdminDatabasePage() {
44+
return (
45+
<AdminGuard>
46+
<AdminDatabaseInner />
47+
</AdminGuard>
48+
);
49+
}
50+
51+
function AdminDatabaseInner() {
52+
// 客户端挂载后拿 hostname 选 URL。首屏 SSR 先渲染 prod URL 占位,useEffect
53+
// 里按实际 hostname 刷成 localhost 版(如果需要)。dev 不做 SSR 不影响。
54+
// setState 走 Promise.resolve 异步化,避开 "cascading renders" lint 规则。
55+
const [pgadminUrl, setPgadminUrl] = useState(PROD_PGADMIN_URL);
56+
useEffect(() => {
57+
const next = pickPgadminUrl(window.location.hostname);
58+
Promise.resolve().then(() => setPgadminUrl(next));
59+
}, []);
60+
61+
return (
62+
<main className="pt-32 pb-16 bg-[var(--background)] min-h-screen">
63+
<div className="max-w-3xl mx-auto px-6 lg:px-8">
64+
<header className="border-t-4 border-[var(--foreground)] pt-6 mb-10">
65+
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">
66+
Admin · Database
67+
</div>
68+
<h1 className="font-serif text-3xl md:text-4xl font-black uppercase mt-2 tracking-tight">
69+
数据库管理
70+
</h1>
71+
<p className="mt-3 text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
72+
点按钮新标签打开 pgAdmin,在它自己的页面里做备份 / 恢复 / 查表 / 跑
73+
SQL。第一次进要用{" "}
74+
<code className="font-mono text-[11px] bg-neutral-200 dark:bg-neutral-800 px-1">
75+
PGADMIN_EMAIL
76+
</code>{" "}
77+
/{" "}
78+
<code className="font-mono text-[11px] bg-neutral-200 dark:bg-neutral-800 px-1">
79+
PGADMIN_PASSWORD
80+
</code>{" "}
81+
登录(看服务器 .env)。左侧树自动预注册了{" "}
82+
<code className="font-mono text-[11px] bg-neutral-200 dark:bg-neutral-800 px-1">
83+
InvolutionHell (local)
84+
</code>
85+
,双击即连。
86+
</p>
87+
</header>
88+
89+
<Link
90+
key={pgadminUrl}
91+
href={pgadminUrl}
92+
target="_blank"
93+
rel="noopener noreferrer"
94+
className="block border border-[var(--foreground)] p-8 hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors group"
95+
data-umami-event="admin_open_pgadmin"
96+
>
97+
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500 group-hover:text-[var(--background)] mb-2">
98+
外部窗口打开
99+
</div>
100+
<h2 className="font-serif text-2xl font-black uppercase tracking-tight mb-2">
101+
打开 pgAdmin →
102+
</h2>
103+
<p className="text-sm leading-relaxed opacity-80 font-mono break-all">
104+
{pgadminUrl}
105+
</p>
106+
</Link>
107+
108+
<aside className="mt-10 text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed">
109+
<p className="mb-2 font-mono uppercase tracking-[0.2em] text-[10px]">
110+
备份文件位置
111+
</p>
112+
<p>
113+
pg-backup 每天 03:00 自动 pg_dump 写到{" "}
114+
<code className="font-mono text-[11px] bg-neutral-200 dark:bg-neutral-800 px-1">
115+
/var/lib/pgadmin/storage/admin_involutionhell.com/backups/
116+
</code>
117+
(daily / weekly / monthly / last 四个子目录)。Restore 对话框选
118+
文件时直接能看到。
119+
</p>
120+
</aside>
121+
</div>
122+
</main>
123+
);
124+
}

app/admin/page.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ function AdminHomeInner() {
4949
href="/admin/events"
5050
badge="Admin+"
5151
/>
52+
<AdminCard
53+
title="数据库管理"
54+
description="嵌入 pgAdmin,用按钮完成备份 / 恢复 / 查表 / 跑 SQL。"
55+
href="/admin/database"
56+
badge="Admin+"
57+
/>
5258
{isSuperadmin && (
5359
<AdminCard
5460
title="用户管理"
@@ -88,7 +94,9 @@ function AdminCard({
8894
>
8995
<div
9096
className={`font-mono text-[10px] uppercase tracking-[0.3em] mb-2 ${
91-
accent ? "text-[#CC0000] group-hover:text-white" : "text-neutral-500 group-hover:text-[var(--background)]"
97+
accent
98+
? "text-[#CC0000] group-hover:text-white"
99+
: "text-neutral-500 group-hover:text-[var(--background)]"
92100
}`}
93101
>
94102
{badge}

app/api/chat/route.ts

Lines changed: 47 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { prisma } from "@/lib/db";
21
import { streamText, UIMessage, convertToModelMessages } from "ai";
32
import { getModel, requiresApiKey, type AIProvider } from "@/lib/ai/models";
43
import { buildSystemMessage } from "@/lib/ai/prompt";
@@ -187,55 +186,59 @@ export async function POST(req: Request) {
187186
system: systemMessage,
188187
messages: await convertToModelMessages(messages || []),
189188
onFinish: async ({ text }) => {
189+
// 2026-04-17 起对话历史改由后端 /api/chat/sessions/save 统一持久化(事务保证
190+
// Chat + Message 一起落库)。原本 prisma 直连 Neon 的方案在 Neon → 自建 PG
191+
// 切换后会产生前后端双写不同库的脏数据,所以整条路径下掉。
190192
try {
191-
// 等待用户身份解析(与流式传输并行运行,此时大概率已完成)
192-
const userId = await userIdPromise;
193-
194-
// 1. 保存/更新会话,绑定用户 ID
195-
// update 也写入 userId:覆盖此前匿名创建的记录(用户登录后继续同一 chatId)
196-
await prisma.chat.upsert({
197-
where: { id: effectiveChatId },
198-
update: {
199-
updatedAt: new Date(),
200-
...(userId != null && { userId }),
201-
},
202-
create: {
203-
id: effectiveChatId,
204-
...(userId != null && { userId }),
205-
},
206-
});
193+
const backendUrl = process.env.BACKEND_URL;
194+
if (!backendUrl) {
195+
console.warn(
196+
"[Chat History] BACKEND_URL 未配置,跳过持久化(不阻塞流式返回)",
197+
);
198+
return;
199+
}
207200

208-
// 2. 保存用户消息 (取最后一条)
209-
// AI SDK v5 中,UIMessage 不再有 content 字段,内容在 parts 数组中
201+
// 从 parts 数组中提取最后一条 user 消息的纯文本;AI SDK v5 没有 content
202+
// 字段,需要自己拼。空消息(role 不是 user 或 parts 为空)就传 null,
203+
// 后端看到 null/空串会跳过插入,语义和原 Prisma 版 if 判断保持一致。
210204
const safeMessages = messages || [];
211205
const lastUserMessage = safeMessages[safeMessages.length - 1];
212-
if (lastUserMessage && lastUserMessage.role === "user") {
213-
// 从 parts 数组中提取所有文本内容并拼接
214-
const userContent = Array.isArray(lastUserMessage.parts)
215-
? lastUserMessage.parts
216-
.filter((part) => part.type === "text")
217-
.map((part) => (part as { type: "text"; text: string }).text)
218-
.join("\n")
219-
: (lastUserMessage as unknown as { content?: string })?.content ||
220-
"";
221-
222-
await prisma.message.create({
223-
data: {
224-
chatId: effectiveChatId,
225-
role: "user",
226-
content: userContent,
227-
},
228-
});
229-
}
230-
231-
// 3. 保存 AI 回复
232-
await prisma.message.create({
233-
data: {
234-
chatId: effectiveChatId,
235-
role: "assistant",
236-
content: text,
206+
const userContent =
207+
lastUserMessage && lastUserMessage.role === "user"
208+
? Array.isArray(lastUserMessage.parts)
209+
? lastUserMessage.parts
210+
.filter((part) => part.type === "text")
211+
.map(
212+
(part) => (part as { type: "text"; text: string }).text,
213+
)
214+
.join("\n")
215+
: (lastUserMessage as unknown as { content?: string })
216+
?.content || ""
217+
: "";
218+
219+
// 后端用 sa-token 从 header 取 userId 关联会话;匿名请求不带 satoken
220+
// 时后端自动把 userId 置 NULL,行为与原 prisma.chat.upsert 一致。
221+
// 静默 await 用户解析(原代码 await userIdPromise,这里保持阻塞等待
222+
// 以确保后端鉴权不会错用过期数据;失败已在 resolveUserId 内降级)。
223+
await userIdPromise;
224+
225+
const resp = await fetch(`${backendUrl}/api/chat/sessions/save`, {
226+
method: "POST",
227+
headers: {
228+
"Content-Type": "application/json",
229+
...(satoken ? { satoken } : {}),
237230
},
231+
body: JSON.stringify({
232+
chatId: effectiveChatId,
233+
userMessage: userContent,
234+
assistantMessage: text,
235+
}),
238236
});
237+
if (!resp.ok) {
238+
console.warn(
239+
`[Chat History] backend save returned ${resp.status}, history may be lost for chat ${effectiveChatId}`,
240+
);
241+
}
239242
} catch (error) {
240243
console.error("Failed to save chat history:", error);
241244
}

lib/use-auth.tsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,37 @@ function getStoredToken(): string | null {
3636
return localStorage.getItem("satoken");
3737
}
3838

39+
/**
40+
* 把 satoken 同步写一份到 `.involutionhell.com` 域名的 cookie。
41+
*
42+
* 为什么需要:直接访问 api.involutionhell.com/admin/pgadmin/*(新标签页打开 pgAdmin)
43+
* 时浏览器不会主动发 `satoken` header——业务 API 是走 Next.js rewrite 同源的,所以
44+
* 前端能手动附 header;但新标签直连 api 子域就只能靠 cookie 自动带。
45+
*
46+
* Caddy 在 api 子域前放了 forward_auth 钩子调后端 /api/admin/pgadmin-check,
47+
* sa-token 默认从 cookie 读 token(sa-token.is-read-cookie=true 默认开),
48+
* 只要 cookie 存在且对应 satoken 在后端会话库里且拥有 admin 角色就放行。
49+
*
50+
* 本地开发(localhost)的 Domain 属性要留空,浏览器会默认绑当前 host;
51+
* 生产(involutionhell.com + api.involutionhell.com)要显式写 `.involutionhell.com`
52+
* 否则两个子域 cookie 各存各的。
53+
*/
54+
function syncTokenCookie(token: string | null) {
55+
if (typeof document === "undefined") return;
56+
const isLocalhost =
57+
window.location.hostname === "localhost" ||
58+
window.location.hostname === "127.0.0.1";
59+
const domainAttr = isLocalhost ? "" : "; Domain=.involutionhell.com";
60+
const secureAttr = window.location.protocol === "https:" ? "; Secure" : "";
61+
if (token) {
62+
// 30 天 TTL 和 sa-token 服务端配置保持一致(application.properties 里 2592000)
63+
document.cookie = `satoken=${encodeURIComponent(token)}; Path=/${domainAttr}; Max-Age=2592000; SameSite=Lax${secureAttr}`;
64+
} else {
65+
// 清除:设空值并 Max-Age=0;Domain / Path 必须与写入时一致浏览器才认这是"同一条"
66+
document.cookie = `satoken=; Path=/${domainAttr}; Max-Age=0; SameSite=Lax${secureAttr}`;
67+
}
68+
}
69+
3970
// 调用后端 /auth/me 验证 token 并获取用户信息
4071
// 走 Next.js rewrite(/auth/* → 后端),浏览器无跨域问题
4172
async function fetchCurrentUser(token: string): Promise<UserView | null> {
@@ -63,8 +94,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
6394
const urlToken = hashParams.get("token");
6495

6596
if (urlToken) {
66-
// 存入 localStorage
97+
// 存入 localStorage + 同步写 cookie(供 api 子域直连场景使用,比如 pgAdmin)
6798
localStorage.setItem("satoken", urlToken);
99+
syncTokenCookie(urlToken);
68100
// 用 replaceState 清除 URL 中的 fragment,避免刷新或分享时 token 泄露
69101
hashParams.delete("token");
70102
const newHash = hashParams.toString();
@@ -87,9 +119,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
87119
if (u) {
88120
setUser(u);
89121
setStatus("authenticated");
122+
// 已登录用户每次刷新也重写 cookie,覆盖掉可能过期 / 丢失的副本
123+
syncTokenCookie(token);
90124
} else {
91-
// token 无效或已过期,清除
125+
// token 无效或已过期,localStorage + cookie 都清
92126
localStorage.removeItem("satoken");
127+
syncTokenCookie(null);
93128
setStatus("unauthenticated");
94129
}
95130
});
@@ -110,6 +145,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
110145
}
111146
localStorage.removeItem("satoken");
112147
}
148+
syncTokenCookie(null);
113149
setUser(null);
114150
setStatus("unauthenticated");
115151
};

0 commit comments

Comments
 (0)