refactor(提交): 删掉从来没用起来的「提交互相可见」
Some checks failed
Deploy / deploy (push) Has been cancelled

problem.share_submission(题目级)和 submission.shared(单条)两个开关,连同
判定分支一起删掉。

生产备份(2026-08-07)里的实际用量:956 道题只有 2 道开过 share_submission,
还都是 contest_id=1 的比赛题 —— 比赛未结束时那条分支根本走不到,等于一天都没
生效过。123140 条提交里 shared=true 的有 40 条,39 条在 2022 年、1 条在
2023-03-30,之后近三年零使用;出题页从来没给过题目级开关,单条分享的入口在更早
那版前端上,ojnext 和 OJ2 都没搬过来,OJ2 里 PUT /submissions/:id 一个调用方
都没有。

canViewSubmission 剩下三条:本人 / 管理员(比赛中的 Student Admin 除外)/ 本题
作者,其余一律看不到。结尾的 `shareSubmission || shared` 没了;它上面那条「比赛
未结束一律不给」也一并去掉 —— 走到那里的必然不是这三种人,现在无论比赛与否都是
false,留着是重复的。allowShared 参数随之消失,它唯一的用途就是分享开关的归属
校验。

一并删掉:PUT /submissions/:id、响应里的 shared / canUnshare(列表、详情、站内
信内嵌三处)、题目详情与后台题目里的 shareSubmission、shareSubmissionRequestSchema,
以及新建提交时那句 shared: false(列上本来就有 default false)。

两个数据库列保留不删,只在 schema.ts 上注明已停用:删列是破坏性迁移,而留着不读
不写零成本,还留着那 40 条的历史痕迹。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB
This commit is contained in:
2026-09-07 07:18:41 -06:00
parent 2256597838
commit 25b86ec17e
11 changed files with 22 additions and 56 deletions

View File

@@ -379,6 +379,12 @@ export const problem = pgTable("problem", {
statisticInfo: jsonb("statistic_info").default({}).notNull(), statisticInfo: jsonb("statistic_info").default({}).notNull(),
contestId: integer("contest_id"), contestId: integer("contest_id"),
isPublic: boolean("is_public").default(false).notNull(), isPublic: boolean("is_public").default(false).notNull(),
/**
* 已停用。「提交互相可见」的两个开关这个是题目级submission.shared 是单条级)
* 连同判定分支一起删掉了:生产库 956 道题里只有 2 道打开过,还都是比赛题
* (比赛未结束时那条分支根本走不到),出题页也从来没给过开关。
* 列保留不删:删列是破坏性迁移,而留着不写不读没有任何代价。
*/
shareSubmission: boolean("share_submission").default(false).notNull(), shareSubmission: boolean("share_submission").default(false).notNull(),
prompt: text(), prompt: text(),
answers: jsonb(), answers: jsonb(),
@@ -446,6 +452,11 @@ export const submission = pgTable("submission", {
result: integer().default(6).notNull(), result: integer().default(6).notNull(),
info: jsonb().default({}).notNull(), info: jsonb().default({}).notNull(),
language: text().notNull(), language: text().notNull(),
/**
* 已停用,见 problem.share_submission 的说明。历史上 12.3 万条提交里有 40 条
* 为真2022 年 39 条、2023 年 1 条入口在更早的那版前端上ojnext 和 OJ2
* 都没有把它搬过来。现在没有任何代码读写它,行里的历史值原样留着。
*/
shared: boolean().default(false).notNull(), shared: boolean().default(false).notNull(),
statisticInfo: jsonb("statistic_info").default({}).notNull(), statisticInfo: jsonb("statistic_info").default({}).notNull(),
username: text().notNull(), username: text().notNull(),

View File

@@ -153,7 +153,6 @@ async function serialize(row: ProblemRow) {
submissionNumber: row.submissionNumber, submissionNumber: row.submissionNumber,
acceptedNumber: row.acceptedNumber, acceptedNumber: row.acceptedNumber,
statisticInfo: objectValue(row.statisticInfo), statisticInfo: objectValue(row.statisticInfo),
shareSubmission: row.shareSubmission,
contestId: row.contestId, contestId: row.contestId,
createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName), createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName),
isPublic: row.isPublic, isPublic: row.isPublic,
@@ -247,7 +246,6 @@ function problemValues(data: ReturnType<typeof createProblemRequestSchema.parse>
visible: data.visible, visible: data.visible,
difficulty: data.difficulty, difficulty: data.difficulty,
source: data.source, source: data.source,
shareSubmission: data.shareSubmission,
allowFlowchart: data.allowFlowchart, allowFlowchart: data.allowFlowchart,
showFlowchart: data.showFlowchart, showFlowchart: data.showFlowchart,
mermaidCode: data.mermaidCode, mermaidCode: data.mermaidCode,

View File

@@ -100,12 +100,10 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
// info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 —— // info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 ——
// 对齐旧后端 SubmissionSafeModelSerializer 的 exclude这三个键不出现在响应中 // 对齐旧后端 SubmissionSafeModelSerializer 的 exclude这三个键不出现在响应中
language: submission.language, language: submission.language,
shared: submission.shared,
statisticInfo: objectValue(submission.statisticInfo), statisticInfo: objectValue(submission.statisticInfo),
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号> // 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
problem: displayId, problem: displayId,
showLink: true, showLink: true,
canUnshare: false,
}), }),
})), })),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,

View File

@@ -186,7 +186,6 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
submissionNumber: allowed ? row.problem.submissionNumber : 0, submissionNumber: allowed ? row.problem.submissionNumber : 0,
acceptedNumber: allowed ? row.problem.acceptedNumber : 0, acceptedNumber: allowed ? row.problem.acceptedNumber : 0,
statisticInfo: allowed ? objectValue(row.problem.statisticInfo) : {}, statisticInfo: allowed ? objectValue(row.problem.statisticInfo) : {},
shareSubmission: row.problem.shareSubmission,
contestId: contest.id, contestId: contest.id,
tags: tags.get(row.problem.id) ?? [], tags: tags.get(row.problem.id) ?? [],
createdBy: sampleUser(row.user, row.realName), createdBy: sampleUser(row.user, row.realName),

View File

@@ -304,7 +304,6 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
submissionNumber: row.problem.submissionNumber, submissionNumber: row.problem.submissionNumber,
acceptedNumber: row.problem.acceptedNumber, acceptedNumber: row.problem.acceptedNumber,
statisticInfo: objectValue(row.problem.statisticInfo), statisticInfo: objectValue(row.problem.statisticInfo),
shareSubmission: row.problem.shareSubmission,
contestId: row.problem.contestId, contestId: row.problem.contestId,
tags: tagRows.map((tag) => tag.name), tags: tagRows.map((tag) => tag.name),
createdBy: sampleUser({ id: row.creatorId, username: row.creatorUsername }, null), createdBy: sampleUser({ id: row.creatorId, username: row.creatorUsername }, null),

View File

@@ -5,7 +5,6 @@ import {
createSubmissionResponseSchema, createSubmissionResponseSchema,
formatCodeRequestSchema, formatCodeRequestSchema,
formatCodeResponseSchema, formatCodeResponseSchema,
shareSubmissionRequestSchema,
submissionDetailSchema, submissionDetailSchema,
submissionListItemSchema, submissionListItemSchema,
submissionListSchema, submissionListSchema,
@@ -141,7 +140,6 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
result: JudgeStatus.PENDING, result: JudgeStatus.PENDING,
info: {}, info: {},
language: parsed.data.language, language: parsed.data.language,
shared: false,
statisticInfo: {}, statisticInfo: {},
contestId, contestId,
}) })
@@ -423,19 +421,15 @@ async function problemSetJoinTimes(userId: number, problemIds: number[]) {
// 传不进完整行。完整行在结构上满足这两个窄类型,详情接口照旧调用不受影响。 // 传不进完整行。完整行在结构上满足这两个窄类型,详情接口照旧调用不受影响。
function canViewSubmission( function canViewSubmission(
user: AuthUser | null, user: AuthUser | null,
row: { userId: number; shared: boolean; problemId: number; createTime: string }, row: { userId: number; problemId: number; createTime: string },
problem: { createdById: number; shareSubmission: boolean }, problem: { createdById: number },
contest: typeof schema.contest.$inferSelect | null, contest: typeof schema.contest.$inferSelect | null,
allowShared = true,
problemSetJoinTime?: Map<number, string>, problemSetJoinTime?: Map<number, string>,
) { ) {
if (!user) return false if (!user) return false
// 题单防作弊,见 problemSetJoinTimes。只对学生自己的提交生效管理员不受限对齐旧后端 // 题单防作弊,见 problemSetJoinTimes。只对学生自己的提交生效管理员不受限对齐旧后端
// `get_show_link` 里的 `obj.user_id == self.user.id and self.user.is_regular_user()`。 // `get_show_link` 里的 `obj.user_id == self.user.id and self.user.is_regular_user()`。
// if (row.userId === user.id && !isAdminRole(user)) {
// 只挡「看代码」这一路,不挡 allowShared=false 的那一路:后者是分享/取消分享的归属校验,
// 与作弊无关,挡了会让学生连自己旧提交的分享开关都动不了。
if (allowShared && row.userId === user.id && !isAdminRole(user)) {
const joinTime = problemSetJoinTime?.get(row.problemId) const joinTime = problemSetJoinTime?.get(row.problemId)
if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false
} }
@@ -448,10 +442,12 @@ function canViewSubmission(
// 他早就知道答案了,挡他没有意义。 // 他早就知道答案了,挡他没有意义。
const elevated = isAdminRole(user) const elevated = isAdminRole(user)
&& !(contest && contestStatus(contest) !== "-1" && user.adminType === "Student Admin") && !(contest && contestStatus(contest) !== "-1" && user.adminType === "Student Admin")
if (row.userId === user.id || elevated || problem.createdById === user.id) return true // 这三条就是全部:别人的代码谁都看不到,比赛内外一样。
if (!allowShared) return false // 分享功能problem.share_submission 题目级 / submission.shared 单条)已经删掉,
if (contest && contestStatus(contest) !== "-1") return false // 原来结尾的 `return problem.shareSubmission || row.shared` 随之消失;它上面那条
return problem.shareSubmission || row.shared // 「比赛未结束一律不给」也一并去掉 —— 走到那里的必然不是本人/管理员/作者,
// 现在无论比赛与否都是 false留着是重复的。
return row.userId === user.id || elevated || problem.createdById === user.id
} }
/** /**
@@ -470,7 +466,6 @@ const submissionListColumns = {
username: schema.submission.username, username: schema.submission.username,
result: schema.submission.result, result: schema.submission.result,
language: schema.submission.language, language: schema.submission.language,
shared: schema.submission.shared,
statisticInfo: schema.submission.statisticInfo, statisticInfo: schema.submission.statisticInfo,
// 只取 id题单标题按页单独查一次见 /submissions——把 problemset 一起 join 进来 // 只取 id题单标题按页单独查一次见 /submissions——把 problemset 一起 join 进来
// 会动到下面那条调过的分页查询而每页最多两三个不同的题单PK 查一次更便宜 // 会动到下面那条调过的分页查询而每页最多两三个不同的题单PK 查一次更便宜
@@ -479,7 +474,6 @@ const submissionListColumns = {
problem: { problem: {
displayId: schema.problem.displayId, displayId: schema.problem.displayId,
title: schema.problem.title, title: schema.problem.title,
shareSubmission: schema.problem.shareSubmission,
createdById: schema.problem.createdById, createdById: schema.problem.createdById,
}, },
} as const } as const
@@ -496,7 +490,7 @@ async function submissionDetail(id: string, user: AuthUser) {
const joinTimes = isAdminRole(user) || row.submission.userId !== user.id const joinTimes = isAdminRole(user) || row.submission.userId !== user.id
? undefined ? undefined
: await problemSetJoinTimes(user.id, [row.submission.problemId]) : await problemSetJoinTimes(user.id, [row.submission.problemId])
if (!canViewSubmission(user, row.submission, row.problem, row.contest, true, joinTimes)) return null if (!canViewSubmission(user, row.submission, row.problem, row.contest, joinTimes)) return null
// info含每个测试点的 test_case 编号与 output_md5只给管理员对齐旧后端 // info含每个测试点的 test_case 编号与 output_md5只给管理员对齐旧后端
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与 // submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。 // SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
@@ -510,7 +504,6 @@ async function submissionDetail(id: string, user: AuthUser) {
result: row.submission.result, result: row.submission.result,
info: full ? row.submission.info : {}, info: full ? row.submission.info : {},
language: row.submission.language, language: row.submission.language,
shared: row.submission.shared,
statisticInfo: objectValue(row.submission.statisticInfo), statisticInfo: objectValue(row.submission.statisticInfo),
// contest 也在旧后端的排除名单里,同样只给管理员 // contest 也在旧后端的排除名单里,同样只给管理员
contestId: full ? row.submission.contestId : null, contestId: full ? row.submission.contestId : null,
@@ -518,7 +511,6 @@ async function submissionDetail(id: string, user: AuthUser) {
// problem 表本来就 join 了,不额外查库 // problem 表本来就 join 了,不额外查库
problemDisplayId: row.problem.displayId, problemDisplayId: row.problem.displayId,
showLink: true, showLink: true,
canUnshare: canViewSubmission(user, row.submission, row.problem, row.contest, false),
}) })
} }
@@ -634,13 +626,12 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
id: submission.id, id: submission.id,
problem: problem.displayId, problem: problem.displayId,
problemTitle: problem.title, problemTitle: problem.title,
showLink: user ? canViewSubmission(user, submission, problem, null, true, joinTimes) : false, showLink: user ? canViewSubmission(user, submission, problem, null, joinTimes) : false,
createTime: submission.createTime, createTime: submission.createTime,
userId: submission.userId, userId: submission.userId,
username: submission.username, username: submission.username,
result: submission.result, result: submission.result,
language: submission.language, language: submission.language,
shared: submission.shared,
statisticInfo: objectValue(submission.statisticInfo), statisticInfo: objectValue(submission.statisticInfo),
// 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了 // 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了
problemSet: submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId) problemSet: submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId)
@@ -693,7 +684,6 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
username: submission.username, username: submission.username,
result: submission.result, result: submission.result,
language: submission.language, language: submission.language,
shared: submission.shared,
statisticInfo: objectValue(submission.statisticInfo), statisticInfo: objectValue(submission.statisticInfo),
// 比赛提交没有来源题单题单只收非比赛题admin/problemset.ts 加题时卡了 // 比赛提交没有来源题单题单只收非比赛题admin/problemset.ts 加题时卡了
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段 // isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
@@ -711,20 +701,3 @@ submissionRoutes.get("/submissions/:id", requireAuth, async (c) => {
} }
return success(c, data) return success(c, data)
}) })
submissionRoutes.put("/submissions/:id", requireAuth, async (c) => {
const parsed = shareSubmissionRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid share payload")
const [row] = await db.select({ submission: schema.submission, problem: schema.problem, contest: schema.contest })
.from(schema.submission).innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.leftJoin(schema.contest, eq(schema.submission.contestId, schema.contest.id))
.where(eq(schema.submission.id, c.req.param("id"))).limit(1)
if (!row || !canViewSubmission(c.get("user")!, row.submission, row.problem, row.contest, false)) {
return failure(c, 404, "submission-not-found", "Submission does not exist")
}
if (row.contest && contestStatus(row.contest) === "0") {
return failure(c, 403, "contest-underway", "Can not share submission now")
}
await db.update(schema.submission).set({ shared: parsed.data.shared }).where(eq(schema.submission.id, row.submission.id))
return success(c, null)
})

View File

@@ -274,7 +274,6 @@ function toProblemBody(problem: AdminProblem | BlankProblem) {
source: p.source ?? null, source: p.source ?? null,
prompt: p.prompt ?? null, prompt: p.prompt ?? null,
answers: p.answers ?? [], answers: p.answers ?? [],
shareSubmission: p.shareSubmission ?? false,
allowFlowchart: p.allowFlowchart ?? false, allowFlowchart: p.allowFlowchart ?? false,
showFlowchart: p.showFlowchart ?? false, showFlowchart: p.showFlowchart ?? false,
mermaidCode: p.mermaidCode ?? null, mermaidCode: p.mermaidCode ?? null,

View File

@@ -62,7 +62,6 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
memoryLimit: 64, memoryLimit: 64,
difficulty: "Low", difficulty: "Low",
visible: false, visible: false,
shareSubmission: false,
tags: [], tags: [],
languages: ["Python3", "C"] as LANGUAGE[], languages: ["Python3", "C"] as LANGUAGE[],
template: {} as { [key in LANGUAGE]?: string }, template: {} as { [key in LANGUAGE]?: string },
@@ -241,7 +240,6 @@ async function getProblemDetail() {
problem.value.memoryLimit = data.memoryLimit problem.value.memoryLimit = data.memoryLimit
problem.value.difficulty = data.difficulty problem.value.difficulty = data.difficulty
problem.value.visible = data.visible problem.value.visible = data.visible
problem.value.shareSubmission = data.shareSubmission
problem.value.tags = normalizeTagNames(data.tags) problem.value.tags = normalizeTagNames(data.tags)
problem.value.languages = data.languages problem.value.languages = data.languages
problem.value.template = data.template problem.value.template = data.template

View File

@@ -678,7 +678,6 @@ export const adminProblemSchema = z.object({
submissionNumber: z.number().int(), submissionNumber: z.number().int(),
acceptedNumber: z.number().int(), acceptedNumber: z.number().int(),
statisticInfo: z.record(z.string(), z.unknown()), statisticInfo: z.record(z.string(), z.unknown()),
shareSubmission: z.boolean(),
contestId: z.number().int().nullable(), contestId: z.number().int().nullable(),
createdBy: sampleUserSchema, createdBy: sampleUserSchema,
isPublic: z.boolean(), isPublic: z.boolean(),
@@ -726,7 +725,6 @@ export const createProblemRequestSchema = z.object({
source: z.string().max(256).nullable().default(null), source: z.string().max(256).nullable().default(null),
prompt: z.string().nullable().default(null), prompt: z.string().nullable().default(null),
answers: z.array(problemAnswerSchema).default([]), answers: z.array(problemAnswerSchema).default([]),
shareSubmission: z.boolean(),
allowFlowchart: z.boolean().default(false), allowFlowchart: z.boolean().default(false),
showFlowchart: z.boolean().default(false), showFlowchart: z.boolean().default(false),
mermaidCode: z.string().nullable().default(null), mermaidCode: z.string().nullable().default(null),

View File

@@ -308,7 +308,6 @@ export const problemDetailSchema = z.object({
submissionNumber: z.number().int(), submissionNumber: z.number().int(),
acceptedNumber: z.number().int(), acceptedNumber: z.number().int(),
statisticInfo: z.record(z.string(), z.unknown()), statisticInfo: z.record(z.string(), z.unknown()),
shareSubmission: z.boolean(),
contestId: z.number().int().nullable(), contestId: z.number().int().nullable(),
tags: z.array(z.string()), tags: z.array(z.string()),
createdBy: z.object({ createdBy: z.object({

View File

@@ -46,7 +46,6 @@ export const submissionDetailSchema = z.object({
result: judgeStatusSchema, result: judgeStatusSchema,
info: z.unknown(), info: z.unknown(),
language: z.string(), language: z.string(),
shared: z.boolean(),
statisticInfo: z.record(z.string(), z.unknown()), statisticInfo: z.record(z.string(), z.unknown()),
contestId: z.number().int().nullable(), contestId: z.number().int().nullable(),
problemId: z.number().int(), problemId: z.number().int(),
@@ -58,7 +57,6 @@ export const submissionDetailSchema = z.object({
*/ */
problemDisplayId: z.string(), problemDisplayId: z.string(),
showLink: z.boolean(), showLink: z.boolean(),
canUnshare: z.boolean(),
}) })
/** /**
@@ -102,7 +100,6 @@ export const submissionListItemSchema = z.object({
username: z.string(), username: z.string(),
result: judgeStatusSchema, result: judgeStatusSchema,
language: z.string(), language: z.string(),
shared: z.boolean(),
statisticInfo: z.record(z.string(), z.unknown()), statisticInfo: z.record(z.string(), z.unknown()),
/** /**
* 来源题单,非题单入口提交的为 null。比赛提交恒为 null比赛题不会进题单 * 来源题单,非题单入口提交的为 null。比赛提交恒为 null比赛题不会进题单
@@ -114,8 +111,6 @@ export const submissionListItemSchema = z.object({
export const submissionListSchema = paginatedSchema(submissionListItemSchema) export const submissionListSchema = paginatedSchema(submissionListItemSchema)
export const shareSubmissionRequestSchema = z.object({ shared: z.boolean() })
/** /**
* 未完成学生。`realName` 是从用户名里剥掉 `ks<班级号>` 前缀后剩下的那一段, * 未完成学生。`realName` 是从用户名里剥掉 `ks<班级号>` 前缀后剩下的那一段,
* 不是 user.real_name 列 —— 与 F2「真名默认不下发」不冲突这里只有教师能看到 * 不是 user.real_name 列 —— 与 F2「真名默认不下发」不冲突这里只有教师能看到
@@ -173,5 +168,4 @@ export type EmbeddedSubmission = z.infer<typeof embeddedSubmissionSchema>
export type CreateSubmissionResponse = z.infer<typeof createSubmissionResponseSchema> export type CreateSubmissionResponse = z.infer<typeof createSubmissionResponseSchema>
export type FormatCodeResponse = z.infer<typeof formatCodeResponseSchema> export type FormatCodeResponse = z.infer<typeof formatCodeResponseSchema>
export type ShareSubmissionRequest = z.infer<typeof shareSubmissionRequestSchema>
export type FormatCodeRequest = z.infer<typeof formatCodeRequestSchema> export type FormatCodeRequest = z.infer<typeof formatCodeRequestSchema>