feat(db): 自建 PostgreSQL + pgAdmin GUI + 自动备份,替代 Neon#12
Conversation
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 描述在部署机上同步更新。
There was a problem hiding this comment.
Pull request overview
将数据库从 Neon 迁移到自建 Docker PostgreSQL,并补齐 pgAdmin GUI 与定时备份能力,降低因外部配额/暂停导致的全站不可用风险。
Changes:
- 在
docker-compose.yml增加pgadmin与pg-backup服务,并新增pgadmin-data、pg-backups命名卷 - 增加 pgAdmin 预注册连接配置(
servers.json)与 pgpass 示例/忽略规则 - 新增数据库运维手册(备份/恢复/初始化与迁移记录)
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/database.md | 新增自建 PG + pgAdmin + 备份/恢复操作手册与迁移记录 |
| docker/pgadmin/servers.json | pgAdmin 预注册服务器连接配置 |
| docker/pgadmin/pgpass.example | pgpass 示例文件 |
| docker-compose.yml | 增加 pgAdmin 与定时备份容器、挂载备份卷与健康依赖 |
| .gitignore | 忽略真实 pgpass 密码文件 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # 备份卷挂到 pgAdmin 用户默认存储目录,这样在 Restore 对话框里能直接选到 | ||
| # pg-backup 容器每天生成的 dump 文件。路径格式:<email 替换 @ 为 _>/ | ||
| - pg-backups:/var/lib/pgadmin/storage/admin_involutionhell.com/backups |
There was a problem hiding this comment.
pg-backups 被挂载到 pgAdmin 的路径硬编码为 admin_involutionhell.com(依赖 PGADMIN_DEFAULT_EMAIL 把 @ 替换成 _ 的规则)。一旦部署时用自定义 PGADMIN_EMAIL,pgAdmin 的默认 storage 路径会变化,导致 Restore/Storage Manager 看不到同一个备份卷目录。建议将卷挂载到更稳定的路径(例如挂到 /backups 或挂到 /var/lib/pgadmin/storage 顶层并在文档中说明目录名),或明确强制/校验 PGADMIN_EMAIL 必须是 admin@involutionhell.com。
| PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-change_me} | ||
| PGADMIN_CONFIG_SERVER_MODE: "False" | ||
| PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False" | ||
| ports: | ||
| - "8082:80" |
There was a problem hiding this comment.
pgAdmin 通过 ports: "8082:80" 直接对外暴露,但同时给了默认账号/密码(admin@involutionhell.com / change_me)并关闭 master password。若部署机未显式配置 .env,会产生可被弱口令/默认口令访问的管理面板风险。建议去掉默认密码(要求必须配置 PGADMIN_PASSWORD),并考虑仅绑定到 127.0.0.1:8082 或放到反向代理后加额外认证/访问控制。
| PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-change_me} | |
| PGADMIN_CONFIG_SERVER_MODE: "False" | |
| PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False" | |
| ports: | |
| - "8082:80" | |
| PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:?PGADMIN_PASSWORD must be set} | |
| PGADMIN_CONFIG_SERVER_MODE: "False" | |
| PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False" | |
| ports: | |
| - "127.0.0.1:8082:80" |
| docker-compose.yml 里四个相关服务: | ||
| postgres — PostgreSQL 18 主库,数据卷 involution-postgres-data(命名卷,持久化) | ||
| backend — Spring Boot,通过内网 jdbc:postgresql://postgres:5432/involution_hell 访问 | ||
| pg-backup — prodrigestivill/postgres-backup-local,每天 03:00 跑 pg_dump,保留 30 天日备/8 周周备/12 月月备 |
There was a problem hiding this comment.
这里写“每天 03:00 跑 pg_dump”,但当前 compose 里 pg-backup 的 SCHEDULE 是 @daily(不一定是 03:00)。文档应与实际配置保持一致:要么改文档时间描述,要么把 SCHEDULE 改成明确的 03:00 cron 表达式并注明时区。
| pg-backup — prodrigestivill/postgres-backup-local,每天 03:00 跑 pg_dump,保留 30 天日备/8 周周备/12 月月备 | |
| pg-backup — prodrigestivill/postgres-backup-local,按 `@daily` 调度运行 pg_dump,保留 30 天日备/8 周周备/12 月月备 |
| ```bash | ||
| docker exec involution-pg-backup /backup.sh | ||
| # 产物:pg-backups 卷里 last/daily/weekly/monthly 各一份 | ||
| # 文件名示例:involution_hell-20260417-201149.sql.gz(plain SQL + gzip) |
There was a problem hiding this comment.
手动备份这一段把产物描述为 .sql.gz(plain SQL + gzip),但 compose 里 pg-backup 已配置 POSTGRES_EXTRA_OPTS: "-Fc --blobs"(custom format),通常需要配合 pg_restore,且文件后缀/压缩方式可能不是 .sql.gz。建议统一:要么把 compose 改回 plain SQL + gzip(并在 env 设置对应 suffix),要么把本文这里的产物示例和后续恢复命令改为 custom format 的写法。
| # 文件名示例:involution_hell-20260417-201149.sql.gz(plain SQL + gzip) | |
| # 文件为 pg_dump -Fc --blobs 生成的 custom format,文件名/后缀以容器实际产物为准 | |
| # 恢复这类备份时通常使用 pg_restore |
| # 1. 选一个备份文件 | ||
| FILE=involution_hell-20260417.sql.gz | ||
|
|
||
| # 2. 停写入(避免恢复期间 backend 又写进去造成冲突) | ||
| docker stop involution-hell-backend | ||
|
|
||
| # 3. 清空当前库 | ||
| docker exec involution-postgres psql -U neondb_owner -d postgres \ | ||
| -c "DROP DATABASE involution_hell WITH (FORCE);" \ | ||
| -c "CREATE DATABASE involution_hell OWNER neondb_owner;" | ||
|
|
||
| # 4. 恢复(plain SQL.gz 格式用 psql + gunzip;若是 -Fc 自定义格式则改用 pg_restore) | ||
| docker exec involution-pg-backup sh -c \ | ||
| "gunzip -c /backups/daily/$FILE | psql -h postgres -U neondb_owner -d involution_hell" |
There was a problem hiding this comment.
恢复命令示例默认用 gunzip | psql 并且 FILE 也写成 .sql.gz,但当前 compose 已开启 -Fc(custom format)。在 custom format 下应使用 pg_restore(并匹配实际备份文件后缀/路径),否则按文档操作会直接失败。建议把示例拆成两套:plain SQL.gz 的恢复流程和 custom -Fc 的恢复流程,并确保默认配置对应其中一套。
| @@ -0,0 +1 @@ | |||
| postgres:5432:*:involution:change_me | |||
There was a problem hiding this comment.
该示例文件使用的用户名是 involution,但本 PR 的 servers.json 预注册服务器用户名是 neondb_owner,文档里恢复命令也用 neondb_owner。如果照这个示例生成 pgpass,pgAdmin 仍会因为用户不匹配而无法自动填充密码/连接失败。建议让 pgpass.example 的用户与 servers.json/默认 POSTGRES_USER 保持一致,或在文档中明确三处必须同步修改。
| postgres:5432:*:involution:change_me | |
| postgres:5432:*:neondb_owner:change_me |
| 备份文件写入 `pg-backups` 命名卷,pgAdmin 也挂载同一个卷到 | ||
| `/var/lib/pgadmin/storage/admin_involutionhell.com/backups/`, | ||
| 在 pgAdmin 的 Restore 对话框里直接选得到。 |
There was a problem hiding this comment.
这里的 pgAdmin storage 路径同样硬编码了 admin_involutionhell.com,并依赖 PGADMIN_EMAIL 的具体值(@ 替换为 _)。如果部署时改了 PGADMIN_EMAIL,文档中给的路径将不再成立,可能导致“pgAdmin 看不到备份文件”的困惑。建议在文档里改为描述规则/动态路径,或要求固定 PGADMIN_EMAIL 并在 compose/文档中显式写出该约束。
| 备份文件写入 `pg-backups` 命名卷,pgAdmin 也挂载同一个卷到 | |
| `/var/lib/pgadmin/storage/admin_involutionhell.com/backups/`, | |
| 在 pgAdmin 的 Restore 对话框里直接选得到。 | |
| 备份文件写入 `pg-backups` 命名卷,pgAdmin 也挂载同一个卷。 | |
| 在容器内,pgAdmin 可见的目录通常位于 | |
| `/var/lib/pgadmin/storage/<由 PGADMIN_EMAIL 派生的目录名>/backups/`, | |
| 其中目录名依赖 `.env` 里的 `PGADMIN_EMAIL`(通常可理解为将 `@` 替换为 `_`)。 | |
| 如果部署时修改了 `PGADMIN_EMAIL`,请按实际派生后的目录查看;在 pgAdmin 的 Restore 对话框里可直接选到该目录中的备份文件。 |
| - ./docker/pgadmin/servers.json:/pgadmin4/servers.json:ro | ||
| # pgpass 主机文件必须是 UID 5050 所有且 0600,否则 pgAdmin 拒绝加载。 | ||
| # 用 `sudo chown 5050:5050 docker/pgadmin/pgpass && sudo chmod 600 …` 设好。 | ||
| - ./docker/pgadmin/pgpass:/tmp/pgpass |
There was a problem hiding this comment.
pgpass 在 PR 描述里提到“只读挂载”,但这里的 bind mount 没有加 :ro。如果容器内进程(或被入侵后)能改写该文件,会影响后续连接行为;也不符合上面注释的“只读”意图。建议把该挂载改成只读,或更新注释/文档与实际保持一致。
| - ./docker/pgadmin/pgpass:/tmp/pgpass | |
| - ./docker/pgadmin/pgpass:/tmp/pgpass:ro |
| # 自动定时备份:每天 03:00 对 postgres 容器做 pg_dump(custom format), | ||
| # 输出到共享卷 pg-backups,pgAdmin 能直接在 Restore 对话框里看到这些文件。 | ||
| # 保留最近 30 天日备 / 8 周周备 / 12 个月月备。 | ||
| pg-backup: | ||
| image: prodrigestivill/postgres-backup-local:18-alpine | ||
| container_name: involution-pg-backup | ||
| restart: unless-stopped | ||
| environment: | ||
| POSTGRES_HOST: postgres | ||
| POSTGRES_DB: ${POSTGRES_DB:-involution_hell} | ||
| POSTGRES_USER: ${POSTGRES_USER:-involution} | ||
| POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change_me} | ||
| POSTGRES_EXTRA_OPTS: "-Fc --blobs" | ||
| SCHEDULE: "@daily" |
There was a problem hiding this comment.
注释写“每天 03:00”,但实际 SCHEDULE 配置为 @daily,通常只表示“每天一次”而不保证 03:00(具体取决于镜像的 cron 实现/时区)。建议将注释与配置对齐:要么把 SCHEDULE 改为明确的 cron 表达式(并在文档注明容器时区),要么把注释和运维手册里的时间描述改成“每天一次”。
| "MaintenanceDB": "involution_hell", | ||
| "Username": "neondb_owner", |
There was a problem hiding this comment.
servers.json 里将 Username 固定为 neondb_owner、MaintenanceDB 固定为 involution_hell。这会与 compose/.env 默认的 POSTGRES_USER=involution 不一致,并且在按文档执行 drop/recreate 数据库时,维护库如果就是 involution_hell 会导致 pgAdmin 连接在恢复窗口期直接断开/无法重连。建议:1) 将 MaintenanceDB 设为更稳定的 postgres;2) 账号名与 compose 默认保持一致,或在文档里明确要求部署必须把 POSTGRES_USER/导入账号设为 neondb_owner,避免 pgAdmin 预注册连接不可用。
| "MaintenanceDB": "involution_hell", | |
| "Username": "neondb_owner", | |
| "MaintenanceDB": "postgres", | |
| "Username": "involution", |
主站 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 架构与环境变量说明。
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。
替换掉上一版 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 不用再去三处代码翻找。
- 注释里显式写清楚: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 路径。
配合后端 /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): /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 规则。
背景
Neon 免费月度 100 CU-h 配额耗尽后计算节点被暂停,
/api/events、登录、文档等全部报错(2026-04-17 事故)。方案
本机 compose 自建
postgres:18-alpine替代 Neon,附带:pg_dump,保留 30d / 8w / 12m迁移过程
pg_dump -Fc从 Neon pooler 拉出完整快照(14 张表、5487 行)involution_hell_test比对行数 ✅involution_hell.envPGHOST从 Neon endpoint 改为 compose 服务名postgresdocker compose up -d --force-recreate重启全栈/api/events返回 4 条真实数据 ✅,/actuator/healthdb UP ✅变更内容
docker-compose.yml:新增pgadmin+pg-backup服务和共享卷pg-backupsdocker/pgadmin/servers.json:预注册 InvolutionHell (local) 连接docker/pgadmin/pgpass.example+.gitignore:pgpass 走只读挂载,实际文件不入库docs/database.md:完整运维手册(日常使用、手动/自动备份、GUI/CLI 恢复、初始化步骤)部署机需要做的
本 PR 合并后,部署机上:
docs/database.md的 "初始化 pgpass 文件" 生成docker/pgadmin/pgpass(UID 5050, 0600).env补上PGADMIN_EMAIL/PGADMIN_PASSWORD,并把PGHOST/ JDBC URL 指向postgresdocker compose up -d测试
/api/events返回真实数据pg-backup手动触发/backup.sh成功产出.sql.gz/var/lib/pgadmin/storage/.../backups/