submission 新增 problemset_id,学生从 /problemset/:id/problem/:pid 入口提交时 前端带上、后端落库,提交列表在题目后面挂一个「题单 xxx」的标签,点了进题单。 只是来源标记:题单进度和奖章仍由判完之后的 recordSolvedProblem 按「已加入且含 这道题的所有题单」记账,和从哪个入口进来无关,所以这个字段带错顶多标签不准, 不影响成绩。校验只确认这道题在那个题单里 —— 不查 visible / status、也不查有没有 加入,藏起来的题单里还困着已加入的学生;对不上就当没带,提交照收。 外键 ON DELETE SET NULL:删题单不该带走提交,清掉标记就行。索引建成部分索引 (WHERE problemset_id IS NOT NULL),绝大多数提交不来自题单,全列索引是给 12 万行 白建一遍;谓词能被 problemset_id = $1 蕴含,删题单时的外键检查也用得上它。 列表按页单独查一次题单标题,没有把 problemset join 进那条调过的深翻页查询。 比赛提交恒为 null:题单只收非比赛题。 历史回填从 problemset_submission 取「当年首次 AC 那条」,其余老提交无从判断入口, 一律留空。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7AsqceaUfC81k7UCDcSk2
This commit is contained in:
14
apps/api/src/db/0007_add_submission_problemset_id.sql
Normal file
14
apps/api/src/db/0007_add_submission_problemset_id.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE "submission" ADD COLUMN "problemset_id" bigint;--> statement-breakpoint
|
||||
ALTER TABLE "submission" ADD CONSTRAINT "submission_problemset_id_fk_problemset_id" FOREIGN KEY ("problemset_id") REFERENCES "public"."problemset"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "submission_problemset_id_idx" ON "submission" USING btree ("problemset_id") WHERE "submission"."problemset_id" is not null;--> statement-breakpoint
|
||||
-- 历史回填。老提交没有入口信息,唯一可考的是 problemset_submission:它记的是
|
||||
-- 「这条提交让这道题在这个题单里算完成了」,本来就是刷题单刷出来的那一条,标出来不算冤枉。
|
||||
-- 覆盖不到的是同一道题在此之前的 WA 和之后的重复 AC —— 那些只能留空,往后新提交才准。
|
||||
-- 一条提交在多个题单里都记过账时(recordSolvedProblem 会记进所有已加入的题单),
|
||||
-- 任取其一:来源入口只有一个,但事后已经分不出是哪个了。
|
||||
UPDATE "submission" SET "problemset_id" = "ps"."problemset_id"
|
||||
FROM (
|
||||
SELECT DISTINCT ON ("submission_id") "submission_id", "problemset_id"
|
||||
FROM "problemset_submission" ORDER BY "submission_id", "problemset_id"
|
||||
) AS "ps"
|
||||
WHERE "ps"."submission_id" = "submission"."id" AND "submission"."problemset_id" IS NULL;
|
||||
3967
apps/api/src/db/meta/0007_snapshot.json
Normal file
3967
apps/api/src/db/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
||||
"when": 1788402925980,
|
||||
"tag": "0006_drop_contest_announcement",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1788408053304,
|
||||
"tag": "0007_add_submission_problemset_id",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -444,6 +444,11 @@ export const submission = pgTable("submission", {
|
||||
statisticInfo: jsonb("statistic_info").default({}).notNull(),
|
||||
username: text().notNull(),
|
||||
ip: text(),
|
||||
// 来源题单:学生从题单入口(/problemset/:id/problem/:pid)提交时记下来,
|
||||
// 提交列表据此标出「这条来自题单」。**只是来源标记**,题单进度、奖章一概不看它,
|
||||
// 那些由判完之后的 recordSolvedProblem 按「已加入且含这道题的所有题单」记账。
|
||||
// 老数据里只有迁移 0007 从 problemset_submission 回填的首次 AC 有值。
|
||||
problemsetId: bigint("problemset_id", { mode: "number" }),
|
||||
}, (table) => [
|
||||
// 同上,不写 .op()。原先 pull 出来的 opclass 还串了位(contest_id 标成 timestamptz_ops、
|
||||
// create_time 标成 int4_ops),那条 SQL 真拿去执行 Postgres 会直接拒绝。
|
||||
@@ -486,6 +491,16 @@ export const submission = pgTable("submission", {
|
||||
foreignColumns: [problem.id],
|
||||
name: "submission_problem_id_76847b55_fk_problem_id"
|
||||
}),
|
||||
// 部分索引:绝大多数提交不来自题单,全列索引等于给 12 万行白建一遍。
|
||||
// 谓词是 IS NOT NULL,`problemset_id = $1` 蕴含非空,所以删题单时的外键检查
|
||||
// 也能用上它——不然那条检查要顺序扫全表。
|
||||
index("submission_problemset_id_idx").using("btree", table.problemsetId.asc().nullsLast()).where(sql`${table.problemsetId} is not null`),
|
||||
// 删掉题单不该带走提交:置空来源标记就行,提交本身照旧存在。
|
||||
foreignKey({
|
||||
columns: [table.problemsetId],
|
||||
foreignColumns: [problemset.id],
|
||||
name: "submission_problemset_id_fk_problemset_id"
|
||||
}).onDelete("set null"),
|
||||
]);
|
||||
|
||||
export const tutorial = pgTable("tutorial", {
|
||||
|
||||
@@ -118,6 +118,23 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
|
||||
)
|
||||
}
|
||||
|
||||
// 来源题单:前端只在 /problemset/:id/problem/:pid 那个入口带上它,落库纯粹是为了
|
||||
// 在提交列表里标出「这条是刷题单刷出来的」。校验只确认这道题确实在那个题单里 ——
|
||||
// 不查 visible / status,因为藏起来的题单里还困着已加入的学生(他们照样在做题),
|
||||
// 也不查有没有加入:没加入照样能从题单页点进题目,标记来源不该比入口本身更严。
|
||||
// 对不上就当没带,提交照收:来源标记错了顶多列表少个标签,不值得挡下一次提交。
|
||||
let problemsetId: number | null = null
|
||||
if (contestId === null && parsed.data.problemSetId) {
|
||||
const [link] = await db.select({ id: schema.problemsetProblem.id })
|
||||
.from(schema.problemsetProblem)
|
||||
.where(and(
|
||||
eq(schema.problemsetProblem.problemsetId, parsed.data.problemSetId),
|
||||
eq(schema.problemsetProblem.problemId, problem.id),
|
||||
))
|
||||
.limit(1)
|
||||
if (link) problemsetId = parsed.data.problemSetId
|
||||
}
|
||||
|
||||
const user = c.get("user")!
|
||||
const submissionId = randomBytes(16).toString("hex")
|
||||
const createTime = new Date().toISOString()
|
||||
@@ -126,6 +143,7 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
|
||||
await db.insert(schema.submission).values({
|
||||
id: submissionId,
|
||||
problemId: problem.id,
|
||||
problemsetId,
|
||||
createTime,
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
@@ -465,6 +483,9 @@ const submissionListColumns = {
|
||||
language: schema.submission.language,
|
||||
shared: schema.submission.shared,
|
||||
statisticInfo: schema.submission.statisticInfo,
|
||||
// 只取 id,题单标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来
|
||||
// 会动到下面那条调过的分页查询,而每页最多两三个不同的题单,PK 查一次更便宜
|
||||
problemsetId: schema.submission.problemsetId,
|
||||
},
|
||||
problem: {
|
||||
displayId: schema.problem.displayId,
|
||||
@@ -568,6 +589,19 @@ async function paginateSubmissionRows(
|
||||
).limit(limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一页里出现过的来源题单,id → 标题。传进来的数组允许带 null 和重复值。
|
||||
* 一页最多 250 行、实际能落到的题单数是个位数,按主键 IN 查一次就完了。
|
||||
*/
|
||||
async function problemsetTitleMap(ids: Array<number | null>) {
|
||||
const unique = [...new Set(ids.filter((id): id is number => id !== null))]
|
||||
if (unique.length === 0) return new Map<number, string>()
|
||||
const rows = await db.select({ id: schema.problemset.id, title: schema.problemset.title })
|
||||
.from(schema.problemset)
|
||||
.where(inArray(schema.problemset.id, unique))
|
||||
return new Map(rows.map((row) => [row.id, row.title]))
|
||||
}
|
||||
|
||||
submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
@@ -600,11 +634,15 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
||||
paginateSubmissionRows(where, limit, offset, Boolean(displayId)),
|
||||
])
|
||||
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
|
||||
const joinTimes = user && !isAdminRole(user)
|
||||
? await problemSetJoinTimes(user.id, [...new Set(
|
||||
rows.filter((row) => row.submission.userId === user.id).map((row) => row.submission.problemId),
|
||||
)])
|
||||
: undefined
|
||||
const [joinTimes, problemsetTitles] = await Promise.all([
|
||||
user && !isAdminRole(user)
|
||||
? problemSetJoinTimes(user.id, [...new Set(
|
||||
rows.filter((row) => row.submission.userId === user.id).map((row) => row.submission.problemId),
|
||||
)])
|
||||
: undefined,
|
||||
// 来源题单的标题。一页里不同题单最多几个,按主键查一次就够
|
||||
problemsetTitleMap(rows.map((row) => row.submission.problemsetId)),
|
||||
])
|
||||
return success(c, submissionListSchema.parse({
|
||||
results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({
|
||||
id: submission.id,
|
||||
@@ -618,6 +656,10 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
||||
language: submission.language,
|
||||
shared: submission.shared,
|
||||
statisticInfo: objectValue(submission.statisticInfo),
|
||||
// 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了
|
||||
problemSet: submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId)
|
||||
? { id: submission.problemsetId, title: problemsetTitles.get(submission.problemsetId)! }
|
||||
: null,
|
||||
})),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
@@ -667,6 +709,9 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
|
||||
language: submission.language,
|
||||
shared: submission.shared,
|
||||
statisticInfo: objectValue(submission.statisticInfo),
|
||||
// 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了
|
||||
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
|
||||
problemSet: null,
|
||||
})),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
|
||||
@@ -137,6 +137,11 @@ async function submit() {
|
||||
if (contestID) {
|
||||
data.contestId = parseInt(contestID)
|
||||
}
|
||||
// 从题单入口进来的,把来源题单一起报上去:提交列表要据此标出「来自题单」。
|
||||
// 只是来源标记,题单进度仍由后端判完之后自己记账(见上面那段注释)
|
||||
if (problemSetId) {
|
||||
data.problemSetId = parseInt(problemSetId)
|
||||
}
|
||||
// 2. 提交代码到后端
|
||||
isSubmittingRequest.value = true
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag, NText } from "naive-ui"
|
||||
import { NButton, NFlex, NTag, NText } from "naive-ui"
|
||||
import { useRouteQuery } from "@vueuse/router"
|
||||
import {
|
||||
adminRejudge,
|
||||
@@ -322,9 +322,9 @@ const columns = computed(() => {
|
||||
{
|
||||
title: renderTableTitle("题目", "streamline-emojis:blossom"),
|
||||
key: "problem",
|
||||
minWidth: 300,
|
||||
render: (row) =>
|
||||
h(
|
||||
minWidth: 360,
|
||||
render: (row) => {
|
||||
const problem = h(
|
||||
ButtonWithSearch,
|
||||
{
|
||||
type: "题目",
|
||||
@@ -332,7 +332,28 @@ const columns = computed(() => {
|
||||
onSearch: () => (query.problem = row.problem),
|
||||
},
|
||||
() => `${row.problem} ${row.problemTitle}`,
|
||||
),
|
||||
)
|
||||
// 从题单入口做出来的提交才有这个标记(后端只在提交时记来源),
|
||||
// 同一道题从普通题库刷的不会带。老提交里只有当年首次 AC 那条有
|
||||
const problemSet = row.problemSet
|
||||
if (!problemSet) return problem
|
||||
return h(NFlex, { align: "center", size: 8, wrap: false }, () => [
|
||||
problem,
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
size: "small",
|
||||
round: true,
|
||||
type: "info",
|
||||
bordered: false,
|
||||
style: { cursor: "pointer", flexShrink: 0 },
|
||||
onClick: () =>
|
||||
window.open("/problemset/" + problemSet.id, "_blank"),
|
||||
},
|
||||
() => "题单 " + problemSet.title,
|
||||
),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
title: renderTableTitle("语言", "streamline-ultimate-color:earth-pin-2"),
|
||||
|
||||
@@ -22,6 +22,15 @@ export const createSubmissionRequestSchema = z.object({
|
||||
language: z.string().min(1).max(32),
|
||||
code: z.string().min(1).max(1024 * 1024),
|
||||
contestId: z.number().int().positive().optional(),
|
||||
/**
|
||||
* 来源题单。学生从 `/problemset/:id/problem/:pid` 那个入口提交时前端带上,
|
||||
* 后端落进 `submission.problemset_id`,提交列表据此标出「这条是刷题单刷出来的」。
|
||||
*
|
||||
* 只是**来源标记**,不参与判题、也不参与题单进度记账 —— 进度由判完之后的
|
||||
* `recordSolvedProblem` 记进所有已加入且含这道题的题单,和从哪个入口进来无关。
|
||||
* 所以这里带错了顶多是标记不准,不会影响成绩。
|
||||
*/
|
||||
problemSetId: z.number().int().positive().optional(),
|
||||
})
|
||||
|
||||
export const createSubmissionResponseSchema = z.object({
|
||||
@@ -96,6 +105,12 @@ export const submissionListItemSchema = z.object({
|
||||
language: z.string(),
|
||||
shared: z.boolean(),
|
||||
statisticInfo: z.record(z.string(), z.unknown()),
|
||||
/**
|
||||
* 来源题单,非题单入口提交的为 null。比赛提交恒为 null(比赛题不会进题单)。
|
||||
* 历史提交里只有「当年首次 AC 那一条」有值 —— 迁移 0007 从 problemset_submission
|
||||
* 回填的就是这些,其余老提交无从判断入口,一律留空。
|
||||
*/
|
||||
problemSet: z.object({ id: z.number().int(), title: z.string() }).nullable(),
|
||||
})
|
||||
|
||||
export const submissionListSchema = paginatedSchema(submissionListItemSchema)
|
||||
|
||||
Reference in New Issue
Block a user