站群架构联系与演进史
一句话:这不是四个独立网站,而是一个「一个门户 + 三个子站 + 一条隧道 + 一套计划任务」的单机站群;所有公网流量从
cag.quest:443进来,落到127.0.0.1:3000,再由 portal 按路径分发给 3001、3102、3103。
一、定位与入口
| 站点 | 公网地址 | 端口 | basePath | 计划任务 |
|---|---|---|---|---|
| portal 门户 | https://cag.quest/ | 3000 | 无 | KaoyanPortal |
| kaoyan-vocab 词汇站 | https://cag.quest/vocab/ | 3001 | /vocab | KaoyanNextServer |
| notes 笔记站 | https://cag.quest/notes/ | 3102 | /notes | KaoyanNotes |
| c-lab 实验室 | https://cag.quest/c-lab/ | 3103 | /c-lab | KaoyanCLab |
| prob 概率游戏 | https://cag.quest/prob/ | 无独立进程 | 静态 /prob | 由 portal 直接读盘 |
| 隧道 | cag.odn.cc 备用 | 3000 | 无 | KaoyanTunnel(Cloudflare)/ PassNAT frpc |
实测监听:0.0.0.0:3000(portal)、::3001(vocab)、::3102(notes)、127.0.0.1:3103(c-lab)。任务描述里提过 3200,实际不存在;C-Lab 也不是 3102,而是 3103。
二、技术栈与目录结构
2026-10-11-06-00-16/
portal/ 原生 Node http + node:sqlite,入口与身份中枢
kaoyan-vocab/ Next.js 14 + Prisma + SQLite,单词记忆
notes/ Next.js 14 App Router + Prisma + SQLite,最复杂的一站
c-lab/ 零依赖 Node http + node:sqlite + Python 沙箱
ops/
launch.ps1 所有计划任务的统一启动器(按 -Name 分支)
kaoyannotes.task.xml / KaoyanNotes.backup.xml
cf/ Cloudflare Tunnel 配置与启停脚本
bin/ cloudflared.exe 等二进制
C语言学习网站-方案.md c-lab 总方案(2026-11-13)
题目制作台-方案.md 制作台二期方案(2026-11-13)
四个站的技术选择并不统一:两个 Next、两个原生 Node;三个 Prisma/node:sqlite 各管各的库。这种「按站选型」让每站都能独立演进,但也意味着没有共享代码层。
三、数据模型(跨站视角)
站群没有统一数据库,四个 SQLite 文件彼此独立:
| 库 | 路径 | 实测表数 | 关键内容 |
|---|---|---|---|
| portal | portal/data/portal.db | 6 | users 5、sessions 7、login_log 8、公告、已读、反馈 |
| vocab | kaoyan-vocab/prisma/dev.db | 16 | Word 528、WordSense 145、WordVersion 41、易混组 52 |
| notes | notes/prisma/notes.db | 20 | Note 91、NoteVersion 150、Whitebook 18、SheetDoc 34、PageManual 14 |
| c-lab | c-lab/data/clab.db | 8 | problems 2、test_points 24、runs 1、code_notes 0 |
跨库没有外键,只有「约定 ID」:
notes.Note.ownerId存 portal 的users.id,ownerName冗余登录名;c-lab.problems.note_id存 notes 的Note.id;c-lab.code_notes.note_id存 notes 中代码笔记的Note.id;notes.PageManual.noteId是站内自关联,不出站。
这类「软关联」让每站可以单独备份/重建,但代价是删除、改名、迁移时没有数据库级约束,只能靠应用层补偿。
四、请求链路
浏览器
│ https://cag.quest
▼
Cloudflare 边缘
│ QUIC(cloudflared tunnel run kaoyan)
▼
cloudflared.exe ──────────────► 127.0.0.1:3000 portal
│ 原始 Host 保留 │
│ ├─ / index.html
│ ├─ /login /register /me /notices /features
│ ├─ /assets/* portal.css / portal.js / guide.js
│ ├─ /auth/* portal 本地认证 API
│ ├─ /api/portal/* 公告与反馈
│ ├─ /prob/* 另一个项目目录的静态文件
│ ├─ /vocab/* ──► 127.0.0.1:3001 Next basePath=/vocab
│ ├─ /api/* ──► 127.0.0.1:3001 重写为 /vocab/api/*
│ ├─ /notes/* ──► 127.0.0.1:3102 Next basePath=/notes
│ └─ /c-lab/* ──► 127.0.0.1:3103 原生 Node
▼
子站校验 x-portal-secret,读 X-User-* 还原 viewer
身份头是整条链路的关键:portal 先删客户端自带的 x-user-id、x-user-name、x-user-username、x-user-role、x-portal-secret,再注入自己的。x-user-name 可能是中文,所以 URL 编码后传输。子站只有拿到正确 x-portal-secret 才认这些头,否则一律匿名。
备用链路是 PassNAT:cag.odn.cc 由两条 frpc 配置同时指向 127.0.0.1:3000(18093f6f7432318b.toml 是 https2http,4e57717ead3e96e0.toml 是 TCP),实测两个 frpc 进程都在跑。这条链路不经过 Cloudflare,用于主隧道故障时的兜底。
五、关键机制:六条耦合关系
耦合一:路径前缀即部署契约。 三个子站的 basePath 分别是 /vocab、/notes、/c-lab,portal 的反代规则与它们一一对应。改一个 basePath,必须同时改 next.config.js、portal server.js、页面里的链接、以及 C-Lab 的 PREFIX。这不是配置,是四处硬编码的契约。
耦合二:共享密钥把门户与子站绑在一起。 portal/data/portal-secret.txt 是 64 字符 hex。portal 每次反代都注入,notes 与 c-lab 每次请求都读文件、比 mtime、校验。密钥轮换后所有子站自动跟上;文件丢失时子站降级为「不校验」,此时直连端口即可伪造身份。vocab 完全不参与这条链,是唯一的例外。
耦合三:C-Lab 依赖笔记站,但只走白名单。 题目笔记、代码笔记、提问笔记的正文权威都在 notes 的 NoteVersion;C-Lab 只通过 lib/notes-client.js 的白名单接口读写。这条耦合是单向的:notes 不知道 c-lab 存在,c-lab 离开 notes 就只能用本地示例题。
耦合四:笔记站复用门户的静态资源。 notes 的 OnboardingGuide 加载 /assets/guide.js,这个文件由 portal 提供。直连 3102 时该请求必然 404,属于预期降级;也意味着笔记站的首次引导体验依赖门户在线。
耦合五:所有进程共用一套 Node 与启动器。 四个站都由 ops/launch.ps1 拉起,Node 固定为 C:\Users\cag\.workbuddy\binaries\node\versions\22.22.2-6\node.exe。升级 Node 要四个站一起验证;launch.ps1 里 -Name 的分支就是站群的「进程表」。
耦合六:公网入口与 Cookie 域绑定。 pf_sid 是 Path=/ 的根域 Cookie,所以 cag.quest 下所有子路径天然共享登录态。如果未来把某个站拆到子域(notes.cag.quest),Cookie 不再自动共享,必须改 Domain 或重新设计跨域认证。
六、发布与运维
站群没有统一发布系统,靠「计划任务 + 启动器 + 手工构建」:
| 站 | 构建 | 重启 | 关键约束 |
|---|---|---|---|
| portal | 无 | Stop/Start-ScheduledTask KaoyanPortal | 改 server.js 即生效 |
| vocab | npm run build | Stop/Start KaoyanNextServer | basePath /vocab,前端 /api/* 依赖门户重写 |
| notes | npm run build(带 trace-guard) | Stop/Start KaoyanNotes | Prisma client 在 generated/db3;启动前自动 generate;BOM 与文件锁 |
| c-lab | 无 | Stop/Start KaoyanCLab | gcc/python 绝对路径;Job Object 仅 Windows |
公共运维事实:
- 七个计划任务:
KaoyanPortal、KaoyanNextServer、KaoyanNotes、KaoyanCLab、KaoyanTunnel均 Running;KaoyanSelfHealReady;KaoyanNatappDisabled。 - 任务以 S4U 登录、隐藏窗口运行;
Start-ScheduledTask对已运行任务不生效,必须 Stop 再 Start。 KaoyanSelfHeal跑kaoyan-vocab/natapp/selfheal.mjs,属于旧 natapp 时代的自愈脚本,现在 natapp 已禁用,脚本仍在。- 健康检查只有 portal 的
/healthz,聚合 vocab/notes/prob/clab 四项;没有集中日志、没有指标、没有告警。
七、演进时间线与现状
时间线(复盘口径 + 文件时间戳交叉验证):
- 2026-10-11 前后:两站起步。 项目根建立,portal 与 kaoyan-vocab 先落地。vocab 的
schema.prisma时间戳是 10-13,README也是 10-13,说明词库与白皮书解析在两周内成型。 - 2026-10-20:笔记站立项。 notes 的
.env、start-notes.bat、layout.tsx时间戳集中在 11-09,但方案与目录结构显示它是 10-20 立项、11 月上旬完成主体。 - 2026-10-21:门户统一入口。
kaoyan-vocab/start-next.bat注释明确写「2026-10-21: port 3001,3000 留给 portal」,这是从「多端口各自暴露」转为「单入口反代」的分界点。 - 2026-11-13:C-Lab 与题目制作台。
c-lab/server.js12:56、handlers/notes.js17:21、notes/prisma/schema.prisma20:17,一天内完成从零依赖服务、题库、编译判题到制作台导入/自检的全链路。C语言学习网站-方案.md与题目制作台-方案.md都标注 11-13。
当前架构债:
- 各站独立库。 四个 SQLite、四套 schema、四套备份策略;用户、权限、收藏、统计无法跨站聚合。portal 有 5 个用户,vocab 却不知道他们是谁。
- 端口硬编码。 portal
server.js写 3001/3102/3103,launch.ps1再写一遍,Cloudflareconfig.yml写 3000,C-Labconfig.js写 3103。没有环境变量或配置中心。 - 身份链不完整。 notes 与 c-lab 校验密钥,vocab 完全不校验;同一站群存在两种权限模型。
- 单机单点。 所有进程、四个库、隧道客户端都在一台 Windows 上;没有容器、没有编排、没有异地备份。
- 可观测性缺失。 只有
/healthz和各自.log;故障定位靠人肉看端口与任务状态。
改进建议(按性价比排序):
- 建
ops/config.json:端口、上游、密钥路径、Node 路径集中一处,server.js、launch.ps1、config.yml共同读取。这是最小改动、最大收益。 - 给 vocab 接入
X-User-*与x-portal-secret,至少把写接口保护起来;再给Word加ownerId,实现按用户隔离。 - 把
/healthz升级为站群健康页:逐站返回进程、端口、数据库可读性、最近错误;配一个KaoyanWatch任务做失败重启。 - 统一备份:四个
.db每日VACUUM INTO到带日期的目录,保留 14 天;notes 已有backups/,其余三站没有。 - 中期再把 c-lab 的判题沙箱抽成独立服务,notes 的 Prisma 生成从「换目录」改成构建期固定目录,为将来容器化留出边界。
站群的真正优势不是技术统一,而是「一个入口 + 一套身份 + 一天能加一个站」的演进速度。下一步要做的不是重写,而是把已经存在的隐式契约变成显式配置。