feat(阶段4): 后台地基 + 公告管理

地基:
- 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>
This commit is contained in:
2026-08-07 07:06:22 -06:00
parent 359b91ab4d
commit 9a6c3ba225
9 changed files with 255 additions and 26 deletions

View File

@@ -0,0 +1,114 @@
import {
adminAnnouncementListSchema,
adminAnnouncementSchema,
createAnnouncementRequestSchema,
updateAnnouncementRequestSchema,
} from "@oj2/contract"
import { count, desc, eq } from "drizzle-orm"
import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { queryInteger, sampleUser } from "../helpers"
export const adminAnnouncementRoutes = new Hono<AppEnv>()
function serialize(row: {
announcement: typeof schema.announcement.$inferSelect
user: typeof schema.user.$inferSelect
realName: string | null
}) {
return adminAnnouncementSchema.parse({
id: row.announcement.id,
title: row.announcement.title,
tag: row.announcement.tag,
content: row.announcement.content,
visible: row.announcement.visible,
top: row.announcement.top,
createdBy: sampleUser(row.user, row.realName),
createTime: row.announcement.createTime,
lastUpdateTime: row.announcement.lastUpdateTime,
})
}
function selectOne(id: number) {
return db
.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
.from(schema.announcement)
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.announcement.id, id))
.limit(1)
}
adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.announcement),
db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
.from(schema.announcement)
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.orderBy(desc(schema.announcement.createTime))
.limit(limit)
.offset(offset),
])
return success(c, adminAnnouncementListSchema.parse({
// 列表 schema omit 掉了 contentZod 会 strip 掉多出来的键,这里不必手工再挑一遍
results: rows.map(serialize),
total: totalRows[0]?.value ?? 0,
}))
})
adminAnnouncementRoutes.post("/announcements", requireSuperAdmin, async (c) => {
const parsed = createAnnouncementRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const now = new Date().toISOString()
const [created] = await db.insert(schema.announcement).values({
...parsed.data,
createTime: now,
lastUpdateTime: now,
createdById: c.get("user")!.id,
}).returning({ id: schema.announcement.id })
const [row] = await selectOne(created!.id)
return success(c, serialize(row!), 201)
})
adminAnnouncementRoutes.get("/announcements/:id", requireSuperAdmin, async (c) => {
const [row] = await selectOne(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist")
return success(c, serialize(row))
})
adminAnnouncementRoutes.put("/announcements/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAnnouncementRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const updated = await db.update(schema.announcement)
.set({ ...parsed.data, lastUpdateTime: new Date().toISOString() })
.where(eq(schema.announcement.id, id))
.returning({ id: schema.announcement.id })
if (updated.length === 0) {
return failure(c, 404, "announcement-not-found", "Announcement does not exist")
}
const [row] = await selectOne(id)
return success(c, serialize(row!))
})
adminAnnouncementRoutes.delete("/announcements/:id", requireSuperAdmin, async (c) => {
// 旧后端删不存在的公告也返回成功filter().delete() 不报错)。这里改成 404
// 后台是人手点删除,静默成功会让人以为删掉了,刷新后它还在。
const deleted = await db.delete(schema.announcement)
.where(eq(schema.announcement.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.returning({ id: schema.announcement.id })
if (deleted.length === 0) {
return failure(c, 404, "announcement-not-found", "Announcement does not exist")
}
return success(c, null)
})

View File

@@ -0,0 +1,16 @@
import { Hono } from "hono"
import type { AppEnv } from "../../auth/middleware"
import { adminAnnouncementRoutes } from "./announcement"
/**
* 后台路由总入口,挂在 `/api/admin` 下。
*
* 每个子路由自己挂角色守卫requireAdmin / requireTeacher / requireSuperAdmin /
* requireProblemPermission**不在这里统一兜一层** —— 旧后端的权限就是逐个视图不同的,
* 在总入口兜一个最宽的会让「这个接口到底要什么角色」从注册行上看不出来,
* 正是阶段 3 的 Minor M2 踩过的坑。
*/
export const adminRoutes = new Hono<AppEnv>()
adminRoutes.route("/", adminAnnouncementRoutes)