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
124 changes: 124 additions & 0 deletions app/admin/database/page.tsx
Original file line number Diff line number Diff line change
@@ -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,省心。管理员反正不高频用。
*
* 权限:<AdminGuard required="admin"> 兜底(真正的安全由 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 (
<AdminGuard>
<AdminDatabaseInner />
</AdminGuard>
);
}

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 (
<main className="pt-32 pb-16 bg-[var(--background)] min-h-screen">
<div className="max-w-3xl mx-auto px-6 lg:px-8">
<header className="border-t-4 border-[var(--foreground)] pt-6 mb-10">
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500">
Admin · Database
</div>
<h1 className="font-serif text-3xl md:text-4xl font-black uppercase mt-2 tracking-tight">
数据库管理
</h1>
<p className="mt-3 text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed">
点按钮新标签打开 pgAdmin,在它自己的页面里做备份 / 恢复 / 查表 / 跑
SQL。第一次进要用{" "}
<code className="font-mono text-[11px] bg-neutral-200 dark:bg-neutral-800 px-1">
PGADMIN_EMAIL
</code>{" "}
/{" "}
<code className="font-mono text-[11px] bg-neutral-200 dark:bg-neutral-800 px-1">
PGADMIN_PASSWORD
</code>{" "}
登录(看服务器 .env)。左侧树自动预注册了{" "}
<code className="font-mono text-[11px] bg-neutral-200 dark:bg-neutral-800 px-1">
InvolutionHell (local)
</code>
,双击即连。
</p>
</header>

<Link
key={pgadminUrl}
href={pgadminUrl}
target="_blank"
rel="noopener noreferrer"
className="block border border-[var(--foreground)] p-8 hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors group"
data-umami-event="admin_open_pgadmin"
>
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-neutral-500 group-hover:text-[var(--background)] mb-2">
外部窗口打开
</div>
<h2 className="font-serif text-2xl font-black uppercase tracking-tight mb-2">
打开 pgAdmin →
</h2>
<p className="text-sm leading-relaxed opacity-80 font-mono break-all">
{pgadminUrl}
</p>
</Link>

<aside className="mt-10 text-xs text-neutral-500 dark:text-neutral-400 leading-relaxed">
<p className="mb-2 font-mono uppercase tracking-[0.2em] text-[10px]">
备份文件位置
</p>
<p>
pg-backup 每天 03:00 自动 pg_dump 写到{" "}
<code className="font-mono text-[11px] bg-neutral-200 dark:bg-neutral-800 px-1">
/var/lib/pgadmin/storage/admin_involutionhell.com/backups/
</code>
(daily / weekly / monthly / last 四个子目录)。Restore 对话框选
文件时直接能看到。
</p>
</aside>
</div>
</main>
);
}
10 changes: 9 additions & 1 deletion app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ function AdminHomeInner() {
href="/admin/events"
badge="Admin+"
/>
<AdminCard
title="数据库管理"
description="嵌入 pgAdmin,用按钮完成备份 / 恢复 / 查表 / 跑 SQL。"
href="/admin/database"
badge="Admin+"
/>
{isSuperadmin && (
<AdminCard
title="用户管理"
Expand Down Expand Up @@ -88,7 +94,9 @@ function AdminCard({
>
<div
className={`font-mono text-[10px] uppercase tracking-[0.3em] mb-2 ${
accent ? "text-[#CC0000] group-hover:text-white" : "text-neutral-500 group-hover:text-[var(--background)]"
accent
? "text-[#CC0000] group-hover:text-white"
: "text-neutral-500 group-hover:text-[var(--background)]"
}`}
>
{badge}
Expand Down
91 changes: 47 additions & 44 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down
40 changes: 38 additions & 2 deletions lib/use-auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserView | null> {
Expand Down Expand Up @@ -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();
Expand All @@ -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");
}
});
Expand All @@ -110,6 +145,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
localStorage.removeItem("satoken");
}
syncTokenCookie(null);
setUser(null);
setStatus("unauthenticated");
};
Expand Down
Loading