SYZOJ OJ前端改版:Express+EJS模板与评测状态轮询避坑

发布时间:2026/10/23 18:29:58
SYZOJ OJ前端改版:Express+EJS模板与评测状态轮询避坑 简介ZHZXOJ-Web 是一套基于 SYZOJ 前端改版而来的在线评测系统前端工程面向需要自建 OJ 的站长、校内竞赛平台维护者以及想研究评测系统界面实现的 JavaScript 开发者。项目虽已停止维护但页面结构与构建产物相对完整可作二次开发或学习前端工程组织的参考底稿。压缩包共 524 个文件约 12.48MB以 211 个 js 脚本、105 个 css 样式表和 49 个 ejs 模板为主另含 svg 图标与 ttf、woff、woff2、eot 等多套字体资源以及 17 个 TypeScript 文件、若干 json 配置与 source map基本覆盖页面渲染、组件样式与静态资源加载各环节。资源描述还给出了启用 HTTPS 时的 Nginx 配置片段涉及 443 端口监听、HTTP 跳转重写与证书路径设置并附可选的 TLS 协议与加密套件参数便于部署时对照调整。目前已有 210 人学习下载适合希望获得一份可读、可改的 OJ 前端模板的读者。1. 一个 OJ 前端改版最难的不是配色从评测状态那个小圆点说起选手点下“提交”页面右上角跳出一个小圆点从灰色的 Waiting 变成黄色的 Judging几秒后变绿——AC。这一串动作里前端要处理的东西比想象中多模板得把题目正文的 Markdown 正确渲染提交页得校验语言和代码长度状态得靠 JavaScript 定时去问后端权限还得保证未公开的题目不会被猜 id 猜出来。这套基于 SYZOJ 前端改版的项目改的就是这些地方只是顺带把视觉也重做了一遍。它适合两类人手上有一台现成的评测机、只想要一套干净前端的人以及接手了老 OJ、被一堆内联 jQuery 和写死路径折磨过的维护者。别把它当成“换个皮”真跑起来你会发现动刀最多的是模板层和状态同步。2. 技术栈与目录结构Express EJS 的老配方改版后哪些层被换掉了2.1 技术栈盘点为什么还留着服务端渲染这套这套前端的主体是 Node.js Express模板用 EJS数据层走 Sequelize 连 MySQL/MariaDB会话用 express-session 存在数据库里实时状态的原版实现靠 socket.io 和评测机通信。前端资源是老搭档Bootstrap 那一代的栅格和组件、jQuery 做 DOM 操作、marked 负责 Markdown 转 HTML、MathJax 或 KaTeX 处理公式、highlight.js 做代码高亮。改版最容易犯的错是一上来就把 EJS 换成 Vue/React 单页应用。OJ 的页面大多是“读多写少”的文档型页面题目详情、比赛列表、榜单、提交记录。这类页面服务端渲染出来的首屏体验更好SEO 和分享链接也正常而且不用维护一套前后端分离的接口契约。我的建议是保留 EJS只把页面里那些散落的内联脚本收敛成独立的 JavaScript 模块用原生 fetch 替代 jQuery 的$.ajax。Bootstrap 可以换成更轻的自定义 CSS 变量方案但没必要为了“现代化”三个字把整套渲染链路推翻——老项目里最贵的不是技术选型是你改完之后没人敢再动它。需要提醒的是依赖年龄。这套代码里的部分包停留在三四年前node-sass、老版本sequelize、socket.io都有原生模块或 API 变更的坑Node 版本建议压在 16 LTS 这个档位别直接上最新的奇数版本。2.2 目录对照五层各管什么改版动的是哪几层把仓库摊开看结构大致是这样几块改版时你得知道每一层归谁管目录职责改版时的动作routes/路由与参数解析把 URL 映射到模板少量改新增页面时加路由modules/业务逻辑权限判断、提交入队、比赛状态谨慎改容易牵一发动全身models/Sequelize 模型定义字段与关联基本不动动了要迁移数据views/EJS 模板页面结构改版主战场static/CSS、JavaScript、图片、第三方库改版主战场middlewares/登录态注入、权限拦截、验证码看情况权限收紧时改interfaces/与评测机通信的协议类型定义别动动了评测机收不到活一个常见的误区是把views/当成纯静态页面来改改完发现数据没出来——EJS 里每个变量名都得和路由res.render传的键对上字段名错一个字母页面就是一片空白而且不报错这是玄学感的来源之一。稳妥的做法是先跑通原始版本再一个页面一个页面地替换每次只改一个模板用 Git 对比。static/这一层要注意挂载点。原版通常把静态目录挂在/static这种绝对路径上改版如果把目录改成assets或者要部署到子路径下比如/oj/模板里所有写死的/static/...全都要跟着改否则就是整站样式消失。2.3 一次题目页请求的完整链路理解链路比背目录有用。用户访问/problem/1001请求进入 Express依次经过会话中间件把当前用户塞进res.locals、权限中间件、路由处理路由去数据库捞题目模板引擎把 EJS 和数据合成 HTML 返回。app.js里的几个挂载点决定了这条链路的形状改版时最先要看的就是这几行// app.js —— 改版时最先确认的挂载点 app.set(view engine, ejs); // 模板引擎改版后沿用不要轻易换 app.set(views, path.join(__dirname, views)); // 模板目录换目录这里必须同步 // 静态资源挂载第一个参数是 URL 前缀第二个是磁盘目录 // 部署到子路径时这个前缀和模板里的引用要一起改 app.use(/static, express.static(path.join(__dirname, static), { maxAge: 7d // 生产环境给长缓存改版后靠文件名 hash 或查询串刷新 })); // 会话secret 从配置读store 走数据库重启不掉登录态 app.use(session({ secret: config.server.session_secret, store: sessionStore, resave: false, saveUninitialized: false, cookie: { maxAge: 1000 * 60 * 60 * 24 * 7 } // 一周 })); // 把当前用户注入模板所有 EJS 里都能直接用 user app.use((req, res, next) { res.locals.user req.session.user || null; res.locals.csrfToken req.session.csrfToken || ; next(); }); app.use(/, routes);几个参数值得单独说。maxAge是浏览器缓存时间改版期间建议先设成0或者给静态资源加版本查询串否则你改了 CSS 刷新十次还是旧样式会怀疑人生。saveUninitialized: false是为了不给出每个未登录访客都建一条 session 记录OJ 这种流量下这个差别不小。res.locals.user注入之后模板里判断登录态就是一句% if (user) { %不用每个路由自己传。这条链路里最容易出问题的是中间件顺序权限中间件必须排在会话中间件之后否则拿不到req.session静态资源中间件最好排在会话之前让图片和 CSS 请求不触发数据库查询。3. 核心模块拆解题目页、提交页、状态轮询三处代码逐段过3.1 路由层problem.js 怎么把 URL 参数变成模板数据题目页是整个 OJ 里最复杂的单页正文、输入输出格式、样例、时空限制、相关提交记录还要处理“题目未公开但有人猜 id”的情况。路由的骨架大致长这样// routes/problem.js const express require(express); const router express.Router(); const Problem require(../models/problem); const md require(../libs/markdown); // 包装过的 marked统一配置 router.get(/:id, async (req, res, next) { // 1. 参数校验id 必须是正整数否则直接交给 404 处理不查库 const id Number.parseInt(req.params.id, 10); if (!Number.isInteger(id) || id 0) return next(); // 2. 查库只取页面需要的字段避免把测试数据之类的字段带出来 const problem await Problem.findByPk(id, { attributes: [ id, title, is_public, background, description, input_format, output_format, hint, sample_input, sample_output, time_limit, memory_limit ] }); if (!problem) return next(); // 3. 权限未公开题目只有登录用户或管理员能看 // 注意这里不能返回 404否则管理页跳转过来会失效 const user res.locals.user; if (!problem.is_public !user) { return res.status(403).render(error, { message: 该题目暂未公开 }); } // 4. 渲染Markdown 在这里统一转成 HTML模板只负责套壳 res.render(problem, { problem, statementHtml: md.render(problem.description || ), sampleHtml: md.render(problem.sample_input || ), activeNav: problems }); }); module.exports router;逐段说。参数校验放在最前面是因为 OJ 会被人用脚本爬/problem/abc、/problem/999999999这种请求一天能来几千次每次都不必要地打一次数据库是很典型的性能损耗点。attributes白名单是个血泪经验默认全字段查询很容易把不该给前端看的字段比如测试数据路径、内部备注一起塞进模板虽然模板里不写就不会输出但一旦有人写了个JSON.stringify(problem)打日志就全泄了。权限那段的选择值得展开。未公开题目返回 403 还是 404是个产品决策返回 403 会暴露“这个 id 存在”返回 404 则更安全但管理员的预览链接会失效。我的做法是登录用户返回 403、未登录用户返回 404兼顾体验和隐蔽性。最后是渲染数据的命名。statementHtml这种带后缀的键名是个好习惯模板里一眼能看出这是已经处理过的 HTML、不需要再转义。改版的时候模板要重写键名保持稳定能省掉大量对照调试的时间。3.2 视图层%和%-差一个字符差的是 XSS 和满屏乱码EJS 有两个输出标签新手最容易混%会做 HTML 转义%-原样输出。题目正文是 Markdown 转来的 HTML必须用%-才能正常显示段落和代码块而标题、用户名这类纯文本必须用%否则一个叫script的用户名就能在榜单页执行脚本。!-- views/problem.ejs 片段 -- div classproblem-head !-- 标题是纯文本转义输出 -- h1% problem.id %. % problem.title %/h1 div classproblem-meta span classtag时间 % problem.time_limit % ms/span span classtag内存 % problem.memory_limit % MB/span /div /div !-- 正文是富文本 HTML只能用非转义输出但前提是上游已经过滤过危险标签 -- article classmarkdown-body%- statementHtml %/article % if (sampleHtml) { % section classproblem-sample h3样例输入/h3 precode% problem.sample_input %/code/pre /section % } %注意样例那块sample_input直接进precode用%转义。因为样例里可能有、、这些字符不转义会被浏览器当成标签吃掉题面就“缺字”了这种 bug 往往只有选手反馈才知道。而%- statementHtml %之所以敢用非转义是因为md.render出来的 HTML 在libs/markdown.js里统一配置过禁用原始 HTML 或者接一层白名单过滤。如果改版时把渲染函数换成默认配置的 marked用户就能在题面里塞img onerror...。页面里的交互脚本同理改成独立文件按页面注册// static/js/problem.js —— 只在题目页加载 document.addEventListener(DOMContentLoaded, () { // 复制样例用 Clipboard API老浏览器留个 execCommand 兜底 document.querySelectorAll([data-copy]).forEach(btn { btn.addEventListener(click, async () { const target document.querySelector(btn.dataset.copy); if (!target) return; try { await navigator.clipboard.writeText(target.innerText); btn.textContent 已复制; setTimeout(() (btn.textContent 复制), 1500); } catch (e) { // 非 HTTPS 环境下 clipboard 不可用这是最常见的翻车点 console.warn(clipboard unavailable, e); } }); }); });navigator.clipboard只在安全上下文HTTPS 或 localhost可用学校内网用 HTTP 部署的话复制按钮会静默失效必须留兜底。这类问题不报错只是“点了没反应”排查起来很耗时间。3.3 提交页与状态轮询把开环刷新改成可控的退避轮询原版靠 socket.io 推送评测状态好处是实时坏处是前端要维护连接、重连、心跳改版时如果只换了 UI 没动这块很容易出现“状态不动了”。改版里我一般会先落一个轮询方案简单、可控、好排查// static/js/submission-status.js const POLL_INTERVAL 800; // 起始间隔 const MAX_INTERVAL 5000; // 最长间隔避免服务器被打 const TERMINAL [accepted, wrong_answer, time_limit_exceeded, memory_limit_exceeded, runtime_error, compile_error, system_error, canceled]; async function pollStatus(submissionId, onUpdate) { let interval POLL_INTERVAL; const tick async () { try { const res await fetch(/api/submission/${submissionId}/status, { headers: { Accept: application/json } }); if (!res.ok) throw new Error(HTTP ${res.status}); const data await res.json(); onUpdate(data); // 交给页面更新圆点和文案 if (TERMINAL.includes(data.status)) return; // 终态停止轮询 // 还在评测队列里就把间隔拉长减轻后端压力 interval Math.min(interval * 1.4, MAX_INTERVAL); setTimeout(tick, interval); } catch (e) { // 网络抖动不要直接放弃退避后重试连续失败三次才停 interval Math.min(interval * 2, MAX_INTERVAL); setTimeout(tick, interval); } }; tick(); }逻辑上有三个点。第一是终态判断必须和白名单对齐后端返回的状态字符串一旦改名比如accepted写成AC轮询就永远停不下来页面会一直转圈这是最容易翻车的地方前后端的状态枚举要放在同一个常量文件里。第二是退避几百个选手同时提交的时候固定 1 秒轮询就是一记小型 DDoS起始 800ms 逐步退到 5 秒是折中。第三是异常不放弃OJ 部署在内网网络抖动很常见连续失败才停比一次失败就停要稳。后端那个状态接口本身很简单返回 id、status、score、time、memory 就够了别把编译日志一次性塞进去——编译错误日志可能几 KB轮询时每次都传会拖慢页面。3.4 静态资源与主题层改版真正动刀的地方视觉层的改造最容易做成“一堆散落的十六进制颜色值”第二个人接手就没人敢改。稳妥的做法是把颜色、圆角、间距抽成 CSS 变量主题切换只改:root上的几个值/* static/css/theme.css */ :root { --color-bg: #ffffff; --color-fg: #1f2328; --color-muted: #6b7280; --color-primary: #2563eb; --color-accepted: #16a34a; --color-wrong: #dc2626; --radius-card: 8px; --gap-page: 16px; } /* 暗色主题只需要覆盖变量页面结构一行不动 */ [data-themedark] { --color-bg: #16181d; --color-fg: #e6e6e6; --color-muted: #9ca3af; } body { background: var(--color-bg); color: var(--color-fg); margin: 0 auto; max-width: 1100px; padding: var(--gap-page); } /* 评测状态色统一走变量榜单和提交页共用一套 */ .status-accepted { color: var(--color-accepted); } .status-wrong_answer { color: var(--color-wrong); }把状态色做成类名映射好处是榜单、提交记录、题目页三处的颜色永远一致。改版里最容易出现的不一致就是“提交记录页 AC 是绿色排行榜 AC 是蓝色”看着别扭但没人说得清哪错了本质就是颜色散落在三个文件里。4. 本地跑起来Node 版本、MySQL、config.json 与第一次启动4.1 环境与依赖Node 版本怎么选依赖装不起来是这套老项目最高频的拦路虎多数时候不是代码问题是 Node 版本对不上。我的选择是 Node 16 LTS理由很实际node-sass这类包在 Node 18 以上需要重编译Windows 上还要装编译工具链新手在这一步就劝退了。如果你不需要 sass最省事的做法是把源码里预编译好的 CSS 直接拿来用把node-sass从package.json里摘掉改成sassdart-sass纯 JavaScript 实现没有原生依赖。# 确认版本Node 16 是这套老依赖比较稳的档位 node -v # v16.x npm -v # 用 ci 而不是 install有 lock 文件时保证依赖树完全一致 npm ci # 如果卡在 node-sass 编译直接换成 dart-sass npm uninstall node-sass npm install --save-dev sassnpm ci和npm install的区别值得强调前者严格按 lock 文件装后者会去解最新的兼容版本。老项目里^号开头的依赖install装出来的可能是比作者当年新两三个大版本的包行为不一致排查时会把注意力引向错误的方向。4.2 配置文件config.json 三段怎么填配置一般是根目录的config.json有的版本是config.example.json需要复制改名。核心是三段服务、数据库、评测机。{ server: { host: 0.0.0.0, port: 8080, session_secret: 换成随机字符串 }, db: { database: oj, username: oj_user, password: your_password, host: 127.0.0.1, port: 3306, dialect: mysql, logging: false }, judge: { key: judge-shared-key, port: 2333 } }session_secret必须换掉用默认值等于把会话签名公开。dialect一般是mysqlMariaDB 也走这个。logging: false在本地调试时可以先设true能看到 Sequelize 打的每条 SQL改版后页面数据不对很多时候一眼就能看出是哪个字段名写错了。judge.key是 Web 端和评测机之间的共享密钥两边必须一致不一致的表现是评测机连上了但拿不到任务页面永远停在 Waiting。数据库字符集必须用utf8mb4四字节字符题面里的特殊符号、公式里的希腊字母用utf8存会直接报错或者变问号。4.3 建库、同步表结构与启动# 1. 建库字符集一定带上 utf8mb4 mysql -uroot -p -e CREATE DATABASE oj DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; # 2. 启动。首次启动 Sequelize 会 sync 出表结构 NODE_ENVdevelopment node app.js # 3. 看到监听日志就说明 Web 层起来了 # Server listening on 0.0.0.0:8080首次启动会自动建表但不会给你一个管理员账号。常见做法是先注册一个普通用户然后直接改数据库把is_admin置为 1-- 把第一个注册的用户提升为管理员 UPDATE users SET is_admin 1 WHERE id 1;这一步是新建 OJ 绕不开的很多人卡在“后台进不去”就是因为没做这步。另外NODE_ENVdevelopment会关掉模板缓存改 EJS 刷新即生效生产环境用production时模板会被缓存改完不重启看不到变化这个差异坑过不少人。4.4 不接评测机的前端调试法改版主要动的是前端但评测机是个独立的 C 守护进程本地不一定能跑起来。这时候的策略是“只验渲染不验评测”题面、列表、榜单、提交记录这些页面都不依赖评测机能正常渲染就说明模板改对了提交后状态停在 Waiting 是正常现象不用怀疑代码。如果一定要看状态流转可以在数据库里手动改一条提交记录的状态-- 手动模拟一次评测完成用来调试前端状态渲染 UPDATE submissions SET status accepted, score 100, time_used 120, memory_used 2048 WHERE id 1;刷新页面看到绿色 AC说明状态映射和样式都对。这招在改版配色的时候特别好用五种状态挨个改一遍就能把颜色对照表验证完。5. 避坑排查改版过程中五个典型翻车现场5.1 改了 EJS 刷新页面没变化现象模板里明明加了一段 HTML浏览器刷新后看不到清缓存、换浏览器都没用。原因生产模式下 Express 默认开启view cache模板编译结果被缓存另一种情况是启动的进程和你编辑的不是同一份代码比如开了 pm2 或者 nodemon 没重启成功。解决确认NODE_ENV不是production或者在app.js里显式关掉app.set(view cache, false)。用ps aux | grep node看有几个进程在跑比清浏览器缓存管用得多。5.2 题面里的小于号把后面的内容全吃掉了现象题目正文写着“输入保证 a b”页面上从开始后半段全没了或者代码块里的内容错乱。原因Markdown 渲染器把b之类当成了 HTML 标签或者模板里该用%的地方写成了%-导致用户原始输入直接进了 DOM。解决渲染前先做 HTML 转义再交给 marked或者给 marked 配置禁用内联 HTML 的选项。样例区块统一用%输出。改完之后拿一段scriptalert(1)/script当题面测一遍看到它是原样显示的文字而不是弹窗才算过关。5.3 整站样式消失控制台一片 404现象HTML 出来了但完全没样式Network 面板里/static/css/main.css全是 404。原因静态目录或挂载前缀被改了但模板里还是写死的绝对路径/static/...部署在子路径如/oj/下时绝对路径会指向站点根直接跑偏。解决模板里改用相对路径或统一的辅助函数生成前缀express.static的挂载前缀和模板引用必须同步改。用curl -I http://127.0.0.1:8080/static/css/main.css单独确认这个 URL 通不通比在浏览器里猜快。5.4 提交后一直停在 Waiting评测机日志没动静现象代码能提交记录也进了数据库但状态永远不变评测机那边日志干干净净。原因Web 端和评测机的连接没建立通常是judge.key两边不一致或者评测机的连接地址/端口配错或者防火墙拦了那个端口。也可能是只跑了 Web 没跑评测机——本地开发常见。解决先看 Web 端启动日志里有没有评测机连接记录再看评测机日志有没有鉴权失败。只做前端调试的话按 4.4 的方式手动改数据库状态即可不要在这上面死磕。5.5 比赛开始时间和实际差八小时现象设的是晚上八点开赛到点页面还显示“未开始”。原因数据库连接时区没配Sequelize 默认按 UTC 存取服务器本地时区是东八区一进一出就差 8 小时。解决在config.json的db段里加timezone: 08:00并且建库时确认字段类型是DATETIME而不是TIMESTAMP后者会跟着时区自动转换更容易乱。改完拿一条已有数据对比数据库里存的值和页面显示的值比看文档靠谱。6. 二次开发把一次改版沉淀成一套能反复用的主题层改版做完如果只留下“一堆被改过的 EJS”那下一个接手的人会重新经历一遍你踩过的坑。我后来习惯在收尾时做三件事把一次性的改造变成能复用的东西。第一件抽一个统一的渲染中间件把所有页面都要的变量一次性注进去模板里不再各传各的// middlewares/view-locals.js module.exports (req, res, next) { res.locals.siteName config.server.site_name || 在线评测系统; res.locals.user req.session.user || null; res.locals.currentPath req.path; // 导航高亮用 res.locals.formatTime (ts) { // 统一时间格式避免各页各写 if (!ts) return -; const d new Date(ts); const p (n) String(n).padStart(2, 0); return ${d.getFullYear()}-${p(d.getMonth() 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}; }; next(); };挂上去之后views/里所有formatTime的调用都走同一份逻辑改时区、改格式只改一处。currentPath是为了导航高亮避免每个路由都传一次activeNav。第二件做一个主题目录把布局拆成 partial主布局只管引用改造点涉及文件风险等级说明换配色static/css/theme.css低只动 CSS 变量换布局views/layout.ejs partials中注意每个页面的 block 名要对上换 Markdown 渲染libs/markdown.js中高改完必须回归样例和公式加新页面routes/views/static/js/低三处同步加别漏脚本引入改状态枚举前后端各一处常量高必须同时改否则轮询不停第三件写一个三十行的冒烟脚本改完随手跑一遍比手动点五个页面快得多#!/usr/bin/env bash # smoke.sh —— 改完前端跑一遍确认关键页面没有 500 BASEhttp://127.0.0.1:8080 for p in / /problems /problem/1 /submissions /contests /user/1; do code$(curl -s -o /dev/null -w %{http_code} $BASE$p) printf %-16s %s\n $code $p done只要全是 200 和 403未登录访问受限页返回 403 是正常的就说明模板层没有语法错误。模板报错在 Express 里通常表现为 500 加一段堆栈浏览器上看不出是哪一行用这个脚本能第一时间定位到是哪个页面挂的。从那以后我每次动views/之前都会先跑一遍这个脚本记下基线改完再跑一遍对比。看起来是多此一举但 OJ 这种页面多、模板互相引用的项目改一个 partial 影响七八个页面是常事没有基线就只能靠人肉点。希望这套改版的拆解能帮到你至少别在 Node 版本和字符集上栽跟头。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询