Compare commits

...

2 Commits

Author SHA1 Message Date
fafeebd281 feat(运维): 加 oj2-api recount,把反范式计数列对回 submission 表
Some checks failed
Deploy / deploy (push) Has been cancelled
problem.submission_number / accepted_number / statistic_info 和 user_profile 的
submission_number / accepted_number / acm_problems_status 由 judge/run.ts 的
persistResult 在判题时手工加减,上线至今没人事后核对过。

已知的漂移来源是重判:routes/submission.ts 的 rejudge 把 result 打回 PENDING
就重新入队,不回退任何计数,persistResult 随后再加一次。实测重判一条提交,
problem 的 submission_number、statistic_info 和 user_profile 的 submission_number
全部虚增,而提交一条没多。

默认只读预演,--apply 才写,落库后用同一份 computePlan 复核,还剩差异就非零退出。
口径逐条照抄 persistResult:题目侧连比赛提交一起算、用户侧只算非比赛;
accepted_number 是去重到题的首次通过;acm_problems_status 通过过就恒为 ACCEPTED,
没通过过取最后一次结果。acm_problems_status 里 problems / contest_problems 之外
的顶层键原样保留 —— 来历不明的数据不该被重算顺手抹掉。

不管的:acm_contest_rank(罚时与每题尝试次数口径复杂,单独一件事)、
achievement.unlock_count(0010 之后随成就级联,漂不了)、题单进度与奖章
(走 backfill-problemsets)。

--apply 要挑没人做题的时候跑:差异在事务外算、写的是绝对值,算完到写完之间判完
的那一笔加法会被覆盖;复核会把它报成「仍有 N 处差异」并以 1 退出,不会静默。

验证:dev 库先备份计数列,测完原样还原(差异数 0)。验过收敛(--apply 后再跑报
一致)、真实重判造成的漂移精确报出 3 处且无误报、人为删掉某学生已 AC 的格子能按
「曾经 AC → ACCEPTED」恢复、注入的未知顶层键完好保留。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeJoYc2t2d7cThVqMBYrBF
2026-09-02 22:58:01 -06:00
2c4d56b29a 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
2026-09-02 22:57:45 -06:00
43 changed files with 12374 additions and 492 deletions

View File

@@ -141,7 +141,18 @@ C 那 14 个 target 在 C++ 树里逐个实测通用。但**调用形态两者
## 数据库
Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它长得像 Django 建的表
表名、bigint/int4 混用、外键全是 NO ACTION`schema.ts` 顶部记了哪些地方是手工修的。
表名、bigint/int4 混用),`schema.ts` 顶部记了哪些地方是手工修的。
**外键的删除动作从 0010 起是显式的**,不再是 Django 留下的一律 NO ACTION
- **CASCADE**:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表
problem_tags、题单/教程/成就的组成部分、一对一附属user_profile与可重算的
缓存user_stat
- **NO ACTION即拦住**:需要人看见的删除 —— `submission.problem_id`、以及 `user`
的绝大多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。
**加新子表时必须回来想一遍该走哪一档**,别默认新外键会自己连坐 —— drizzle 不写
`.onDelete()` 就是 NO ACTION而 0010 只改了当时存在的那批。
**schema 现在归 OJ2 独占。** 旧后端已下线,「改 schema 要考虑回滚」这条约束不再存在,
结构变更走下面的 migration 正常演进即可。

View File

@@ -1,3 +1,4 @@
import { ADMIN_ROLES, TEACHER_ROLES } from "@oj2/contract"
import type { Context, MiddlewareHandler } from "hono"
import { failure } from "../http"
@@ -56,9 +57,6 @@ function requireRole(
}
}
const ADMIN_ROLES = ["Student Admin", "Teacher Admin", "Super Admin"]
const TEACHER_ROLES = ["Teacher Admin", "Super Admin"]
/** 旧 `@admin_role_required` */
export const requireAdmin = requireRole((user) => ADMIN_ROLES.includes(user.adminType))

View File

@@ -1,5 +1,12 @@
import { randomBytes } from "node:crypto"
import {
toAdminType,
toProblemPermission,
type AdminType,
type ProblemPermission,
} from "@oj2/contract"
import { eq } from "drizzle-orm"
import type { Context } from "hono"
import { deleteCookie, getCookie, setCookie } from "hono/cookie"
@@ -17,12 +24,17 @@ interface StoredSession {
contestPasswords: Record<string, string>
}
/**
* 会话里的用户。`adminType` / `problemPermission` 是**联合类型而不是 string** ——
* 全仓二十多处 `user.adminType === "Super Admin"` 靠它兜底,拼错一个字母就编译不过。
* 收窄发生在下面读库那一处,是整个后端唯一一个把裸字符串变成角色的地方。
*/
export interface AuthUser {
id: number
username: string
email: string | null
adminType: string
problemPermission: string
adminType: AdminType
problemPermission: ProblemPermission
isDisabled: boolean
className: string | null
}
@@ -126,7 +138,14 @@ async function getUserByToken(token: string | undefined): Promise<SessionResult>
}
await redis.expire(sessionKey(token), config.sessionTtlSeconds)
return { user }
// 唯一的收窄点。库里是 text 列,认不出来的值降成最低权限,见 toAdminType 的注释。
return {
user: {
...user,
adminType: toAdminType(user.adminType),
problemPermission: toProblemPermission(user.problemPermission),
},
}
}
/** 要区分「未登录」和「已被禁用」的用这个 —— 目前只有鉴权中间件需要 */

View File

@@ -2,6 +2,8 @@ import { and, eq, isNull } from "drizzle-orm"
import { touchSession } from "../auth/session"
import { db, schema } from "../db"
import { toAdminType } from "@oj2/contract"
import { TEACHER_ROLES } from "../routes/helpers"
import {
addRequest,
@@ -24,7 +26,7 @@ import {
} from "./state"
function isTeacher(ws: CollabSocket) {
return TEACHER_ROLES.includes(ws.data.adminType ?? "")
return TEACHER_ROLES.includes(toAdminType(ws.data.adminType ?? ""))
}
/** 推给老师的列表条目。不含 socket也不含任何代码内容 */
@@ -339,7 +341,7 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
.from(schema.user)
.where(and(eq(schema.user.id, ws.data.userId), eq(schema.user.isDisabled, false)))
.limit(1)
if (!teacher || !TEACHER_ROLES.includes(teacher.adminType)) {
if (!teacher || !TEACHER_ROLES.includes(toAdminType(teacher.adminType))) {
ws.close(1008, "Permission revoked")
return
}
@@ -404,7 +406,7 @@ async function handleReject(ws: CollabSocket, studentId: unknown) {
.from(schema.user)
.where(and(eq(schema.user.id, ws.data.userId), eq(schema.user.isDisabled, false)))
.limit(1)
if (!teacher || !TEACHER_ROLES.includes(teacher.adminType)) {
if (!teacher || !TEACHER_ROLES.includes(toAdminType(teacher.adminType))) {
ws.close(1008, "Permission revoked")
return
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE "contest" DROP COLUMN "allowed_ip_ranges";--> statement-breakpoint
ALTER TABLE "submission" DROP COLUMN "ip";

View File

@@ -0,0 +1,9 @@
ALTER TABLE "user" DROP COLUMN "auth_token";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "open_api";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "open_api_appkey";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "session_keys";--> statement-breakpoint
ALTER TABLE "user_profile" DROP COLUMN "blog";--> statement-breakpoint
ALTER TABLE "user_profile" DROP COLUMN "github";--> statement-breakpoint
ALTER TABLE "user_profile" DROP COLUMN "school";--> statement-breakpoint
ALTER TABLE "user_profile" DROP COLUMN "major";--> statement-breakpoint
ALTER TABLE "user_profile" DROP COLUMN "language";

View File

@@ -0,0 +1,51 @@
ALTER TABLE "exercise" DROP CONSTRAINT "exercise_tutorial_id_6fd04055_fk_tutorial_id";
--> statement-breakpoint
ALTER TABLE "flowchart_submission" DROP CONSTRAINT "flowchart_submission_problem_id_8551edbf_fk_problem_id";
--> statement-breakpoint
ALTER TABLE "message" DROP CONSTRAINT "message_submission_id_2fdf8a47_fk_submission_id";
--> statement-breakpoint
ALTER TABLE "problem_tags" DROP CONSTRAINT "problem_tags_problem_id_866ecb8d_fk_problem_id";
--> statement-breakpoint
ALTER TABLE "problem_tags" DROP CONSTRAINT "problem_tags_problemtag_id_72d20571_fk_problem_tag_id";
--> statement-breakpoint
ALTER TABLE "problemset_badge" DROP CONSTRAINT "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id";
--> statement-breakpoint
ALTER TABLE "problemset_problem" DROP CONSTRAINT "problemset_problem_problem_id_fff2d686_fk_problem_id";
--> statement-breakpoint
ALTER TABLE "problemset_problem" DROP CONSTRAINT "problemset_problem_problemset_id_350d17fb_fk_problemset_id";
--> statement-breakpoint
ALTER TABLE "problemset_progress" DROP CONSTRAINT "problemset_progress_problemset_id_20a9632e_fk_problemset_id";
--> statement-breakpoint
ALTER TABLE "problemset_submission" DROP CONSTRAINT "problemset_submission_problem_id_5629b105_fk_problem_id";
--> statement-breakpoint
ALTER TABLE "problemset_submission" DROP CONSTRAINT "problemset_submission_problemset_id_85290e17_fk_problemset_id";
--> statement-breakpoint
ALTER TABLE "problemset_submission" DROP CONSTRAINT "problemset_submission_submission_id_78e2b807_fk_submission_id";
--> statement-breakpoint
ALTER TABLE "reaction" DROP CONSTRAINT "reaction_problem_id_a7f3b9f3_fk_problem_id";
--> statement-breakpoint
ALTER TABLE "user_achievement" DROP CONSTRAINT "user_achievement_achievement_id_29db600d_fk_achievement_id";
--> statement-breakpoint
ALTER TABLE "user_badge" DROP CONSTRAINT "user_badge_badge_id_92a983e9_fk_problemset_badge_id";
--> statement-breakpoint
ALTER TABLE "user_profile" DROP CONSTRAINT "user_profile_user_id_8fdce8e2_fk_user_id";
--> statement-breakpoint
ALTER TABLE "user_stat" DROP CONSTRAINT "user_stat_user_id_73337fc0_fk_user_id";
--> statement-breakpoint
ALTER TABLE "exercise" ADD CONSTRAINT "exercise_tutorial_id_6fd04055_fk_tutorial_id" FOREIGN KEY ("tutorial_id") REFERENCES "public"."tutorial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "flowchart_submission" ADD CONSTRAINT "flowchart_submission_problem_id_8551edbf_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "message" ADD CONSTRAINT "message_submission_id_2fdf8a47_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "problem_tags" ADD CONSTRAINT "problem_tags_problem_id_866ecb8d_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "problem_tags" ADD CONSTRAINT "problem_tags_problemtag_id_72d20571_fk_problem_tag_id" FOREIGN KEY ("problemtag_id") REFERENCES "public"."problem_tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "problemset_badge" ADD CONSTRAINT "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id" FOREIGN KEY ("problemset_id") REFERENCES "public"."problemset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "problemset_problem" ADD CONSTRAINT "problemset_problem_problem_id_fff2d686_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "problemset_problem" ADD CONSTRAINT "problemset_problem_problemset_id_350d17fb_fk_problemset_id" FOREIGN KEY ("problemset_id") REFERENCES "public"."problemset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "problemset_progress" ADD CONSTRAINT "problemset_progress_problemset_id_20a9632e_fk_problemset_id" FOREIGN KEY ("problemset_id") REFERENCES "public"."problemset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "problemset_submission" ADD CONSTRAINT "problemset_submission_problem_id_5629b105_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "problemset_submission" ADD CONSTRAINT "problemset_submission_problemset_id_85290e17_fk_problemset_id" FOREIGN KEY ("problemset_id") REFERENCES "public"."problemset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "problemset_submission" ADD CONSTRAINT "problemset_submission_submission_id_78e2b807_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reaction" ADD CONSTRAINT "reaction_problem_id_a7f3b9f3_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_achievement" ADD CONSTRAINT "user_achievement_achievement_id_29db600d_fk_achievement_id" FOREIGN KEY ("achievement_id") REFERENCES "public"."achievement"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_badge" ADD CONSTRAINT "user_badge_badge_id_92a983e9_fk_problemset_badge_id" FOREIGN KEY ("badge_id") REFERENCES "public"."problemset_badge"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_profile" ADD CONSTRAINT "user_profile_user_id_8fdce8e2_fk_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_stat" ADD CONSTRAINT "user_stat_user_id_73337fc0_fk_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,27 @@
"when": 1788408053304,
"tag": "0007_add_submission_problemset_id",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1788409130862,
"tag": "0008_drop_ip_columns",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1788409690565,
"tag": "0009_drop_django_dead_columns",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1788409961010,
"tag": "0010_fk_cascade_on_delete",
"breakpoints": true
}
]
}

View File

@@ -1,260 +0,0 @@
import { relations } from "drizzle-orm/relations";
import { user, aiAnalysis, announcement, contest, problem, flowchartSubmission, message, submission, tutorial, exercise, problemset, problemsetProblem, problemsetProgress, problemsetSubmission, reaction, problemTags, problemTag, userStat, achievement, userAchievement, problemsetBadge, userBadge, userProfile, acmContestRank } from "./schema";
export const aiAnalysisRelations = relations(aiAnalysis, ({one}) => ({
user: one(user, {
fields: [aiAnalysis.userId],
references: [user.id]
}),
}));
export const userRelations = relations(user, ({many}) => ({
aiAnalyses: many(aiAnalysis),
announcements: many(announcement),
contests: many(contest),
flowchartSubmissions: many(flowchartSubmission),
messages_recipientId: many(message, {
relationName: "message_recipientId_user_id"
}),
messages_senderId: many(message, {
relationName: "message_senderId_user_id"
}),
problemsets: many(problemset),
problemsetProgresses: many(problemsetProgress),
problemsetSubmissions: many(problemsetSubmission),
reactions: many(reaction),
problems: many(problem),
tutorials: many(tutorial),
userStats: many(userStat),
userAchievements: many(userAchievement),
userBadges: many(userBadge),
userProfiles: many(userProfile),
acmContestRanks: many(acmContestRank),
}));
export const announcementRelations = relations(announcement, ({one}) => ({
user: one(user, {
fields: [announcement.createdById],
references: [user.id]
}),
}));
export const contestRelations = relations(contest, ({one, many}) => ({
user: one(user, {
fields: [contest.createdById],
references: [user.id]
}),
problems: many(problem),
submissions: many(submission),
acmContestRanks: many(acmContestRank),
}));
export const flowchartSubmissionRelations = relations(flowchartSubmission, ({one}) => ({
problem: one(problem, {
fields: [flowchartSubmission.problemId],
references: [problem.id]
}),
user: one(user, {
fields: [flowchartSubmission.userId],
references: [user.id]
}),
}));
export const problemRelations = relations(problem, ({one, many}) => ({
flowchartSubmissions: many(flowchartSubmission),
problemsetProblems: many(problemsetProblem),
problemsetSubmissions: many(problemsetSubmission),
reactions: many(reaction),
contest: one(contest, {
fields: [problem.contestId],
references: [contest.id]
}),
user: one(user, {
fields: [problem.createdById],
references: [user.id]
}),
problemTags: many(problemTags),
submissions: many(submission),
}));
export const messageRelations = relations(message, ({one}) => ({
user_recipientId: one(user, {
fields: [message.recipientId],
references: [user.id],
relationName: "message_recipientId_user_id"
}),
user_senderId: one(user, {
fields: [message.senderId],
references: [user.id],
relationName: "message_senderId_user_id"
}),
submission: one(submission, {
fields: [message.submissionId],
references: [submission.id]
}),
}));
export const submissionRelations = relations(submission, ({one, many}) => ({
messages: many(message),
problemsetSubmissions: many(problemsetSubmission),
contest: one(contest, {
fields: [submission.contestId],
references: [contest.id]
}),
problem: one(problem, {
fields: [submission.problemId],
references: [problem.id]
}),
}));
export const exerciseRelations = relations(exercise, ({one}) => ({
tutorial: one(tutorial, {
fields: [exercise.tutorialId],
references: [tutorial.id]
}),
}));
export const tutorialRelations = relations(tutorial, ({one, many}) => ({
exercises: many(exercise),
user: one(user, {
fields: [tutorial.createdById],
references: [user.id]
}),
}));
export const problemsetRelations = relations(problemset, ({one, many}) => ({
user: one(user, {
fields: [problemset.createdById],
references: [user.id]
}),
problemsetProblems: many(problemsetProblem),
problemsetProgresses: many(problemsetProgress),
problemsetSubmissions: many(problemsetSubmission),
problemsetBadges: many(problemsetBadge),
}));
export const problemsetProblemRelations = relations(problemsetProblem, ({one}) => ({
problem: one(problem, {
fields: [problemsetProblem.problemId],
references: [problem.id]
}),
problemset: one(problemset, {
fields: [problemsetProblem.problemsetId],
references: [problemset.id]
}),
}));
export const problemsetProgressRelations = relations(problemsetProgress, ({one}) => ({
problemset: one(problemset, {
fields: [problemsetProgress.problemsetId],
references: [problemset.id]
}),
user: one(user, {
fields: [problemsetProgress.userId],
references: [user.id]
}),
}));
export const problemsetSubmissionRelations = relations(problemsetSubmission, ({one}) => ({
problem: one(problem, {
fields: [problemsetSubmission.problemId],
references: [problem.id]
}),
problemset: one(problemset, {
fields: [problemsetSubmission.problemsetId],
references: [problemset.id]
}),
submission: one(submission, {
fields: [problemsetSubmission.submissionId],
references: [submission.id]
}),
user: one(user, {
fields: [problemsetSubmission.userId],
references: [user.id]
}),
}));
export const reactionRelations = relations(reaction, ({one}) => ({
problem: one(problem, {
fields: [reaction.problemId],
references: [problem.id]
}),
user: one(user, {
fields: [reaction.userId],
references: [user.id]
}),
}));
export const problemTagsRelations = relations(problemTags, ({one}) => ({
problem: one(problem, {
fields: [problemTags.problemId],
references: [problem.id]
}),
problemTag: one(problemTag, {
fields: [problemTags.problemtagId],
references: [problemTag.id]
}),
}));
export const problemTagRelations = relations(problemTag, ({many}) => ({
problemTags: many(problemTags),
}));
export const userStatRelations = relations(userStat, ({one}) => ({
user: one(user, {
fields: [userStat.userId],
references: [user.id]
}),
}));
export const userAchievementRelations = relations(userAchievement, ({one}) => ({
achievement: one(achievement, {
fields: [userAchievement.achievementId],
references: [achievement.id]
}),
user: one(user, {
fields: [userAchievement.userId],
references: [user.id]
}),
}));
export const achievementRelations = relations(achievement, ({many}) => ({
userAchievements: many(userAchievement),
}));
export const userBadgeRelations = relations(userBadge, ({one}) => ({
problemsetBadge: one(problemsetBadge, {
fields: [userBadge.badgeId],
references: [problemsetBadge.id]
}),
user: one(user, {
fields: [userBadge.userId],
references: [user.id]
}),
}));
export const problemsetBadgeRelations = relations(problemsetBadge, ({one, many}) => ({
userBadges: many(userBadge),
problemset: one(problemset, {
fields: [problemsetBadge.problemsetId],
references: [problemset.id]
}),
}));
export const userProfileRelations = relations(userProfile, ({one}) => ({
user: one(user, {
fields: [userProfile.userId],
references: [user.id]
}),
}));
export const acmContestRankRelations = relations(acmContestRank, ({one}) => ({
contest: one(contest, {
fields: [acmContestRank.contestId],
references: [contest.id]
}),
user: one(user, {
fields: [acmContestRank.userId],
references: [user.id]
}),
}));

View File

@@ -5,6 +5,12 @@
// django_session已由 0002_drop_django_leftovers 删除drizzle.config.ts 的
// tablesFilter 随之移除。库里现在就是这 27 张业务表。
//
// 2026-09-02又清了一批只有 Django 时代写过、OJ2 一次都没读过的列 ——
// 0008 删比赛 IP 白名单与 submission.ip判题机的 judge_server.ip 保留,那是运维数据),
// 0009 删 user 的 auth_token / open_api / open_api_appkey / session_keys 和
// user_profile 的 blog / github / school / major / language。**删的判据是「全仓零读取」**
// 不是「看着没用」raw_password 同样刺眼却是在用的(老师要能查学生密码),别一起清掉。
//
// 手工修正(都是 `pull` 自己没法无损 round-trip 的地方,改回去会让 generate 产生假 diff
// 详见 CLAUDE.md「改 schema 走 drizzle migration」
// * bigint identity 的 maxValue 用字符串,不能写成 JS number 字面量(会丢精度)。
@@ -16,6 +22,7 @@
// problem、contest、submission都是 int4。现存最大 id 一万出头,确实都用不上 bigint
// 但 2026-08-26 评估后决定**不改**:省 4 字节/行毫无意义ALTER TYPE 要重写整表并拿
// ACCESS EXCLUSIVE 锁,而且其中 6 处 id 被外键绑着得连坐。别再提这件事了。
import type { AdminType, ProblemPermission } from "@oj2/contract"
import { pgTable, index, foreignKey, primaryKey, bigint, text, jsonb, timestamp, integer, boolean, serial, doublePrecision, varchar, unique, uniqueIndex } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
@@ -90,7 +97,6 @@ export const contest = pgTable("contest", {
lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }).notNull(),
visible: boolean().notNull(),
createdById: integer("created_by_id").notNull(),
allowedIpRanges: jsonb("allowed_ip_ranges").notNull(),
tag: text().notNull(),
}, (table) => [
index("contest_created_by_id_a763ca7e").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
@@ -129,7 +135,7 @@ export const flowchartSubmission = pgTable("flowchart_submission", {
columns: [table.problemId],
foreignColumns: [problem.id],
name: "flowchart_submission_problem_id_8551edbf_fk_problem_id"
}),
}).onDelete("cascade"),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
@@ -164,7 +170,7 @@ export const message = pgTable("message", {
columns: [table.submissionId],
foreignColumns: [submission.id],
name: "message_submission_id_2fdf8a47_fk_submission_id"
}),
}).onDelete("cascade"),
]);
export const judgeServer = pgTable("judge_server", {
@@ -195,7 +201,7 @@ export const exercise = pgTable("exercise", {
columns: [table.tutorialId],
foreignColumns: [tutorial.id],
name: "exercise_tutorial_id_6fd04055_fk_tutorial_id"
}),
}).onDelete("cascade"),
]);
export const optionsSysoptions = pgTable("options_sysoptions", {
@@ -244,12 +250,12 @@ export const problemsetProblem = pgTable("problemset_problem", {
columns: [table.problemId],
foreignColumns: [problem.id],
name: "problemset_problem_problem_id_fff2d686_fk_problem_id"
}),
}).onDelete("cascade"),
foreignKey({
columns: [table.problemsetId],
foreignColumns: [problemset.id],
name: "problemset_problem_problemset_id_350d17fb_fk_problemset_id"
}),
}).onDelete("cascade"),
unique("unique_problemset_problem").on(table.problemId, table.problemsetId),
]);
@@ -274,7 +280,7 @@ export const problemsetProgress = pgTable("problemset_progress", {
columns: [table.problemsetId],
foreignColumns: [problemset.id],
name: "problemset_progress_problemset_id_20a9632e_fk_problemset_id"
}),
}).onDelete("cascade"),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
@@ -304,17 +310,17 @@ export const problemsetSubmission = pgTable("problemset_submission", {
columns: [table.problemId],
foreignColumns: [problem.id],
name: "problemset_submission_problem_id_5629b105_fk_problem_id"
}),
}).onDelete("cascade"),
foreignKey({
columns: [table.problemsetId],
foreignColumns: [problemset.id],
name: "problemset_submission_problemset_id_85290e17_fk_problemset_id"
}),
}).onDelete("cascade"),
foreignKey({
columns: [table.submissionId],
foreignColumns: [submission.id],
name: "problemset_submission_submission_id_78e2b807_fk_submission_id"
}),
}).onDelete("cascade"),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
@@ -336,7 +342,7 @@ export const reaction = pgTable("reaction", {
columns: [table.problemId],
foreignColumns: [problem.id],
name: "reaction_problem_id_a7f3b9f3_fk_problem_id"
}),
}).onDelete("cascade"),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
@@ -414,12 +420,12 @@ export const problemTags = pgTable("problem_tags", {
columns: [table.problemId],
foreignColumns: [problem.id],
name: "problem_tags_problem_id_866ecb8d_fk_problem_id"
}),
}).onDelete("cascade"),
foreignKey({
columns: [table.problemtagId],
foreignColumns: [problemTag.id],
name: "problem_tags_problemtag_id_72d20571_fk_problem_tag_id"
}),
}).onDelete("cascade"),
unique("problem_tags_problem_id_problemtag_id_318459d1_uniq").on(table.problemId, table.problemtagId),
]);
@@ -443,7 +449,6 @@ export const submission = pgTable("submission", {
shared: boolean().default(false).notNull(),
statisticInfo: jsonb("statistic_info").default({}).notNull(),
username: text().notNull(),
ip: text(),
// 来源题单:学生从题单入口(/problemset/:id/problem/:pid提交时记下来
// 提交列表据此标出「这条来自题单」。**只是来源标记**,题单进度、奖章一概不看它,
// 那些由判完之后的 recordSolvedProblem 按「已加入且含这道题的所有题单」记账。
@@ -534,7 +539,7 @@ export const userStat = pgTable("user_stat", {
columns: [table.userId],
foreignColumns: [user.id],
name: "user_stat_user_id_73337fc0_fk_user_id"
}),
}).onDelete("cascade"),
unique("user_stat_user_id_key").on(table.userId),
]);
@@ -556,7 +561,7 @@ export const userAchievement = pgTable("user_achievement", {
columns: [table.achievementId],
foreignColumns: [achievement.id],
name: "user_achievement_achievement_id_29db600d_fk_achievement_id"
}),
}).onDelete("cascade"),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
@@ -579,7 +584,7 @@ export const userBadge = pgTable("user_badge", {
columns: [table.badgeId],
foreignColumns: [problemsetBadge.id],
name: "user_badge_badge_id_92a983e9_fk_problemset_badge_id"
}),
}).onDelete("cascade"),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
@@ -592,22 +597,17 @@ export const userProfile = pgTable("user_profile", {
id: serial().primaryKey().notNull(),
acmProblemsStatus: jsonb("acm_problems_status").default({}).notNull(),
avatar: text().notNull(),
blog: varchar({ length: 200 }),
mood: text(),
acceptedNumber: integer("accepted_number").default(0).notNull(),
submissionNumber: integer("submission_number").default(0).notNull(),
github: text(),
school: text(),
major: text(),
userId: integer("user_id").notNull(),
realName: text("real_name"),
language: text(),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "user_profile_user_id_8fdce8e2_fk_user_id"
}),
}).onDelete("cascade"),
unique("user_profile_user_id_key").on(table.userId),
]);
@@ -644,13 +644,11 @@ export const user = pgTable("user", {
username: text().notNull(),
email: text(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }),
adminType: text("admin_type").notNull(),
authToken: text("auth_token"),
openApi: boolean("open_api").default(false).notNull(),
openApiAppkey: text("open_api_appkey"),
// $type 只是 TS 层的收窄,不产生任何 SQL —— 让 eq(schema.user.adminType, "...")
// 里的角色名也受类型检查。运行时的兜底仍在 auth/session.ts 的 toAdminType。
adminType: text("admin_type").notNull().$type<AdminType>(),
isDisabled: boolean("is_disabled").default(false).notNull(),
problemPermission: text("problem_permission").notNull(),
sessionKeys: jsonb("session_keys").default([]).notNull(),
problemPermission: text("problem_permission").notNull().$type<ProblemPermission>(),
rawPassword: varchar("raw_password", { length: 20 }),
className: text("class_name"),
}, (table) => [
@@ -673,7 +671,7 @@ export const problemsetBadge = pgTable("problemset_badge", {
columns: [table.problemsetId],
foreignColumns: [problemset.id],
name: "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id"
}),
}).onDelete("cascade"),
]);
/**

View File

@@ -13,6 +13,7 @@
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn不该手动调
* oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调
* oj2-api backfill-problemsets # 把题单进度与奖章订正到与规则一致,默认只读预演
* oj2-api recount # 把题目/用户的计数列重算回与 submission 一致,默认只读预演
*
* 用动态 import 而非顶层 import这几个模块都有导入即执行的副作用
* Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。
@@ -43,6 +44,11 @@ switch (command) {
allowRevoke: args.includes("--allow-revoke"),
}))
}
// 同上,一次性的数据订正。反范式计数列被重判等操作带偏之后拿它对账。
case "recount": {
const { recount } = await import("./scripts/recount")
process.exit(await recount({ apply: process.argv.slice(3).includes("--apply") }))
}
case "sql-child": {
const { runSqlChild } = await import("./judge/sql/child")
await runSqlChild()
@@ -63,6 +69,6 @@ switch (command) {
}
}
default:
console.error(`未知子命令:${command}\n可用serve | worker | migrate | backfill-problemsets | healthcheck | sql-child`)
console.error(`未知子命令:${command}\n可用serve | worker | migrate | backfill-problemsets | recount | healthcheck | sql-child`)
process.exit(2)
}

View File

@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto"
import { extname, resolve } from "node:path"
import {
STUDENT_ROLES,
activityRankItemSchema,
metricsSchema,
problemRankSchema,
@@ -25,6 +26,7 @@ import {
lt,
lte,
min,
ne,
or,
sql,
} from "drizzle-orm"
@@ -74,12 +76,8 @@ accountRoutes.post("/users", async (c) => {
lastLogin: null,
createTime: now,
adminType: "Regular User",
authToken: null,
openApi: false,
openApiAppkey: null,
isDisabled: false,
problemPermission: "None",
sessionKeys: [],
className: null,
}).returning({ id: schema.user.id })
if (!created) throw new Error("User insert did not return an id")
@@ -161,7 +159,7 @@ const LEADERBOARD_SIZE = 100
/** 入榜人群:正常状态的学生与学生管理员。教师和超管不参与排名。 */
const leaderboardWhere = and(
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
)
@@ -266,7 +264,7 @@ accountRoutes.get("/rankings/activity", async (c) => {
gte(schema.submission.createTime, start),
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
eq(schema.user.isDisabled, false),
sql`${schema.user.adminType} <> 'Super Admin'`,
ne(schema.user.adminType, "Super Admin"),
))
.groupBy(schema.submission.username).orderBy(desc(countDistinct(schema.submission.problemId))).limit(10)
return success(c, rows.map((row) => activityRankItemSchema.parse({ username: row.username, count: row.value })))

View File

@@ -1,4 +1,6 @@
import {
STUDENT_ROLES,
adminTypeSchema,
adminUserListSchema,
adminUserRankSchema,
adminUserSchema,
@@ -7,6 +9,8 @@ import {
rankProfileSchema,
resetPasswordResponseSchema,
updateUserRequestSchema,
type AdminType,
type ProblemPermission,
} from "@oj2/contract"
import { randomInt } from "node:crypto"
import { and, asc, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle-orm"
@@ -50,7 +54,7 @@ function classNameOf(username: string): { ok: true; value: string | null } | { o
* 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。
* 不这么做的话,把一个超管降级成普通用户后,他还留着 All 的题目权限。
*/
function normalizePermission(adminType: string, requested: string) {
function normalizePermission(adminType: AdminType, requested: ProblemPermission): ProblemPermission {
if (adminType === "Super Admin") return "All"
if (adminType === "Regular User") return "None"
return requested || "Own"
@@ -69,7 +73,6 @@ function serialize(row: {
realName: row.realName,
createTime: row.user.createTime,
lastLogin: row.user.lastLogin,
openApi: row.user.openApi,
isDisabled: row.user.isDisabled,
rawPassword: row.user.rawPassword,
className: row.user.className,
@@ -98,7 +101,7 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => {
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const keyword = c.req.query("keyword")?.trim()
const where = and(
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
keyword ? ilike(schema.user.username, `%${keyword}%`) : undefined,
)
@@ -134,7 +137,13 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
const filters = []
const type = c.req.query("type")?.trim()
const keyword = c.req.query("keyword")?.trim()
if (type) filters.push(eq(schema.user.adminType, type))
if (type) {
// 以前这里直接把 query 塞进 eq(),传个不存在的角色名只会静默返回空列表。
// 列加了 $type 之后编译器会拦下来,顺势改成校验:前端的下拉只有这四个值。
const parsedType = adminTypeSchema.safeParse(type)
if (!parsedType.success) return failure(c, 400, "invalid-request", "角色筛选值不合法")
filters.push(eq(schema.user.adminType, parsedType.data))
}
if (keyword) {
filters.push(or(
ilike(schema.user.username, `%${keyword}%`),
@@ -203,13 +212,6 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
patch.password = await hashPassword(data.password)
patch.rawPassword = data.password
}
if (data.openApi) {
// 已经开着就不重置 appkey否则每次保存用户都会把对方的 key 换掉
if (!existing.user.openApi) patch.openApiAppkey = randomBytes32()
} else {
patch.openApiAppkey = null
}
patch.openApi = data.openApi
await db.transaction(async (tx) => {
await tx.update(schema.user).set(patch).where(eq(schema.user.id, id))
@@ -276,12 +278,10 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
rawPassword: item.raw,
email: item.email,
className: item.className,
adminType: "Regular User",
problemPermission: "None",
adminType: "Regular User" as const,
problemPermission: "None" as const,
createTime: new Date().toISOString(),
openApi: false,
isDisabled: false,
sessionKeys: [],
}))).returning({ id: schema.user.id, username: schema.user.username })
const byName = new Map(users.map((row) => [row.username, row.id]))
await tx.insert(schema.userProfile).values(prepared.map((item) => ({
@@ -308,12 +308,15 @@ adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
// 用户是被引用最广的一张表(提交、题目、比赛、公告……),级联删除牵连太大,
// 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
// 撞外键说明该用户还有历史数据,应当禁用而不是删除。
//
// 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、题单进度、
// AI 分析、站内信全都继续拦着。只有 user_profile 和 user_stat 走 CASCADE ——
// 一个是一对一附属、一个是可重算的统计缓存,都不构成「这人做过什么」的证据。
// 别顺手把这里也改成全 CASCADEsubmission.user_id 压根没有外键Django 那边就是个
// 裸 IntegerField全连坐的结果是成就没了、提交却留成孤儿行一半删一半留。
try {
const deleted = await db.transaction(async (tx) => {
await tx.delete(schema.userProfile).where(inArray(schema.userProfile.userId, parsed.data.ids))
return tx.delete(schema.user).where(inArray(schema.user.id, parsed.data.ids))
.returning({ id: schema.user.id })
})
const deleted = await db.delete(schema.user).where(inArray(schema.user.id, parsed.data.ids))
.returning({ id: schema.user.id })
return success(c, { deleted: deleted.length })
} catch {
return failure(c, 409, "user-in-use", "该用户还有提交、题目等历史数据,无法删除;请改为禁用账号")
@@ -333,8 +336,3 @@ adminAccountRoutes.post("/users/:id/reset-password", requireSuperAdmin, async (c
}).where(eq(schema.user.id, id))
return success(c, resetPasswordResponseSchema.parse({ password }))
})
function randomBytes32() {
return Array.from({ length: 32 }, () =>
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"[randomInt(62)]).join("")
}

View File

@@ -103,12 +103,9 @@ adminAchievementRoutes.put("/achievements/:id", requireSuperAdmin, async (c) =>
adminAchievementRoutes.delete("/achievements/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// user_achievement 的外键同样是 NO ACTIONDjango 的级联在应用层),先清子表
const deleted = await db.transaction(async (tx) => {
await tx.delete(schema.userAchievement).where(eq(schema.userAchievement.achievementId, id))
return tx.delete(schema.achievement).where(eq(schema.achievement.id, id))
.returning({ id: schema.achievement.id })
})
// 解锁记录随成就一起没:user_achievement.achievement_id 是 CASCADE0010
const deleted = await db.delete(schema.achievement).where(eq(schema.achievement.id, id))
.returning({ id: schema.achievement.id })
if (deleted.length === 0) return failure(c, 404, "achievement-not-found", "成就不存在")
return success(c, null)
})

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,

View File

@@ -1,4 +1,5 @@
import {
STUDENT_ROLES,
TUTORIAL_READ_SECONDS,
learnExerciseAttemptSchema,
learnExerciseProgressListSchema,
@@ -24,9 +25,6 @@ import { queryInteger, rounded } from "../helpers"
*/
export const adminLearnRoutes = new Hono<AppEnv>()
/** 统计只算学生,不算老师和管理员自己 —— 和班级榜classroom.ts的口径一致 */
const STUDENT_ROLES = ["Regular User", "Student Admin"]
function tutorialTypeOf(value: string | undefined) {
return value === "c" ? "c" : "python"
}
@@ -61,7 +59,7 @@ function classCondition(value: string | null) {
function studentCondition(value: string | null) {
return and(
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, STUDENT_ROLES),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
classCondition(value),
)
}

View File

@@ -423,7 +423,15 @@ adminProblemRoutes.delete("/problems/:id", requireProblemPermission, async (c) =
})
/**
* 删题的共用实现。子表全是 NO ACTION 外键Django 的级联在应用层,得手工清。
* 删题的共用实现。
*
* 「有提交就不让删」这道守卫**不能去掉**submission.problem_id 是本次唯一**没有**
* 改成 CASCADE 的题目外键0010就是为了让 12 万条历史提交不会被一次误删带走。
* 守卫先拦,报的是人话;库级外键是最后一道保险。
* 其余子表problem_tags / problemset_problem / problemset_submission / reaction /
* flowchart_submission全部交给 CASCADE原先这里手抄了四条 delete、且漏了
* problemset_submission —— 那条漏清被上面的守卫挡着,一直没能真的发作。
*
* 测试用例目录**不删** —— 与旧后端一致(它把 rmtree 注释掉了)。
* 删错了还能从磁盘捞回来,而误删的测试数据没有别处备份;孤儿目录另有清理入口。
*/
@@ -433,13 +441,7 @@ async function deleteProblem(c: Parameters<typeof success>[0], id: number) {
if ((submissions?.value ?? 0) > 0) {
return failure(c, 409, "problem-has-submissions", "该题目已有提交记录,不能删除")
}
await db.transaction(async (tx) => {
await tx.delete(schema.problemTags).where(eq(schema.problemTags.problemId, id))
await tx.delete(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemId, id))
await tx.delete(schema.flowchartSubmission).where(eq(schema.flowchartSubmission.problemId, id))
await tx.delete(schema.reaction).where(eq(schema.reaction.problemId, id))
await tx.delete(schema.problem).where(eq(schema.problem.id, id))
})
await db.delete(schema.problem).where(eq(schema.problem.id, id))
return success(c, null)
}

View File

@@ -178,20 +178,9 @@ adminProblemSetRoutes.put("/problem-sets/:id/status", requireTeacher, async (c)
adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
// 五张子表全是 NO ACTION 外键Django 的级联在应用层。顺序不能反:
// user_badge 挂在 problemset_badge 上,得先于 badge 删
await db.transaction(async (tx) => {
const badges = await tx.select({ id: schema.problemsetBadge.id }).from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, row.id))
if (badges.length) {
await tx.delete(schema.userBadge).where(inArray(schema.userBadge.badgeId, badges.map((b) => b.id)))
}
await tx.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, row.id))
await tx.delete(schema.problemsetSubmission).where(eq(schema.problemsetSubmission.problemsetId, row.id))
await tx.delete(schema.problemsetProgress).where(eq(schema.problemsetProgress.problemsetId, row.id))
await tx.delete(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, row.id))
await tx.delete(schema.problemset).where(eq(schema.problemset.id, row.id))
})
// 子表交给库级 CASCADE0010problemset_{badge,problem,progress,submission} 直接连坐,
// user_badge problemset_badge 二级连坐。原先这里手抄五条 delete 并要求「顺序不能反」。
await db.delete(schema.problemset).where(eq(schema.problemset.id, row.id))
return success(c, null)
})
@@ -366,10 +355,8 @@ adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher
eq(schema.problemsetBadge.problemsetId, row.id),
)).limit(1)
if (!badge) return failure(c, 404, "badge-not-found", "奖章不存在")
await db.transaction(async (tx) => {
await tx.delete(schema.userBadge).where(eq(schema.userBadge.badgeId, badge.id))
await tx.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id))
})
// 获奖记录随奖章一起没user_badge.badge_id 是 CASCADE0010
await db.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id))
return success(c, null)
})

View File

@@ -81,7 +81,8 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
problemtagId: target.id,
})))
}
await tx.delete(schema.problemTags).where(eq(schema.problemTags.problemtagId, id))
// 旧标签上剩下的关系行随标签一起没:problem_tags.problemtag_id 是 CASCADE0010
// 上面那批 insert 已经把题目挂到 target 上了,这里删掉的只是旧的那一份关系。
await tx.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
return links.length
})
@@ -92,12 +93,9 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 中间表是 NO ACTION 外键,得先清关系再删标签
const deleted = await db.transaction(async (tx) => {
await tx.delete(schema.problemTags).where(eq(schema.problemTags.problemtagId, id))
return tx.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
.returning({ id: schema.problemTag.id })
})
// 中间表 problem_tags 随标签一起清problemtag_id 是 CASCADE0010
const deleted = await db.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
.returning({ id: schema.problemTag.id })
if (deleted.length === 0) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
return success(c, null)
})

View File

@@ -113,14 +113,11 @@ adminTutorialRoutes.put("/tutorials/:id/visibility", requireSuperAdmin, async (c
adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 必须先删练习。Django 的 on_delete=CASCADE 是**应用层**实现的,
// 库里的外键实际是 NO ACTION已核对 pg_constraint.confdeltype='a'
// 直接删教程会撞外键约束、变成 500。后台每个 DELETE 都要照此核一遍子表
const deleted = await db.transaction(async (tx) => {
await tx.delete(schema.exercise).where(eq(schema.exercise.tutorialId, id))
return tx.delete(schema.tutorial).where(eq(schema.tutorial.id, id))
.returning({ id: schema.tutorial.id })
})
// 练习与学习留痕都随教程一起没exercise.tutorial_id 与 tutorial_progress.tutorial_id
// 都是库级 CASCADE。**加子表时要回来想一遍该 CASCADE 还是该拦住**
// 别默认新表会自己连坐 —— 0010 只改了当时存在的那批外键
const deleted = await db.delete(schema.tutorial).where(eq(schema.tutorial.id, id))
.returning({ id: schema.tutorial.id })
if (deleted.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, null)
})

View File

@@ -1,4 +1,5 @@
import {
STUDENT_ROLES,
classComparisonRequestSchema,
classComparisonResponseSchema,
classComparisonSchema,
@@ -32,7 +33,7 @@ interface ClassUser {
async function loadClassUsers(classNames?: string[], gradePrefix?: string) {
const filters = [
eq(schema.user.isDisabled, false),
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
sql`${schema.user.className} is not null`,
]
if (classNames) filters.push(inArray(schema.user.className, classNames))

View File

@@ -1,4 +1,5 @@
import {
STUDENT_ROLES,
contestAccessSchema,
contestListSchema,
contestPasswordRequestSchema,
@@ -207,7 +208,7 @@ contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("rank
const contest = c.get("contest")!
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const where = and(eq(schema.acmContestRank.contestId, contest.id), inArray(schema.user.adminType, ["Regular User", "Student Admin"]), eq(schema.user.isDisabled, false))
const where = and(eq(schema.acmContestRank.contestId, contest.id), inArray(schema.user.adminType, [...STUDENT_ROLES]), eq(schema.user.isDisabled, false))
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id)).where(where),
db.select({ rank: schema.acmContestRank, user: schema.user, realName: schema.userProfile.realName })

View File

@@ -1,4 +1,9 @@
import { sampleUserSchema, type SampleUser } from "@oj2/contract"
import {
ADMIN_ROLES,
TEACHER_ROLES,
sampleUserSchema,
type SampleUser,
} from "@oj2/contract"
import type { AuthUser } from "../auth/session"
@@ -64,14 +69,9 @@ export function queryInteger(
return parsed
}
// 角色判断一律用白名单,对齐旧后端 `account/models.py:65-73` 的 is_admin_role /
// is_teacher_or_above 显式列举写法
//
// 不要写成黑名单(`adminType !== "Regular User"`):当前四种角色下两者等价,但将来新增
// 任何角色(助教、家长……)都会**默认拿到管理员权限**,包括 canViewSubmission 里的
//「看所有人代码」。加角色的人多半想不到要回来改这里,白名单则会默认拒绝。
const ADMIN_ROLES = ["Student Admin", "Teacher Admin", "Super Admin"]
export const TEACHER_ROLES = ["Teacher Admin", "Super Admin"]
// 角色白名单本身在 `@oj2/contract` 的 roles.ts那是全仓唯一的定义处
// 这里只是把它们包成吃 AuthUser 的谓词。为什么必须是白名单,见那边的注释
export { TEACHER_ROLES }
// 注意:不要再加 isRegularUser(user) 这类「是普通用户才受限」的判断 ——
// 匿名用户 user 为 null 时它返回 false守卫会整体短路匿名的权限反而大于登录学生。

View File

@@ -29,7 +29,6 @@ import {
canAccessContest,
contestStatus,
findVisibleContest,
ipAllowed,
isContestAdmin,
requireContestAccess,
type ContestEnv,
@@ -59,11 +58,6 @@ function objectValue(value: unknown): Record<string, unknown> {
: {}
}
function requestIp(c: { req: { header(name: string): string | undefined } }) {
const forwarded = c.req.header("x-forwarded-for")?.split(",")[0]?.trim()
return forwarded || c.req.header("x-real-ip") || null
}
submissionRoutes.post("/submissions", requireAuth, async (c) => {
const parsed = createSubmissionRequestSchema.safeParse(
await c.req.json().catch(() => null),
@@ -80,9 +74,6 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
const access = await canAccessContest(c, contest, "problems")
if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
if (contestStatus(contest) === "-1") return failure(c, 403, "contest-ended", "The contest has ended")
if (!isContestAdmin(c.get("user"), contest) && !ipAllowed(requestIp(c), contest.allowedIpRanges)) {
return failure(c, 403, "ip-not-allowed", "Your IP is not allowed in this contest")
}
contestId = contest.id
}
@@ -138,7 +129,6 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
const user = c.get("user")!
const submissionId = randomBytes(16).toString("hex")
const createTime = new Date().toISOString()
const ip = requestIp(c)
await db.insert(schema.submission).values({
id: submissionId,
@@ -153,7 +143,6 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
language: parsed.data.language,
shared: false,
statisticInfo: {},
ip,
contestId,
})
@@ -508,10 +497,9 @@ async function submissionDetail(id: string, user: AuthUser) {
? undefined
: await problemSetJoinTimes(user.id, [row.submission.problemId])
if (!canViewSubmission(user, row.submission, row.problem, row.contest, true, joinTimes)) return null
// info含每个测试点的 test_case 编号与 output_md5与 ip 只给管理员,对齐旧后端:
// info含每个测试点的 test_case 编号与 output_md5只给管理员对齐旧后端
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
// SubmissionSafeModelSerializer(exclude=("info", "contest", "ip")) 之间二选一,
// 把关的是角色,不是「是不是自己的提交」。
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
const full = isAdminRole(user)
return submissionDetailSchema.parse({
id: row.submission.id,
@@ -524,9 +512,7 @@ async function submissionDetail(id: string, user: AuthUser) {
language: row.submission.language,
shared: row.submission.shared,
statisticInfo: objectValue(row.submission.statisticInfo),
ip: full ? row.submission.ip : null,
// contest 也在旧后端的排除名单里exclude 的三个字段是 info / contest / ip
// 首轮修复只处理了 info 与 ip这里补齐。
// contest 也在旧后端的排除名单里,同样只给管理员
contestId: full ? row.submission.contestId : null,
problemId: row.submission.problemId,
// problem 表本来就 join 了,不额外查库

View File

@@ -0,0 +1,286 @@
import { eq, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { JudgeStatus, isAccepted } from "../judge/status"
import { objectValue } from "../routes/helpers"
/**
* 把反范式的计数列重算回与 submission 表一致。
*
* 这几个列不是缓存、是真值的副本:判题落库时由 `judge/run.ts` 的 persistResult 手工
* 加减,谁都没在事后核对过。已知的漂移来源是**重判**——`routes/submission.ts` 的
* rejudge 把 result 打回 PENDING 就重新入队,**不回退任何计数**,于是 persistResult
* 再加一次:重判一条题目的 submission_number 就永久多一。删提交、直接改库同理。
*
* 管的六个列:
* problem.submission_number / accepted_number / statistic_info
* user_profile.submission_number / accepted_number / acm_problems_status
*
* **不管**的acm_contest_rank比赛榜有自己的一套罚时累计重算要连带 submission_info
* 里每题的尝试次数口径复杂单独一件事、achievement.unlock_count0010 之后
* user_achievement 随成就级联,漂不了)、题单进度与奖章(走 backfill-problemsets
*
* 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate
*
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api recount
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api recount --apply
*
* ⚠️ **--apply 要挑没人做题的时候跑。** 差异是在事务外算的,写的是绝对值:算完到写完
* 之间要是有一条判完了,它那一笔加法会被覆盖掉。落库后的复核会把这种情况报成「仍有
* N 处差异」并以 1 退出,不会静默 —— 见到了重跑一次即可,但别在上课高峰按。
*/
/** 判完的提交才计数。PENDING / JUDGING 是在途状态persistResult 还没给它们记过账 */
const UNJUDGED = [JudgeStatus.PENDING, JudgeStatus.JUDGING]
type ProblemExpected = {
submissionNumber: number
acceptedNumber: number
statisticInfo: Record<string, number>
}
/**
* 题目侧的期望值。**比赛提交也算**——persistResult 更新 problem 这一段没有区分
* contestId只有 user_profile 那一段才分。
*/
async function expectedProblems() {
const rows = await db.execute<{ problem_id: number; result: number; n: number }>(sql`
select problem_id, result, count(*)::int as n
from submission
where result not in (${UNJUDGED[0]}, ${UNJUDGED[1]})
group by problem_id, result
`)
const expected = new Map<number, ProblemExpected>()
for (const row of rows) {
const current = expected.get(row.problem_id) ?? {
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
}
current.submissionNumber += row.n
if (isAccepted(row.result)) current.acceptedNumber += row.n
current.statisticInfo[String(row.result)] = row.n
expected.set(row.problem_id, current)
}
return expected
}
type ProfileExpected = {
submissionNumber: number
acceptedNumber: number
status: Record<string, Record<string, { status: number; _id: string }>>
}
/**
* 用户侧的期望值。三条口径都照抄 persistResult
*
* - submission_number只数**非比赛**的判完提交。
* - accepted_number只数非比赛、**去重到题**的首次通过(`acceptedNow && !wasAccepted`
* 等价于「这道题此前没通过过」,累计下来就是 AC 的不同题目数)。
* - acm_problems_status`{ problems / contest_problems: { 题号: { status, _id } } }`。
* 通过过就恒为 ACCEPTEDpersistResult 里 `wasAccepted` 之后不再改写);
* 从没通过过则取**最后一次**判完的结果。
*
* ⚠️ 「最后一次」这里按 create_time 排,而 persistResult 是按**判完的先后**写的。
* 两者在重判乱序时可能不同 —— 一条早提交的被重判、比晚提交的更晚判完,真值是那条早的,
* 本工具会算成那条晚的。这种情况只影响「从没 AC 过的题」显示成哪种失败,不影响任何计数,
* 所以按 create_time 算,不额外记判完时间。
*/
async function expectedProfiles() {
const totals = await db.execute<{ user_id: number; submissions: number; accepted: number }>(sql`
select user_id,
count(*)::int as submissions,
count(distinct problem_id) filter (where result in (${JudgeStatus.ACCEPTED}, ${JudgeStatus.AST_CHECK_FAILED}))::int as accepted
from submission
where result not in (${UNJUDGED[0]}, ${UNJUDGED[1]}) and contest_id is null
group by user_id
`)
const perProblem = await db.execute<{
user_id: number
is_public: boolean
problem_id: number
display_id: string
ever_accepted: boolean
last_result: number
}>(sql`
select s.user_id,
(s.contest_id is null) as is_public,
s.problem_id,
p._id as display_id,
bool_or(s.result in (${JudgeStatus.ACCEPTED}, ${JudgeStatus.AST_CHECK_FAILED})) as ever_accepted,
(array_agg(s.result order by s.create_time desc, s.id desc))[1] as last_result
from submission s
join problem p on p.id = s.problem_id
where s.result not in (${UNJUDGED[0]}, ${UNJUDGED[1]})
group by s.user_id, (s.contest_id is null), s.problem_id, p._id
`)
const expected = new Map<number, ProfileExpected>()
const blank = (): ProfileExpected => ({ submissionNumber: 0, acceptedNumber: 0, status: {} })
for (const row of totals) {
const current = expected.get(row.user_id) ?? blank()
current.submissionNumber = row.submissions
current.acceptedNumber = row.accepted
expected.set(row.user_id, current)
}
for (const row of perProblem) {
const current = expected.get(row.user_id) ?? blank()
const bucket = row.is_public ? "problems" : "contest_problems"
current.status[bucket] ??= {}
current.status[bucket]![String(row.problem_id)] = {
status: row.ever_accepted ? JudgeStatus.ACCEPTED : row.last_result,
_id: row.display_id,
}
expected.set(row.user_id, current)
}
return expected
}
/** 稳定序列化,用来比对 jsonb —— 键序不同不该被当成差异 */
function stable(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`
if (value && typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : 1))
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stable(v)}`).join(",")}}`
}
return JSON.stringify(value) ?? "null"
}
type Diff = { label: string; field: string; before: unknown; after: unknown }
type Plan = {
diffs: Diff[]
problemFixes: { id: number; value: ProblemExpected }[]
profileFixes: { id: number; value: ProfileExpected & { merged: Record<string, unknown> } }[]
}
/** 只算差异,不写库。预演和落库后的复核共用它 —— 两边口径必须是同一份代码 */
async function computePlan(): Promise<Plan> {
const [problems, profiles, expectedProblem, expectedProfile] = await Promise.all([
db.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
submissionNumber: schema.problem.submissionNumber,
acceptedNumber: schema.problem.acceptedNumber,
statisticInfo: schema.problem.statisticInfo,
}).from(schema.problem),
db.select({
id: schema.userProfile.id,
userId: schema.userProfile.userId,
submissionNumber: schema.userProfile.submissionNumber,
acceptedNumber: schema.userProfile.acceptedNumber,
acmProblemsStatus: schema.userProfile.acmProblemsStatus,
}).from(schema.userProfile),
expectedProblems(),
expectedProfiles(),
])
const plan: Plan = { diffs: [], problemFixes: [], profileFixes: [] }
for (const problem of problems) {
const want = expectedProblem.get(problem.id) ?? {
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
}
const label = `题目 ${problem.displayId}(id=${problem.id})`
const rows: Diff[] = []
if (problem.submissionNumber !== want.submissionNumber) {
rows.push({ label, field: "submission_number", before: problem.submissionNumber, after: want.submissionNumber })
}
if (problem.acceptedNumber !== want.acceptedNumber) {
rows.push({ label, field: "accepted_number", before: problem.acceptedNumber, after: want.acceptedNumber })
}
if (stable(objectValue(problem.statisticInfo)) !== stable(want.statisticInfo)) {
rows.push({ label, field: "statistic_info", before: problem.statisticInfo, after: want.statisticInfo })
}
if (rows.length) {
plan.diffs.push(...rows)
plan.problemFixes.push({ id: problem.id, value: want })
}
}
for (const profile of profiles) {
const want = expectedProfile.get(profile.userId) ?? {
submissionNumber: 0,
acceptedNumber: 0,
status: {},
}
// acm_problems_status 里除了 problems / contest_problems 之外的键原样保留 ——
// persistResult 只写这两个桶,别的键是从哪来的没人说得清,重算不该顺手抹掉。
const existing = objectValue(profile.acmProblemsStatus)
const merged: Record<string, unknown> = { ...existing }
delete merged.problems
delete merged.contest_problems
for (const [bucket, value] of Object.entries(want.status)) merged[bucket] = value
const label = `用户 ${profile.userId}`
const rows: Diff[] = []
if (profile.submissionNumber !== want.submissionNumber) {
rows.push({ label, field: "submission_number", before: profile.submissionNumber, after: want.submissionNumber })
}
if (profile.acceptedNumber !== want.acceptedNumber) {
rows.push({ label, field: "accepted_number", before: profile.acceptedNumber, after: want.acceptedNumber })
}
if (stable(existing) !== stable(merged)) {
const keys = new Set([...Object.keys(objectValue(existing.problems)), ...Object.keys(want.status.problems ?? {})])
rows.push({ label, field: "acm_problems_status", before: `${Object.keys(objectValue(existing.problems)).length}`, after: `${keys.size} 题(含比赛桶重建)` })
}
if (rows.length) {
plan.diffs.push(...rows)
plan.profileFixes.push({ id: profile.id, value: { ...want, merged } })
}
}
return plan
}
function report(plan: Plan) {
console.log(`发现 ${plan.diffs.length} 处不一致(题目 ${plan.problemFixes.length} 道 / 用户 ${plan.profileFixes.length} 人):`)
for (const diff of plan.diffs.slice(0, 40)) {
console.log(` ${diff.label} ${diff.field}: ${JSON.stringify(diff.before)}${JSON.stringify(diff.after)}`)
}
if (plan.diffs.length > 40) console.log(` ……另有 ${plan.diffs.length - 40}`)
}
/** 退出码0 = 一致或预演正常1 = 落库后复核仍有差异 */
export async function recount(options: { apply: boolean }) {
const plan = await computePlan()
if (plan.diffs.length === 0) {
console.log("计数列与 submission 表一致,没有要订正的。")
return 0
}
report(plan)
if (!options.apply) {
console.log("\n以上为预演没有写库。确认无误后加 --apply 落库。")
return 0
}
await db.transaction(async (tx) => {
for (const fix of plan.problemFixes) {
await tx.update(schema.problem).set({
submissionNumber: fix.value.submissionNumber,
acceptedNumber: fix.value.acceptedNumber,
statisticInfo: fix.value.statisticInfo,
}).where(eq(schema.problem.id, fix.id))
}
for (const fix of plan.profileFixes) {
await tx.update(schema.userProfile).set({
submissionNumber: fix.value.submissionNumber,
acceptedNumber: fix.value.acceptedNumber,
acmProblemsStatus: fix.value.merged,
}).where(eq(schema.userProfile.id, fix.id))
}
})
console.log(`\n已订正题目 ${plan.problemFixes.length} 道、用户 ${plan.profileFixes.length} 人,复核中……`)
// 复核跑的是同一份 computePlan。这里还剩差异说明口径本身有问题不是数据脏
// 必须让部署脚本看见非零退出码,而不是打一行字了事。
const after = await computePlan()
if (after.diffs.length === 0) {
console.log("复核通过:计数列与 submission 表一致")
return 0
}
console.error(`复核未通过,仍有 ${after.diffs.length} 处差异:`)
report(after)
return 1
}

View File

@@ -61,9 +61,7 @@ async function seed(account: SeedAccount) {
createTime: now,
adminType: account.adminType,
problemPermission: account.problemPermission,
openApi: false,
isDisabled: false,
sessionKeys: [],
})
.onConflictDoUpdate({
target: schema.user.username,

View File

@@ -103,25 +103,3 @@ export function requireContestAccess(
await next()
}
}
function ipv4Number(value: string) {
const parts = value.split(".").map(Number)
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null
return parts.reduce((result, part) => (result * 256 + part) >>> 0, 0)
}
export function ipAllowed(ip: string | null, ranges: unknown) {
if (!Array.isArray(ranges) || ranges.length === 0) return true
if (!ip) return false
const target = ipv4Number(ip.replace(/^::ffff:/, ""))
if (target === null) return false
return ranges.some((raw) => {
const value = typeof raw === "string" ? raw : raw && typeof raw === "object" ? String((raw as { value?: unknown }).value ?? "") : ""
const [address, prefixText = "32"] = value.split("/")
const network = ipv4Number(address ?? "")
const prefix = Number(prefixText)
if (network === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0
return (target & mask) === (network & mask)
})
}

View File

@@ -22,7 +22,6 @@ export async function getUserProfileById(userId: number, showRealName: boolean)
problemPermission: row.user.problemPermission,
createTime: row.user.createTime,
lastLogin: row.user.lastLogin,
openApi: row.user.openApi,
isDisabled: row.user.isDisabled,
className: row.user.className,
}),

View File

@@ -6,9 +6,12 @@ import { getOptions } from "./options"
*
* 参数与旧后端 `options/options.py:120` 的默认值逐字对齐:
* user: { capacity: 20, fill_rate: 0.03, default_capacity: 10 }
* ip: { capacity: 100, fill_rate: 0.1, default_capacity: 50 }
* 和旧后端一样,实际值以数据库 `throttling` 配置项为准,缺失时用上面的默认值。
*
* 旧后端还有一个按 IP 计数的桶OJ2 从来没调用过(机房整个班共用一个出口 IP
* 按 IP 限流等于按班限流),已随其余 IP 功能一并删除。库里 `throttling` 配置项
* 残留的 `ip` 键读不到就忽略,不用清。
*
* 旧实现在注释里写明「对于单个 key 的操作不是线程安全的」;这里改用 Lua 脚本做成原子操作,
* 算法和参数不变 —— 限流要挡的正是并发突发,读改写有竞态的话等于没挡。
*/
@@ -19,8 +22,7 @@ export type BucketConfig = {
default_capacity: number
}
export const throttlingDefaults: Record<"ip" | "user", BucketConfig> = {
ip: { capacity: 100, fill_rate: 0.1, default_capacity: 50 },
export const throttlingDefaults: Record<"user", BucketConfig> = {
user: { capacity: 20, fill_rate: 0.03, default_capacity: 10 },
}
@@ -73,7 +75,7 @@ function parseBucketConfig(value: unknown, fallback: BucketConfig): BucketConfig
}
}
export async function getBucketConfig(scope: "ip" | "user"): Promise<BucketConfig> {
export async function getBucketConfig(scope: "user"): Promise<BucketConfig> {
const fallback = throttlingDefaults[scope]
try {
const values = await getOptions(["throttling"])
@@ -88,7 +90,7 @@ export async function getBucketConfig(scope: "ip" | "user"): Promise<BucketConfi
export type ConsumeResult = { allowed: true } | { allowed: false; wait: number }
export async function consumeToken(
scope: "ip" | "user",
scope: "user",
identity: string,
num = 1,
): Promise<ConsumeResult> {

View File

@@ -175,7 +175,6 @@ export function editUser(user: User) {
problemPermission: user.problemPermission,
realName: user.realName ?? null,
isDisabled: user.isDisabled,
openApi: user.openApi,
password: user.password ?? "",
})
}
@@ -312,7 +311,6 @@ function toContestBody(contest: Contest | BlankContest) {
endTime: contest.endTime,
password: contest.password || null,
visible: contest.visible,
allowedIpRanges: contest.allowedIpRanges ?? [],
}
}

View File

@@ -58,7 +58,6 @@ const contest = reactive<BlankContest & { id: number }>({
endTime: "",
password: "",
visible: false,
allowedIpRanges: [],
})
async function getContestDetail() {
@@ -79,7 +78,6 @@ async function getContestDetail() {
contest.endTime = data.endTime
contest.password = data.password
contest.visible = data.visible
contest.allowedIpRanges = []
// 显示
startTime.value = Date.parse(data.startTime)

View File

@@ -183,7 +183,6 @@ function createNewUser() {
problemPermission: "None",
createTime: null,
lastLogin: null,
openApi: false,
isDisabled: false,
rawPassword: null,
className: null,

View File

@@ -132,18 +132,9 @@ export const CONTEST_STATUS: {
},
}
export const USER_TYPE = {
REGULAR_USER: "Regular User",
STUDENT_ADMIN: "Student Admin",
TEACHER_ADMIN: "Teacher Admin",
SUPER_ADMIN: "Super Admin",
}
export const PROBLEM_PERMISSION = {
NONE: "None",
OWN: "Own",
ALL: "All",
}
// 角色与题目权限的字符串在 `@oj2/contract` 的 roles.ts 定义,这里只是转出去,
// 调用方照旧写 `USER_TYPE.SUPER_ADMIN`。原先这里是这批字符串的第三份手抄副本。
export { USER_TYPE, PROBLEM_PERMISSION } from "@oj2/contract"
export const STORAGE_KEY = {
AUTHED: "authed",

View File

@@ -1,3 +1,4 @@
import { toAdminType } from "@oj2/contract"
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
import { User } from "./types"
import { USER_TYPE } from "./constants"
@@ -176,7 +177,9 @@ export function getUserRole(role: User["adminType"]): {
},
}
return roleMap[role] || roleMap[USER_TYPE.REGULAR_USER]
// role 是从接口来的裸字符串toAdminType 把认不出来的值归到「普通」,
// 归一逻辑和后端共用同一个函数(@oj2/contract 的 roles.ts
return roleMap[toAdminType(role)]
}
export function unique<T>(arr: T[]): T[] {

View File

@@ -360,7 +360,7 @@ export type {
/** 后台比赛。oj 侧的 contestSchema 永远不含 password后台要能看到告诉学生 */
export type Contest = AdminContest
/** 学生侧的比赛:不含 password / visible / allowedIpRanges */
/** 学生侧的比赛:不含 password / visible */
export type { Contest as OjContest } from "@oj2/contract"
export type BlankContest = Omit<

View File

@@ -1,5 +1,7 @@
import { z } from "zod"
import { adminTypeSchema, problemPermissionSchema } from "./roles"
import { achievementRaritySchema } from "./achievement"
import { rankProfileSchema } from "./account"
import { paginatedSchema, sampleUserSchema } from "./common"
@@ -206,7 +208,6 @@ export const adminUserSchema = z.object({
realName: z.string().nullable(),
createTime: z.string().nullable(),
lastLogin: z.string().nullable(),
openApi: z.boolean(),
isDisabled: z.boolean(),
// 明文密码。是有意保留的运营需求:老师要能查学生的密码。
// 只在超管专属的这一个接口下发,别往任何其它地方复制。
@@ -219,11 +220,10 @@ export const adminUserListSchema = paginatedSchema(adminUserSchema)
export const updateUserRequestSchema = z.object({
username: z.string().trim().min(1).max(32),
email: z.email().max(64),
adminType: z.enum(["Regular User", "Student Admin", "Teacher Admin", "Super Admin"]),
problemPermission: z.enum(["None", "Own", "All"]),
adminType: adminTypeSchema,
problemPermission: problemPermissionSchema,
realName: z.string().max(32).nullable().default(null),
isDisabled: z.boolean(),
openApi: z.boolean(),
// 空串表示不改密码,与旧 EditUserSerializer 的 allow_blank 一致
password: z.string().max(128).default(""),
})
@@ -322,7 +322,6 @@ export const adminContestSchema = z.object({
// 后台要能看到自己设的密码用来告诉学生oj 侧的 contestSchema 则永远不含它
password: z.string().nullable(),
visible: z.boolean(),
allowedIpRanges: z.array(z.string()),
createdBy: sampleUserSchema,
status: z.enum(["1", "0", "-1"]),
contestType: z.enum(["Public", "Password Protected"]),
@@ -339,7 +338,6 @@ export const createContestRequestSchema = z.object({
// 空串等同于「不设密码」,与旧 CreateConetestSeriaizer 的 allow_blank 一致
password: z.string().max(32).nullable().default(null),
visible: z.boolean(),
allowedIpRanges: z.array(z.string().max(32)).default([]),
})
export const updateContestRequestSchema = createContestRequestSchema

View File

@@ -13,7 +13,6 @@ export const sessionUserSchema = z.object({
problemPermission: z.string(),
createTime: z.string().nullable(),
lastLogin: z.string().nullable(),
openApi: z.boolean(),
isDisabled: z.boolean(),
className: z.string().nullable(),
})

View File

@@ -10,5 +10,6 @@ export * from "./contest"
export * from "./flowchart"
export * from "./problem"
export * from "./problemset"
export * from "./roles"
export * from "./site"
export * from "./submission"

View File

@@ -0,0 +1,79 @@
import { z } from "zod"
/**
* 角色与题目权限的字面量。**全仓唯一的定义处。**
*
* 这些字符串是**落库的值** —— Django choices 的遗产,带空格、带大小写,`user` 表
* 一千多行存的就是它们,前端也按这些值判断菜单。所以只能新增、不能改写已有的。
*
* 收到这里之前:`auth/middleware.ts` 和 `routes/helpers.ts` 各抄了一份
* ADMIN_ROLES / TEACHER_ROLES`["Regular User", "Student Admin"]` 这个学生口径
* 在四个文件里各写一遍,另有二十多处散落的 `=== "Super Admin"`。拼错一个字母
* TypeScript 一个字都不会说,只会在运行时静默放行或静默拒绝。
*/
export const ADMIN_TYPES = [
"Regular User",
"Student Admin",
"Teacher Admin",
"Super Admin",
] as const
export type AdminType = (typeof ADMIN_TYPES)[number]
export const adminTypeSchema = z.enum(ADMIN_TYPES)
export const PROBLEM_PERMISSIONS = ["None", "Own", "All"] as const
export type ProblemPermission = (typeof PROBLEM_PERMISSIONS)[number]
export const problemPermissionSchema = z.enum(PROBLEM_PERMISSIONS)
/**
* 三个分组一律是**白名单**,对齐旧后端 `account/models.py:65-73` 的显式列举写法。
*
* 不要改成黑名单(`!== "Regular User"`):当前四种角色下两者等价,但将来新增任何角色
* (助教、家长……)都会**默认拿到管理员权限**。白名单则默认拒绝,加角色的人必须
* 回到这里才能放行,而这正是应该被逼着想一遍的地方。
*/
/** 能进后台的三种 */
export const ADMIN_ROLES: readonly AdminType[] = ["Student Admin", "Teacher Admin", "Super Admin"]
/** 老师及以上 */
export const TEACHER_ROLES: readonly AdminType[] = ["Teacher Admin", "Super Admin"]
/**
* 学生口径:排行榜、班级榜、比赛榜、自学统计都只算这两种,教师和超管不入榜。
* 注意 Student Admin 算学生 —— 他要参赛、要上榜,只是多了个后台入口。
*/
export const STUDENT_ROLES: readonly AdminType[] = ["Regular User", "Student Admin"]
/**
* 把库里读出来的裸字符串收成联合类型。**认不出来的一律降成最低权限**
* 不抛错:脏数据不该让人登不上,但更不该让人凭一个拼错的角色名拿到权限。
*/
export function toAdminType(value: string): AdminType {
return (ADMIN_TYPES as readonly string[]).includes(value) ? (value as AdminType) : "Regular User"
}
export function toProblemPermission(value: string): ProblemPermission {
return (PROBLEM_PERMISSIONS as readonly string[]).includes(value)
? (value as ProblemPermission)
: "None"
}
/**
* 按名字取值的写法(`USER_TYPE.SUPER_ADMIN`),给前端用 —— 它到处是这种比较,
* 换成数组反而更难读。和上面 ADMIN_TYPES 是同一批字符串,
* `satisfies` 保证两边不会各自漂走。
*
* 原先 `apps/web/src/utils/constants.ts` 里另抄了一份,那是这四个字符串的第三份副本。
*/
export const USER_TYPE = {
REGULAR_USER: "Regular User",
STUDENT_ADMIN: "Student Admin",
TEACHER_ADMIN: "Teacher Admin",
SUPER_ADMIN: "Super Admin",
} as const satisfies Record<string, AdminType>
export const PROBLEM_PERMISSION = {
NONE: "None",
OWN: "Own",
ALL: "All",
} as const satisfies Record<string, ProblemPermission>

View File

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