feat(admin): /admin/database 页面嵌入 pgAdmin iframe#301
Conversation
管理员用一个主站入口进 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 主域。
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Adds an admin-facing “Database” entrypoint in the Next.js admin area by embedding pgAdmin via an iframe, enabling basic DB operations (backup/restore/query) without leaving the main site UI.
Changes:
- Add new
/admin/databasepage that wraps an iframe (pgAdmin) withAdminGuard. - Add a new “数据库管理” card link on the
/adminhome page. - Minor formatting tweak in
AdminCardternary className for readability.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| app/admin/page.tsx | Adds a new admin card linking to /admin/database; minor formatting change. |
| app/admin/database/page.tsx | New admin page embedding pgAdmin via iframe with an optional NEXT_PUBLIC_PGADMIN_URL override. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // pgAdmin 所在路径。默认打 production Caddy,dev 如果要本地联调可以用 | ||
| // NEXT_PUBLIC_PGADMIN_URL 覆盖(比如指到 http://localhost:8082/admin/pgadmin/)。 | ||
| const PGADMIN_URL = | ||
| process.env.NEXT_PUBLIC_PGADMIN_URL ?? | ||
| "https://api.involutionhell.com/admin/pgadmin/"; |
There was a problem hiding this comment.
这里新增了硬编码的 production fallback URL(NEXT_PUBLIC_PGADMIN_URL 未设置时直接指向 https://api.involutionhell.com/...)。仓库文档约定里提到新代码不建议在生产环境使用硬编码 fallback,否则会把“漏配/配错环境变量”变成静默连到错误地址;在 preview/staging 或自建部署时也可能误连生产 pgAdmin。建议改成:要求必须显式配置 NEXT_PUBLIC_PGADMIN_URL(缺失时在页面上提示配置方式/返回空白),或至少根据 Vercel env/hostname 区分 preview 与 prod。
| /** | ||
| * /admin/database — 数据库管理后台(iframe 嵌入 pgAdmin)。 | ||
| * | ||
| * 权限:<AdminGuard required="admin"> 兜底,非 admin 直接 403; |
There was a problem hiding this comment.
文件头注释里写的是 <AdminGuard required="admin">,但实际代码使用的是 <AdminGuard>(依赖 default required)。虽然行为一致,但注释和实现不一致会误导后续维护者;建议把注释改为与当前用法一致(或在代码里显式传 required="admin")。
| * 权限:<AdminGuard required="admin"> 兜底,非 admin 直接 403; | |
| * 权限:<AdminGuard> 兜底,依赖默认 required="admin",非 admin 直接 403; |
| <p className="mt-2 text-xs text-neutral-600 dark:text-neutral-400 leading-relaxed"> | ||
| 下方嵌入的是 pgAdmin。首次进入要用{" "} | ||
| <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>{" "} | ||
| 登录(在{" "} | ||
| <code className="font-mono text-[11px] bg-neutral-200 dark:bg-neutral-800 px-1"> | ||
| .env | ||
| </code>{" "} | ||
| 里)。 左树自动预注册了 “InvolutionHell (local)” | ||
| 连接,双击即连。 备份/恢复在数据库右键菜单里;定时备份落在{" "} | ||
| <code className="font-mono text-[11px] bg-neutral-200 dark:bg-neutral-800 px-1"> | ||
| Storage → backups/ | ||
| </code> | ||
| 。 |
There was a problem hiding this comment.
页面文案提示用 PGADMIN_EMAIL / PGADMIN_PASSWORD 并写“在 .env 里”,但这两个变量在本仓库中并不存在/也不会暴露给浏览器端用户;对于管理员来说也很难“去看服务器 .env”。建议把文案改成更准确的操作指引(例如:由运维/后端部署环境配置 pgAdmin 登录账号密码;不确定时联系维护者),避免让人误以为需要在前端项目里配置或能直接查看。
| {/* iframe 占满剩余视口,便于操作。高度用 calc 减去 header 高度约 220px。 */} | ||
| <div className="flex-1 border-t border-[var(--foreground)]"> | ||
| <iframe | ||
| src={PGADMIN_URL} | ||
| title="pgAdmin" | ||
| className="w-full h-[calc(100vh-220px)] min-h-[600px] border-0" |
There was a problem hiding this comment.
iframe 高度通过 calc(100vh-220px) 依赖一个“约 220px”的 magic number;header 文案/字号/响应式变化后容易出现双滚动条或底部留白。既然外层用了 flex flex-col + flex-1,建议让 iframe 通过 flex 填满剩余空间(例如容器/iframe 使用 flex-1 h-full min-h-0 等),避免硬编码高度。
| {/* iframe 占满剩余视口,便于操作。高度用 calc 减去 header 高度约 220px。 */} | |
| <div className="flex-1 border-t border-[var(--foreground)]"> | |
| <iframe | |
| src={PGADMIN_URL} | |
| title="pgAdmin" | |
| className="w-full h-[calc(100vh-220px)] min-h-[600px] border-0" | |
| {/* iframe 通过 flex 填满 header 下方的剩余空间,避免依赖固定高度 magic number。 */} | |
| <div className="flex-1 min-h-0 flex border-t border-[var(--foreground)]"> | |
| <iframe | |
| src={PGADMIN_URL} | |
| title="pgAdmin" | |
| className="flex-1 w-full h-full min-h-0 border-0" |
背景:2026-04-17 把 Neon 切到自建 Docker PG 后,前端 Next.js 的 Prisma 还 指向 Neon,形成"前端写 Neon 旧库、后端读自建 PG"的脏数据分叉。方案 A: 前端 onFinish 不再直接写 DB,改调后端 API 由后端统一持久化。 - chat/repository/ChatHistoryRepository + JdbcChatHistoryRepository:@transactional 原子写 chat 表 + user 消息 + assistant 消息;chat 用 ON CONFLICT upsert, 匿名/登录混用同 chatId 时 COALESCE 保留已有 userId,避免被 NULL 覆盖 - chat/controller/ChatHistoryController:POST /api/chat/sessions/save,匿名也 放行(SaTokenConfigure 加 notMatch),登录时自动从 sa-token 取 userId - chat/dto/ChatTurnSaveRequest:一次请求三件事(chatId + userMessage + assistantMessage) - schema.sql:补 "Chat" 和 "Message" DDL,让新部署能直接起库;名字带双引号 保持与 Prisma schema 生成的大小写一致 配套前端 PR:InvolutionHell/involutionhell#301 改 app/api/chat/route.ts 的 onFinish 从 prisma.chat.upsert/message.create 换成 fetch 本接口。
背景: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
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。
pgAdmin 自身跑在 SERVER_MODE=False(无登录页,desktop 模式)对公网暴露是个
critical 漏洞:扫到路径就能对生产 DB 跑 SQL。改方案:外层 Caddy 在
/admin/pgadmin/* 前插 forward_auth 钩子,钩本接口;接口用 @SaCheckRole("admin")
判定当前请求带的 cookie.satoken 对应的用户是不是 admin,通过就 200,否则 sa-token
自动抛异常走 401 / 403。
- 新建 admin/controller 包专门放跨业务的基础设施级接口(不塞进 events/controller
避免语义混淆)
- 响应体故意空壳,Caddy 只看状态码不读 body
- superadmin 自动包含 admin 角色,天然放行,无需额外分支
- sa-token.is-read-cookie 默认开启,不用改 application.properties
配套前端改动:InvolutionHell/involutionhell#301 登录时把 satoken 同步写到
.involutionhell.com 域名 cookie,浏览器跨子域自动带。
配套 Caddy:/home/ubuntu/caddy-gateway/Caddyfile 改 handle 块,见 docs/database.md。
配合后端 /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
开发时访问 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 规则。
* feat(db): 自建 PostgreSQL + pgAdmin GUI + 自动备份,替代 Neon Neon 免费月度 100 CU-h 配额耗尽后计算节点被暂停,业务全挂(2026-04-17 事故)。 改用本机 compose 起 postgres:18-alpine,附带 pgAdmin 作为带按钮的备份/恢复 GUI、 pg-backup 容器做每日自动快照(保留 30d/8w/12m)。 - docker-compose.yml 新增 pgadmin 与 pg-backup 服务,共享 pg-backups 命名卷 - pgAdmin 预注册 InvolutionHell 服务器,pgpass 通过只读挂载提供,不需每次手填 - pgpass 走 .gitignore,提供 pgpass.example 作模板 - docs/database.md 完整记录:日常使用、手动/定时备份、GUI/CLI 恢复流程、迁移历史 .env 的 PGHOST 已在服务器上从 Neon endpoint 改为 compose 服务名 postgres, 仓库中 .env 不入库故未一并提交,需按 docs/database.md 描述在部署机上同步更新。 * feat(db): pgAdmin 走 /admin/pgadmin 反向代理,供主站 iframe 嵌入 主站 involutionhell.com/admin/database 页面 iframe pgAdmin,管理员一个入口 解决。pgAdmin 端口从 0.0.0.0 收回到 127.0.0.1:8082,只接受 Caddy 从本机转发。 - SCRIPT_NAME=/admin/pgadmin 让 pgAdmin 自生成 URL 带正确前缀 - X_FRAME_OPTIONS 清空,由上游 Caddy 用 CSP frame-ancestors 控制 - WTF_CSRF_SSL_STRICT 关闭,避免跨子域 iframe 触发 CSRF 拒绝 Caddy 配置(独立文件 /home/ubuntu/caddy-gateway/Caddyfile,不在本仓库)同步 添加 /admin/pgadmin/* handle,剥 X-Frame-Options 并下发 CSP: frame-ancestors 'self' https://involutionhell.com https://*.involutionhell.com ... docs/database.md 补充 iframe 架构与环境变量说明。 * feat(admin): /api/admin/pgadmin-check 作为 Caddy forward_auth 目标 pgAdmin 自身跑在 SERVER_MODE=False(无登录页,desktop 模式)对公网暴露是个 critical 漏洞:扫到路径就能对生产 DB 跑 SQL。改方案:外层 Caddy 在 /admin/pgadmin/* 前插 forward_auth 钩子,钩本接口;接口用 @SaCheckRole("admin") 判定当前请求带的 cookie.satoken 对应的用户是不是 admin,通过就 200,否则 sa-token 自动抛异常走 401 / 403。 - 新建 admin/controller 包专门放跨业务的基础设施级接口(不塞进 events/controller 避免语义混淆) - 响应体故意空壳,Caddy 只看状态码不读 body - superadmin 自动包含 admin 角色,天然放行,无需额外分支 - sa-token.is-read-cookie 默认开启,不用改 application.properties 配套前端改动:InvolutionHell/involutionhell#301 登录时把 satoken 同步写到 .involutionhell.com 域名 cookie,浏览器跨子域自动带。 配套 Caddy:/home/ubuntu/caddy-gateway/Caddyfile 改 handle 块,见 docs/database.md。 * docs(db): pgAdmin 走 Caddy forward_auth + 前端 cookie 同步的完整架构说明 替换掉上一版 iframe-嵌入的描述(那版已经废弃,走不通 CSRF)。新架构: - pgAdmin SERVER_MODE=False 无自身登录页,仅 127.0.0.1:8082 监听 - 唯一公网入口 api.involutionhell.com/admin/pgadmin/* 由 Caddy handle 块 管控,前置 forward_auth 调 127.0.0.1:8080/api/admin/pgadmin-check - 后端 @SaCheckRole("admin") 依赖 sa-token 从 cookie 读 token - 前端 lib/use-auth.tsx 登录成功时把 satoken 同步写 .involutionhell.com 域名 cookie,浏览器跨子域自动带 补 Caddy 配置片段 + 后端 Controller 片段 + 前端同步逻辑说明, future reviewer 不用再去三处代码翻找。 * chore(pgadmin): 把 SERVER_MODE=False 的前提写在 compose 注释里 + 修 pg-backups 挂载 - 注释里显式写清楚:pgAdmin 跑 desktop 模式(无登录页)的安全前提是外层 Caddy forward_auth 必须先到位。直接暴露 8082 是重大漏洞。 - pg-backups 卷从 /var/lib/pgadmin/storage/... 改挂到 /backups:ro。 之前 SERVER_MODE=True 尝试时会因为 root 所有的备份目录触发 'user does not have permission to read and write the specified storage directory' 让 pgAdmin 无法启动;切回 desktop 模式后也避免继续污染 pgAdmin 自己的 storage 路径。restore 对话框现在要手填 /backups/daily/xxx.dump 路径。
背景:2026-04-17 把 Neon 切到自建 Docker PG 后,前端 Next.js 的 Prisma 还 指向 Neon,形成"前端写 Neon 旧库、后端读自建 PG"的脏数据分叉。方案 A: 前端 onFinish 不再直接写 DB,改调后端 API 由后端统一持久化。 - chat/repository/ChatHistoryRepository + JdbcChatHistoryRepository:@transactional 原子写 chat 表 + user 消息 + assistant 消息;chat 用 ON CONFLICT upsert, 匿名/登录混用同 chatId 时 COALESCE 保留已有 userId,避免被 NULL 覆盖 - chat/controller/ChatHistoryController:POST /api/chat/sessions/save,匿名也 放行(SaTokenConfigure 加 notMatch),登录时自动从 sa-token 取 userId - chat/dto/ChatTurnSaveRequest:一次请求三件事(chatId + userMessage + assistantMessage) - schema.sql:补 "Chat" 和 "Message" DDL,让新部署能直接起库;名字带双引号 保持与 Prisma schema 生成的大小写一致 配套前端 PR:InvolutionHell/involutionhell#301 改 app/api/chat/route.ts 的 onFinish 从 prisma.chat.upsert/message.create 换成 fetch 本接口。
背景:2026-04-17 把 Neon 切到自建 Docker PG 后,前端 Next.js 的 Prisma 还 指向 Neon,形成"前端写 Neon 旧库、后端读自建 PG"的脏数据分叉。方案 A: 前端 onFinish 不再直接写 DB,改调后端 API 由后端统一持久化。 - chat/repository/ChatHistoryRepository + JdbcChatHistoryRepository:@transactional 原子写 chat 表 + user 消息 + assistant 消息;chat 用 ON CONFLICT upsert, 匿名/登录混用同 chatId 时 COALESCE 保留已有 userId,避免被 NULL 覆盖 - chat/controller/ChatHistoryController:POST /api/chat/sessions/save,匿名也 放行(SaTokenConfigure 加 notMatch),登录时自动从 sa-token 取 userId - chat/dto/ChatTurnSaveRequest:一次请求三件事(chatId + userMessage + assistantMessage) - schema.sql:补 "Chat" 和 "Message" DDL,让新部署能直接起库;名字带双引号 保持与 Prisma schema 生成的大小写一致 配套前端 PR:InvolutionHell/involutionhell#301 改 app/api/chat/route.ts 的 onFinish 从 prisma.chat.upsert/message.create 换成 fetch 本接口。
背景
Neon → 自建 PG 迁移后,管理员需要一个入口做备份/恢复/查表/跑 SQL。
不想额外打开 `api.involutionhell.com:8082` 这种裸页面,直接内嵌进主站。
变更
配套(在后端仓)
验证
UX 说明
pgAdmin 自身的视觉风格跟主站割裂——这是方案 A 的内在缺点。用户明确说
"管理员不配享受好 UI",优先接通能力。后续若要统一风格,可以切方案 B
(自建 admin UI 调后端 API),或 C(常用操作自建 + 高级操作 iframe 兜底)。