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:
@@ -22,3 +22,46 @@ export const requireAuth: MiddlewareHandler<AppEnv> = async (c, next) => {
|
|||||||
c.set("user", user)
|
c.set("user", user)
|
||||||
await next()
|
await next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台接口的角色守卫,对应旧后端 `account/decorators.py` 的四个装饰器。
|
||||||
|
*
|
||||||
|
* 未登录一律 401 `login-required`、登录但角色不够一律 403 `permission-denied`,
|
||||||
|
* 与旧 `BasePermissionDecorator._permission_error` 的两分支一致 —— 前端 `utils/api2.ts`
|
||||||
|
* 的拦截器就是按这两个 code 分别弹登录框和弹提示的。
|
||||||
|
*
|
||||||
|
* 「账号已禁用」这一支不需要单独处理:`getSessionUser` 对禁用用户直接返回 null,
|
||||||
|
* 于是落到 401,比旧后端先认证再报 403 更早拦一步。
|
||||||
|
*/
|
||||||
|
function requireRole(
|
||||||
|
allowed: (user: AuthUser) => boolean,
|
||||||
|
): MiddlewareHandler<AppEnv> {
|
||||||
|
return async (c, next) => {
|
||||||
|
const user = await getSessionUser(c)
|
||||||
|
if (!user) return failure(c, 401, "login-required", "请先登录")
|
||||||
|
if (!allowed(user)) return failure(c, 403, "permission-denied", "权限不足")
|
||||||
|
c.set("user", user)
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_ROLES = ["Student Admin", "Teacher Admin", "Super Admin"]
|
||||||
|
const TEACHER_ROLES = ["Teacher Admin", "Super Admin"]
|
||||||
|
|
||||||
|
/** 旧 `@admin_role_required` */
|
||||||
|
export const requireAdmin = requireRole((user) => ADMIN_ROLES.includes(user.adminType))
|
||||||
|
|
||||||
|
/** 旧 `@teacher_admin_required` */
|
||||||
|
export const requireTeacher = requireRole((user) => TEACHER_ROLES.includes(user.adminType))
|
||||||
|
|
||||||
|
/** 旧 `@super_admin_required` */
|
||||||
|
export const requireSuperAdmin = requireRole((user) => user.adminType === "Super Admin")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧 `@problem_permission_required`:先要是管理员,再要 problem_permission 不为 None。
|
||||||
|
* 注意它只管「能不能进这个接口」,「能改哪些题」(Own vs All)由各 handler 自己按
|
||||||
|
* created_by 过滤 —— 旧后端也是这么分工的,别把两件事混在一起。
|
||||||
|
*/
|
||||||
|
export const requireProblemPermission = requireRole(
|
||||||
|
(user) => ADMIN_ROLES.includes(user.adminType) && user.problemPermission !== "None",
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { basename, resolve } from "node:path"
|
|||||||
|
|
||||||
import { getRequestSessionUser } from "./auth/session"
|
import { getRequestSessionUser } from "./auth/session"
|
||||||
import { config } from "./config"
|
import { config } from "./config"
|
||||||
|
import { adminRoutes } from "./routes/admin"
|
||||||
import { authRoutes } from "./routes/auth"
|
import { authRoutes } from "./routes/auth"
|
||||||
import { accountRoutes } from "./routes/account"
|
import { accountRoutes } from "./routes/account"
|
||||||
import { judgeServerRoutes } from "./routes/judge-server"
|
import { judgeServerRoutes } from "./routes/judge-server"
|
||||||
@@ -38,6 +39,7 @@ app.route("/api", flowchartRoutes)
|
|||||||
app.route("/api", problemRoutes)
|
app.route("/api", problemRoutes)
|
||||||
app.route("/api", submissionRoutes)
|
app.route("/api", submissionRoutes)
|
||||||
app.route("/api", judgeServerRoutes)
|
app.route("/api", judgeServerRoutes)
|
||||||
|
app.route("/api/admin", adminRoutes)
|
||||||
|
|
||||||
app.onError((error, c) => {
|
app.onError((error, c) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|||||||
114
apps/api/src/routes/admin/announcement.ts
Normal file
114
apps/api/src/routes/admin/announcement.ts
Normal 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 掉了 content,Zod 会 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)
|
||||||
|
})
|
||||||
16
apps/api/src/routes/admin/index.ts
Normal file
16
apps/api/src/routes/admin/index.ts
Normal 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)
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import http from "utils/http"
|
import http from "utils/http"
|
||||||
|
import api2 from "utils/api2"
|
||||||
|
import { legacyResponse } from "utils/legacy"
|
||||||
import { toProblemListItem } from "admin/transforms"
|
import { toProblemListItem } from "admin/transforms"
|
||||||
import type {
|
import type {
|
||||||
AdminProblem,
|
AdminProblem,
|
||||||
@@ -266,25 +268,29 @@ export function deleteJudgeServer(hostname: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAnnouncementList(offset = 0, limit = 10) {
|
export function getAnnouncementList(offset = 0, limit = 10) {
|
||||||
return http.get("admin/announcement", {
|
return legacyResponse(
|
||||||
params: { paging: true, offset, limit },
|
api2.get("admin/announcements", {
|
||||||
})
|
params: { offset, limit },
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnnouncement(id: number) {
|
export function getAnnouncement(id: number) {
|
||||||
return http.get<Announcement>("admin/announcement", { params: { id } })
|
return legacyResponse<Announcement>(api2.get(`admin/announcements/${id}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteAnnouncement(id: number) {
|
export function deleteAnnouncement(id: number) {
|
||||||
return http.delete("admin/announcement", { params: { id } })
|
return api2.delete(`admin/announcements/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function editAnnouncement(announcement: AnnouncementEdit) {
|
export function editAnnouncement(announcement: AnnouncementEdit) {
|
||||||
return http.put("admin/announcement", announcement)
|
const { id, ...body } = announcement
|
||||||
|
return legacyResponse(api2.put(`admin/announcements/${id}`, body))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAnnouncement(announcement: AnnouncementEdit) {
|
export function createAnnouncement(announcement: AnnouncementEdit) {
|
||||||
return http.post("admin/announcement", announcement)
|
const { id: _id, ...body } = announcement
|
||||||
|
return legacyResponse(api2.post("admin/announcements", body))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTutorialList() {
|
export async function getTutorialList() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type SubmissionStatistics,
|
type SubmissionStatistics,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import api2 from "utils/api2"
|
import api2 from "utils/api2"
|
||||||
|
import { legacyResponse, toLegacy } from "utils/legacy"
|
||||||
import type { ApiResponse } from "utils/http"
|
import type { ApiResponse } from "utils/http"
|
||||||
import { filterResult } from "oj/transforms"
|
import { filterResult } from "oj/transforms"
|
||||||
import type {
|
import type {
|
||||||
@@ -19,25 +20,6 @@ import type {
|
|||||||
WebsiteConfig,
|
WebsiteConfig,
|
||||||
} from "utils/types"
|
} from "utils/types"
|
||||||
|
|
||||||
function snakeKey(key: string) {
|
|
||||||
return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toLegacy<T>(value: unknown): T {
|
|
||||||
if (Array.isArray(value)) return value.map((item) => toLegacy(item)) as T
|
|
||||||
if (!value || typeof value !== "object") return value as T
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(value).map(([key, item]) => [snakeKey(key), toLegacy(item)]),
|
|
||||||
) as T
|
|
||||||
}
|
|
||||||
|
|
||||||
async function legacyResponse<T>(
|
|
||||||
request: Promise<ApiResponse<unknown>>,
|
|
||||||
): Promise<ApiResponse<T>> {
|
|
||||||
const response = await request
|
|
||||||
return { error: response.error, data: toLegacy<T>(response.data) }
|
|
||||||
}
|
|
||||||
|
|
||||||
function listProblem(value: any): Problem {
|
function listProblem(value: any): Problem {
|
||||||
return {
|
return {
|
||||||
id: value.id,
|
id: value.id,
|
||||||
|
|||||||
27
apps/web/src/utils/legacy.ts
Normal file
27
apps/web/src/utils/legacy.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { ApiResponse } from "./http"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新后端一律 camelCase,而现存组件读的都是旧 Django 的 snake_case。
|
||||||
|
* 迁移期在 api 层做一次键名转换,组件不动 —— 否则每搬一个端点就要顺带改一堆 .vue,
|
||||||
|
* 改动面大到没法一个个验。
|
||||||
|
*
|
||||||
|
* 迁移完成后这一层应当整体拆掉,届时组件改成 camelCase 是一次性的机械替换。
|
||||||
|
*/
|
||||||
|
function snakeKey(key: string) {
|
||||||
|
return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toLegacy<T>(value: unknown): T {
|
||||||
|
if (Array.isArray(value)) return value.map((item) => toLegacy(item)) as T
|
||||||
|
if (!value || typeof value !== "object") return value as T
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).map(([key, item]) => [snakeKey(key), toLegacy(item)]),
|
||||||
|
) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function legacyResponse<T>(
|
||||||
|
request: Promise<ApiResponse<unknown>>,
|
||||||
|
): Promise<ApiResponse<T>> {
|
||||||
|
const response = await request
|
||||||
|
return { error: response.error, data: toLegacy<T>(response.data) }
|
||||||
|
}
|
||||||
38
packages/contract/src/admin.ts
Normal file
38
packages/contract/src/admin.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { paginatedSchema, sampleUserSchema } from "./common"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台侧的契约。与 oj 侧分开放:同一张表在两侧下发的字段集通常不同
|
||||||
|
* (后台要 `visible` 这类管理字段,oj 侧连键都不该出现),
|
||||||
|
* 混在一个 schema 里迟早会有人为了省事在 oj 侧复用后台那个。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const adminAnnouncementSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
title: z.string(),
|
||||||
|
tag: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
visible: z.boolean(),
|
||||||
|
top: z.boolean(),
|
||||||
|
createdBy: sampleUserSchema,
|
||||||
|
createTime: z.string(),
|
||||||
|
lastUpdateTime: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const adminAnnouncementListSchema = paginatedSchema(
|
||||||
|
// 列表不带 content:公告正文是 8MB 上限的富文本,列表页只显示标题
|
||||||
|
adminAnnouncementSchema.omit({ content: true }),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const createAnnouncementRequestSchema = z.object({
|
||||||
|
title: z.string().trim().min(1).max(64),
|
||||||
|
tag: z.string().max(64),
|
||||||
|
content: z.string().max(1024 * 1024 * 8),
|
||||||
|
visible: z.boolean(),
|
||||||
|
top: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateAnnouncementRequestSchema = createAnnouncementRequestSchema
|
||||||
|
|
||||||
|
export type AdminAnnouncement = z.infer<typeof adminAnnouncementSchema>
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from "./admin"
|
||||||
export * from "./auth"
|
export * from "./auth"
|
||||||
export * from "./account"
|
export * from "./account"
|
||||||
export * from "./achievement"
|
export * from "./achievement"
|
||||||
|
|||||||
Reference in New Issue
Block a user