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:
1 parent
e624c62502
commit
2c4d56b29a
41 files changed
+12081
-491
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user