Qbao

Qbao 系统架构(总览与事实源)

文档定位:本文是「当前架构」的管理型总览——从公网 HTTPS 链路、双环境(内测/生产)部署,到代码分层、安全边界与工程工具链, 回答三个问题:系统现在长什么样、一次改动会碰到哪几层、该去哪篇文档查细节。

配套文档:环境说明书(非专业视角)ENVIRONMENTS.md · 部署操作 DEPLOY.md · 流程与红线 DEVELOPMENT_FLOW.md · 发布分发 PUBLISHING.md · 游戏空间 GAMES.md · 隐私与信息分离 DEVELOPMENT.md。

隐私铁律:本文面向 GitHub 公开,一律使用占位符 —— {DOMAIN} 生产域名、{BETA_HOST} 内测域名、 {HK_IP} 服务器 IP、{HOST_ROOT} 服务器上的部署父目录、{PROD_ROOT} / {BETA_ROOT} 两套部署根、 {BACKUP_DIR} 备份目录。({ORIGIN_IP} 随大陆源站退役已废弃,不应再出现。) 真实值只存在于 gitignored 的 local/ENV.md(对照表)与 local/stage.env.ps1(部署参数),详见 docs/DEVELOPMENT.md §1。

1. 架构总览

一句话:一套代码、三种形态(网页 / 桌面 / 手机壳)、两层在线环境(L1 内测 / L2 生产)、一台香港服务器直出(Caddy 终结 TLS + 静态托管 + 反向代理);同机上两个相互隔离的实例共享同一个 PostgreSQL 集群。

                          ┌──────────── 用户侧:三种形态共用同一份前端 ────────────┐
                          │ 浏览器网页 │ 手机壳 App(Capacitor)│ 桌面 Electron  │
                          └──────┬──────────────────────────────┬──────────────┘
                      https://{DOMAIN}                  https://{BETA_HOST}
                                 │                            │
                        Cloudflare(DNS + 代理)         DNS 仅解析(直连)
                                 │ HTTPS                      │ HTTPS
                                 ▼                            ▼
        ┌─────────────────────────────────────────────────────────────────────┐
        │ 单台服务器 {HK_IP} · Caddy(TLS 终结 / Let's Encrypt 自动证书 / gzip) │
        │   {DOMAIN}      块:静态 → {PROD_ROOT}/app(7 天缓存)→ API :3000     │
        │   {BETA_HOST}   块:静态 → {BETA_ROOT}/app(不缓存)  → API :3100     │
        └──────────────────────────────────────────┬──────────────────────────┘
                                                   │ 本机 127.0.0.1
                                                   ▼
        ┌─────────────────────────────────────────────────────────────────────┐
        │ {HK_IP} 同机运行                                                      │
        │   静态直出:{PROD_ROOT}/app · {BETA_ROOT}/app(file_server + SPA 兜底)│
        │   /api /uploads /avatars /dl → Node :3000(生产)/ :3100(内测)       │
        │   /games/werewolf/{api,werewolf-ws} → Node :3011(房间服务,内存态)   │
        └──────────────┬───────────────────────────────────┬──────────────────┘
                  qbao-api :3000                    qbao-api-beta :3100
                        │                                    │
              PostgreSQL(同一集群,127.0.0.1:5432)
                        │                                    │
                  库 qbao(生产账本)                库 qbao_beta(内测账本)

改动流动方向固定为从左到右:L0 本机 → L1 内测 → L2 生产(概念与入口见 docs/ENVIRONMENTS.md;顺序纪律见 docs/DEVELOPMENT_FLOW.md §7)。

2026-09-10 全量迁港:原「大陆源站 + 香港边缘网关」两层已合并为香港单机,源站已退役清理。 因此此前为规避大陆机房 ICP Host 拦截而设计的「回源 Host 改写 + X-Qbao-Route 路由头 + 出口 IP 白名单 map」 整套机制已废弃;环境区分回归最朴素的方式——Caddy 两个 site 块 + 两个本机端口。

2. 公网链路与 HTTPS(部署架构)

2.1 为什么是这条链路(背景与约束)

  1. 公网产品需要域名 + HTTPS:域名作为稳定入口(DNS 托管于 Cloudflare),TLS 是浏览器的硬性前提。
  2. 大陆机房不可用(2026-09-08 实测):大陆源站会拦截公网请求中「未单列备案」的 Host 头(返回 403 Non-compliance ICP Filing)。 为此曾搭出一套 workaround:香港边缘网关终结 TLS + 回源 Host 一律改写为源站 IP + 自定义头 X-Qbao-Route: beta + 源站 nginx map「路由头 + 网关出口 IP 白名单」分流内测。
  3. 结论(2026-09-10 定版):放弃大陆源站,服务全量迁至香港单机。这一步把上面整套 workaround 一并删除—— 香港主机不受大陆 ICP Host 拦截约束,可以直接按域名区分服务,于是:
    • 不再需要「回源」这一跳,Caddy 就地终结 TLS、就地托管静态、就近反代 API;
    • 不再需要 X-Qbao-Route 请求头与出口 IP 白名单(该机制已废弃,本文不再描述其配置);
    • 环境区分回归 Caddy 的两个 site 块 + 两个本机端口。
  4. 入口与兜底:主域名 {DOMAIN} 走 Cloudflare 代理(边缘 TLS + 缓存加速);内测域名 {BETA_HOST} 仅 DNS 解析直连。 原 http://{ORIGIN_IP} IP 直连兜底随源站退役已不存在。

2.2 各层职责

层 载体 职责 关键点
DNS / CDN Cloudflare 域名解析;{DOMAIN} 走代理(缓存加速、边缘 TLS);{BETA_HOST} 仅 DNS 控制台操作需 VPN(见 local/ENV.md)
入口 / TLS / 静态 / 反代 {HK_IP} · Caddy TLS 终结、Let’s Encrypt 自动证书、gzip、静态直出、SPA 兜底、API 反代 两个 site 块;配置 /etc/caddy/Caddyfile(见 2.4)
业务 API Node · :3000 / :3100 REST API(Express) systemd:qbao-api / qbao-api-beta
房间服务 Node · :3011 狼人杀实时(Koa + socket.io,房间在内存) systemd:qbao-werewolf;重启即清零、不落库
数据库 PostgreSQL(localhost) 两本独立账本 qbao / qbao_beta 见 §3.3
静态与分发 各环境 app/ 根 + downloads/ 储藏室 SPA / 游戏门户 / 安装包与清单 缓存策略差异见 2.5

2.3 证书与加密模型

2.4 请求路由与环境识别(Caddy 实际机制)

单文件 /etc/caddy/Caddyfile,两个 site 块各服务一个环境;块内用 order-preserving 的 route 块 + 显式 matcher 分流 (不用无 matcher 的兜底块,避免内部指令排序导致 404 抢先命中;占位符示意的等价形式):

{DOMAIN} {
        encode gzip

        route /download { redir * /api/v1/desktop/download 302 }       # 桌面端安装包短链

        route /games/werewolf/api/* {                                   # 狼人杀 API:剥前缀
                uri strip_prefix /games/werewolf/api
                reverse_proxy 127.0.0.1:3011 { header_up Host {host} }
        }

        @static { not path /api /api/* /uploads/* /avatars/* /dl /dl/* /download \
                          /games/werewolf/werewolf-ws /games/werewolf/api/* }
        route @static {                                                 # 静态直出 + SPA 兜底
                root * {PROD_ROOT}/app
                try_files {path} {path}/ /index.html
                file_server
        }

        @ws path /games/werewolf/werewolf-ws                            # 狼人杀 WebSocket:不剥前缀
        route @ws { reverse_proxy 127.0.0.1:3011 { header_up Host {host} } }

        @dyn path /api/* /api /uploads/* /avatars/* /dl/* /dl          # 其余动态 → 本机 API
        route @dyn { reverse_proxy 127.0.0.1:3000 { header_up Host {host} } }

        @assets {                                                       # 静态按扩展名;必须排除 /api/*
                path *.js *.css *.png *.jpg *.jpeg *.gif *.ico *.svg *.woff *.woff2
                not path /api/*
        }
        header @assets Cache-Control "public, max-age=604800"           # 生产静态 7 天
}

{BETA_HOST} 块结构与上完全相同,仅三处不同:root * {BETA_ROOT}/app、reverse_proxy 127.0.0.1:3100、 Cache-Control "no-cache, no-store, must-revalidate"。

设计效果:一个 Caddy 进程服务两条公网域名、两个静态根与两个 API 实例; 环境由「访问哪个域名」唯一决定,不存在任何可伪造的请求头入口。 改配置后先 caddy validate --config /etc/caddy/Caddyfile,再 systemctl reload caddy。

not path /api/* 不能省,也不能用续行写(v3.37.5 踩坑):受保护媒体端点 (/api/v1/chat/files/*.png、/api/v1/issues/images/*)的扩展名与静态资源相同,纯扩展名匹配会把 下载响应改写成静态缓存策略 —— 生产上等于把 401/404 缓存 7 天(图片一旦裂开就再也好不了)。 而写成 @assets path *.png … \ 换行 not path /api/* 不是「且」:适配器把 "not"、"path"、 "/api/*" 当成三个扩展名模式塞进同一个 path 列表,条件恒假,且 caddy validate 照样通过。 必须用块式匹配器,或 path *.png !/api/*;改完查 http://127.0.0.1:2019/config/ 确认 "not" 是键而非数组元素。

2.5 缓存纪律(与改版可见性强相关)

环境 静态缓存 改版生效方式
生产 {DOMAIN} public, max-age=604800(7 天);CF 边缘另有一层缓存 引用 js/css 必须带版本参数 ?v=(否则老浏览器拿旧文件);必要时 CF 控制台 Purge Cache
内测 {BETA_HOST} no-cache, no-store, must-revalidate 部署刷新即见
动态接口 / 下载文件 不缓存(下载经 Node,支持 Range/206) —
受保护媒体(聊天附件 / 工单截图) 服务端显式下发 private, max-age=31536000, immutable(v3.37.5) 文件名随机且内容不可变,故长缓存安全;用 private 是因为属用户私有数据。反向代理的静态规则必须排除 /api/*,否则会覆盖此头(并把 401/404 也缓存下来)
列表缩略图 /chat/files/x.webp?w=480 同上(同一处理器、同一长缓存) 客户端上传时随主图一并送一张长边 480 的小图,服务端存成 <原名>.w480.<ext>;没有派生图就静默回退原图(老消息不改库表、不裂图)。票仍按原文件名校验,故 w 只是取图开关,不能越权

URL 即缓存键,票据必须稳定(v3.37.6):媒体地址带签名票 ?t=<exp>.<hmac>, 若每次下发消息列表都按 Date.now()+1h 重签,同一张图每次都是新 URL,浏览器缓存命中率恒为 0。 现在签发时刻按 30 分钟时间桶对齐(同桶内逐字节相同),实际有效期 60~90 分钟; 客户端另有一层按文件名的 IndexedDB Blob 缓存(换桶、换会话、离线都不再回源)。

跨境链路的单流吞吐是图片体验的硬上限(v3.37.6 实测):服务器在香港、用户在境内, 内核拥塞控制从 cubic 换到 BBR 后,同一条 TCP 流(≈浏览器的一条 HTTP/2 连接) 从 10.6 KB/s 提升到 126.9 KB/s(12 倍),9.4MB 原图端到端由 471s 降到 22.4s。 服务器自身出网 43.9MB/s、负载 0.02、磁盘 r_await 1.24ms —— 瓶颈从来不在服务器的算力或磁盘。 跨境链路的单流吞吐是图片体验的硬上限(v3.37.6 实测):服务器在香港、用户在境内, 内核拥塞控制从 cubic 换到 BBR 后,同一条 TCP 流(≈浏览器的一条 HTTP/2 连接) 从 10.6 KB/s 提升到 126.9 KB/s(12 倍),9.4MB 原图端到端由 471s 降到 22.4s。 服务器自身出网 43.9MB/s、负载 0.02、磁盘 r_await 1.24ms —— 瓶颈从来不在服务器的算力或磁盘。 参数与持久化见 docs/DEPLOY.md §2。

2.6 聊天媒体治理:四道闸门(v3.37.7)

聊天定位是学习资料级别的传递,不是网盘、不是长期存储、更不是无限流量出口。 此前上传只受「单文件 50MB」一条约束,四个口子全开着:不计数(可循环上传写满磁盘)、 孤儿不清理(只上传不发消息即长期占盘)、附件永久留存(当网盘用)、 下载只看次数不看流量(签名票在有效期内可无限重放)。

闸门 位置 口径 作用
① 配额 services/chatMediaService.js 的 uploadGate(),排在 multer 之前 按账号:60 次/小时、200MB/天、留存 500MB;另有 Content-Length 预检(主文件+小图+64KB 余量) 被拒的请求一个字节都不落盘,是防刷盘最省带宽的一道
② 磁盘水位 同上 assertDiskHeadroom() 整机剩余 < 2GB 拒绝一切上传(fs.statfsSync,探测不可用则跳过) 配额是「每人」,水位是「整机」,互补
③ 归属 发送消息时 resolveAttachments() 地址必须是本站 /chat/files/;文件必须存在;台账归属必须是本人;一条消息最多 9 张图 不能引用他人文件,也不能把站外 URL 当附件分发
④ 回收 每小时 sweepOnce() 孤儿(message_id IS NULL)24h;已引用媒体 180 天释放字节;撤回消息立即释放 磁盘只涨不减的问题被闭环

台账是唯一事实来源:chat_media_assets(sql/020_v3.43_chat_media_guard.sql)记 stored_name / user_id / file_size / message_id / purged_at。上传先落台账、发消息时再回填 message_id(两段式),因此「上传了但没发出去」天然可识别为孤儿;撤回/过期只删台账行或打 purged_at,配额随之释放。派生小图 <主名>.w480.<ext> 不单独建账,但跟着主文件一起删。

降级优先于强一致:若部署顺序导致 019 迁移尚未执行,台账读写全部降级为「打一次告警 + 按 v3.37.6 的行为继续服务」,上传不会 500。

下载侧是「次数 + 流量」双闸门(lib/mediaLimits.js):媒体路径从通用限流(120/min/IP) 里 skip 出来,改由独立限流器计数(300/min/IP,校园 NAT 共享出口不误伤);真正的兜底是 按 IP 的字节预算 1GB/10min,在 sendMediaFile 之前 allows()、调用之后 consume(), 超限 429。两个计数器都是进程内内存态:本服务单实例部署(docs/DEPLOY.md §3), 若将来水平扩容,这两处必须迁到 Redis/PG。

3. 运行实体拓扑(服务器视角)

3.1 进程与端口

全部常驻进程运行在同一台香港服务器上,应用端口只绑回环:

服务 端口 systemd 单元 工作目录(占位) 说明
Caddy 80(ACME + 跳转)、443 tcp/udp caddy — 配置:/etc/caddy/Caddyfile;TLS 终结 + 静态直出 + 反代
qbao-api(生产) 127.0.0.1:3000 qbao-api.service {PROD_ROOT}/server 读本目录 .env → 库 qbao
qbao-api-beta(内测) 127.0.0.1:3100 qbao-api-beta.service {BETA_ROOT}/server .env 含 AUTO_ADMIN=1、AI Key 留空 → 库 qbao_beta
qbao-werewolf 127.0.0.1:3011 qbao-werewolf.service {PROD_ROOT}/party/werewolf 房间内存态;两环境共用同一房间服务
PostgreSQL 14 127.0.0.1:5432 postgresql — 不对外

运行时:Node v26.x(服务器自装路径,systemd 单元内以绝对路径 ExecStart 指定)、swap 1G(小内存主机)。 ufw 默认拒绝入站,仅放行 22/80/443 tcp 与 443 udp(QUIC),并显式 deny 3000/3100/3011。

3.2 目录布局

每个在线环境都是一套独立的「仓库布局目录」(部署侧非 git 检出,由脚本同步 tar 产物):

{PROD_ROOT}/            # 生产(真实路径见 local/ENV.md)
  app/                  # Caddy 静态根:index.html + 资源 + games/(7 天缓存)
  server/               # API 代码 + .env + sql 迁移(systemd 工作目录)
  downloads/            # 分发储藏室:manifest.json(+.bak) stable/ beta/
  uploads/              # 上传统一目录:pool/ chat/ issues/ avatars/
  party/werewolf/       # 狼人杀房间服务(systemd 工作目录)
  scripts/  docs/ …     # 工具与文档(同步)
{BETA_ROOT}/            # 内测:同样布局(app 不缓存、server 端口/库/密钥独立)

目录更新用「整目录换名 + 滚动备份」:部署时 mv dir dir.bak_<时间戳> && mkdir && 解包,备份保留 5 份(scripts/stage.ps1 参数)。

3.3 数据库

3.4 资源预算与降载预案

4. 环境隔离与安全边界

4.1 隔离矩阵

维度 L1 内测 L2 生产
入口 https://{BETA_HOST}(CF 仅 DNS 直连) https://{DOMAIN}(CF 代理)
Caddy site 块 {BETA_HOST} 块 {DOMAIN} 块
API 进程 / 端口 qbao-api-beta :3100 qbao-api :3000
数据库 qbao_beta(schema 与生产一致) qbao(禁测试写入)
静态目录 {BETA_ROOT}/app(不缓存) {PROD_ROOT}/app(静态 7 天)
下载/上传 各自 downloads/ 与 uploads/(Caddy 按 site 块分流) 同上(生产)
账号体系 开放注册;AUTO_ADMIN=1 注册即管理员 管理员仅后台授予(bootstrap_admin / ADMIN_USERNAMES)
测试动作 注册/对局/兑换/领奖/清库全部允许 仅金丝雀账号只读巡检
部署目标 scripts/stage.ps1 -Env beta scripts/stage.ps1 -Env prod
共享 同一台主机、同一个房间服务(:3011) 同左

4.2 账号、角色与防互害模型(v2 权限体系)

操作 对其他 admin 对自己
封禁(PATCH ban) 403 —
改角色(PUT role) 403(角色变更仅后台脚本) 允许自降为 user
重置密码(PUT password) 403 允许
其他资料修改 允许(非敏感字段) 允许

4.3 数据与凭据安全

5. 应用架构(代码分层)

5.1 形态与产物(同源同构)

5.2 前端分层

层 内容
views/ + components/features/* 视图与业务组件(答题/看板/聊天/反馈/用户中心/设置/下载中心/题库等)
stores/ Pinia store(数据/答题/会话/同步/UI/积分/用户等)
services/ 纯逻辑:subjectStats 统计引擎(单一权威源)、sync 同步引擎、chatVirtual、importExport、persistence、secureStore、API 封装族、desktopRelease
core/ 启动引导 boot.js(跨标签守卫)、运行时环境 env.js
games/ QA 门禁 qa-gate.js、游戏清单 gamesManifest.js

数据底座:persistence.js(localStorage 骨架 + IndexedDB 大字段分流 + 配额自愈)、stateDb.js(IDB 行键按账号分区)、 sync.js(rev 乐观锁:空推跳过、409 实体级并集合并重推、keepalive 补推)。账号隔离:登录门禁 + 属主钉扎 + 跨标签守卫(E2E 验证零串账)。

5.3 后端分层(server/)

5.4 数据模型要点

users(账号/角色)、user_data(业务状态 JSONB)、backups、shared_banks、ai_request_log、answer_sessions、 user_files、issues/issue_messages、points_ledger(积分台账)、user_games_stats(游戏成绩)、 user_marble_profiles(弹珠钱包)、user_marble_rounds(单开一局防重放)、desktop_download_stats 等; 建表基线 server/init.sql + 版本化迁移 server/sql/NNN_*.sql(schema_migrations 追踪,见 §3.3 与 docs/DEVELOPMENT.md §7)。

5.5 经济模型(2026-09 定版,防赌博红线)

6. 分发与更新架构(自托管,manifest-first)

端 正式(stable) 内测/测试(beta)
网页 https://{DOMAIN} https://{BETA_HOST}(独立实例)
手机壳 com.qbao.app · 1.0.x com.qbao.beta(同签名共存安装,应用名带「内测」)
桌面 Qbao-Setup-X.Y.Z.exe · latest.yml X.Y.Z-beta.N · beta 渠道 latest.yml

7. 部署与配置管理(工程工具链)

7.1 配置分层

配置 位置 进 Git
模板/默认值 server/.env.example、scripts/stage.env.example.ps1、mobile/capacitor.config.ts 环境覆盖 ✅
服务器实例 .env 各环境 server/.env(每实例独立 PORT/PGDATABASE/JWT_SECRET/CORS/AUTO_ADMIN…) ❌(仅服务器)
真实环境对照表 local/ENV.md ❌
部署参数(SSH/密钥/远端目录/服务名) local/stage.env.ps1 ❌
服务器配置(Caddy/systemd/ufw) 服务器 /etc/… 与 ufw 规则 ❌

7.2 工具链

工具 作用          
scripts/stage.ps1(-Env beta prod,-Mode all server app migrate restart) 双环境同步部署:本地打包 → scp → 远端换目录解包(滚动备份)→ 迁移 → 重启
scripts/qa/smoke-stage.ps1(-Env beta prod,-Marble,-DryRun) L1 可写 E2E 冒烟(断言只写 qbao_beta);L2 只读金丝雀巡检        
node scripts/run_migration.js 按目录 .env 应用未执行迁移(幂等,每库独立记账)          
node scripts/bootstrap_admin.js 生产管理员后台授予(唯一途径之一)          
发布工具(§6) manifest-first 入库与渠道纪律          
CI(.github/workflows/ci.yml) gitleaks 全历史扫描 · npm audit · 构建冒烟 · Vitest · ESLint          

7.3 服务器配置档案(改网络/入口必读)

8. 变更影响检查单(给未来开发)

改动类型 触碰的层 必读 必做(要点)
前端页面/游戏静态 app/ → 各环境静态根 GAMES §六 npm run build;stage -Mode app 双环境;js/css 引用带 ?v=;冒烟
后端 API / 服务 server/ → 两实例 DEVELOPMENT_FLOW §⑥ 单测;stage -Mode server+restart 双环境;smoke-stage
数据库 server/sql + 两库 DEVELOPMENT.md §7 新增编号幂等迁移;先 L1 后 L2(各自记账)
新游戏接入 public/games + 后端 + 门禁 GAMES §六 QA 门禁双端同步;先 L1 E2E;迁移先行
分发新包/改渠道 downloads + 两 manifest 校验 PUBLISHING channel 纪律;双端校验同步;公网逐字节验证
网络/证书/域名 CF 控制台 + Caddy(+ ufw) DEPLOY §1/4、ENVIRONMENTS Caddy 配置先备份再 validate/reload;@static 与 @dyn 互补性检查;CF 操作需 VPN;更新 local/ENV.md
权限/安全规则 users 路由 + 客户端 + 用例 DEVELOPMENT_FLOW §5 服务端与客户端双端改(界面收敛);补守卫用例
新增真实值 文档/脚本 DEVELOPMENT.md §1 只进 local/*(占位符纪律,push 前敏感扫描)

硬性红线(与 DEVELOPMENT_FLOW §7 一致):push/tag/Release 只在用户验收后;生产库禁测试写入;迁移 L1 先 L2 后; QA 钩子仅内测域名与 localhost;真实地址/凭据永不进跟踪文件。

9. 技术债与整改登记(延续 REVIEW-2026-08 T 体系;删除线 = 已整改)

P0 — 安全与正确性

  1. CORS 反射任意 Origin (已白名单);2. 上传 缺 MIME 白名单 (已完成:扩展名+魔数+附件头+去静态映射);
  2. 全量同步冲突 last-write-wins (rev 乐观锁 + 409 实体级合并);遗留:按字段时间戳精确裁决、按实体拆表、全量 PUT 放大;
  3. 路由手工 try/catch (ApiError + asyncHandler + 全局兜底,auth/data/quiz 已迁移);其余路由仍手工,逐步迁移;
  4. 直接信任入参 (zod:src/lib/validate.js + src/schemas/,auth/data/quiz 已接入);其余路由待接入;
  5. 上传目录不一致 (收敛到各环境 uploads/,prepare_dirs.sh 初始化属主)。

P1 — 可维护性

  1. chat.js 90KB 拆分 (v3.27 完成:components/features/chat 组件族 + stores/chat.js);
  2. 单文件 DOM (v3.27 完成:组件化);9. ES Modules 迁移 (v3.27 完成:Vue3+Vite+Pinia);
  3. 后端分层 routes→services→repositories(SQL 集中),进行中;
  4. 后端 supertest 骨架 (已 233 例);前端纯逻辑测试已补(250 例);12. 配置集中到 src/config.js(进行中)。

P2 — 工程化与正式软件化

  1. 渐进 TypeScript(后端先行,JSDoc 过渡);14. 容器化(Docker Compose);15. CI/CD(CI 已有 lint/test/build,自动部署待做);
  2. 可观测性(结构化日志/指标端点)——错误上报已落地(客户端全局错误 → POST /api/v1/client-errors → journald 告警行, 见 §9「收口记录(v3.37 复核整改,2026-09)」R6);结构化日志与指标端点仍待做;17. 迁移手写 SQL (schema_migrations + run_migration.js);
  3. 发布流程(CHANGELOG + tag + Release 已运作;自动化收尾待做)。

多环境架构专项(2026-09 新增登记)

  1. API 进程以 root 运行(qbao-api / qbao-api-beta,历史单机惯例)→ 目标:专用低权用户 + capability 收敛;
  2. 仓库 init.sql 与线上生产 schema 漂移(qbao_beta 已用 schema-only 克隆规避)→ 目标:迁移 018+ 对齐基线或归档 init.sql 为「新装最小集」; 已完成一部分:迁移 018 补齐 users 身份列与 notices 表(新装库不再 42703),并新增 --verify 编号诊断 与 schema 漂移守卫单测;把 init.sql 收敛为「新装最小集」仍未做(见本 §9 R3);
  3. 清单校验双份实现(installer-lib.js / desktopManifest.js)→ 已互为镜像,改动须双端同步;长期收敛为共享模块;
  4. CF 边缘缓存治理:生产静态经 CF 缓存 7 天,紧急改版需控制台 Purge(手动);长期:接入 CF API 或缩短边缘缓存 TTL;
  5. 单行 JSONB 同步放大(全量 PUT,见 P0-3 遗留)与 user_games_stats 单行增长(警戒 64KB,见 GAMES.md)。

收口记录(v3.37 复核整改,2026-09 · 全库复查 R 系列)

触发:2026-09 对 HEAD 全量只读复查(不依赖历史结论,逐条以代码/数据库/公网端点取证)。 编号 R1–R11 为本次整改项,与上方 T 体系、P 体系独立编号,避免与历史条目混淆。 全部改动仅本地提交,未经用户验收不 push/tag/Release(纪律见 DEVELOPMENT_FLOW §7)。

# 问题(复查发现) 处置
R1 聊天图片/工单截图下载端点仅 requireAuth,但前端用 <img src> 取图无法带 Authorization 头 端点改为「Bearer 或签名短票据」双通道:出网时服务端签发 ?t=<exp>.<hmac>(HMAC(JWT_SECRET) 派生键,TTL 1h),入库前剥离、读取时重签,兼容历史脏行。见 §4.3 媒体票据
R2 全新建库(init.sql + 迁移)缺 users.role/is_banned/avatar_url/last_login_at/last_active_at,且代码引用的 notices 表全仓无 DDL 迁移 018_v3.41_identity_notices.sql 补齐;新增 schema.drift.test.js 双向守卫(列存在 + 代码引用的表必须有 DDL)
R3 run_migration.js 不报编号缺口,线上 schema_migrations 有幽灵版本时无告警 新增 --verify:列仓库编号缺口(允许)+ 告警「已应用但仓库无文件」的版本
R4 files.routes.v2.js 四个端点被注册两次(Express 只命中后者),前者为旧实现 删除旧副本,保留含章节关联与 in_pool 复位的完整实现
R5 AI 请求在参数校验前就写审计行,401 失败请求产生 2 条噪音日志 审计移到校验之后;补回归用例断言失败请求零写入
R6 前后端均无全局错误兜底:Vue 渲染异常/未捕获 Promise/hydration 失败静默吞掉 前端 app.config.errorHandler + unhandledrejection + hydration/boot 失败 toast;新增 POST /api/v1/client-errors(免鉴权、限流 20/min、8KB 上限、仅落日志不落库)
R7 fetchWithRetry 对 4xx 也重试;aiTasks/api 用固定 token 不校验有效性,401 报错文案误导 仅对 5xx/429 重试;统一 effectiveToken(),401 区分「登录过期」与「无权限」
R8 AI 上传通道无扩展名白名单、无总体积上限(可一次塞满磁盘) 与文件池共用白名单常量;单次总量 >60MB → 413 并清理已落盘分片
R9 GET /api/v1/ai/providers 未鉴权,匿名可探测后端配置的供应商清单 加 requireAuth
R10 进程无 SIGTERM/SIGINT 处理:重启硬切在途 AI 任务与同步,ai_tasks 滞留 running 新增 src/lib/gracefulShutdown.js:停定时器 → 停收新连接 → 等待在途(默认 15s,QBAO_SHUTDOWN_GRACE_MS 可调)→ 关连接池 → exit 0;二次信号立即退出
R11 PATCH /issues/:id/status 先读快照后开事务(TOCTOU),并发可写出互相矛盾的系统消息 改为 BEGIN + SELECT … FOR UPDATE 串行化,附图清理移到 COMMIT 之后(尽力而为,不牵连状态变更)
R12 全部 multer 错误一律映射 422 且直接回显英文枚举(File too large/LIMIT_FILE_COUNT):单文件超 20MB 被报成「参数错误」,用户看不懂也无法自查 按 err.code 分流:体积类 → 413(中文文案),其余 → 422;业务侧 ApiError 文案原样透出
R13 Provider 适配器把 fetch 与 JSON.parse 放在同一 try:HTTP 200 + 非 JSON 响应体(模型拒答文案/WAF 拦截页)直接抛出,两条生成链路写在 chatCompletions 之后的「纠正性重试(≤2 次)」永远不可达;且重试用尽后 0 题仍被标成 completed,客户端提示「已导入 0 题」 适配器解析失败改为返回合成 completion(保留原始文本),使重试分支可达;worker 出口新增「0 题 → failed」守卫。新增 aiGenerateFlow.e2e.test.js(4 例)锁定请求形态与三种终态
R14 「AI 自动判定(自检)」提示词缺反向约束,模型对正确题目间歇性直接返回 [](实测 completion_tokens=2、约 1s,等于未执行审核;线上 6 次真实自检出现 4 次),自检调用被白白消耗且「模型没干活」与「审核后删光」共用同一句文案 提示词补「没有问题的题目必须原样保留」;runAiSelfCheck 识别「0 题且 tokens < 20」的空转结果并补显式指令重试(最多 3 次尝试),返回体新增 retried/unengaged;finalizer 对空转给出「自检未生效」专属文案与警告。新增 aiSelfCheck.retry.test.js(6 例)与 finalizer 空转用例

本次新增/加强的自动化守卫:schema.drift.test.js(双向 DDL 漂移 + 同一 method+path 不得重复注册)、 mediaToken.test.js(票据签名/过期/越权)、clientErrors.routes.test.js(限流与体量)、 gracefulShutdown.test.js(停机顺序/超时/二次信号)、errorHandler.unit.test.js(multer 错误分流,R12)、 ai.routes.validation.test.js(白名单零落盘、总量 413、审计零噪音、providers 鉴权)。

收口记录(v3.30–v3.37 复核,2026-09)

T5 积分并发(行锁+幂等快照+keyset 对账)、T6 成功才计费、T7 gemini SSE、T8 定时器 unref、T9 chapterMaterials 合并、 T10 beforeunload keepalive、T11 AI 任务自动续跑、T12 持久化配额治理、T13 无 rev PUT 保护、T14 v1 死代码删除、T16 上传目录收敛、T17 迁移工具化 等全部落地(全量 T1–T23 原始记录随 REVIEW-2026-08 归档 local/archive/,仅本地可见;公开口径以本文件 §9 为准)。v3.31–v3.37 增量:分层持久化 E2E、同步写收敛、safeStorage 加固、 组件/Store 拆分、API 封装统一、虚拟滚动、错题本与 AI 讲解、导入导出、登录门禁与账号隔离三层钉扎、竖屏专项、看板口径统一(恒等式单测锁定)。

10. 迁移与兼容性注意

11. 修订记录