Compare commits

...

2 Commits

Author SHA1 Message Date
cf1eea0810 fix(比赛): 学生管理员开卷、导出只剩十行、排行榜翻页错位等四处
Some checks failed
Deploy / deploy (push) Has been cancelled
## 学生管理员在比赛进行中能看所有人的代码

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
2026-09-02 20:38:04 -06:00
286487acf3 fix(自学): 少于三分钟的不算已读
原来只要打开过(viewCount > 0)就记成已读,于是「点开看一眼就退」和「认真读完」
在老师那张表上长得一模一样,「已读 17/17」并不说明他学了。加一道 3 分钟的门槛 ——
按最短的一课定的下限,读不完还翻不动的课文,扫一眼是到不了这个数的。

阈值 TUTORIAL_READ_SECONDS 放在 contract 里,前后端共用一个数:学生端目录的 ✓、
后台「按学生」的已读课数、「按课程」的读过人数,三处口径必须一致,各写各的迟早分叉。

**累计时长故意不卡**。时长记的是真实停留,不满三分钟的秒数照样算进去,于是
「已读 0 课、累计 25 分钟」成为一种能看见的状态 —— 他翻了很多课,每课都没读满。
这正是这道门槛想让老师看见的东西,把时长一起滤掉反而把信息删了。

「按课程」的人均时长跟着改了分子:分母是读满三分钟的人,分子就得是同一批人的时长,
否则拿全部时长去除达标人数,人均会被翻一眼就走的人凭空抬高。

顺带修掉「✓ 已读 · -」:心跳 15 秒一跳,点开就走会落在 0 秒上,而 readableDuration(0)
返回的是 "-"。新口径下打勾要求 ≥180 秒,这个组合不可能再出现;打开过但没读满的
显示灰色的「读了 N 分钟」,记是记下了,只是还没到「已读」。

后台筛选栏上方写了一行口径说明,免得老师对着「已读 0 课 / 累计 25 分钟」猜是不是坏了。

本机实跑:student 读 A 课 500 秒、B 课 60 秒 → 已读 1/2、累计 560 秒;
A 课「读过 2 人、累计 700、人均 350」,B 课「读过 0 人、累计 60、人均 0」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rpSKCNpVcMhTFhw21YiL7
2026-09-02 20:37:30 -06:00
13 changed files with 4031 additions and 52 deletions

View 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;

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}
}

View File

@@ -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],

View File

@@ -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(),

View File

@@ -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")
}

View File

@@ -1,4 +1,5 @@
import {
TUTORIAL_READ_SECONDS,
learnExerciseAttemptSchema,
learnExerciseProgressListSchema,
learnExerciseProgressSchema,
@@ -93,7 +94,9 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
readCount: count(schema.tutorialProgress.tutorialId),
// 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。
// 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况
readCount: sql<number>`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number),
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(Number),
lastViewedAt: sql<string | null>`max(${schema.tutorialProgress.lastViewedAt})`,
}).from(schema.user)
@@ -148,8 +151,11 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
// 数的是 user.id 而不是 progress.user_idjoin 不上的(老师自己试读的、
// 已禁用的、不在所选班级的)在这一列是 NULLcount(distinct) 正好不算它,
// 而 progress.user_id 那边永远非空,会把过滤当没发生
readers: sql<number>`count(distinct ${schema.user.id})`.mapWith(Number),
readers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number),
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
// 人均时长的分母是 readers读满 3 分钟的人),分子就得是同一批人的时长,
// 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高
readSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith(Number),
}).from(schema.tutorial)
.leftJoin(schema.tutorialProgress, eq(schema.tutorialProgress.tutorialId, schema.tutorial.id))
.leftJoin(schema.user, and(
@@ -164,9 +170,9 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
return success(c, learnTutorialProgressListSchema.parse({
studentCount,
results: rows.map((row) => learnTutorialProgressSchema.parse({
results: rows.map(({ readSeconds, ...row }) => learnTutorialProgressSchema.parse({
...row,
avgSeconds: row.readers ? Math.round(row.totalSeconds / row.readers) : 0,
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
})),
}))
})

View File

@@ -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({

View File

@@ -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

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
import { NProgress, NText } from "naive-ui"
import {
getLearnStudents,
@@ -286,6 +287,10 @@ onMounted(load)
<n-text depth="3">
{{ studentCount }} 名学生{{ startedCount }} 人已经开始学
</n-text>
<!-- 口径写在表上方免得老师对着已读 0 / 累计 25 分钟猜是不是坏了 -->
<n-text depth="3" style="font-size: 12px">
已读按累计停留满 {{ TUTORIAL_READ_SECONDS / 60 }} 分钟算不足的只计时长
</n-text>
</n-flex>
<n-tabs v-model:value="tab" type="line" animated>

View File

@@ -221,11 +221,21 @@ function openExportModal() {
async function downloadExcel() {
exportLoading.value = true
try {
const res = await getContestRank(props.contestID, {
limit: total.value || 10000,
offset: 0,
})
const allRanks: ContestRank[] = res.results
// 自己翻页凑齐全量:后端 limit 上限 250而**超出上限不是截断、是静默回落到
// 默认的 10**routes/helpers.ts 的 queryInteger。原来这里传 total或 10000
// 想一次拉完,参赛超过 250 人时只会拿回 10 行,而下面的等级分档仍按真实总人数算 ——
// 老师拿到的是一份 10 个人、等级全错的名单,还不报错
const PAGE = 250
const allRanks: ContestRank[] = []
for (;;) {
const res = await getContestRank(props.contestID, {
limit: PAGE,
offset: allRanks.length,
})
allRanks.push(...res.results)
// 两个出口都要留:拿不满一页说明到底了;比对 total 是防着最后一页正好整除
if (res.results.length < PAGE || allRanks.length >= res.total) break
}
const rows = allRanks.map((rank, index) => {
const rank1 = index + 1

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
import type { TutorialProgress } from "utils/types"
import { readableDuration } from "utils/functions"
@@ -12,6 +13,12 @@ defineProps<{
}>()
const emit = defineEmits<{ select: [lesson: number] }>()
// 打开过但一秒都没攒够时 readableDuration 给的是 "-",「读了 -」不像人话。
// 心跳 15 秒一跳,点开就走确实会落在 0 上
function readSoFar(seconds: number) {
return seconds > 0 ? readableDuration(seconds) : "不到 1 分钟"
}
</script>
<template>
@@ -31,14 +38,23 @@ const emit = defineEmits<{ select: [lesson: number] }>()
{{ index + 1 }}. {{ item.title }}
</n-text>
<!-- 每篇教程都有一条进度没读过的是一行零所以这里判的是读没读过
不是有没有这条记录 -->
不是有没有这条记录
TUTORIAL_READ_SECONDS 才打 打开过但没读满的仍然显示时长
只是不带勾也不是成功色 记是记下了还没到已读 -->
<n-text
v-if="progress[item.id]?.viewCount"
v-if="progress[item.id]?.totalSeconds >= TUTORIAL_READ_SECONDS"
type="success"
style="font-size: 12px"
>
已读 · {{ readableDuration(progress[item.id].totalSeconds) }}
</n-text>
<n-text
v-else-if="progress[item.id]?.viewCount"
depth="3"
style="font-size: 12px"
>
读了 {{ readSoFar(progress[item.id].totalSeconds) }}
</n-text>
<n-text
v-if="progress[item.id]?.exerciseTotal"
:type="

View File

@@ -141,3 +141,16 @@ export type Exercise = z.infer<typeof exerciseSchema>
export type TutorialProgress = z.infer<typeof tutorialProgressSchema>
export type TutorialProgressPing = z.infer<typeof tutorialProgressPingSchema>
export type ExerciseAttemptRequest = z.infer<typeof exerciseAttemptRequestSchema>
/**
* 「已读」的门槛:累计停留满 3 分钟才算读过这一课。
*
* 之前只要打开过(`viewCount > 0`)就记成已读,于是「点开看一眼就退」和「认真读完」
* 在老师那张表上长得一模一样,「已读 17/17」并不说明他学了。3 分钟是按最短的一课
* 定的下限——读不完还翻不动的课文,扫一眼是到不了这个数的。
*
* 门槛只管**「已读」这个口径**(学生端的 ✓、后台的已读课数与读过的人数),
* 不动累计时长:时长记的是真实停留,不满 3 分钟的那些秒数照样算进去,
* 「已读 0 课、累计 25 分钟」正是要让老师看见的一种情况。
*/
export const TUTORIAL_READ_SECONDS = 180