Files
OJ2/apps/api/src/index.ts
T
xuyueandClaude Opus 5.5 df8af40d61
Deploy / deploy (push) Canceled after 0s
feat(布置作业): 课上老师改了作业,学生页面上弹出改了哪些题,点「刷新」就拿到新题单
看板「改 / 新的一节 / 清掉」和后台课堂作业的增改删,前后比较学生看到的那一节,
真变了才按班推 lesson_update(加了、去掉了、语言、顺序),走学生的 /ws/config,老师不收。
没点刷新又改会合并成一条。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:14:52 +08:00

163 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono'
import { basename, resolve } from 'node:path'
import { WS_AUTH_PROTOCOL } from '@oj2/contract'
import { authenticateWebSocket, loadJwtSecret } from './auth/session'
import { config } from './config'
import { adminRoutes } from './routes/admin'
import { authRoutes } from './routes/auth'
import { accountRoutes } from './routes/account'
import { contestRoutes } from './routes/contest'
import { contentRoutes } from './routes/content'
import { classroomRoutes } from './routes/classroom'
import { rankingRoutes } from './routes/ranking'
import { problemsetRoutes } from './routes/problemset'
import { achievementRoutes } from './routes/achievement'
import { aiRoutes } from './routes/ai'
import { flowchartRoutes } from './routes/flowchart'
import { problemRoutes } from './routes/problem'
import { problemStatsRoutes } from './routes/problem-stats'
import { submissionRoutes } from './routes/submission'
import { trialRunRoutes } from './routes/trial-run'
import { siteRoutes } from './routes/site'
import { scanPendingChallenges } from './services/challenge'
import { bridgeSubmissionEvents, isAllowedWebSocketOrigin, startSessionSweep, submissionWebSocketHandler, type SubmissionSocketData } from './websocket'
const app = new Hono()
app.get('/health', (c) => c.json({ ok: true }))
app.route('/api', authRoutes)
app.route('/api', accountRoutes)
app.route('/api', siteRoutes)
app.route('/api', contestRoutes)
app.route('/api', contentRoutes)
app.route('/api', classroomRoutes)
app.route('/api', rankingRoutes)
app.route('/api', problemsetRoutes)
app.route('/api', achievementRoutes)
app.route('/api', aiRoutes)
app.route('/api', flowchartRoutes)
app.route('/api', problemRoutes)
app.route('/api', problemStatsRoutes)
app.route('/api', submissionRoutes)
app.route('/api', trialRunRoutes)
app.route('/api/admin', adminRoutes)
// 没有这条的话,打错的接口地址回的是 Hono 默认的纯文本「404 Not Found」,前端 utils/api.ts 读不出
// error.code / message,提示就成了笼统的「请求失败」。和其它错误同一个形状
app.notFound((c) => c.json({ error: { code: 'not-found', message: 'Not found' } }, 404))
app.onError((error, c) => {
console.error(error)
return c.json({ error: { code: 'internal-error', message: 'Internal server error' } }, 500)
})
/** 头像取不到时的占位图,避免每个没设头像的学生都打一次 404 */
const DEFAULT_AVATAR_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="64" fill="#e2e8f0"/><circle cx="64" cy="48" r="24" fill="#94a3b8"/><path d="M20 120c4-28 22-42 44-42s40 14 44 42" fill="#94a3b8"/></svg>'
/**
* 伺服 /public 下的用户上传文件。
*
* 只取路径最后一段并要求它和原样一致 —— `..`、子目录、编码过的斜杠都会在这里被拒,
* 拼接进 resolve() 的永远只是一个纯文件名。
*
* 生产环境这些请求也走后端(Caddy 把 /public/* 整段反代过来),不让 Caddy 直接读盘:
* 这样开发(Vite 代理)和生产是同一条代码路径,少一处只在服务器上才出错的差异。
*/
async function serveUpload(pathname: string, prefix: string, directory: string) {
// 畸形的百分号编码(`/public/upload/%`)decodeURIComponent 会抛 URIError,不接住就是 500
let decoded: string
try {
decoded = decodeURIComponent(pathname)
} catch {
return new Response('Not found', { status: 404 })
}
const filename = basename(decoded)
if (!filename || filename !== decoded.slice(prefix.length + 1)) {
return new Response('Not found', { status: 404 })
}
const file = Bun.file(resolve(directory, filename))
if (await file.exists()) {
// 文件名由后端生成且内容不变,可以放心长缓存
return new Response(file, {
headers: {
'cache-control': 'public, max-age=86400',
// 线上 Caddy 也加了这个头,这里再加一道:content-type 按扩展名配,
// 不让浏览器自己嗅探成 HTML 去执行
'x-content-type-options': 'nosniff',
},
})
}
return null
}
await loadJwtSecret()
const server = Bun.serve<SubmissionSocketData>({
port: config.port,
// Bun 默认 10 秒没有字节往来就掐连接,而且不报任何错。AI 的 SSE 两条路都会静默
// 超过 10 秒:streamWhole 在 produce 期间每 15 秒才发一次心跳,streamChat 等首
// token 也常过 10 秒 —— 学生看到的就是「AI 提示生成失败」。对齐反代链上最短的
// 那一环(NPM 的 proxy_read_timeout 60s),两边的心跳 / 空闲超时都在它之内。
idleTimeout: 60,
async fetch(request, bunServer) {
const url = new URL(request.url)
if (url.pathname.startsWith(`${config.avatarUriPrefix}/`)) {
const hit = await serveUpload(url.pathname, config.avatarUriPrefix, config.avatarDirectory)
if (hit) return hit
if (basename(decodeURIComponent(url.pathname)) === 'default.png') {
return new Response(DEFAULT_AVATAR_SVG, {
headers: {
'content-type': 'image/svg+xml',
'cache-control': 'public, max-age=3600',
},
})
}
return new Response('Not found', { status: 404 })
}
// 题面里插的图片。原来没有这一段 —— 后台上传成功、返回 /public/upload/xxx,
// 但没有任何路由伺服它,题面图片一律 404。
if (url.pathname.startsWith(`${config.uploadUriPrefix}/`)) {
return (await serveUpload(url.pathname, config.uploadUriPrefix, config.uploadDirectory)) ?? new Response('Not found', { status: 404 })
}
if (url.pathname === '/ws/submissions' || url.pathname === '/ws/config' || url.pathname === '/ws/collab') {
if (!isAllowedWebSocketOrigin(request.headers.get('origin'), url)) {
return new Response('Forbidden', { status: 403 })
}
const session = await authenticateWebSocket(request)
if (!session) return new Response('Unauthorized', { status: 401 })
const { user } = session
const kind = url.pathname === '/ws/config' ? 'config' : url.pathname === '/ws/collab' ? 'collab' : 'submissions'
if (
bunServer.upgrade(request, {
// 前端是借子协议带 token 来的,不回一个它传过的子协议 Chrome 就判握手失败
headers: { 'Sec-WebSocket-Protocol': WS_AUTH_PROTOCOL },
data: {
userId: user.id,
kind,
sid: session.sid,
tokenVersion: session.tokenVersion,
username: user.username,
adminType: user.adminType,
className: user.className,
},
})
) {
return undefined
}
return new Response('WebSocket upgrade failed', { status: 400 })
}
return app.fetch(request)
},
websocket: submissionWebSocketHandler(),
})
await bridgeSubmissionEvents(server)
startSessionSweep()
console.log(`OJ2 API listening on http://localhost:${server.port}`)
// 迁移刚灌进来、或者上次没补完的挑战:拿历史上做对的代码补一遍谁已经做到了。
// 晚一点再跑,别和刚起来时涌进来的请求抢那两个核
setTimeout(() => void scanPendingChallenges(), 20_000)