地基: - auth/middleware.ts 加四个角色守卫 requireAdmin / requireTeacher / requireSuperAdmin / requireProblemPermission,对应旧 account/decorators.py 的 四个装饰器。未登录 401 login-required、角色不够 403 permission-denied, 与前端 api2 拦截器按 code 分流的两支对上。 - routes/admin/ 目录 + 总入口挂在 /api/admin。角色守卫由各子路由自己挂, 不在总入口兜一层 —— 否则「这个接口要什么角色」从注册行看不出来, 正是阶段 3 Minor M2 踩过的坑。 - packages/contract/src/admin.ts 独立放后台契约。同一张表两侧下发的字段集不同 (后台要 visible,oj 侧连键都不该出现),混在一起迟早有人在 oj 侧复用后台那个。 - utils/legacy.ts:把 toLegacy / legacyResponse 从 oj/api.ts 抽出来共用。 admin 侧组件同样读 snake_case,走同一层适配,组件不动。 公告管理(旧 /api/admin/announcement 一个路径四个动词)拆成: GET/POST admin/announcements GET/PUT/DELETE admin/announcements/:id 一处有意不对齐旧后端:删除不存在的公告,旧后端 filter().delete() 静默成功, 这里返回 404 —— 后台是人手点删除,静默成功会让人以为删掉了,刷新后它还在。 实测:匿名 401 / 学生 403 / 超管 200;增删改查、列表不含 content、 空标题 400、不存在 404 全部符合预期。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
90 lines
3.1 KiB
TypeScript
90 lines
3.1 KiB
TypeScript
import { Hono } from "hono"
|
|
import { basename, resolve } from "node:path"
|
|
|
|
import { getRequestSessionUser } from "./auth/session"
|
|
import { config } from "./config"
|
|
import { adminRoutes } from "./routes/admin"
|
|
import { authRoutes } from "./routes/auth"
|
|
import { accountRoutes } from "./routes/account"
|
|
import { judgeServerRoutes } from "./routes/judge-server"
|
|
import { contestRoutes } from "./routes/contest"
|
|
import { contentRoutes } from "./routes/content"
|
|
import { classroomRoutes } from "./routes/classroom"
|
|
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 { submissionRoutes } from "./routes/submission"
|
|
import { siteRoutes } from "./routes/site"
|
|
import {
|
|
bridgeSubmissionEvents,
|
|
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", problemsetRoutes)
|
|
app.route("/api", achievementRoutes)
|
|
app.route("/api", aiRoutes)
|
|
app.route("/api", flowchartRoutes)
|
|
app.route("/api", problemRoutes)
|
|
app.route("/api", submissionRoutes)
|
|
app.route("/api", judgeServerRoutes)
|
|
app.route("/api/admin", adminRoutes)
|
|
|
|
app.onError((error, c) => {
|
|
console.error(error)
|
|
return c.json(
|
|
{ error: { code: "internal-error", message: "Internal server error" } },
|
|
500,
|
|
)
|
|
})
|
|
|
|
const server = Bun.serve<SubmissionSocketData>({
|
|
port: config.port,
|
|
async fetch(request, bunServer) {
|
|
const url = new URL(request.url)
|
|
if (url.pathname.startsWith(`${config.avatarUriPrefix}/`)) {
|
|
const filename = basename(decodeURIComponent(url.pathname))
|
|
if (filename !== decodeURIComponent(url.pathname).split("/").at(-1)) {
|
|
return new Response("Not found", { status: 404 })
|
|
}
|
|
const file = Bun.file(resolve(config.avatarDirectory, filename))
|
|
if (await file.exists()) return new Response(file)
|
|
if (filename === "default.png") {
|
|
return new Response(
|
|
'<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>',
|
|
{ headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=3600" } },
|
|
)
|
|
}
|
|
return new Response("Not found", { status: 404 })
|
|
}
|
|
if (url.pathname === "/ws/submissions") {
|
|
const user = await getRequestSessionUser(request)
|
|
if (!user) return new Response("Unauthorized", { status: 401 })
|
|
if (
|
|
bunServer.upgrade(request, {
|
|
data: { userId: user.id, username: user.username },
|
|
})
|
|
) {
|
|
return undefined
|
|
}
|
|
return new Response("WebSocket upgrade failed", { status: 400 })
|
|
}
|
|
return app.fetch(request)
|
|
},
|
|
websocket: submissionWebSocketHandler(),
|
|
})
|
|
|
|
await bridgeSubmissionEvents(server)
|
|
console.log(`OJ2 API listening on http://localhost:${server.port}`)
|