fix(Minor M-1): 站内信内嵌提交改用独立 schema,并修复题号链接

两件事:

1. 内嵌的 submission 之前复用 submissionDetailSchema 并把 info / ip 写死成空值,
   于是键仍留在响应里。旧 SubmissionSafeModelSerializer 是 exclude,这三个键
   根本不出现。改成独立的 embeddedSubmissionSchema —— 形状对上了,将来有人
   把空值改成真值也不会变成泄露,因为这里压根没有这三个字段。

2. 顺带修掉一个评审没覆盖的真回归:内嵌 submission 给的是 problemId(数字主键),
   而旧 serializer 的 problem 是 SlugRelatedField(slug_field="_id"),即展示用题号。
   oj/user/message.vue:20 拿它拼 /problem/<题号>,迁移后拼出的是 /problem/undefined,
   题号那一栏也是空的。改为下发 problem,实测返回 "1004"。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 07:01:13 -06:00
parent 61f74054f9
commit cebaa87b13
3 changed files with 26 additions and 10 deletions

View File

@@ -8,7 +8,7 @@ import {
reactionKeySchema, reactionKeySchema,
reactionStateSchema, reactionStateSchema,
setReactionRequestSchema, setReactionRequestSchema,
submissionDetailSchema, embeddedSubmissionSchema,
tutorialSchema, tutorialSchema,
tutorialSummarySchema, tutorialSummarySchema,
} from "@oj2/contract" } from "@oj2/contract"
@@ -73,32 +73,33 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRows, rows] = await Promise.all([ const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.message).where(eq(schema.message.recipientId, user.id)), db.select({ value: count() }).from(schema.message).where(eq(schema.message.recipientId, user.id)),
db.select({ message: schema.message, sender: schema.user, realName: schema.userProfile.realName, submission: schema.submission }) db.select({ message: schema.message, sender: schema.user, realName: schema.userProfile.realName, submission: schema.submission, displayId: schema.problem.displayId })
.from(schema.message).innerJoin(schema.user, eq(schema.message.senderId, schema.user.id)) .from(schema.message).innerJoin(schema.user, eq(schema.message.senderId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.innerJoin(schema.submission, eq(schema.message.submissionId, schema.submission.id)) .innerJoin(schema.submission, eq(schema.message.submissionId, schema.submission.id))
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(eq(schema.message.recipientId, user.id)).orderBy(desc(schema.message.createTime)).limit(limit).offset(offset), .where(eq(schema.message.recipientId, user.id)).orderBy(desc(schema.message.createTime)).limit(limit).offset(offset),
]) ])
return success(c, messageListSchema.parse({ return success(c, messageListSchema.parse({
results: rows.map(({ message, sender, realName, submission }) => messageSchema.parse({ results: rows.map(({ message, sender, realName, submission, displayId }) => messageSchema.parse({
id: message.id, id: message.id,
sender: sampleUser(sender, realName), sender: sampleUser(sender, realName),
createTime: message.createTime, createTime: message.createTime,
message: message.message, message: message.message,
submission: submissionDetailSchema.parse({ submission: embeddedSubmissionSchema.parse({
id: submission.id, id: submission.id,
createTime: submission.createTime, createTime: submission.createTime,
userId: submission.userId, userId: submission.userId,
username: submission.username, username: submission.username,
code: submission.code, code: submission.code,
result: submission.result, result: submission.result,
info: {}, // info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 ——
// 对齐旧后端 SubmissionSafeModelSerializer 的 exclude这三个键不出现在响应中
language: submission.language, language: submission.language,
shared: submission.shared, shared: submission.shared,
statisticInfo: objectValue(submission.statisticInfo), statisticInfo: objectValue(submission.statisticInfo),
ip: null, // 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
contestId: submission.contestId, problem: displayId,
problemId: submission.problemId,
showLink: true, showLink: true,
canUnshare: false, canUnshare: false,
}), }),

View File

@@ -1,7 +1,7 @@
import { z } from "zod" import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common" import { paginatedSchema, sampleUserSchema } from "./common"
import { submissionDetailSchema } from "./submission" import { embeddedSubmissionSchema } from "./submission"
export const announcementSchema = z.object({ export const announcementSchema = z.object({
id: z.number().int(), id: z.number().int(),
@@ -21,7 +21,7 @@ export const messageSchema = z.object({
sender: sampleUserSchema, sender: sampleUserSchema,
createTime: z.string(), createTime: z.string(),
message: z.string(), message: z.string(),
submission: submissionDetailSchema, submission: embeddedSubmissionSchema,
}) })
export const messageListSchema = paginatedSchema(messageSchema) export const messageListSchema = paginatedSchema(messageSchema)

View File

@@ -46,6 +46,21 @@ export const submissionDetailSchema = z.object({
canUnshare: z.boolean(), canUnshare: z.boolean(),
}) })
/**
* 内嵌在别处(目前只有站内信)的提交对象。对齐旧后端的
* `SubmissionSafeModelSerializer(exclude=("info", "contest", "ip"))` ——
* 这三个键**根本不出现**,而不是出现但值为空。
*
* 独立成一个 schema 而不是复用 submissionDetailSchema 传空值:形状一致了,
* 将来有人「顺手」把空值改成真值就不会变成泄露,因为这里压根没有这三个字段。
*/
export const embeddedSubmissionSchema = submissionDetailSchema
.omit({ info: true, ip: true, contestId: true, problemId: true })
// 旧 SubmissionSafeModelSerializer 里 problem 是
// `SlugRelatedField(slug_field="_id")`,即**展示用题号**而非数字主键。
// 站内信页面拿它拼 `/problem/<题号>` 链接,给数字 id 会拼出打不开的地址。
.extend({ problem: z.string() })
export const submissionUpdateSchema = z.object({ export const submissionUpdateSchema = z.object({
type: z.literal("submission_update"), type: z.literal("submission_update"),
submission_id: z.string(), submission_id: z.string(),