## 学生管理员在比赛进行中能看所有人的代码 canViewSubmission 里 isAdminRole(user) 是无条件放行的捷径,而 ADMIN_ROLES 含 Student Admin —— 同时 contest.ts 的排行榜又把这个角色算作参赛者。既在榜上、 又能读别人的提交,就是开卷。比赛未结束(未开始 + 进行中)时不再吃这条捷径。 旧后端这里写的是 `not user.is_regular_user()`,学生管理员同样放行,所以这条是 相对旧栈**收紧**的一处,不是修回归。只掐角色捷径,不掐 problem.createdById —— 那是这道题的作者本人,他早就知道答案,挡他没有意义。老师和超管不受影响,他们不参赛。 实跑(造了一场进行中、一场已结束的比赛,各一条别人的提交):学生管理员在进行中的 比赛里列表 showLink=false、详情 404,比赛结束后恢复 200;超管两种状态都是 200; 学生管理员读**自己**比赛中的提交仍然 200,没有把他自己的记录一起挡掉。 同族的 flowchart.ts canView 有同样的角色捷径,但流程图提交入库时从不写 contest_id (永远是 null),那条路上不存在「比赛中的别人的提交」,不用跟着改。 ## 获奖名单导出,参赛超过 250 人时只导出十个人 rank.vue 用 `limit: total.value || 10000` 想一次拉全量,但 queryInteger 对超出 上限的值是**静默回落到默认的 10**,不是截断到 250。于是 300 人的比赛导出 10 行, 而一二三等奖的分档仍按真实总人数算 —— 老师拿到一份十个人、等级全错的名单,还不报错。 改成按 250 一页循环拉,两个出口都留:拿不满一页说明到底了,比对 total 是防着最后 一页正好整除。班级赛(≤250)碰不到这个坑,全校赛会。 实跑:造 20 条排名,limit=300 后端确实只返回 10 行,limit=250 返回 20, 新逻辑拉回 20。 ## 排行榜翻页没有全序 只按 acceptedNumber desc, totalTime asc 排,同分同罚时行序不稳定,而这条列表是 limit/offset 翻页的 —— 同一个人可能在第 2 页出现两次,另一个人从此消失。末尾补 asc(id) 兜全序,id 不参与名次,只保证同分的人每次按同一顺序排。 实跑:20 个同 AC 数同罚时的选手,每页 5 条翻 4 页,共 20 行去重后仍是 20 人, 连翻两轮顺序完全一致。 ## acm-helper 两个接口对 visible 的口径不一致 GET 要求 visible = true,PUT 不要求。比赛结束后收起来,核查页就打不开了, 而标记接口还能用。赛后核查恰恰常发生在比赛已经收起来之后,按 PUT 的口径放开。 ## 删掉 contest_announcement 旧 Django 栈有「比赛公告」,OJ2 从头到尾没搬:没有路由、没有契约、没有前端页面, 表建在那里纯粹是 introspect 0000 时一起拉进来的。确认不需要,连表带 schema 定义 一起删。 删除前核实:没有任何表外键指向它,序列由本表 owned 会随 DROP TABLE 一并消失, 生产快照(db_backup_2026_08_07)里只有 1 行 —— 2022 年 4 月挂在 contest 1 上的 一条测试公告。迁移不写 CASCADE,同 0002:真有别的东西引用了,宁可报错也别被悄悄 级联掉。 **上生产要显式放行**:migrate.ts 的破坏性闸会拦下 DROP TABLE,得 `OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh`,并按它说的先备份。本机已实跑: 不带环境变量确实被拦,带上之后表和序列都没了,重跑是「没有待执行的迁移」, db:generate 报 No schema changes(snapshot 与 schema.ts 对齐,不给下一条迁移留假 diff)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rpSKCNpVcMhTFhw21YiL7
This commit is contained in:
12
apps/api/src/db/0006_drop_contest_announcement.sql
Normal file
12
apps/api/src/db/0006_drop_contest_announcement.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- 删掉比赛公告表。旧 Django 栈有「比赛公告」这个功能,OJ2 从头到尾没有搬:
|
||||
-- 没有任何路由、契约或前端页面引用它,表建在那里纯粹是 introspect 0000 时一起拉进来的。
|
||||
--
|
||||
-- 已核实:
|
||||
-- * 没有任何表外键引用 contest_announcement,它只有两条指向 contest / user 的出边,
|
||||
-- 删掉是自洽的;
|
||||
-- * 序列 contest_announcement_id_seq 由本表 owned,随 DROP TABLE 一并消失;
|
||||
-- * 数据:生产快照(db_backup_2026_08_07)里只有 1 行,是 2022 年 4 月挂在 contest 1 上的
|
||||
-- 一条测试公告(「四月月赛」)。OJ2 侧没有写入路径,这个数字不会再增长。
|
||||
--
|
||||
-- 不写 CASCADE,同 0002:万一将来真有别的东西引用了,宁可这里报错,也别被悄悄级联掉。
|
||||
DROP TABLE IF EXISTS contest_announcement;
|
||||
3932
apps/api/src/db/meta/0006_snapshot.json
Normal file
3932
apps/api/src/db/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1788272667927,
|
||||
"tag": "0005_add_exercise_attempt",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1788402925980,
|
||||
"tag": "0006_drop_contest_announcement",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { relations } from "drizzle-orm/relations";
|
||||
import { user, aiAnalysis, announcement, contest, contestAnnouncement, problem, flowchartSubmission, message, submission, tutorial, exercise, problemset, problemsetProblem, problemsetProgress, problemsetSubmission, reaction, problemTags, problemTag, userStat, achievement, userAchievement, problemsetBadge, userBadge, userProfile, acmContestRank } from "./schema";
|
||||
import { user, aiAnalysis, announcement, contest, problem, flowchartSubmission, message, submission, tutorial, exercise, problemset, problemsetProblem, problemsetProgress, problemsetSubmission, reaction, problemTags, problemTag, userStat, achievement, userAchievement, problemsetBadge, userBadge, userProfile, acmContestRank } from "./schema";
|
||||
|
||||
export const aiAnalysisRelations = relations(aiAnalysis, ({one}) => ({
|
||||
user: one(user, {
|
||||
@@ -12,7 +12,6 @@ export const userRelations = relations(user, ({many}) => ({
|
||||
aiAnalyses: many(aiAnalysis),
|
||||
announcements: many(announcement),
|
||||
contests: many(contest),
|
||||
contestAnnouncements: many(contestAnnouncement),
|
||||
flowchartSubmissions: many(flowchartSubmission),
|
||||
messages_recipientId: many(message, {
|
||||
relationName: "message_recipientId_user_id"
|
||||
@@ -45,23 +44,11 @@ export const contestRelations = relations(contest, ({one, many}) => ({
|
||||
fields: [contest.createdById],
|
||||
references: [user.id]
|
||||
}),
|
||||
contestAnnouncements: many(contestAnnouncement),
|
||||
problems: many(problem),
|
||||
submissions: many(submission),
|
||||
acmContestRanks: many(acmContestRank),
|
||||
}));
|
||||
|
||||
export const contestAnnouncementRelations = relations(contestAnnouncement, ({one}) => ({
|
||||
contest: one(contest, {
|
||||
fields: [contestAnnouncement.contestId],
|
||||
references: [contest.id]
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [contestAnnouncement.createdById],
|
||||
references: [user.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const flowchartSubmissionRelations = relations(flowchartSubmission, ({one}) => ({
|
||||
problem: one(problem, {
|
||||
fields: [flowchartSubmission.problemId],
|
||||
|
||||
@@ -101,29 +101,6 @@ export const contest = pgTable("contest", {
|
||||
}),
|
||||
]);
|
||||
|
||||
export const contestAnnouncement = pgTable("contest_announcement", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
title: text().notNull(),
|
||||
content: text().notNull(),
|
||||
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
contestId: integer("contest_id").notNull(),
|
||||
createdById: integer("created_by_id").notNull(),
|
||||
visible: boolean().notNull(),
|
||||
}, (table) => [
|
||||
index("contest_announcement_contest_id_a8cb419f").using("btree", table.contestId.asc().nullsLast().op("int4_ops")),
|
||||
index("contest_announcement_created_by_id_469a14ce").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
columns: [table.contestId],
|
||||
foreignColumns: [contest.id],
|
||||
name: "contest_announcement_contest_id_a8cb419f_fk_contest_id"
|
||||
}),
|
||||
foreignKey({
|
||||
columns: [table.createdById],
|
||||
foreignColumns: [user.id],
|
||||
name: "contest_announcement_created_by_id_469a14ce_fk_user_id"
|
||||
}),
|
||||
]);
|
||||
|
||||
export const flowchartSubmission = pgTable("flowchart_submission", {
|
||||
id: text().primaryKey().notNull(),
|
||||
mermaidCode: text("mermaid_code").notNull(),
|
||||
|
||||
@@ -245,8 +245,10 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
|
||||
|
||||
adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
// 不卡 visible:赛后核查恰恰常发生在比赛已经收起来之后,而同一场比赛的
|
||||
// PUT acm-helper 从来不卡这一条 —— 卡着就成了「标记还能改、页面打不开」
|
||||
const [contest] = await db.select().from(schema.contest)
|
||||
.where(and(eq(schema.contest.id, id), eq(schema.contest.visible, true))).limit(1)
|
||||
.where(eq(schema.contest.id, id)).limit(1)
|
||||
if (!contest || !ownedBy(c.get("user")!, contest)) {
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
}
|
||||
|
||||
@@ -213,7 +213,10 @@ contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("rank
|
||||
db.select({ rank: schema.acmContestRank, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
||||
.orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime)).limit(limit).offset(offset),
|
||||
// 末尾的 id 是给排序兜全序用的:同 AC 数同罚时前两列分不出先后,而这条列表是
|
||||
// limit/offset 翻页的,行序不稳定就意味着同一个人在第 2 页出现两次、另一个人
|
||||
// 从此消失。id 本身不参与名次,只保证同分的人每次都按同一个顺序排
|
||||
.orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime), asc(schema.acmContestRank.id)).limit(limit).offset(offset),
|
||||
])
|
||||
const admin = isContestAdmin(c.get("user"), contest)
|
||||
return success(c, contestRankSchema.parse({
|
||||
|
||||
@@ -432,7 +432,16 @@ function canViewSubmission(
|
||||
const joinTime = problemSetJoinTime?.get(row.problemId)
|
||||
if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false
|
||||
}
|
||||
if (row.userId === user.id || isAdminRole(user) || problem.createdById === user.id) return true
|
||||
// 比赛没结束时,学生管理员不吃「管理员看得到所有人代码」这条捷径:他自己也在排行榜里
|
||||
// (contest.ts 的 rank 把 Student Admin 算作参赛者),既参赛又能读别人的提交就是开卷。
|
||||
// 老师和超管不受影响 —— 他们不参赛。旧后端这里是 `not user.is_regular_user()`,
|
||||
// 学生管理员同样放行,所以这条是 OJ2 相对旧栈**收紧**的一处,不是修回归。
|
||||
//
|
||||
// 只掐角色捷径,不掐 `problem.createdById === user.id`:那是这道题的作者本人,
|
||||
// 他早就知道答案了,挡他没有意义。
|
||||
const elevated = isAdminRole(user)
|
||||
&& !(contest && contestStatus(contest) !== "-1" && user.adminType === "Student Admin")
|
||||
if (row.userId === user.id || elevated || problem.createdById === user.id) return true
|
||||
if (!allowShared) return false
|
||||
if (contest && contestStatus(contest) !== "-1") return false
|
||||
return problem.shareSubmission || row.shared
|
||||
|
||||
Reference in New Issue
Block a user