Files
OJ2/apps/web/src/admin/user/generate.vue
yuetsh a2f67eebf6
Some checks failed
Deploy / deploy (push) Has been cancelled
perf(用户导入): 批量导入从几秒降到半秒
导入一个班(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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-09-01 00:28:16 -06:00

110 lines
3.1 KiB
Vue

<script setup lang="ts">
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MAX_VALUE,
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_MIN_VALUE,
} from "utils/constants"
import { importUsers } from "../api"
const message = useMessage()
const prefix = ref(0)
const rawInput = ref("")
const [loading, toggleLoading] = useToggle()
const users = shallowRef<string[][]>([])
function generateUsers() {
if (!rawInput.value || !rawInput.value.trim()) {
message.info("请填写相关内容")
return false
}
// 后端 get_class_name 只认合法位数的班级号,位数不对会整批拒绝导入,
// 这里先拦一道,省得填完一屏用户名才被打回来
if (
prefix.value &&
(prefix.value < CLASS_NAME_MIN_VALUE || prefix.value > CLASS_NAME_MAX_VALUE)
) {
message.error(
`班级号 ${prefix.value} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
)
return false
}
let className = !!prefix.value ? `ks${prefix.value}` : ""
rawInput.value = rawInput.value.trim()
const inputs = rawInput.value.split("\n")
users.value = inputs.map((u, i) => {
const username = className + u
let password = ""
for (let j = 0; j < 6; j++) {
password += "123456789".charAt(Math.floor(Math.random() * 9))
}
const realName = u
const email = `${className}.${i + 1}@example.com`
return [username, password, email, realName]
})
return true
}
async function uploadUsers() {
try {
toggleLoading(true)
await importUsers(users.value)
message.success("用户已上传成功")
// 只导出用户名和密码两列 —— 发给学生的就是这两样,邮箱是占位生成的、
// 真名本来就是老师粘进来的那一列,都不用回传
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"
hiddenElement.download = prefix.value + ".csv"
hiddenElement.click()
hiddenElement.remove()
} catch (err: any) {
message.error("上传失败:" + err.data)
} finally {
toggleLoading(false)
}
}
async function submit() {
const ok = generateUsers()
if (ok) {
uploadUsers()
}
}
</script>
<template>
<n-space>
<n-flex vertical>
<n-flex align="center">
<div style="width: 18px; font-size: 1.2rem">ks</div>
<n-input-number
style="width: 170px"
v-model:value="prefix"
clearable
:max="CLASS_NAME_MAX_VALUE"
:min="0"
:placeholder="`班级号(${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位)`"
/>
</n-flex>
<n-input
type="textarea"
class="inputArea"
placeholder="每行一个用户名"
v-model:value="rawInput"
/>
<n-button type="warning" :loading="loading" @click="submit">
确定导入
</n-button>
</n-flex>
</n-space>
</template>
<style scoped>
.inputArea {
width: 200px;
height: 500px;
}
</style>