面向运维与自托管用户。本文所有地址一律使用占位符:{DOMAIN}/{BETA_HOST} 域名、{HK_IP} 服务器 IP、 {HOST_ROOT} 部署父目录、{PROD_ROOT}/{BETA_ROOT} 两套部署根、{BACKUP_DIR} 备份目录 (真实值只在本机 gitignored 的
local/ENV.md),请勿将真实服务器信息写入本仓库。 ({ORIGIN_IP} 随大陆源站退役已废弃,不应再出现。)
用户 ──HTTPS──> Cloudflare({DOMAIN} 走代理)或 DNS 直连({BETA_HOST} 仅解析)
│
▼
单台服务器 {HK_IP} · Caddy(TLS 终结 / Let's Encrypt 自动证书 / gzip)
├─ 静态本地直出 + SPA 兜底
│ {DOMAIN} → root {PROD_ROOT}/app 静态 7 天
│ {BETA_HOST} → root {BETA_ROOT}/app 不缓存
├─ /api /uploads /avatars /dl → 127.0.0.1:3000(生产)/ :3100(内测)
├─ /games/werewolf/api/*(剥前缀)、/games/werewolf/werewolf-ws(不剥)→ 127.0.0.1:3011
├─ /download → 302 → /api/v1/desktop/download
└─ PostgreSQL 14(127.0.0.1:5432):库 qbao / qbao_beta(同一集群两本账)
原「大陆源站 + 香港边缘网关」两层结构已废弃:大陆机房会拦截未单列备案的 Host, 迁港后香港主机直接就是源站,不再需要 nginx、回源 Host 改写、
X-Qbao-Route路由头或出口 IP 白名单。 机制原理见 docs/ARCHITECTURE.md §2(含 Caddy 实际路由配置),内测环境概念见 docs/ENVIRONMENTS.md。
内核 TCP 拥塞控制必须是 BBR(v3.37.6,实测差 12~21 倍,缺了它图片就是「发不动也打不开」):
# /etc/modules-load.d/qbao-net.conf
tcp_bbr
sch_fq
# /etc/sysctl.d/99-qbao-net.conf
net.core.default_qdisc=fq
net.ipv4.tcp_congestion_control=bbr
sysctl --system 后确认 sysctl net.ipv4.tcp_congestion_control = bbr、tc qdisc show dev eth0 首行是 fq。
原理:本机在境外、用户在国内,跨境链路有丢包;默认的 cubic 把丢包一律当拥塞,单条 TCP 流实测只有
10.6 KB/s,而 BBR 按带宽×RTT 建模,同样一条流跑到 126.9 KB/s。浏览器对一个源只开一条
HTTP/2 连接,所有图片共享这一条流 —— 所以这个内核参数直接决定聊天图片的可用性。
本项目线上实例不使用 PM2:以 systemd 单元运行(qbao-api / qbao-api-beta / qbao-werewolf, 模板 server/deploy/qbao-api.service,见 §4B 与 docs/ARCHITECTURE.md §3.1)。以下为自托管最小部署写法。
# 1) 获取代码(注意:历史已重写,需全新克隆)
git clone git@github.com:Paraso42/Qbao.git {PROD_ROOT}
# 2) 初始化数据库
sudo -u postgres psql -c "CREATE DATABASE qbao"
sudo -u postgres psql -d qbao -f {PROD_ROOT}/server/init.sql
# 历史/新增迁移(T17 版本化:schema_migrations 追踪,只执行未应用项)
cd {PROD_ROOT}/server && npm ci --omit=dev && node scripts/run_migration.js
# 升级后自检(建议每次部署都跑):编号缺口提示 +「已应用但仓库无文件」的幽灵版本告警
node scripts/run_migration.js --verify
# --list 只列状态(--verify 亦会打印该清单);两者都只读,不改库
# 3) 配置环境变量
cd {PROD_ROOT}/server
cp .env.example .env # 必填:PGPASSWORD、JWT_SECRET;按需填各 AI Key
chmod 600 .env
openssl rand -hex 32 # 生成 JWT_SECRET
# 4) 安装并启动
npm ci --omit=dev
npm start # 生产请改用 systemd 单元(见 §4B.2 第 5 步),不要挂在前台
前端为 Vue+Vite 构建产物:cd app && npm ci && npm run build 后发布 app/dist/
(singlefile:index.html + vendor/,CI 亦会构建并冒烟校验 CSP)。
线上实例把构建产物解包发布为独立静态目录 {PROD_ROOT}/app(Caddy
root直接指向该目录,见 §1/§4B); 下列示例以仓库内 app/dist 为自托管最小写法。Caddyfile 示例:
{DOMAIN} {
encode gzip
@static { not path /api /api/* /uploads/* /avatars/* /dl /dl/* /download }
route @static {
root * {PROD_ROOT}/app/dist # 线上实例 = {PROD_ROOT}/app(解包目录)
try_files {path} {path}/ /index.html
file_server
}
@dyn path /api/* /api /uploads/* /avatars/* /dl/* /dl
route @dyn {
reverse_proxy 127.0.0.1:3000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
}
@assets path *.js *.css *.png *.jpg *.jpeg *.gif *.ico *.svg *.woff *.woff2
header @assets Cache-Control "public, max-age=604800"
}
要点:
@static的not path列表必须与@dyn的列表严格互补——漏掉某个动态前缀, 该路径就会被静态兜底吞掉(返回 index.html 而不是 API 响应)。 改配置后先caddy validate --config /etc/caddy/Caddyfile再systemctl reload caddy。
内测环境与生产同服务器、同代码、不同目录/进程/数据库/域名:静态 {BETA_ROOT}/app、API :3100、 库 qbao_beta、域名 {BETA_HOST}。所有测试动作只允许发生在内测环境;生产库禁止测试写入。 流程纪律见 docs/DEVELOPMENT_FLOW.md(§3、§7),概念与内测入口见 docs/ENVIRONMENTS.md, 链路机制见 docs/ARCHITECTURE.md §2。
| 层 | 入口 | 静态根 | API | 数据库 | 缓存 |
|---|---|---|---|---|---|
| L2 生产 | https://{DOMAIN} | {PROD_ROOT}/app | :3000(qbao-api) | qbao | 静态 7 天 |
| L1 内测 | https://{BETA_HOST} | {BETA_ROOT}/app | :3100(qbao-api-beta) | qbao_beta | 不缓存 |
# 1) 目录与代码(部署侧非 git 检出;日常同步走 scripts/stage.ps1 -Env beta,首次可手工铺底)
mkdir -p {BETA_ROOT}/server {BETA_ROOT}/app {BETA_ROOT}/downloads {BETA_ROOT}/uploads
# 2) 建库(同一 PostgreSQL 集群,独立账本;<db_user> 为实际 PG 属主,见 local/ENV.md)
sudo -u postgres createdb -O <db_user> qbao_beta
# 3) 内测环境变量(.env)
cp -n {PROD_ROOT}/server/.env {BETA_ROOT}/server/.env # 或从 .env.example 起
# 必改:PORT=3100、PGDATABASE=qbao_beta、JWT_SECRET=新随机值(openssl rand -hex 32)、CORS_ORIGIN=https://{BETA_HOST}
# AI Key 留空;chmod 600 .env
# 内测网全员管理员:追加 AUTO_ADMIN=1(注册即 admin,仅内测可开;生产严禁)
# 4) Schema 初始化 —— 以生产库 schema-only 克隆(勿用仓库 init.sql:线上生产库与基线已漂移,克隆保证一致)
pg_dump --schema-only -U <db_user> -d qbao | sudo -u postgres psql -d qbao_beta
pg_dump -U <db_user> -d qbao -t schema_migrations --data-only | sudo -u postgres psql -d qbao_beta
# 再执行剩余迁移:cd {BETA_ROOT}/server && node scripts/run_migration.js(读本目录 .env → qbao_beta)
# 5) systemd 单元(以 server/deploy/qbao-api.service 为模板另存 qbao-api-beta.service)
# WorkingDirectory={BETA_ROOT}/server,ExecStart=node server.js
systemctl daemon-reload && systemctl enable --now qbao-api-beta
curl -s http://127.0.0.1:3100/api/v1/health
# 6) 静态首灌与后续同步:scripts/stage.ps1 -Env beta -Mode app(构建产物 → {BETA_ROOT}/app)
# Caddy 只需新增/确认 {BETA_HOST} site 块(见 4B.3),不需要改动生产块
环境由访问哪个域名唯一决定:
{DOMAIN}块指向生产静态根与 :3000,{BETA_HOST}块指向内测静态根与 :3100。 两块的 route 结构完全相同,仅root、reverse_proxy端口与Cache-Control三处不同。 完整示例(含狼人杀剥前缀、WebSocket、/download短链)见 docs/ARCHITECTURE.md §2.4。
{BETA_HOST} {
encode gzip
@static { not path /api /api/* /uploads/* /avatars/* /dl /dl/* /download \
/games/werewolf/werewolf-ws /games/werewolf/api/* }
route @static {
root * {BETA_ROOT}/app
try_files {path} {path}/ /index.html
file_server
}
@dyn path /api/* /api /uploads/* /avatars/* /dl/* /dl
route @dyn { reverse_proxy 127.0.0.1:3100 { header_up Host {host} } }
@assets path *.js *.css *.png *.jpg *.jpeg *.gif *.ico *.svg *.woff *.woff2
header @assets Cache-Control "no-cache, no-store, must-revalidate"
}
DNS:{BETA_HOST} 在 Cloudflare 建 A 记录指向 {HK_IP} 并设为仅 DNS(灰云),Caddy 才能走标准 ACME 校验; {DOMAIN} 走 Cloudflare 代理(橙云)。改动后必须:先备份
/etc/caddy/Caddyfile(Caddyfile.bak_<日期>),再caddy validate+systemctl reload caddy。
# 部署(同步代码/静态 → 内测目录 → 迁移 → 重启),流程见 docs/DEVELOPMENT_FLOW.md §⑥
scripts/stage.ps1 -Env beta -Mode all # 或手工 tgz+scp(同 §8 流程,目标改 beta 目录)
systemctl restart qbao-api-beta
# 冒烟:可写 E2E 走内测库(scripts/qa/smoke-stage.ps1 -Env beta)+ 只读健康检查
curl -s https://{BETA_HOST}/api/v1/health
# 内测库清理重建(允许随时执行,不影响生产;重建后需重开 AUTO_ADMIN 测试账号)
sudo -u postgres dropdb qbao_beta
sudo -u postgres createdb -O <db_user> qbao_beta
# 再按 4B.2 第 4 步重新做 schema-only 克隆 + run_migration.js,然后 restart qbao-api-beta + 冒烟
SKIP_AI_WORKER=1 环境变量支持);AI Key 留空自然不消费额度。systemctl status qbao-api-beta / ps -o rss,cmd -p <pid>。进程对 SIGTERM/SIGINT 有处理(server/src/lib/gracefulShutdown.js,R10):停止领取新任务 → 停止接收新连接
→ 等待在途 AI 任务与请求收尾 → 关闭连接池 → 退出。
因此 systemctl restart(默认发 SIGTERM)不会硬切正在进行的 AI 出题任务。
TimeoutStopSec=15 对齐;任务普遍较长时可调大环境变量
QBAO_SHUTDOWN_GRACE_MS(毫秒),并同时把 TimeoutStopSec 调到不小于该值(否则 systemd 会 SIGKILL)。[shutdown] 收到 SIGTERM,开始优雅停机(宽限 15000ms) → [shutdown] 已停止;
若出现「宽限期已到,仍有未完成的工作」,说明有任务超过宽限期,考虑调大该值。systemctl kill -s TERM)会立即退出,用于卡住时的逃生。?qa=1 钩子仅 {BETA_HOST}/localhost 生效(代码门禁,见 docs/GAMES.md §六)。所有上传统一在 <仓库根>/uploads/(T16 已收敛,AI 临时文件与其余通道同根):
| 目录 | 用途 |
|---|---|
uploads/pool/ |
共享文件池(AI 出题资料,生产数据,必须备份) |
uploads/chat/ |
聊天附件 |
uploads/issues/ |
反馈图片 |
uploads/avatars/ |
头像 |
非 root 运行(推荐,见 §3.5)时,用 server/deploy/prepare_dirs.sh 初始化属主:
sudo bash server/deploy/prepare_dirs.sh {PROD_ROOT}
uploads/chat/ 不是长期存储,四道闸门(详见 docs/ARCHITECTURE.md §2.6)会持续回收它:
| 现象 | 含义 | 运维动作 |
|---|---|---|
日志 [chat-media] 回收完成:孤儿 N,过期 N,残留 N,释放 NMB |
每小时一次的正常回收 | 无需干预(残留=迁移前遗留的无台账文件,按 180 天保留期清) |
日志 [chat-media] 台账表不可用(可能尚未执行 020 迁移) |
新代码先上线、迁移没跑 | 立即 node scripts/run_migration.js;期间上传仍可用(降级) |
| 用户报「聊天文件存储已满(上限 500MB)」 | 该账号留存总量到顶 | 让用户删大文件;180 天前的附件会自动释放 |
| 用户报「下载流量已超出限制」 | 该 IP 10 分钟内下载超 1GB | 正常浏览到不了,多半是脚本重放;必要时调 MEDIA_DOWNLOAD_BYTES_PER_10MIN |
两个下载计数器在进程内存里(
lib/mediaLimits.js):单实例部署下正确;若将来加第二个 API 实例, 必须改成 Redis/PG 共享计数,否则限额会按实例数翻倍。 所有阈值集中在server/src/config/files.js(CHAT_*/MEDIA_*),改完重启生效。
线上链路:TLS 由服务器上的 Caddy 就地终结并自动续期(Let’s Encrypt / ACME HTTP-01,{DOMAIN} 与 {BETA_HOST} 各一张);
{DOMAIN} 另经 Cloudflare 代理(用户侧先经 CF 边缘证书,CF ↔ 服务器回源使用 Caddy 的正式证书)。
80 端口仅用于 ACME 校验与跳转。改 Caddyfile 后 caddy validate + systemctl reload caddy。
自托管用户若要换用 nginx/certbot,按常规签发即可:
sudo certbot --nginx -d your.domain.com
服务器以 cron 每日 04:00 自动备份双库(保留 30 天):
# /etc/cron.d/qbao-backup —— 生产 + 内测两本账一并 dump
pg_dump -U qbao qbao | gzip > {BACKUP_DIR}/qbao_$(date +\%F).sql.gz
pg_dump -U qbao qbao_beta | gzip > {BACKUP_DIR}/qbao_beta_$(date +\%F).sql.gz
# 上传文件(可另行每日)
rsync -a {PROD_ROOT}/uploads/ {BACKUP_DIR}/uploads/
恢复没有”回滚到源站”这条路:大陆源站已于 2026-09-10 退役清理(Qbao 零残留), 恢复只能走 HK 的每日 dump,或本机 gitignored 的
local/backups/(迁港时点快照 + 源站退役归档)。
git pull(首次从旧历史切换必须先重新克隆)。cd server && node scripts/run_migration.js(schema_migrations 自动跳过已应用项;旧库手工迁移过可先 --mark-applied)。cd server && npm ci --omit=dev(依赖有变化时)。sudo bash server/deploy/prepare_dirs.sh {PROD_ROOT}(目录属主修正)后重启服务。cd app && npm ci && npm run build 后,用 app/dist/ 覆盖式发布到 Caddy 静态根({PROD_ROOT}/app),必要时刷新浏览器缓存。香港主机若需重建(服务与数据库同机,这是唯一线上主机):
/etc/sysctl.d/99-qbao-net.conf 与 /etc/modules-load.d/qbao-net.conf)——
漏掉这一步不会报任何错,但跨境单流吞吐会掉到 1/12,表现为「图片发得极慢、打开聊天极慢」。run_migration.js)。{BACKUP_DIR}/qbao_*.sql.gz(或本机 local/backups/pre-hk-migration/)导入;内测库同理。downloads/ 分发储藏室(manifest.json 是发布事实源,务必一并恢复)。systemctl enable --now caddy。systemctl enable --now qbao-api qbao-api-beta qbao-werewolf;ufw 规则重放(放行 22/80/443,deny 3000/3100/3011)。https://{DOMAIN}/api/v1/health 与 https://{BETA_HOST}/api/v1/health 均返回 ok,且 DB 连接正常。桌面端更新、下载完全由本站服务器提供(不依赖 GitHub;GitHub 仅作 CI 与发布归档,服务器永不直连 GitHub)。
<repo>/downloads,位于 server/ 之外,发布清理脚本不触碰):downloads/
manifest.json # 唯一事实源(scripts/publish-installer.js 生成,服务器只读)
manifest.json.bak # 每次发布前的滚动备份(回滚依据)
stable/latest.yml Qbao-Setup-<v>.exe Qbao-Setup-<v>.exe.blockmap
beta/latest.yml ...
/api/v1/desktop/manifest?channel=stable|beta — 版本清单(latest 在前,含 required/retracted/stopped)/api/v1/desktop/latest — 最新稳定版元信息(旧版兼容,字段不变)/api/v1/desktop/download?file=<fileName> — 任意留存版本精确下载(缺省=最新稳定版;retracted → 410)/api/v1/desktop/update/<channel>/latest.yml 与 <file> — 桌面端 electron-updater generic feed(exe/blockmap)/api/v1/desktop/stats — 下载统计(版本×日聚合,无 PII)/dl — 公开下载落地页(中国大陆镜像站点,服务端动态渲染)/download — 短链 302 → /api/v1/desktop/download/api 反代已覆盖全部 API 端点,只需短链):route /download { redir * /api/v1/desktop/download 302 }
大文件下载(85MB+ 安装包)在 Caddy 的
reverse_proxy下按流式转发,无 nginx 那样的proxy_read_timeout默认 60s 限制;若前面另加了 CDN/代理,注意放宽其读超时。
013_desktop_download_stats.sql(下载统计表;run_migration.js 自动应用)。docs/PUBLISHING.md(scripts/publish-installer.js:add/promote/retract/verify/ls)。| 端口 | 协议 | 用途 | 开放范围 |
|---|---|---|---|
| 22 | TCP | SSH 管理 | 公网(密钥登录,密码登录已禁用) |
| 80 | TCP | Caddy:ACME 校验 + HTTPS 跳转 | 公网 |
| 443 | TCP | Caddy:HTTPS 主入口 | 公网 |
| 443 | UDP | Caddy:HTTP/3(QUIC) | 公网 |
| 3000 | TCP | Node 后端(生产) | 永不对外(ufw 显式 deny,仅 127.0.0.1) |
| 3100 | TCP | Node 后端(内测) | 永不对外(ufw 显式 deny,仅 127.0.0.1) |
| 3011 | TCP | 狼人杀房间服务 | 永不对外(ufw 显式 deny,仅 127.0.0.1) |
| 5432 | TCP | PostgreSQL | 永不对外(仅 localhost) |
自托管若走「内网 VPN 模式」,可只对 VPN 网段开放 80/443,后端与数据库端口规则不变。
.env 权限 600,JWT_SECRET 使用强随机值。PermitRootLogin 从 yes 改为 prohibit-password。x-ai-api-key 请求头透传)。ADMIN_USERNAMES 环境变量中时自动成为 admin(server/.env.example 有说明)。AUTO_ADMIN=1(注册即管理员,仅内测);生产严禁开启。管理员不可被其他管理员封禁/改密/改角色(服务端强制 403,客户端界面同步收敛),角色变更仅后台脚本(见 docs/ARCHITECTURE.md §4.2)。npm audit 检查依赖漏洞;CI 已含 gitleaks 密钥扫描与产物冒烟。