chore(站内信): 消息页、后台空壳页、两个接口和 message 表一起下线
Deploy / deploy (push) Canceled after 0s

历史上一共 6 条站内信(最后一条 2025-06),发信接口两代前端都没接过,后台页面只有一句
「未完待续」,OJ2 顶栏也没有入口。

0033 是 DROP TABLE,部署要显式放行:OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh,
或 CI 手工触发时勾 allow_destructive。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 00:51:12 +08:00
1 parent 734f4aed05
commit 4240a451e4
13 files changed
+3838 -308

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
-- 站内信整条下线(2026-10-13):表里历史上一共 6 条(最后一条 2025-06),发信接口两代前端都
-- 没接过,后台页面只是一句「未完待续」,OJ2 的顶栏也没有入口。页面、接口、契约同一个提交里删掉。
DROP TABLE "message" CASCADE;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -232,6 +232,13 @@
"when": 1791821976519, "when": 1791821976519,
"tag": "0032_achievement_cleanup", "tag": "0032_achievement_cleanup",
"breakpoints": true "breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1791823809856,
"tag": "0033_drop_message",
"breakpoints": true
} }
] ]
} }
+1 -53
View File
@@ -273,58 +273,6 @@ export const flowchartSubmission = pgTable(
], ],
) )
export const message = pgTable(
"message",
{
id: integer().primaryKey().generatedByDefaultAsIdentity({
name: "message_id_seq",
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 2147483647,
cache: 1,
}),
message: text().notNull(),
createTime: timestamp("create_time", {
withTimezone: true,
mode: "string",
}).notNull(),
recipientId: integer("recipient_id").notNull(),
senderId: integer("sender_id").notNull(),
submissionId: text("submission_id").notNull(),
},
(table) => [
index("message_recipient_time_idx").using(
"btree",
table.recipientId.asc().nullsLast().op("timestamptz_ops"),
table.createTime.asc().nullsLast().op("int4_ops"),
),
index("message_sender_id_a2a2e825").using(
"btree",
table.senderId.asc().nullsLast().op("int4_ops"),
),
index("message_submission_id_2fdf8a47").using(
"btree",
table.submissionId.asc().nullsLast().op("text_ops"),
),
foreignKey({
columns: [table.recipientId],
foreignColumns: [user.id],
name: "message_recipient_id_2aa5dd76_fk_user_id",
}),
foreignKey({
columns: [table.senderId],
foreignColumns: [user.id],
name: "message_sender_id_a2a2e825_fk_user_id",
}),
foreignKey({
columns: [table.submissionId],
foreignColumns: [submission.id],
name: "message_submission_id_2fdf8a47_fk_submission_id",
}).onDelete("cascade"),
],
)
export const judgeServer = pgTable("judge_server", { export const judgeServer = pgTable("judge_server", {
id: serial().primaryKey().notNull(), id: serial().primaryKey().notNull(),
hostname: text().notNull(), hostname: text().notNull(),
@@ -903,7 +851,7 @@ export const submission = pgTable(
* - `since_prev_ms` 是唯一由**服务端**算的一列(距同一用户同一道题上一次提交), * - `since_prev_ms` 是唯一由**服务端**算的一列(距同一用户同一道题上一次提交),
* 客户端伪造不了;这道题的第一次提交为 null。 * 客户端伪造不了;这道题的第一次提交为 null。
* - CASCADE 挂在 submission 上、不挂 user:它是提交的附属,提交没了它没有意义, * - CASCADE 挂在 submission 上、不挂 user:它是提交的附属,提交没了它没有意义,
* 人是谁顺着 submission 就能查到。同表的 message / problemset_submission 也是这一档。 * 人是谁顺着 submission 就能查到。同表的 problemset_submission 也是这一档。
*/ */
export const submissionTrace = pgTable( export const submissionTrace = pgTable(
"submission_trace", "submission_trace",
+1 -1
View File
@@ -481,7 +481,7 @@ adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
// 撞外键说明该用户还有历史数据,应当禁用而不是删除。 // 撞外键说明该用户还有历史数据,应当禁用而不是删除。
// //
// 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、题单进度、 // 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、题单进度、
// AI 分析、站内信全都继续拦着。只有 user_profile 和 user_stat 走 CASCADE —— // AI 分析全都继续拦着。只有 user_profile 和 user_stat 走 CASCADE ——
// 一个是一对一附属、一个是可重算的统计缓存,都不构成「这人做过什么」的证据。 // 一个是一对一附属、一个是可重算的统计缓存,都不构成「这人做过什么」的证据。
// 别顺手把这里也改成全 CASCADE:submission.user_id 压根没有外键(Django 那边就是个 // 别顺手把这里也改成全 CASCADE:submission.user_id 压根没有外键(Django 那边就是个
// 裸 IntegerField),全连坐的结果是成就没了、提交却留成孤儿行,一半删一半留。 // 裸 IntegerField),全连坐的结果是成就没了、提交却留成孤儿行,一半删一半留。
+1 -97
View File
@@ -1,5 +1,4 @@
import { import {
createMessageRequestSchema,
exerciseAttemptRequestSchema, exerciseAttemptRequestSchema,
reactionKeySchema, reactionKeySchema,
setReactionRequestSchema, setReactionRequestSchema,
@@ -7,10 +6,7 @@ import {
type Announcement, type Announcement,
type AnnouncementList, type AnnouncementList,
type AnnouncementListItem, type AnnouncementListItem,
type EmbeddedSubmission,
type Exercise, type Exercise,
type Message,
type MessageList,
type ReactionCounts, type ReactionCounts,
type ReactionState, type ReactionState,
type Tutorial, type Tutorial,
@@ -20,7 +16,7 @@ import {
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm" import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { optionalAuth, requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware" import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db" import { db, schema } from "../db"
import { failure, parseBody, success } from "../http" import { failure, parseBody, success } from "../http"
import { JudgeStatus } from "../judge/status" import { JudgeStatus } from "../judge/status"
@@ -95,98 +91,6 @@ contentRoutes.get("/announcements/:id", async (c) => {
} satisfies Announcement) } satisfies Announcement)
}) })
contentRoutes.get("/messages", requireAuth, async (c) => {
const user = c.get("user")!
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRows, rows] = await Promise.all([
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,
displayId: schema.problem.displayId,
})
.from(schema.message)
.innerJoin(schema.user, eq(schema.message.senderId, 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.problem, eq(schema.submission.problemId, schema.problem.id))
.where(eq(schema.message.recipientId, user.id))
.orderBy(desc(schema.message.createTime))
.limit(limit)
.offset(offset),
])
return success(c, {
results: rows.map(
({ message, sender, realName, submission, displayId }) =>
({
id: message.id,
sender: sampleUser(sender, realName),
createTime: message.createTime,
message: message.message,
submission: {
id: submission.id,
createTime: submission.createTime,
userId: submission.userId,
username: submission.username,
code: submission.code,
result: submission.result,
// info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 ——
// 对齐旧后端 SubmissionSafeModelSerializer 的 exclude,这三个键不出现在响应中
language: submission.language,
statisticInfo: asRecord(submission.statisticInfo),
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
problemDisplayId: displayId,
showLink: true,
} satisfies EmbeddedSubmission,
}) satisfies Message,
),
total: totalRows[0]?.value ?? 0,
} satisfies MessageList)
})
/**
* 发站内信。**目前没有任何前端在调用它** —— 后台那个页面
* (apps/web/src/admin/communication/messages.vue)两代前端都只是一句
* 「未完待续」的占位,ojnext 里定义过一个 createMessage 但同样零调用,
* 已在前端删掉。端点本身是完整实现的,要接 UI 从这里开始。
*/
contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
const user = c.get("user")!
const parsed = await parseBody(c, createMessageRequestSchema, "Invalid message payload")
if (!parsed.success) return parsed.response
if (parsed.data.recipientId === user.id)
return failure(c, 400, "invalid-recipient", "Can not send a message to yourself")
const [[recipient], [submission]] = await Promise.all([
db
.select({ id: schema.user.id })
.from(schema.user)
.where(and(eq(schema.user.id, parsed.data.recipientId), eq(schema.user.isDisabled, false)))
.limit(1),
db
.select({ id: schema.submission.id })
.from(schema.submission)
.where(eq(schema.submission.id, parsed.data.submissionId))
.limit(1),
])
if (!recipient) return failure(c, 404, "user-not-found", "User does not exist")
if (!submission) return failure(c, 404, "submission-not-found", "Submission does not exist")
await db.insert(schema.message).values({
message: parsed.data.message,
createTime: new Date().toISOString(),
recipientId: recipient.id,
senderId: user.id,
submissionId: submission.id,
})
return success(c, null, 201)
})
async function reactionState(problemId: number, userId: number) { async function reactionState(problemId: number, userId: number) {
const [mine] = await db const [mine] = await db
.select({ type: schema.reaction.type }) .select({ type: schema.reaction.type })
@@ -1,2 +0,0 @@
<template>未完待续</template>
<script lang="ts" setup></script>
-8
View File
@@ -57,7 +57,6 @@ import type {
Announcement, Announcement,
AnnouncementListItem, AnnouncementListItem,
Profile, Profile,
Message,
SubmissionListItem, SubmissionListItem,
Exercise, Exercise,
ProblemDetail, ProblemDetail,
@@ -380,13 +379,6 @@ export function getAnnouncement(id: number) {
return api.get<Announcement>(`announcements/${id}`) return api.get<Announcement>(`announcements/${id}`)
} }
export function getMessageList(offset = 0, limit = 10) {
// language 的收窄同 getSubmissions,见那里的说明
return api.get<{ results: Message[]; total: number }>("messages", {
params: { limit, offset },
})
}
export function getReaction(problemID: number) { export function getReaction(problemID: number) {
return api.get<ReactionState>(`problems/${problemID}/reaction`) return api.get<ReactionState>(`problems/${problemID}/reaction`)
} }
-78
View File
@@ -1,78 +0,0 @@
<template>
<div class="oj-page message-page">
<h2>消息</h2>
<n-list v-if="messages.length">
<n-list-item :style="{ overflow: 'auto' }" v-for="(item, index) in messages" :key="index">
<n-flex size="large" vertical>
<n-flex align="center">
<div>发送时间</div>
<div>{{ parseTime(item.createTime, "YYYY年M月D日 HH:mm:ss") }}</div>
<div>发送者</div>
<div>{{ item.sender.username }}</div>
</n-flex>
<n-flex align="center">
<div>题目序号</div>
<n-button
text
type="info"
@click="router.push('/problem/' + item.submission.problemDisplayId)"
>
{{ item.submission.problemDisplayId }}
</n-button>
<n-text :type="JUDGE_STATUS[item.submission.result]['type']">
{{ JUDGE_STATUS[item.submission.result]["name"] }}
</n-text>
<Copy :value="item.submission.code" />
</n-flex>
<n-code
:language="LANGUAGE_FORMAT_VALUE[item.submission.language]"
:code="item.submission.code"
show-line-numbers
/>
<div v-html="item.message"></div>
</n-flex>
</n-list-item>
</n-list>
<n-empty v-else description="没有消息" style="margin: 48px 0"></n-empty>
<Pagination v-model:limit="query.limit" v-model:page="query.page" :total="total" />
</div>
</template>
<script lang="ts" setup>
import { getMessageList } from "oj/api"
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE } from "utils/constants"
import Copy from "shared/components/Copy.vue"
import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions"
import type { Message } from "utils/types"
const router = useRouter()
const messages = ref<Message[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
})
async function listMessages() {
const offset = (query.page - 1) * query.limit
const res = await getMessageList(offset, query.limit)
total.value = res.total
messages.value = res.results
}
onMounted(listMessages)
watch(query, listMessages, { deep: true })
</script>
<style scoped>
.message-page h2 {
margin: 0 0 8px;
font-size: var(--oj-fs-title);
}
.message-page :deep(.n-list-item) {
padding: 20px 0;
font-size: var(--oj-fs-sec);
}
</style>
-11
View File
@@ -111,11 +111,6 @@ export const ojs: RouteRecordRaw = {
component: () => import("oj/achievement/index.vue"), component: () => import("oj/achievement/index.vue"),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: "message",
component: () => import("oj/user/message.vue"),
meta: { requiresAuth: true },
},
{ {
path: "learn", path: "learn",
name: "learn home", name: "learn home",
@@ -268,12 +263,6 @@ export const admins: RouteRecordRaw = {
props: true, props: true,
meta: { requiresSuperAdmin: true }, meta: { requiresSuperAdmin: true },
}, },
{
path: "message/list",
name: "admin message list",
component: () => import("admin/communication/messages.vue"),
meta: { requiresSuperAdmin: true },
},
{ {
path: "tutorial/list", path: "tutorial/list",
name: "admin tutorial list", name: "admin tutorial list",
-16
View File
@@ -7,8 +7,6 @@ import type {
RankProfile, RankProfile,
SessionUser, SessionUser,
UserProfile, UserProfile,
EmbeddedSubmission as ContractEmbeddedSubmission,
Message as ContractMessage,
ProblemDifficulty, ProblemDifficulty,
JudgeStatus, JudgeStatus,
AstRules, AstRules,
@@ -245,12 +243,6 @@ export type Submission = Omit<SubmissionDetail, "result"> & {
result: SUBMISSION_RESULT result: SUBMISSION_RESULT
} }
/**
* 站内信里嵌的提交:problem 是展示题号而非数字 id,且不含 info / ip / contestId。
* 契约里已经窄化好了(`embeddedSubmissionSchema`),这里不再重复 Omit + 增补。
*/
export type EmbeddedSubmission = ContractEmbeddedSubmission
export type SubmissionListItem = ContractSubmissionListItem export type SubmissionListItem = ContractSubmissionListItem
export interface SubmissionListPayload { export interface SubmissionListPayload {
@@ -358,14 +350,6 @@ export type {
/** 后台编辑表单:请求体 + id(新建时填 0,提交前由 api 层剥掉) */ /** 后台编辑表单:请求体 + id(新建时填 0,提交前由 api 层剥掉) */
export type AnnouncementEdit = CreateAnnouncementRequest & { id: number } export type AnnouncementEdit = CreateAnnouncementRequest & { id: number }
/**
* 站内信。**直接取契约** —— 原来这里把 `submission` 换成前端窄化过的
* `EmbeddedSubmission`,但那个窄化已经搬进 `embeddedSubmissionSchema`
* (statisticInfo / language 在契约里不再是 unknown),两者双向可赋值、
* 完全等价,这层 Omit 已经没有内容。
*/
export type Message = ContractMessage
/** /**
* 题目表情。三个类型都直接取自契约 —— 语义 key 必须与后端 reaction/models.py * 题目表情。三个类型都直接取自契约 —— 语义 key 必须与后端 reaction/models.py
* 的 ReactionType 一致(见根 CLAUDE.md),手抄一份迟早对不上。 * 的 ReactionType 一致(见根 CLAUDE.md),手抄一份迟早对不上。
-23
View File
@@ -1,7 +1,6 @@
import { z } from "zod" import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common" import { paginatedSchema, sampleUserSchema } from "./common"
import { embeddedSubmissionSchema } from "./submission"
export const announcementSchema = z.object({ export const announcementSchema = z.object({
id: z.number().int(), id: z.number().int(),
@@ -26,25 +25,6 @@ export const announcementListItemSchema = announcementSchema.omit({
export const announcementListSchema = paginatedSchema(announcementListItemSchema) export const announcementListSchema = paginatedSchema(announcementListItemSchema)
export const messageSchema = z.object({
id: z.number().int(),
sender: sampleUserSchema,
createTime: z.string(),
message: z.string(),
submission: embeddedSubmissionSchema,
})
export const messageListSchema = paginatedSchema(messageSchema)
export const createMessageRequestSchema = z.object({
recipientId: z.number().int().positive(),
submissionId: z.string().min(1),
message: z
.string()
.min(1)
.max(1024 * 1024),
})
export const reactionKeySchema = z.enum([ export const reactionKeySchema = z.enum([
"too_easy", "too_easy",
"too_hard", "too_hard",
@@ -194,14 +174,11 @@ export const exerciseSchema = z.object({
solved: z.boolean(), solved: z.boolean(),
}) })
export type Message = z.infer<typeof messageSchema>
export type MessageList = z.infer<typeof messageListSchema>
export type Announcement = z.infer<typeof announcementSchema> export type Announcement = z.infer<typeof announcementSchema>
export type AnnouncementListItem = z.infer<typeof announcementListItemSchema> export type AnnouncementListItem = z.infer<typeof announcementListItemSchema>
export type TutorialSummary = z.infer<typeof tutorialSummarySchema> export type TutorialSummary = z.infer<typeof tutorialSummarySchema>
export type AnnouncementList = z.infer<typeof announcementListSchema> export type AnnouncementList = z.infer<typeof announcementListSchema>
export type CreateMessageRequest = z.infer<typeof createMessageRequestSchema>
export type ReactionKey = z.infer<typeof reactionKeySchema> export type ReactionKey = z.infer<typeof reactionKeySchema>
export type ReactionCounts = z.infer<typeof reactionCountsSchema> export type ReactionCounts = z.infer<typeof reactionCountsSchema>
export type ReactionState = z.infer<typeof reactionStateSchema> export type ReactionState = z.infer<typeof reactionStateSchema>
-19
View File
@@ -229,24 +229,6 @@ export const submissionDetailSchema = z.object({
caseSummary: caseSummarySchema, caseSummary: caseSummarySchema,
}) })
/**
* 内嵌在别处(目前只有站内信)的提交对象。对齐旧后端的
* `SubmissionSafeModelSerializer(exclude=("info", "contest", "ip"))` ——
* 这些键**根本不出现**,而不是出现但值为空。(`ip` 已随 IP 功能整体删除。)
*
* 独立成一个 schema 而不是复用 submissionDetailSchema 传空值:形状一致了,
* 将来有人「顺手」把空值改成真值就不会变成泄露,因为这里压根没有这些字段。
*/
export const embeddedSubmissionSchema = submissionDetailSchema
// 留下 problemDisplayId(展示用题号)而不是数字主键:站内信页面拿它拼
// `/problem/<题号>` 链接,给数字 id 会拼出打不开的地址。
.omit({
info: true,
contestId: true,
problemId: true,
caseSummary: true,
})
/** /**
* 判题进度推送。**只带前端真正要用的东西**:靠 submissionId 认领、靠 result / * 判题进度推送。**只带前端真正要用的东西**:靠 submissionId 认领、靠 result /
* status 决定是继续等还是去拉详情。 * status 决定是继续等还是去拉详情。
@@ -623,7 +605,6 @@ export type AttemptedStudent = z.infer<typeof attemptedStudentSchema>
export type SubmissionListItem = z.infer<typeof submissionListItemSchema> export type SubmissionListItem = z.infer<typeof submissionListItemSchema>
export type SubmissionList = z.infer<typeof submissionListSchema> export type SubmissionList = z.infer<typeof submissionListSchema>
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>