From 8237909d001e0efab661709a0a3fc24b2a63e508 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 7 Aug 2026 01:59:19 -0600 Subject: [PATCH] =?UTF-8?q?fix(=E9=98=B6=E6=AE=B53):=20=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=E6=80=A7=E5=AE=88=E5=8D=AB=E8=A1=A5=E4=B8=8A?= =?UTF-8?q?=E5=8C=BF=E5=90=8D=EF=BC=8C=E8=AF=A6=E6=83=85=E8=84=B1=E6=95=8F?= =?UTF-8?q?=EF=BC=8C=E6=8F=90=E4=BA=A4=E6=8E=A5=E5=8F=A3=E5=8A=A0=E9=99=90?= =?UTF-8?q?=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F3:`isRegularUser(user)` 对匿名用户(user 为 null)返回 false, `submission_list_show_all` 关闭时守卫整体短路 —— 匿名能看到全部提交, 权限反而大于登录学生。实测开关关闭时匿名 total=23、登录学生 total=0。 而关闭这个开关的典型场景正是考试。改为 `!isAdminRole(user)`(非管理员即受限), 并删掉 isRegularUser(全仓只有这一处调用,留着就是下一个坑)。 F4:提交详情把 info(含每个测试点的 test_case 编号与 output_md5)与 ip 按「是不是自己的提交」下发。旧后端 submission/views/oj.py 把关的是角色: is_admin_role() 决定用 SubmissionModelSerializer 还是 SubmissionSafeModelSerializer(exclude=("info", "contest", "ip"))。改为仅管理员可见。 F6:旧后端 SubmissionAPI.throttling 的 TokenBucket 没搬过来。 按旧参数重建(options/options.py:120 的 user 桶:capacity 20、fill_rate 0.03、 default_capacity 10,实际值仍以数据库 throttling 配置项为准), 落在 redis 上并改用 Lua 脚本做成原子操作 —— 旧实现自己注明「不是线程安全的」, 而限流要挡的正是并发突发。挂点位置与旧后端一致:比赛权限校验之后、取题目之前。 Co-Authored-By: Claude Opus 5 --- apps/api/src/http.ts | 2 +- apps/api/src/routes/submission.ts | 20 ++++- apps/api/src/services/throttling.ts | 113 ++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/services/throttling.ts diff --git a/apps/api/src/http.ts b/apps/api/src/http.ts index e75f331..73bd31e 100644 --- a/apps/api/src/http.ts +++ b/apps/api/src/http.ts @@ -6,7 +6,7 @@ export function success(c: Context, data: T, status = 200) { export function failure( c: Context, - status: 400 | 401 | 403 | 404 | 409 | 500 | 502, + status: 400 | 401 | 403 | 404 | 409 | 429 | 500 | 502, code: string, message: string, ) { diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 1be4a59..56e3392 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -28,7 +28,8 @@ import { } from "../services/contest" import { CodeFormatError, formatCode } from "../services/format-code" import { getBooleanOption } from "../services/options" -import { isAdminRole, isRegularUser, queryInteger, todayStart } from "./helpers" +import { consumeToken } from "../services/throttling" +import { isAdminRole, queryInteger, todayStart } from "./helpers" export const submissionRoutes = new Hono() @@ -69,6 +70,13 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => { contestId = contest.id } + // 限流,位置与旧后端 submission/views/oj.py 的 SubmissionAPI.post 一致: + // 比赛权限校验之后、取题目之前,按用户 id 消耗一个令牌。判题沙箱是有限资源。 + const throttle = await consumeToken("user", String(c.get("user")!.id)) + if (!throttle.allowed) { + return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`) + } + const [problem] = await db .select({ id: schema.problem.id, @@ -184,7 +192,11 @@ async function submissionDetail(id: string, user: AuthUser) { .leftJoin(schema.contest, eq(schema.submission.contestId, schema.contest.id)) .where(eq(schema.submission.id, id)).limit(1) if (!row || !canViewSubmission(user, row.submission, row.problem, row.contest)) return null - const full = isAdminRole(user) || row.submission.userId === user.id + // info(含每个测试点的 test_case 编号与 output_md5)与 ip 只给管理员,对齐旧后端: + // submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与 + // SubmissionSafeModelSerializer(exclude=("info", "contest", "ip")) 之间二选一, + // 把关的是角色,不是「是不是自己的提交」。 + const full = isAdminRole(user) return submissionDetailSchema.parse({ id: row.submission.id, createTime: row.submission.createTime, @@ -208,7 +220,9 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => { const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const user = c.get("user") - if (!(await getBooleanOption("submission_list_show_all", true)) && isRegularUser(user)) { + // 「非管理员即受限」,不能写成「是普通用户才受限」—— + // 后者对匿名用户(user 为 null)会短路,匿名反而能看到全部提交,权限大于登录学生。 + if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) { return success(c, submissionListSchema.parse({ results: [], total: 0 })) } const filters = [isNull(schema.submission.contestId)] diff --git a/apps/api/src/services/throttling.ts b/apps/api/src/services/throttling.ts new file mode 100644 index 0000000..40c1287 --- /dev/null +++ b/apps/api/src/services/throttling.ts @@ -0,0 +1,113 @@ +import { redis } from "../redis" +import { getOptions } from "./options" + +/** + * 令牌桶限流,搬运自旧后端 `utils/throttling.py` 的 TokenBucket。 + * + * 参数与旧后端 `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` 配置项为准,缺失时用上面的默认值。 + * + * 旧实现在注释里写明「对于单个 key 的操作不是线程安全的」;这里改用 Lua 脚本做成原子操作, + * 算法和参数不变 —— 限流要挡的正是并发突发,读改写有竞态的话等于没挡。 + */ + +export type BucketConfig = { + capacity: number + fill_rate: number + default_capacity: number +} + +export const throttlingDefaults: Record<"ip" | "user", BucketConfig> = { + ip: { capacity: 100, fill_rate: 0.1, default_capacity: 50 }, + user: { capacity: 20, fill_rate: 0.03, default_capacity: 10 }, +} + +// KEYS[1] = bucket key +// ARGV = capacity, fill_rate, default_capacity, now(seconds), num, ttl(seconds) +const CONSUME_SCRIPT = ` +local key = KEYS[1] +local capacity = tonumber(ARGV[1]) +local fill_rate = tonumber(ARGV[2]) +local default_capacity = tonumber(ARGV[3]) +local now = tonumber(ARGV[4]) +local num = tonumber(ARGV[5]) +local ttl = tonumber(ARGV[6]) + +local last_capacity = tonumber(redis.call('HGET', key, 'last_capacity')) +local last_timestamp = tonumber(redis.call('HGET', key, 'last_timestamp')) +if last_capacity == nil or last_timestamp == nil then + last_capacity = default_capacity + last_timestamp = now +end + +local current = last_capacity + fill_rate * (now - last_timestamp) +if current > capacity then current = capacity end + +local allowed = 0 +local wait = 0 +if current >= num then + current = current - num + allowed = 1 +else + wait = (num - current) / fill_rate +end + +redis.call('HSET', key, 'last_capacity', tostring(current), 'last_timestamp', tostring(now)) +redis.call('EXPIRE', key, ttl) +return { allowed, tostring(wait) } +` + +function parseBucketConfig(value: unknown, fallback: BucketConfig): BucketConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) return fallback + const raw = value as Record + const pick = (key: keyof BucketConfig) => + typeof raw[key] === "number" && Number.isFinite(raw[key]) && (raw[key] as number) > 0 + ? (raw[key] as number) + : fallback[key] + return { + capacity: pick("capacity"), + fill_rate: pick("fill_rate"), + default_capacity: pick("default_capacity"), + } +} + +export async function getBucketConfig(scope: "ip" | "user"): Promise { + const fallback = throttlingDefaults[scope] + try { + const values = await getOptions(["throttling"]) + const throttling = values.throttling + if (!throttling || typeof throttling !== "object" || Array.isArray(throttling)) return fallback + return parseBucketConfig((throttling as Record)[scope], fallback) + } catch { + return fallback + } +} + +export type ConsumeResult = { allowed: true } | { allowed: false; wait: number } + +export async function consumeToken( + scope: "ip" | "user", + identity: string, + num = 1, +): Promise { + const bucket = await getBucketConfig(scope) + // 桶全满需要 capacity / fill_rate 秒;留出余量后过期,避免残留 key 无限堆积。 + // 每次调用都会刷新 TTL,因此只有长时间无提交才会过期,届时桶早已回满, + // 重新按 default_capacity 初始化只会更严,不会放水。 + const ttl = Math.ceil(bucket.capacity / bucket.fill_rate) + 60 + const result = (await redis.eval( + CONSUME_SCRIPT, + 1, + `throttling:${scope}:${identity}`, + String(bucket.capacity), + String(bucket.fill_rate), + String(bucket.default_capacity), + String(Date.now() / 1000), + String(num), + String(ttl), + )) as [number, string] + if (Number(result[0]) === 1) return { allowed: true } + return { allowed: false, wait: Number(result[1]) || 0 } +}