## user-progress 缺归属校验 学生端那条 GET /problem-sets/:id/user-progress 只有 requireTeacher,没有归属校验, 任何 Teacher Admin 都能读到别人建的题单的学生名单与进度。补上,口径与后台 loadOwned 一致(超管放行,其余人只能看自己建的),越权报 404。 顺带订正 docs/specs/phase4-review-authz.md:449:那条写着题单进度「显式下发真名」, 与代码不符 —— 学生端这条走 sampleUser 且没传 includeRealName,realName 恒为 null (SQL 里那次 leftJoin userProfile 是白查的)。真正下发真名的是后台那条 GET /admin/problem-sets/:id/progress,结论仍成立,但当时漏掉了归属校验这个缺口。 ## 头部进度的分母 分母只算必做题之后,ProblemSetHeader 还在拿 completedCount / problemsCount 算 —— 前者是必做完成数,后者是总题数。做完全部必做题的人会看到「9 / 10、90%」,而同一张 卡片上又标着「已完成」,题单 6 那 10 个人正是这种。改成读 userProgress,另外把 「另有 N 道选做」标出来,否则「共 10 道题目」和「9 / 9」对不上。 ## 截止时间 end_time 管的不是「到点不能做了」,是「到点之前看不到自己加入题单之前的旧代码」, 而学生端一个字都不显示 —— 被挡住的人不知道为什么,也不知道什么时候解锁。头部加一个 带解释的「截止 …」标签;提交列表那个锁图标的说明也补上另外两条解锁路径。 ## 两个必然筛空的筛选器 学生端题单列表的难度、状态两个下拉:线上 16 个题单全是 Easy / active,选「中等」 「困难」「已归档」永远是空列表。撤掉,保留关键词搜索。接口那两个 query 参数留着, 哪天真的用起这两个字段再把 select 加回来。 ## 题目移出题单时的提交记录 旧栈 problemset/signals.py 的 post_delete 会清掉该题在本题单的 ProblemSetSubmission, OJ2 没做,于是那张表一直在攒指向已移出题单的孤儿行。补上。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
This commit is contained in:
@@ -275,8 +275,14 @@ adminProblemSetRoutes.delete("/problem-sets/:id/problems/:itemId", requireTeache
|
|||||||
const deleted = await db.delete(schema.problemsetProblem).where(and(
|
const deleted = await db.delete(schema.problemsetProblem).where(and(
|
||||||
eq(schema.problemsetProblem.id, queryInteger(c.req.param("itemId"), 0, { min: 1 })),
|
eq(schema.problemsetProblem.id, queryInteger(c.req.param("itemId"), 0, { min: 1 })),
|
||||||
eq(schema.problemsetProblem.problemsetId, row.id),
|
eq(schema.problemsetProblem.problemsetId, row.id),
|
||||||
)).returning({ id: schema.problemsetProblem.id })
|
)).returning({ id: schema.problemsetProblem.id, problemId: schema.problemsetProblem.problemId })
|
||||||
if (deleted.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
|
if (deleted.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
|
||||||
|
// 这道题在本题单里的提交记录也要清掉,对齐旧栈 problemset/signals.py 的 post_delete。
|
||||||
|
// 不清的话 problemset_submission 会一直攒指向已移出题单的孤儿行。
|
||||||
|
await db.delete(schema.problemsetSubmission).where(and(
|
||||||
|
eq(schema.problemsetSubmission.problemsetId, row.id),
|
||||||
|
eq(schema.problemsetSubmission.problemId, deleted[0]!.problemId),
|
||||||
|
))
|
||||||
await resyncProgress(row.id)
|
await resyncProgress(row.id)
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -365,10 +365,17 @@ problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
|
|||||||
|
|
||||||
problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => {
|
problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset).where(and(
|
const [problemSet] = await db.select({ id: schema.problemset.id, createdById: schema.problemset.createdById })
|
||||||
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
|
.from(schema.problemset).where(and(
|
||||||
)).limit(1)
|
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
|
||||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
)).limit(1)
|
||||||
|
// 归属校验,和后台那条同类接口(admin/problemset.ts 的 loadOwned)一致:超管放行,
|
||||||
|
// 其余老师只能看自己建的题单。少了这一道,任何 Teacher Admin 都能读到别人班的名单。
|
||||||
|
// 越权报「不存在」,不泄露题单存在与否。
|
||||||
|
const user = c.get("user")!
|
||||||
|
if (!problemSet || (user.adminType !== "Super Admin" && problemSet.createdById !== user.id)) {
|
||||||
|
return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
|
}
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
const className = c.req.query("className")?.trim()
|
const className = c.req.query("className")?.trim()
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const columns: DataTableColumn<SubmissionListItem>[] = [
|
|||||||
h(Icon, { icon: "catppuccin:lock" }),
|
h(Icon, { icon: "catppuccin:lock" }),
|
||||||
),
|
),
|
||||||
default: () =>
|
default: () =>
|
||||||
"这道题在你已经加入的题单中,只有在题单中完成此题,代码才可见。",
|
"这道题在你已经加入的题单里,加入之前的提交先藏起来了。在题单中做出此题即可解锁;题单过了截止时间也会解锁。",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import type { ProblemSet, UserBadge as UserBadgeType } from "utils/types"
|
import type { ProblemSet, UserBadge as UserBadgeType } from "utils/types"
|
||||||
|
import { parseTime } from "utils/functions"
|
||||||
import UserBadge from "shared/components/UserBadge.vue"
|
import UserBadge from "shared/components/UserBadge.vue"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
|
|
||||||
@@ -32,14 +33,22 @@ function getDifficultyTag(difficulty: string) {
|
|||||||
return difficultyMap[difficulty] || { type: "default", text: "未知" }
|
return difficultyMap[difficulty] || { type: "default", text: "未知" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 进度一律读 userProgress:它的分母是**必做题数**,而 problemsCount 是总题数。
|
||||||
|
// 拿总题数当分母的话,做完全部必做题的人会看到「9 / 10、90%」,而同一张卡片上
|
||||||
|
// 又标着「已完成」—— 题单 6 那 10 个人正是这种。
|
||||||
function getProgressPercentage() {
|
function getProgressPercentage() {
|
||||||
if (!props.problemSet) return 0
|
return Math.round(props.problemSet?.userProgress?.progressPercentage ?? 0)
|
||||||
return Math.round(
|
|
||||||
((props.problemSet.completedCount ?? 0) / props.problemSet.problemsCount) *
|
|
||||||
100,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 有选做题时把「必做 N 题」标出来,否则「共 10 道题目」和「9 / 9」对不上
|
||||||
|
const optionalCount = computed(
|
||||||
|
() => props.problemSet.problemsCount - (props.problemSet.userProgress?.totalCount ?? 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
const endTimeText = computed(() =>
|
||||||
|
props.problemSet.endTime ? parseTime(props.problemSet.endTime, "YYYY-MM-DD HH:mm") : "",
|
||||||
|
)
|
||||||
|
|
||||||
function handleJoin() {
|
function handleJoin() {
|
||||||
emit("join")
|
emit("join")
|
||||||
}
|
}
|
||||||
@@ -55,6 +64,14 @@ function handleJoin() {
|
|||||||
<n-tag :type="getDifficultyTag(problemSet.difficulty).type">
|
<n-tag :type="getDifficultyTag(problemSet.difficulty).type">
|
||||||
{{ getDifficultyTag(problemSet.difficulty).text }}
|
{{ getDifficultyTag(problemSet.difficulty).text }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
|
<!-- 截止时间不是「到点不能做了」,是「到点之前看不到自己加入题单之前的旧代码」,
|
||||||
|
所以这里要连着解释一句,否则学生只看到一个日期,不知道它管什么 -->
|
||||||
|
<n-tooltip trigger="hover" v-if="endTimeText">
|
||||||
|
<template #trigger>
|
||||||
|
<n-tag type="info">截止 {{ endTimeText }}</n-tag>
|
||||||
|
</template>
|
||||||
|
这个时间之前,你在加入题单之前提交过的代码是看不到的;在题单里做出该题即可解锁。
|
||||||
|
</n-tooltip>
|
||||||
<n-h2 style="margin: 0">{{ problemSet.title }}</n-h2>
|
<n-h2 style="margin: 0">{{ problemSet.title }}</n-h2>
|
||||||
<n-tooltip trigger="hover" v-if="problemSet.description">
|
<n-tooltip trigger="hover" v-if="problemSet.description">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
@@ -79,7 +96,11 @@ function handleJoin() {
|
|||||||
<n-flex align="center" v-if="isJoined">
|
<n-flex align="center" v-if="isJoined">
|
||||||
<n-text strong>完成进度</n-text>
|
<n-text strong>完成进度</n-text>
|
||||||
<n-text>
|
<n-text>
|
||||||
{{ problemSet.completedCount }} / {{ problemSet.problemsCount }}
|
{{ problemSet.userProgress?.completedCount ?? 0 }} /
|
||||||
|
{{ problemSet.userProgress?.totalCount ?? 0 }}
|
||||||
|
</n-text>
|
||||||
|
<n-text depth="3" v-if="optionalCount > 0">
|
||||||
|
(另有 {{ optionalCount }} 道选做)
|
||||||
</n-text>
|
</n-text>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-progress
|
<n-progress
|
||||||
|
|||||||
@@ -16,45 +16,22 @@ const problemSets = ref<ProblemSet[]>([])
|
|||||||
|
|
||||||
interface ProblemSetQuery {
|
interface ProblemSetQuery {
|
||||||
keyword: string
|
keyword: string
|
||||||
difficulty: string
|
|
||||||
status: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用分页 composable
|
// 使用分页 composable
|
||||||
const { query, clearQuery } = usePagination<ProblemSetQuery>(
|
const { query, clearQuery } = usePagination<ProblemSetQuery>(
|
||||||
{
|
{
|
||||||
keyword: useRouteQuery("keyword", "").value,
|
keyword: useRouteQuery("keyword", "").value,
|
||||||
difficulty: useRouteQuery("difficulty", "").value,
|
|
||||||
status: useRouteQuery("status", "").value,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defaultLimit: 30,
|
defaultLimit: 30,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const difficultyOptions = [
|
|
||||||
{ label: "全部", value: "" },
|
|
||||||
{ label: "简单", value: "Easy" },
|
|
||||||
{ label: "中等", value: "Medium" },
|
|
||||||
{ label: "困难", value: "Hard" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
{ label: "全部", value: "" },
|
|
||||||
{ label: "活跃", value: "active" },
|
|
||||||
{ label: "已归档", value: "archived" },
|
|
||||||
]
|
|
||||||
|
|
||||||
async function listProblemSets() {
|
async function listProblemSets() {
|
||||||
if (query.page < 1) query.page = 1
|
if (query.page < 1) query.page = 1
|
||||||
const offset = (query.page - 1) * query.limit
|
const offset = (query.page - 1) * query.limit
|
||||||
const res = await getProblemSetList(
|
const res = await getProblemSetList(offset, query.limit, query.keyword)
|
||||||
offset,
|
|
||||||
query.limit,
|
|
||||||
query.keyword,
|
|
||||||
query.difficulty,
|
|
||||||
query.status,
|
|
||||||
)
|
|
||||||
total.value = res.total
|
total.value = res.total
|
||||||
problemSets.value = res.results
|
problemSets.value = res.results
|
||||||
}
|
}
|
||||||
@@ -102,35 +79,15 @@ watchDebounced(() => query.keyword, listProblemSets, {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 监听其他查询条件变化
|
// 监听其他查询条件变化
|
||||||
watch(
|
watch(() => [query.page, query.limit], listProblemSets)
|
||||||
() => [query.page, query.limit, query.difficulty, query.status],
|
|
||||||
listProblemSets,
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex vertical size="large">
|
<n-flex vertical size="large">
|
||||||
|
<!-- 难度和状态两个筛选器撤了:线上 16 个题单全是 Easy / active,选「中等」「困难」
|
||||||
|
「已归档」永远是空列表。接口那两个 query 参数还在,哪天真的用起这两个字段,
|
||||||
|
把 select 加回来即可。 -->
|
||||||
<n-space>
|
<n-space>
|
||||||
<n-space align="center">
|
|
||||||
<n-text>难度</n-text>
|
|
||||||
<n-select
|
|
||||||
v-model:value="query.difficulty"
|
|
||||||
:options="difficultyOptions"
|
|
||||||
placeholder="选择难度"
|
|
||||||
style="width: 120px"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</n-space>
|
|
||||||
<n-space align="center">
|
|
||||||
<n-text>状态</n-text>
|
|
||||||
<n-select
|
|
||||||
v-model:value="query.status"
|
|
||||||
:options="statusOptions"
|
|
||||||
placeholder="选择状态"
|
|
||||||
style="width: 120px"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</n-space>
|
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="query.keyword"
|
v-model:value="query.keyword"
|
||||||
placeholder="搜索题单..."
|
placeholder="搜索题单..."
|
||||||
|
|||||||
@@ -446,7 +446,17 @@ POST /api/admin/problems/batch-tag {problemIds:[40]} → 404 no-problems
|
|||||||
- `DELETE /problem-tags/:id`(`tag.ts:92-102`)的事务里先删 `problemTags` 再删 `problemTag`,但删的是同一个 id,标签不存在时前一句是空操作,**不存在 C1 那种连带破坏**
|
- `DELETE /problem-tags/:id`(`tag.ts:92-102`)的事务里先删 `problemTags` 再删 `problemTag`,但删的是同一个 id,标签不存在时前一句是空操作,**不存在 C1 那种连带破坏**
|
||||||
- `DELETE /users`(`account.ts:237-257`)禁止删除自己,已实跑确认返回 400 `cannot-delete-self`
|
- `DELETE /users`(`account.ts:237-257`)禁止删除自己,已实跑确认返回 400 `cannot-delete-self`
|
||||||
- `PUT /users/:id` 的 `normalizePermission`(`account.ts:49-53`)与 `account/views/admin.py:98-105` 的归一逻辑一致,降级超管会同步清掉 All
|
- `PUT /users/:id` 的 `normalizePermission`(`account.ts:49-53`)与 `account/views/admin.py:98-105` 的归一逻辑一致,降级超管会同步清掉 All
|
||||||
- 真名下发受控:`sampleUser`(`routes/helpers.ts:15-25`)默认 `realName: null`,只有 `acm-helper`(`contest.ts:272`)和题单进度(`problemset.ts:426`)两处显式下发,两处都在 requireTeacher + 归属校验之后
|
- 真名下发受控:`sampleUser`(`routes/helpers.ts:15-25`)默认 `realName: null`,只有 `acm-helper`(`contest.ts:272`)显式下发,在 requireTeacher + 归属校验之后
|
||||||
|
|
||||||
|
> **2026-08-31 订正**:本条原先还写了「题单进度(`problemset.ts:426`)」,与代码不符 ——
|
||||||
|
> 学生端那条 `GET /problem-sets/:id/user-progress` 走的是 `sampleUser(progressUser, realName)`,
|
||||||
|
> 没传 `includeRealName`,`realName` 恒为 `null`(SQL 里那次 `leftJoin userProfile` 是白查的)。
|
||||||
|
> 真正显式下发真名的是**后台**那条 `GET /admin/problem-sets/:id/progress`,它手写
|
||||||
|
> `adminProblemSetProgressSchema`,在 requireTeacher + `loadOwned` 归属校验之后,结论仍成立。
|
||||||
|
>
|
||||||
|
> 另外当时漏了一条:学生端那条 `user-progress` 只有 `requireTeacher`、**没有归属校验**,
|
||||||
|
> 任何 Teacher Admin 都能读到别人建的题单的学生名单与进度(不含真名)。已补上归属校验,
|
||||||
|
> 口径与后台的 `loadOwned` 一致,越权报 404。
|
||||||
- `GET /ai/reports/:id`(`ai.ts:70-83`)不下发 `data` / `systemPrompt` / `userPrompt`
|
- `GET /ai/reports/:id`(`ai.ts:70-83`)不下发 `data` / `systemPrompt` / `userPrompt`
|
||||||
- `GET /judge-servers`(`conf.ts:90-100`)下发 judge token,但在 requireSuperAdmin 之后,与旧 `conf/views.py:66-74` 一致
|
- `GET /judge-servers`(`conf.ts:90-100`)下发 judge token,但在 requireSuperAdmin 之后,与旧 `conf/views.py:66-74` 一致
|
||||||
- `DELETE /orphan-test-cases`(`conf.ts:147-160`)对指定 id 也先确认是孤儿,比旧 `conf/views.py:162-171` 严 —— 合理收紧
|
- `DELETE /orphan-test-cases`(`conf.ts:147-160`)对指定 id 也先确认是孤儿,比旧 `conf/views.py:162-171` 严 —— 合理收紧
|
||||||
|
|||||||
Reference in New Issue
Block a user