diff --git a/app/admin/database/page.tsx b/app/admin/database/page.tsx new file mode 100644 index 0000000..ae8bdef --- /dev/null +++ b/app/admin/database/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +/** + * /admin/database — 数据库管理入口。 + * + * 早期版本用 iframe 嵌入 pgAdmin,但跨域 / 同源两种嵌法都各有坑: + * - 跨域(iframe src = api.involutionhell.com/admin/pgadmin/): + * pgAdmin 的 session + CSRF cookie 走 SameSite=Lax,子域 iframe 发 POST + * 时浏览器不带 cookie,登录永远报 "CSRF session token is missing"。 + * - 同源(走 Next.js rewrite 把 pgAdmin 代到 localhost:3010 下): + * pgAdmin 自己会发绝对 URL 的重定向(host 用它自己以为的值), + * 浏览器跟着跳到 http://localhost:8082 踩 "拒绝连接"。 + * + * 所以改简单方案——这里只放一个跳转按钮,新标签页打开 pgAdmin,让它自己管 + * 自己的 session / CSRF,省心。管理员反正不高频用。 + * + * 权限: 兜底(真正的安全由 pgAdmin 登录把守)。 + */ + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { AdminGuard } from "../events/AdminGuard"; + +// pgAdmin URL 选择逻辑(客户端运行时决定,否则 SSR 拿不到 hostname): +// 1. NEXT_PUBLIC_PGADMIN_URL 显式覆盖(最高优先级) +// 2. 浏览器 hostname 是 localhost / 127.0.0.1 → 走本地 http://localhost:8082/admin/pgadmin/ +// (要求开发者先 ssh -L 8082:127.0.0.1:8082 server 把端口引到本机) +// 3. 其他情况 → 公网入口 https://api.involutionhell.com/admin/pgadmin/ +// (需要 Caddy forward_auth + cookie,prod 正常使用路径) +const PROD_PGADMIN_URL = "https://api.involutionhell.com/admin/pgadmin/"; +const LOCAL_PGADMIN_URL = "http://localhost:8082/admin/pgadmin/"; + +function pickPgadminUrl(hostname: string | null): string { + if (process.env.NEXT_PUBLIC_PGADMIN_URL) { + return process.env.NEXT_PUBLIC_PGADMIN_URL; + } + if (hostname === "localhost" || hostname === "127.0.0.1") { + return LOCAL_PGADMIN_URL; + } + return PROD_PGADMIN_URL; +} + +export default function AdminDatabasePage() { + return ( + + + + ); +} + +function AdminDatabaseInner() { + // 客户端挂载后拿 hostname 选 URL。首屏 SSR 先渲染 prod URL 占位,useEffect + // 里按实际 hostname 刷成 localhost 版(如果需要)。dev 不做 SSR 不影响。 + // setState 走 Promise.resolve 异步化,避开 "cascading renders" lint 规则。 + const [pgadminUrl, setPgadminUrl] = useState(PROD_PGADMIN_URL); + useEffect(() => { + const next = pickPgadminUrl(window.location.hostname); + Promise.resolve().then(() => setPgadminUrl(next)); + }, []); + + return ( +
+
+
+
+ Admin · Database +
+

+ 数据库管理 +

+

+ 点按钮新标签打开 pgAdmin,在它自己的页面里做备份 / 恢复 / 查表 / 跑 + SQL。第一次进要用{" "} + + PGADMIN_EMAIL + {" "} + /{" "} + + PGADMIN_PASSWORD + {" "} + 登录(看服务器 .env)。左侧树自动预注册了{" "} + + InvolutionHell (local) + + ,双击即连。 +

+
+ + +
+ 外部窗口打开 +
+

+ 打开 pgAdmin → +

+

+ {pgadminUrl} +

+ + + +
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 9f51bbf..7210e0e 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -49,6 +49,12 @@ function AdminHomeInner() { href="/admin/events" badge="Admin+" /> + {isSuperadmin && (
{badge} diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index b2fc592..2d48f91 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,4 +1,3 @@ -import { prisma } from "@/lib/db"; import { streamText, UIMessage, convertToModelMessages } from "ai"; import { getModel, requiresApiKey, type AIProvider } from "@/lib/ai/models"; import { buildSystemMessage } from "@/lib/ai/prompt"; @@ -187,55 +186,59 @@ export async function POST(req: Request) { system: systemMessage, messages: await convertToModelMessages(messages || []), onFinish: async ({ text }) => { + // 2026-04-17 起对话历史改由后端 /api/chat/sessions/save 统一持久化(事务保证 + // Chat + Message 一起落库)。原本 prisma 直连 Neon 的方案在 Neon → 自建 PG + // 切换后会产生前后端双写不同库的脏数据,所以整条路径下掉。 try { - // 等待用户身份解析(与流式传输并行运行,此时大概率已完成) - const userId = await userIdPromise; - - // 1. 保存/更新会话,绑定用户 ID - // update 也写入 userId:覆盖此前匿名创建的记录(用户登录后继续同一 chatId) - await prisma.chat.upsert({ - where: { id: effectiveChatId }, - update: { - updatedAt: new Date(), - ...(userId != null && { userId }), - }, - create: { - id: effectiveChatId, - ...(userId != null && { userId }), - }, - }); + const backendUrl = process.env.BACKEND_URL; + if (!backendUrl) { + console.warn( + "[Chat History] BACKEND_URL 未配置,跳过持久化(不阻塞流式返回)", + ); + return; + } - // 2. 保存用户消息 (取最后一条) - // AI SDK v5 中,UIMessage 不再有 content 字段,内容在 parts 数组中 + // 从 parts 数组中提取最后一条 user 消息的纯文本;AI SDK v5 没有 content + // 字段,需要自己拼。空消息(role 不是 user 或 parts 为空)就传 null, + // 后端看到 null/空串会跳过插入,语义和原 Prisma 版 if 判断保持一致。 const safeMessages = messages || []; const lastUserMessage = safeMessages[safeMessages.length - 1]; - if (lastUserMessage && lastUserMessage.role === "user") { - // 从 parts 数组中提取所有文本内容并拼接 - const userContent = Array.isArray(lastUserMessage.parts) - ? lastUserMessage.parts - .filter((part) => part.type === "text") - .map((part) => (part as { type: "text"; text: string }).text) - .join("\n") - : (lastUserMessage as unknown as { content?: string })?.content || - ""; - - await prisma.message.create({ - data: { - chatId: effectiveChatId, - role: "user", - content: userContent, - }, - }); - } - - // 3. 保存 AI 回复 - await prisma.message.create({ - data: { - chatId: effectiveChatId, - role: "assistant", - content: text, + const userContent = + lastUserMessage && lastUserMessage.role === "user" + ? Array.isArray(lastUserMessage.parts) + ? lastUserMessage.parts + .filter((part) => part.type === "text") + .map( + (part) => (part as { type: "text"; text: string }).text, + ) + .join("\n") + : (lastUserMessage as unknown as { content?: string }) + ?.content || "" + : ""; + + // 后端用 sa-token 从 header 取 userId 关联会话;匿名请求不带 satoken + // 时后端自动把 userId 置 NULL,行为与原 prisma.chat.upsert 一致。 + // 静默 await 用户解析(原代码 await userIdPromise,这里保持阻塞等待 + // 以确保后端鉴权不会错用过期数据;失败已在 resolveUserId 内降级)。 + await userIdPromise; + + const resp = await fetch(`${backendUrl}/api/chat/sessions/save`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(satoken ? { satoken } : {}), }, + body: JSON.stringify({ + chatId: effectiveChatId, + userMessage: userContent, + assistantMessage: text, + }), }); + if (!resp.ok) { + console.warn( + `[Chat History] backend save returned ${resp.status}, history may be lost for chat ${effectiveChatId}`, + ); + } } catch (error) { console.error("Failed to save chat history:", error); } diff --git a/lib/use-auth.tsx b/lib/use-auth.tsx index bb255cb..9738616 100644 --- a/lib/use-auth.tsx +++ b/lib/use-auth.tsx @@ -36,6 +36,37 @@ function getStoredToken(): string | null { return localStorage.getItem("satoken"); } +/** + * 把 satoken 同步写一份到 `.involutionhell.com` 域名的 cookie。 + * + * 为什么需要:直接访问 api.involutionhell.com/admin/pgadmin/*(新标签页打开 pgAdmin) + * 时浏览器不会主动发 `satoken` header——业务 API 是走 Next.js rewrite 同源的,所以 + * 前端能手动附 header;但新标签直连 api 子域就只能靠 cookie 自动带。 + * + * Caddy 在 api 子域前放了 forward_auth 钩子调后端 /api/admin/pgadmin-check, + * sa-token 默认从 cookie 读 token(sa-token.is-read-cookie=true 默认开), + * 只要 cookie 存在且对应 satoken 在后端会话库里且拥有 admin 角色就放行。 + * + * 本地开发(localhost)的 Domain 属性要留空,浏览器会默认绑当前 host; + * 生产(involutionhell.com + api.involutionhell.com)要显式写 `.involutionhell.com` + * 否则两个子域 cookie 各存各的。 + */ +function syncTokenCookie(token: string | null) { + if (typeof document === "undefined") return; + const isLocalhost = + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1"; + const domainAttr = isLocalhost ? "" : "; Domain=.involutionhell.com"; + const secureAttr = window.location.protocol === "https:" ? "; Secure" : ""; + if (token) { + // 30 天 TTL 和 sa-token 服务端配置保持一致(application.properties 里 2592000) + document.cookie = `satoken=${encodeURIComponent(token)}; Path=/${domainAttr}; Max-Age=2592000; SameSite=Lax${secureAttr}`; + } else { + // 清除:设空值并 Max-Age=0;Domain / Path 必须与写入时一致浏览器才认这是"同一条" + document.cookie = `satoken=; Path=/${domainAttr}; Max-Age=0; SameSite=Lax${secureAttr}`; + } +} + // 调用后端 /auth/me 验证 token 并获取用户信息 // 走 Next.js rewrite(/auth/* → 后端),浏览器无跨域问题 async function fetchCurrentUser(token: string): Promise { @@ -63,8 +94,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const urlToken = hashParams.get("token"); if (urlToken) { - // 存入 localStorage + // 存入 localStorage + 同步写 cookie(供 api 子域直连场景使用,比如 pgAdmin) localStorage.setItem("satoken", urlToken); + syncTokenCookie(urlToken); // 用 replaceState 清除 URL 中的 fragment,避免刷新或分享时 token 泄露 hashParams.delete("token"); const newHash = hashParams.toString(); @@ -87,9 +119,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (u) { setUser(u); setStatus("authenticated"); + // 已登录用户每次刷新也重写 cookie,覆盖掉可能过期 / 丢失的副本 + syncTokenCookie(token); } else { - // token 无效或已过期,清除 + // token 无效或已过期,localStorage + cookie 都清 localStorage.removeItem("satoken"); + syncTokenCookie(null); setStatus("unauthenticated"); } }); @@ -110,6 +145,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } localStorage.removeItem("satoken"); } + syncTokenCookie(null); setUser(null); setStatus("unauthenticated"); };