C-Lab 与题目制作台 · 题库与代码练习
一句话:C-Lab 是一个零依赖 Node HTTP 服务,跑在
127.0.0.1:3103、前缀/c-lab,提供 C/C++ 在线编译、测试点判题、代码版本与 AI 答疑;管理员的「题目制作台」也挂在同一进程里,题库的权威题面则托管在笔记站。
一、定位与入口
| 项 | 实测值 |
|---|---|
| 公网地址 | https://cag.quest/c-lab/(经 portal 3000 反代) |
| 监听地址 | 127.0.0.1:3103(实测只监听回环,PID 49412) |
| 进程命令 | node server.js |
| 前缀 | /c-lab(lib/config.js 的 PREFIX) |
| 计划任务 | KaoyanCLab,Running;动作 ops\launch.ps1 -Name clab |
| 框架 | 零依赖 Node http,无 Next、无 Express、无 Prisma |
| 数据库 | c-lab/data/clab.db(node:sqlite,实测 8 张表) |
| 编译器 | C:\mingw64\bin\gcc.exe / g++.exe |
| 沙箱 | Python 3.13 + Windows Job Object(lib/runner.py) |
| 笔记站 | NOTES_BASE=http://127.0.0.1:3102,只走白名单接口 |
任务描述里提示过 3102/3200,实际都不是:C-Lab 的端口是 3103,3102 是笔记站。这一点已按实测修正。
二、技术栈与目录结构
c-lab/
server.js 入口:加载 handlers、路由匹配、静态服务、统一 ctx
handlers/
base.js GET /healthz、GET /api/me
notes.js 题库/题面/测试点/笔记站只读通道
run.js POST /api/run、GET /api/runs
jobs.js 编译任务轮询
code.js 代码笔记读写
ask.js AI 提问
admin.js 制作台:题目 CRUD、导入、发布、自检、分享码轮换
lib/
config.js 端口、前缀、编译器绝对路径、限额
db.js node:sqlite 建表
identity.js 校验门户头、转发身份头
notes-client.js 笔记站 HTTP 客户端 + 只读白名单
problem-notes.js 本地题目 → 笔记站私有题目笔记同步
problem-admin.js 题目导入/保存/发布
code-notes.js 代码笔记(笔记站 NoteVersion 为权威)
compiler.js 编译运行编排
judge.js 逐测试点评级与汇总
runner.py Windows Job Object 沙箱
queue.js 编译队列(并发 2)
md-testpoints.js 解析 ```testpoints 区块
prompt.js / deepseek.js / ratelimit.js
seed.js 开箱示例题
public/
index.html 单页工作台
app.js / app.css 前端逻辑与样式
data/clab.db
testdata/ RMQ 20 点压力题与校验脚本
tools/ 生成、校验、端到端脚本
server.js 启动时扫描 handlers/*.js,把每个模块导出的 routes 表编译成正则;路由 key 形如 'GET /api/problems/',支持 :id 参数,尾斜杠可有可无。新增接口只需加一个 handler 文件,不改入口。
三、数据模型
clab.db 实测 8 张表:
| 表 | 用途 | 关键字段 | 关系 |
|---|---|---|---|
problems | 题目索引 | id、note_id 唯一、title、summary、local_md、service_owned、enabled | note_id 指向笔记站 Note;local: 前缀表示尚未同步 |
test_points | 测试点 | problem_id、tp_key、label、stdin、expected、hidden、score、time_limit_ms、memory_mb、stack_kb、language、harness | 多对一 problems |
code_notes | 用户×题目的代码笔记映射 | portal_user_id、problem_id、note_id、is_placeholder、current_version | 唯一 (portal_user_id, problem_id) |
runs | 一次运行 | portal_user_id、problem_id、language、mode、status、ms | 一对多 run_points |
run_points | 单测试点结果 | run_id、tp_key、status、stdout、stderr、exit_code、time_ms、peak_mem_kb | 多对一 runs |
ask_notes | AI 提问笔记映射 | 用户、题目、笔记 id | 指向笔记站 |
jobs | 异步编译任务 | id、status、result | 前端轮询 |
settings | 键值配置 | key、value | — |
实测当前:problems 2 条(示例题「求整数平方」与「静态区间最大值查询 RMQ」),test_points 24 条(示例题 4 点、RMQ 20 点),runs 1 条,run_points 3 条,code_notes 与 ask_notes 均为 0(还没有学生提交过代码)。
注意:代码正文不存在 clab.db。lib/db.js 的注释写明「权威来源是笔记站 NoteVersion」,本库只存映射、测试点、运行记录、任务状态。
四、请求链路
以「打开一道题并运行代码」为例:
- 浏览器请求
https://cag.quest/c-lab/→ Cloudflare → cloudflared → portal 3000。 - portal 命中
/c-lab,proxy(req,res,3103,u=>u),注入x-portal-secret与X-User-*。 - C-Lab
lib/identity.js校验密钥,构造viewer;不匹配则为匿名。 GET /c-lab/api/problems/读本地problems与test_points,返回题目列表。- 打开题面时,
GET /c-lab/api/notes/detail/?problemId=...:- 若
service_owned=0且local_md存在,直接返回本地题面,不碰笔记站; - 若
service_owned=1,以clab_service服务身份GET /notes/api/notes/<id>/读私有题面;同时取/缓存只读分享码,拼出notesOpenUrl; - 笔记站不可达时回退
local_md缓存。
- 若
- 用户写代码后
POST /c-lab/api/run/,requireUser()要求登录;服务端把任务放进queue(并发 2),立即返回jobId。 - 前端轮询
GET /c-lab/api/jobs/<id>/,拿到逐测试点结果。 - 编译运行:
compiler.js建独立临时目录 → 写入用户代码(函数模式再拼 harness)→ 调gcc/g++→ 用runner.py在 Job Object 里跑每个测试点 →judge.js比对输出 → 写runs/run_points。 - 若用户保存代码,
code-notes.js以服务身份在笔记站创建 private 子笔记,PUT /notes/api/notes/<id>/content/版本 +1。
五、关键机制
1)题目从哪来:两条来源,一个托管账号。
- 本地示例题:
lib/seed.js在首次启动、problems为空时插入prob_demo_square,note_id为local:square,题面和测试点都在本地,不写笔记站。 - 笔记站私有题目:管理员在笔记站建一篇笔记,正文含
```testpoints区块;制作台「导入」后,problem-admin.js解析题面与测试点,problem-notes.js用clab_service账号以visibility=private、category=c-lab-problem创建正式题目笔记,再把真实note_id写回本地库。
clab_service 是 C 站专用账号(CLAB_SERVICE_USER_ID / CLAB_SERVICE_USERNAME),只用于托管题目笔记与代理创建子笔记,role 仍是 user,不提权 admin。题目笔记不授权给普通用户,学生看不到;学生要打开原题时,C 站用只读分享码拼 /notes/note/<id>/?t=<token>,链接即凭证,不进任何列表。
实测笔记站 Note.category 里 c-lab-problem 有 7 篇;code_notes 表还是 0,说明代码笔记链路已实现但尚未产生真实数据。
2)如何渲染:单页 + Markdown + 双栏工作台。
public/index.html 是一个单页应用:顶部导航有「题库 / 笔记站」,管理员额外看到「制作台」。左侧列表选题,中间是题面 Markdown 渲染区与代码编辑器 textarea#code-editor,右侧三个 tab:运行结果、版本、AI 提问。前端 app.js 调 /c-lab/api/*,所有请求都带 /c-lab 前缀,所以直连 3103 也能工作(只要不带身份)。
题面里的 ```testpoints 区块由 lib/md-testpoints.js 解析成结构化测试点;制作台保存时会把「正文 + 表格里的测试点」重新组装,并自动剥离重复区块。测试点数据量大时注意笔记站正文上限:实测 RMQ 题写回正文 127,448 字符,占笔记站 200,000 上限的 63.7%。
3)代码运行与判题:Job Object 沙箱 + 逐点比对。
server.js 的 POST /api/run/ 限制:代码 ≤64KB、测试点 ≤20 个、body ≤1MB。config.js 的限额是:编译 10 秒、单点运行 2 秒、内存 64MB、栈 1MB、活跃进程 4、输出 64KB、编译并发 2。
runner.py 用 ctypes 调 kernel32,把子进程放进 Windows Job Object,设置 JOB_OBJECT_LIMIT_PROCESS_TIME、JOB_OBJECT_LIMIT_JOB_MEMORY、JOB_OBJECT_LIMIT_ACTIVE_PROCESS 等限制;超时杀进程树,输出一个 JSON:{status, exitCode, timeMs, peakMemKB, violationCode, timedOut}。状态可能是 ok、time_limit、stack_overflow、memory_limit、runtime_error。
judge.js 的比对规则很朴素:把 \r\n/\r 归一成 \n,去掉每行行尾空白,去掉末尾多余空行,然后逐字节完全相等才算 accepted;没有浮点误差容忍、没有 special judge。汇总优先级是 compile_error > time_limit > stack_overflow > memory_limit > runtime_error > wrong_answer,部分通过标 partial。
函数模式只提交函数体,系统按测试点的 harness 分组,每组补 main 与常用头后单独编译;不同 harness 的测试点不能混在一份可执行文件里。
4)与笔记站的关联:代码笔记是题目的 private 子笔记。
每个「用户 × 题目」对应一篇代码笔记:
- 标题格式
[C-Lab·代码] <题目名> · @<用户名>;没有代码时用[C-Lab·空]占位; visibility=private,parentId是题目笔记 id(本地示例题没有父笔记,退化为顶层);- 由
clab_service代理创建,category=c-lab-code;创建后单独POST /notes/api/notes/<id>/grants/把这篇子笔记授权给当前用户write,题目笔记本身不授权; - 每次上传代码走
PUT /notes/api/notes/<id>/content/,笔记站生成新NoteVersion,旧版永不覆盖;code_notes.current_version只记最新版号。
AI 提问笔记同理:[C-Lab提问] ... · @<用户名>,private,挂在代码笔记下。这样学生在笔记站里能看到自己的代码版本与提问,但看不到别人的,也看不到题目原文的授权。
5)只读白名单:C 站不能乱改笔记站。
lib/notes-client.js 的 ALLOWED 是硬编码的正则白名单:只允许 GET 笔记/树/白皮书/关键字、POST 检索、POST 建笔记、PUT 正文、POST/GET 授权、GET/POST/DELETE 分享码。其他方法与路径在客户端层直接 403,不会落到笔记站。这是「只调用、不修改笔记站代码」红线的工程实现。
六、发布与运维
C-Lab 没有构建步骤。改完 server.js、handlers/、lib/、public/ 后重启任务:
Stop-ScheduledTask -TaskName KaoyanCLab
Start-ScheduledTask -TaskName KaoyanCLab
launch.ps1 -Name clab 会先杀 3103 的监听者,再以隐藏窗口执行 node server.js,日志写 logs/clab-server.log。C-Lab 不跑 prisma generate,也不依赖 .next,所以没有笔记站那类引擎 DLL 锁问题。
常见故障:
- 所有写接口 401:没有经过门户,或者
portal-secret.txt与 C 站读到的不一致。C 站只监听127.0.0.1,本机直连也必须带正确密钥才有身份。 - 编译报找不到 gcc:计划任务的 PATH 不含 Git Bash 工具目录,
config.js用绝对路径C:\mingw64\bin\gcc.exe;换机器要改这里。 - 判题全是
runtime_error:优先检查测试点stdin是否是字面\n而不是真实换行;制作台已做自动还原与预警,但外部脚本灌库仍可能踩。 - RMQ 保存失败:测试点写回笔记站正文超过 200,000 字符上限;减少测试点数据或改「测试点只存本地库」。
- 任务卡在 queued:编译队列并发只有 2,前面的编译没释放;查
logs/clab-server.log与孤儿 gcc 进程。
七、现状与改进点
结构债一:Windows 强绑定。 runner.py 用 kernel32 的 Job Object,gcc 是 C:\mingw64 绝对路径,Python 是 WorkBuddy 自带的 3.13。换 Linux 或容器要重写沙箱层。方案里预留了 Docker(L1),但当前是 L2。
结构债二:单机单进程。 编译队列在进程内,重启丢任务;并发 2 是硬编码;runs 历史没有清理策略。学生量上来后会成为瓶颈。
结构债三:题面与测试点分居两库。 题面权威在笔记站,测试点在本地 test_points,但制作台保存时又把测试点写回笔记站 MD 的 ```testpoints 区块。两份数据靠「导入/保存」同步,存在漂移风险。
风险:判题过于严格。 逐字节比对意味着行尾多一个空格、末尾多一个空行都会 WA;对 C 语言初学者不友好。至少应提供「忽略行尾空白」的默认开关,或按题型选择比对策略。
建议:把编译沙箱抽成可替换后端(本地 Job Object / Docker / 远程 judge),队列改成持久化任务表;测试点只保留一个权威源(建议本地库,笔记站只存题面);给判题增加可配置的比对器与部分分规则。