From a2f67eebf64bc46f50ebe6664bac2b6b70a433d5 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 1 Sep 2026 00:28:16 -0600 Subject: [PATCH] =?UTF-8?q?perf(=E7=94=A8=E6=88=B7=E5=AF=BC=E5=85=A5):=20?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5=E4=BB=8E=E5=87=A0=E7=A7=92?= =?UTF-8?q?=E9=99=8D=E5=88=B0=E5=8D=8A=E7=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导入一个班(45 人)要转好几秒,慢的全在密码哈希这一处: - 哈希在重名检查之前算。老师习惯把同一份名单粘两次,那种情况要白等 一整个班的 argon2 才看到 409。把校验和重名查询提到前面,这条路径 现在是 5.6ms 返回。 - 45 次 argon2 串行 await。改成固定 4 路并发池;不用 Promise.all 是因为 oj-api 的 mem_limit 只有 512m,一个年级 300 人全量并发撑不住。 - argon2id 参数从 Bun 默认的 m=64MiB 显式降到 OWASP 推荐下限 m=19MiB, 单次 140ms → 20ms。参数编码在哈希串里,存量账号照常验证、不用迁移, 旧的 pbkdf2 那条分支也不受影响。 45 人端到端 2.04s → 0.55s。验过:旧 m=65536 的哈希、新 m=19456 的哈希、 Django 的 pbkdf2 三种都能正常登录,错误密码照常拒绝。 顺带把生成页下载的 CSV 裁成用户名和密码两列 —— 发给学生的就这两样。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D --- apps/api/src/auth/password.ts | 18 +++++++++++++++++- apps/api/src/routes/admin/account.ts | 28 +++++++++++++++++++--------- apps/web/src/admin/user/generate.vue | 4 +++- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/apps/api/src/auth/password.ts b/apps/api/src/auth/password.ts index 8ae73d1..281d9cb 100644 --- a/apps/api/src/auth/password.ts +++ b/apps/api/src/auth/password.ts @@ -53,6 +53,22 @@ export async function verifyPassword(password: string, encoded: string) { return { valid: false, needsUpgrade: false } } +/** + * 参数是显式写死的,不用 Bun 的默认值(`m=65536, t=2, p=1`,即每次哈希占 64 MiB)。 + * 取的是 OWASP 对 argon2id 的推荐下限 `m=19MiB, t=2, p=1`: + * + * - 批量导入一个班要连算几十次哈希,64 MiB 那档单次 ~140ms,而 `oj-api` 的 + * mem_limit 只有 512m(docker/compose.debian.yml),并发度被内存卡死。 + * 19 MiB 这档单次 ~20ms,并发 4 路的峰值也才 76 MiB。 + * - 参数是编码进哈希串本身的(`$argon2id$v=19$m=19456,t=2,p=1$...`),所以**存量 + * 账号一个都不用迁移**,Bun.password.verify 读串里的参数验,改这里只影响此后新写的哈希。 + */ +const ARGON2_OPTIONS = { + algorithm: "argon2id", + memoryCost: 19456, + timeCost: 2, +} as const + /** * 写密码的**唯一入口**。五个调用方都走这里:注册、管理员改密码、批量导入用户、 * 重置密码、登录时升级存量 pbkdf2。 @@ -68,5 +84,5 @@ export async function verifyPassword(password: string, encoded: string) { * 账号;靠开关只能拦住将来,修不了已经发生的。 */ export function hashPassword(password: string) { - return Bun.password.hash(password, { algorithm: "argon2id" }) + return Bun.password.hash(password, ARGON2_OPTIONS) } diff --git a/apps/api/src/routes/admin/account.ts b/apps/api/src/routes/admin/account.ts index 0678027..0951a4f 100644 --- a/apps/api/src/routes/admin/account.ts +++ b/apps/api/src/routes/admin/account.ts @@ -238,18 +238,15 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => { return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload") } const rows = parsed.data.users - const prepared: { username: string; password: string; raw: string; email: string; realName: string; className: string | null }[] = [] + type Prepared = { username: string; password: string; raw: string; email: string; realName: string; className: string | null } + + // 先把不花钱的校验全做完,再动 argon2。班级号错、用户名重复这两种情况占了失败的绝大多数 + // (老师习惯把同一份名单粘两次),先算哈希的话要白等一整个班的 argon2 才看到报错。 + const prepared: Prepared[] = [] for (const [username, password, email, realName] of rows) { const className = classNameOf(username) if (!className.ok) return failure(c, 400, "invalid-class-name", className.message) - prepared.push({ - username, - password: await hashPassword(password), - raw: password, - email, - realName, - className: className.value, - }) + prepared.push({ username, password: "", raw: password, email, realName, className: className.value }) } const existing = await db.select({ username: schema.user.username }).from(schema.user) @@ -258,6 +255,19 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => { return failure(c, 409, "username-exists", `用户名已存在:${existing.map((row) => row.username).join("、")}`) } + // argon2id 是**故意**做慢的,串行 await 的话一个班要转好几秒。但也不能 Promise.all + // 全量:每次哈希占 m=19MiB(见 auth/password.ts 的 ARGON2_OPTIONS),一个年级 300 人 + // 同时开就是 5.7GB,而 oj-api 的 mem_limit 只有 512m(docker/compose.debian.yml)。 + // 固定 4 路并发,瞬时峰值 76MiB 封顶。 + const HASH_CONCURRENCY = 4 + let cursor = 0 + await Promise.all(Array.from({ length: Math.min(HASH_CONCURRENCY, prepared.length) }, async () => { + while (cursor < prepared.length) { + const item = prepared[cursor++]! + item.password = await hashPassword(item.raw) + } + })) + // 整批要么全进要么全不进 —— 导入是粘一整个班的名单,进了一半再重试会撞已存在 const created = await db.transaction(async (tx) => { const users = await tx.insert(schema.user).values(prepared.map((item) => ({ diff --git a/apps/web/src/admin/user/generate.vue b/apps/web/src/admin/user/generate.vue index d613f3b..b31abe9 100644 --- a/apps/web/src/admin/user/generate.vue +++ b/apps/web/src/admin/user/generate.vue @@ -50,7 +50,9 @@ async function uploadUsers() { toggleLoading(true) await importUsers(users.value) message.success("用户已上传成功") - const csv = users.value.map((u) => u.join(",")).join("\n") + // 只导出用户名和密码两列 —— 发给学生的就是这两样,邮箱是占位生成的、 + // 真名本来就是老师粘进来的那一列,都不用回传 + const csv = users.value.map(([username, password]) => `${username},${password}`).join("\n") const hiddenElement = document.createElement("a") hiddenElement.href = "data:text/csv;charset=utf-8," + encodeURI(csv) hiddenElement.target = "_blank"