refactor(后端): 清掉 Django 遗留的死列与手工级联,角色字符串收成一份

三条迁移,一次部署(0008/0009 含 DROP COLUMN,需要 OJ2_ALLOW_DESTRUCTIVE=1):

- 0008 删 IP 相关:比赛 IP 白名单(前端本来就没有输入框,detail.vue 无条件置空)、
  submission.ip(前端从未显示过)、以及一次都没被调用过的 IP 限流桶。
  judge_server.ip 是运维数据,保留。
- 0009 删九个只有 Django 时代写过、OJ2 一次都没读过的列:user 的 auth_token /
  open_api / open_api_appkey / session_keys,user_profile 的 blog / github /
  school / major / language。open_api 后台连开关都没有,那段「已经开着就不重置
  appkey」的逻辑从上线起没进过 if。判据是「全仓零读取」而不是「看着没用」——
  raw_password 同样刺眼却是在用的,别一起清掉。
- 0010 给 17 条外键补上删除动作,不再是 Django 留下的一律 NO ACTION。父行消失后
  必然无意义、且不构成学生留痕的走 CASCADE(中间表、题单/教程/成就的组成部分、
  user_profile 与 user_stat);需要人看见的继续拦着——submission.problem_id、
  以及 user 的绝大多数外键,删用户撞外键会被 handler 翻译成「请改为禁用账号」,
  这是有意的:全 CASCADE 会静默抹掉成就与进度,而 submission.user_id 压根没有
  外键,结果是一半删一半留。六处手工级联随之删掉。

角色字符串收进 packages/contract/src/roles.ts:原先 ADMIN_ROLES / TEACHER_ROLES
在两个文件各抄一份、学生口径在四个文件各写一遍、前端 USER_TYPE 是第三份副本。
AuthUser.adminType 与 drizzle 的列都收窄成联合类型,二十多处 `=== "Super Admin"`
从此受编译器管着($type 是纯 TS 层的,generate 确认不产生任何 SQL 变更)。

顺带删掉 db/relations.ts —— drizzle-kit pull 的产物,全仓零引用。

一处行为变化:后台用户列表传非法的 ?type= 回 400,不再静默返回空列表;界面上的
下拉只有合法值,打不到这条。

验证:tsc / vue-tsc / vite build / check:routes 全过;三条迁移在 dev 库执行,
并逐条建 fixture 走 HTTP 接口验过删除连坐与拦截(题单五张子表连坐、user_badge
二级连坐、删有提交的题目仍 409、删有表情的用户仍 409)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeJoYc2t2d7cThVqMBYrBF
This commit is contained in:
xuyueandClaude Opus 5 committed 2026-09-02 22:57:45 -06:00
1 parent e624c62502
commit 2c4d56b29a
41 files changed
+12081 -491

No files matched your search

+2 -21
View File
@@ -26,16 +26,6 @@ function ownedBy(user: AuthUser, contest: { createdById: number }) {
return user.adminType === "Super Admin" || contest.createdById === user.id
}
/** CIDR 校验。`ip_network(strict=False)` 的等价物:允许主机位非零,如 192.168.1.5/24 */
function validCidr(value: string) {
const [address, prefixText] = value.split("/")
const octets = (address ?? "").split(".")
if (octets.length !== 4) return false
if (!octets.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return false
if (prefixText === undefined) return true
return /^\d{1,2}$/.test(prefixText) && Number(prefixText) <= 32
}
async function serialize(row: {
contest: typeof schema.contest.$inferSelect
user: typeof schema.user.$inferSelect
@@ -52,9 +42,6 @@ async function serialize(row: {
lastUpdateTime: row.contest.lastUpdateTime,
password: row.contest.password,
visible: row.contest.visible,
allowedIpRanges: Array.isArray(row.contest.allowedIpRanges)
? row.contest.allowedIpRanges.filter((item): item is string => typeof item === "string")
: [],
createdBy: sampleUser(row.user, row.realName),
status: contestStatus(row.contest),
contestType: row.contest.password ? "Password Protected" : "Public",
@@ -69,15 +56,12 @@ function selectContest(id: number) {
.where(eq(schema.contest.id, id)).limit(1)
}
/** 请求体里的时间与 CIDR 校验,创建和编辑共用 */
function validatePayload(data: { startTime: string; endTime: string; allowedIpRanges: string[] }) {
/** 请求体里的时间校验,创建和编辑共用 */
function validatePayload(data: { startTime: string; endTime: string }) {
const start = Date.parse(data.startTime)
const end = Date.parse(data.endTime)
if (!Number.isFinite(start) || !Number.isFinite(end)) return "开始或结束时间不是合法的时间格式"
if (end <= start) return "Start time must occur earlier than end time"
for (const range of data.allowedIpRanges) {
if (!validCidr(range)) return `${range} is not a valid cidr network`
}
return null
}
@@ -132,7 +116,6 @@ adminContestRoutes.post("/contests", requireTeacher, async (c) => {
// 空串归一成 null,否则 contestType 会把「密码是空字符串」当成密码保护赛
password: parsed.data.password || null,
visible: parsed.data.visible,
allowedIpRanges: parsed.data.allowedIpRanges,
createdById: c.get("user")!.id,
createTime: now,
lastUpdateTime: now,
@@ -162,7 +145,6 @@ adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
endTime: new Date(parsed.data.endTime).toISOString(),
password: parsed.data.password || null,
visible: parsed.data.visible,
allowedIpRanges: parsed.data.allowedIpRanges,
lastUpdateTime: new Date().toISOString(),
}).where(eq(schema.contest.id, id))
const [row] = await selectContest(id)
@@ -196,7 +178,6 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
password: null,
// 克隆出来的一律不可见:时间是拍脑袋定的 10 分钟后,直接开放会让学生看到一场没准备好的赛
visible: false,
allowedIpRanges: original.contest.allowedIpRanges,
startTime: start.toISOString(),
endTime: end.toISOString(),
createdById: me,