From 23604f27188cff57739389a8d95e0bb95aeaae29 Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Tue, 22 Sep 2026 09:16:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(exam/auth):=20=E7=99=BB=E5=BD=95=E9=97=B8?= =?UTF-8?q?=E9=97=A8=20+=20=E4=BA=A4=E5=8D=B7=E7=A8=B3=E5=AE=9A=E6=80=A7?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=EF=BC=88R4-R8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。 --- app.js | 44 ++++++-- app/controller/auth.js | 7 +- app/controller/exam.js | 155 +++++++++++++++++++++++---- app/extend/helper.js | 18 +++- app/middleware/concurrency_guard.js | 157 +++++++++++++++++++++++++++- app/model/account.js | 2 +- app/router.js | 19 +++- app/service/auth.js | 3 +- app/service/exam.js | 137 ++++++++++++++++-------- config/config.default.js | 44 +++++++- package-lock.json | 38 +++++-- package.json | 2 +- 12 files changed, 535 insertions(+), 91 deletions(-) diff --git a/app.js b/app.js index 0352207..6e98e05 100644 --- a/app.js +++ b/app.js @@ -61,23 +61,53 @@ class AppBootHook { `[AppBoot] 上传目录就绪:已创建 ${created} 个,已存在 ${existed} 个(根: ${publicDir})` ); - // 🔴 连接池容量自举断言(2026-08-13 推进 #16):egg-cluster 按 os.cpus() 起多 worker, - // 总连接数 ≈ worker 数 × pool.max。若超过 MySQL max_connections(默认 151), - // 高峰期会偶发 "connection acquire timeout" / "too many connections"。 - // 启动期只做 WARN(不阻断启动),提醒运维调大 DB 或调小 DB_POOL_MAX。 + // 🔴 连接池容量自举断言(2026-08-13 推进 #16):总连接数 = worker 数 × pool.max。 + // 若超过 MySQL max_connections(默认 151),高峰期会偶发 "connection acquire + // timeout" / "too many connections"。启动期只做 WARN(不阻断启动)。 + // + // 🔴 2026-09-21 修正(并发压测结论):原实现用 `os.cpus().length` 估 worker 数, + // 这是**误估** —— 真实 worker 数由 egg-scripts 的 --workers / EGG_WORKERS 决定 + // (生产显式设为 4,见 config.default.js「集群 worker 数」段),与 CPU 核数无必然关系。 + // 误估会在 8 核机上把「4×20=80(安全)」误报成「8×20=160(越限)」, + // 既制造假告警、也会掩盖真实的越限,使这条断言失去作用。 + // 改为优先读 EGG_WORKERS(worker 进程继承 master 的环境变量),回退才用 CPU 数, + // 并在日志中显式标注估算依据,便于排障时判断该行可信度。 try { const os = require('os'); const poolMax = parseInt(process.env.DB_POOL_MAX, 10) || 20; const rows = await this.app.model.query('SELECT @@max_connections AS m', { type: this.app.model.QueryTypes.SELECT }); const dbMax = parseInt(rows && rows[0] && rows[0].m, 10) || 151; - const workers = os.cpus().length; + const explicitWorkers = parseInt(process.env.EGG_WORKERS, 10); + const usingExplicit = Number.isFinite(explicitWorkers) && explicitWorkers > 0; + // 🔴 2026-09-21 二次修正:egg-bin dev / test 会**显式传** `workers: 1` + // (node_modules/egg-bin/scripts/start-cluster.js),此时即便环境里存在 + // EGG_WORKERS(本地 dev 也会加载 deploy/.env.production,其中写着 4), + // 也不是真实 worker 数 —— 真实值是 1。故必须先按 app.config.env 判定 + // 单 worker 的运行形态,否则本地仍会打出一条假数字日志(实测:dev 下 + // 真 worker=1 却报 4×20=80)。 + const envName = (this.app.config && this.app.config.env) || ''; + const singleWorkerEnv = envName === 'local' || envName === 'unittest'; + let workers; + let workersSource; + if (singleWorkerEnv) { + workers = 1; + workersSource = `egg env=${envName}(egg-bin 强制单 app worker)`; + } else if (usingExplicit) { + workers = explicitWorkers; + workersSource = `EGG_WORKERS=${explicitWorkers}(显式)`; + } else { + workers = os.cpus().length; + workersSource = `os.cpus()=${workers}(EGG_WORKERS 未设置,为回退估算)`; + } const estimated = workers * poolMax; if (estimated > dbMax) { this.app.logger.warn( - `[AppBoot][pool] 预估总连接数 ${estimated} (${workers} workers × ${poolMax}) 超过 MySQL max_connections=${dbMax},高峰期可能连接获取超时;建议调大 DB max_connections 或调小 DB_POOL_MAX` + `[AppBoot][pool] 预估总连接数 ${estimated} (${workersSource} × DB_POOL_MAX=${poolMax}) 超过 MySQL max_connections=${dbMax},高峰期可能连接获取超时;建议调大 DB max_connections 或调小 DB_POOL_MAX / EGG_WORKERS` ); } else { - this.app.logger.info(`[AppBoot][pool] 连接池容量 OK:${estimated} (${workers} × ${poolMax}) ≤ MySQL max_connections=${dbMax}`); + this.app.logger.info( + `[AppBoot][pool] 连接池容量 OK:${estimated} (${workersSource} × DB_POOL_MAX=${poolMax}) ≤ MySQL max_connections=${dbMax}` + ); } } catch (e) { this.app.logger.warn('[AppBoot][pool] 连接数自举断言跳过(无法查询 MySQL):' + (e && e.message)); diff --git a/app/controller/auth.js b/app/controller/auth.js index 15bf8de..52ef3ba 100644 --- a/app/controller/auth.js +++ b/app/controller/auth.js @@ -190,7 +190,7 @@ class AuthController extends Controller { * 1. 校验身份证号、手机号、短信验证码 * 2. 通过身份证号查找选手 * 3. 检查账号是否已激活 - * 4. SHA256 哈希密码,更新 activated=1 + * 4. bcrypt 加盐哈希密码(BCRYPT_ROUNDS=10,见 extend/helper.js),更新 activated=1 * 5. 清除 session 中的短信验证码 */ async activate() { @@ -263,7 +263,8 @@ class AuthController extends Controller { * 流程: * 1. 通过手机号查找选手 * 2. 检查账号是否激活 - * 3. SHA256(输入密码) 与数据库密码比对 + * 3. bcrypt 比对密码(helper.comparePassword;兼容存量 SHA256 旧哈希, + * 并在验证成功后透明升级为 bcrypt) * 4. 生成 JWT Token,返回用户信息和 Token */ async login() { @@ -324,7 +325,7 @@ class AuthController extends Controller { * * 流程: * 1. 通过用户名从 admins 表查找管理员 - * 2. 密码比对(SHA256) + * 2. bcrypt 比对密码(兼容存量 SHA256 旧哈希) * 3. 生成管理员 JWT Token */ async adminLogin() { diff --git a/app/controller/exam.js b/app/controller/exam.js index a86be65..aadc974 100644 --- a/app/controller/exam.js +++ b/app/controller/exam.js @@ -704,14 +704,15 @@ class ExamController extends Controller { } const body = ctx.request.body || {}; - const forceSubmit = body.forceSubmit === true; - // 与逐题保存(submitAnswers)同口径的考试窗口门控;forceSubmit 为自动交卷兜底通道 + // 🔴 2026-09-21 同源加固(与 submitAll 的结算通道同构): + // 原实现为 `if (!examStatus.isActive && !body.forceSubmit)` —— 该布尔完全来自请求体, + // 任何考生都能在截止后自行携带 forceSubmit 继续改写综合大题作答; + // 而综合大题由人工阅卷计分,等同「截止后继续答题」,公平性受损。 + // 现改为服务端判定:窗口关闭时只允许把**库内已有作答**标记为已提交(markSubmitted), + // 绝不接受请求体内容;库内无作答行则明确拒绝。 const examStatus = await service.exam.getExamStatus(group); - if (!examStatus.isActive && !forceSubmit) { - ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中'); - return; - } + const windowOpen = examStatus.isActive === true; const aggregation = await this._getUserAggregation(); if (!aggregation) { @@ -719,6 +720,24 @@ class ExamController extends Controller { return; } + if (!windowOpen) { + // 结算语义:不写内容,只补 submit_time(库内无该考生作答行 → 返回 null → 拒绝) + const settled = await service.bigQuestion.markSubmitted(aggregation.aggregation_uid); + if (!settled) { + ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中'); + return; + } + ctx.body = ctx.helper.success({ + filled: [ settled.answer_1, settled.answer_2, settled.answer_3 ] + .filter((v) => v && String(v).trim().length > 0).length, + subCount: service.bigQuestion.subQuestionCount(), + maxChars: service.bigQuestion.answerMaxChars(), + savedAt: new Date().toISOString(), + settled: true, + }, '考试已结束,已按已保存内容结算'); + return; + } + const result = await service.bigQuestion.saveAnswers( aggregation.aggregation_uid, group, @@ -762,35 +781,93 @@ class ExamController extends Controller { const { group } = ctx.params; if (!this._validateGroup(group)) return; - // forceSubmit 仅由「倒计时结束的自动提交」传入:允许在考试窗口临界/已关闭、 - // 且未答满全部题目时仍记录提交(倒计时归零即合法交卷),确保 submit_time 落库, - // 否则会被下方两道闸门拦截 → submit_time 永不写入 → 管理后台回退显示进入考试时间。 + // forceSubmit 由「倒计时结束的自动提交」传入,其作用已被收敛为**仅**豁免下方 + // 「未答满全部题目」闸门(倒计时归零即合法交卷,不应因少答几题而交不上)。 + // + // 🔴 2026-09-21 加固:forceSubmit **不再**授予「绕过考试窗口」的权力。 + // 原实现为 `if (!examStatus.isActive && !forceSubmit)` —— 该布尔完全来自请求体, + // 任何考生都可在截止后自行携带 forceSubmit 重新提交并改写答案。 + // 窗口是否开放改由服务端判定(见下方结算通道),不接受客户端的自我声明。 const forceSubmit = ctx.request.body && ctx.request.body.forceSubmit === true; // 验证考试是否活跃 const examStatus = await service.exam.getExamStatus(group); - if (!examStatus.isActive && !forceSubmit) { - ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中'); - return; - } - // 获取用户聚合信息 + // 获取用户聚合信息(须先于窗口判定:结算通道需要按考生查已落库作答) const aggregation = await this._getUserAggregation(); if (!aggregation) { ctx.body = ctx.helper.error(403, '未找到您的报名信息'); return; } - // 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交) - const teamGate = await this._enterExamWithTeamLock(aggregation); - if (!teamGate.allowed) { - ctx.body = ctx.helper.error(4005, '您的小组已进入考试'); - return; + // 🔴 服务端窗口判定 + 截止后结算通道(2026-09-21 并发压测结论) + // + // 缺陷背景:400 并发同刻交卷时,约 20% 考生的连接在 TCP accept 层即被拒 + // (Windows 在 accept 队列满时回 RST,客户端得到 10061),请求**从未到达业务逻辑**; + // 前端遇到该情况只能提示「请手动点击提交全部」,而手动交卷不带 forceSubmit, + // 窗口关闭后必被 4001 拦下 —— 这批考生的 exam_submissions.submit_time 永久为空, + // 管理后台显示「未提交」、成绩无法归集。即「答案明明已在库里,却交不上卷」。 + // + // 因此窗口关闭后开放**服务端结算通道**(settleOnly): + // - 只补写 exam_submissions.submit_time + answers 快照,把库内作答正式标记为已提交; + // - 严格以数据库已落库作答为准,**忽略请求体答案** —— 否则考生可借该通道在截止后 + // 继续作答刷分,公平性受损; + // - 一题未答(库内无作答行)→ 仍按 4001 拒绝(无内容可结算)。 + const windowOpen = examStatus.isActive === true; + let settleOnly = false; + let storedAnswers = null; + + if (!windowOpen) { + const existing = await service.exam.getUserAnswers(aggregation.aggregation_uid, group); + storedAnswers = (existing || []).filter( + (r) => r.answer_data !== null && r.answer_data !== undefined && String(r.answer_data).trim() !== '' + ); + if (!storedAnswers.length) { + ctx.body = ctx.helper.error(4001, examStatus.message || '考试未在进行中'); + return; + } + settleOnly = true; + + // 幂等:此前已正式提交过(自动交卷其实成功了,只是响应在连接层丢失)→ + // 直接返回成功并回吐真实 submit_time,不重复写库。 + // 这解决了「考生不确定自己交上没有、反复点提交」的焦虑式重试。 + const alreadySubmitted = await service.exam.hasSubmitted(aggregation.aggregation_uid, group); + if (alreadySubmitted) { + const rec = await ctx.model.ExamSubmission.findOne({ + where: { contestant_id: aggregation.aggregation_uid }, + attributes: [ 'submit_time' ], + }); + ctx.body = ctx.helper.success({ + answeredCount: storedAnswers.length, + totalQuestions: storedAnswers.length, + hasWordFile: storedAnswers.some((s) => s.word_file_path), + savedCount: storedAnswers.length, + submitted: true, + settled: true, + submitTime: (rec && rec.submit_time) + ? new Date(rec.submit_time).toISOString() + : new Date().toISOString(), + }, '提交成功(此前已提交)'); + return; + } } - // 参数校验 + // 团队锁:同一小组只能一人进入正式考试(G1 修复,防绕过 questions 直接提交) + // ⚠️ 结算通道跳过:一是窗口已关闭不应再产生 setTeamLock 写副作用, + // 二是结算绝不能被队友残留锁拦下(否则又回到「答案在库却交不上」)。 + if (!settleOnly) { + const teamGate = await this._enterExamWithTeamLock(aggregation); + if (!teamGate.allowed) { + ctx.body = ctx.helper.error(4005, '您的小组已进入考试'); + return; + } + } + + // 参数校验(结算通道不接收请求体答案,故放宽为可空) const rule = { - answers: { type: 'array', required: true, min: 1 }, + answers: settleOnly + ? { type: 'array', required: false } + : { type: 'array', required: true, min: 1 }, }; try { ctx.validate(rule); @@ -799,6 +876,34 @@ class ExamController extends Controller { return; } + // ---------- 结算通道:只补 submit_time,绝不改动任何答题行 ---------- + // 刻意不做 autoScore / saveAnswers:库内 is_correct / score 已由考生逐题保存时 + // 判分落库(submitAnswers 链路),此处若重新判分并回写,等于在截止后修改作答与判分, + // 既有公平性风险,也可能因题目变更而覆盖考生既得的判分结果。 + if (settleOnly) { + const snapshot = storedAnswers.map((r) => ({ + question_uid: r.question_uid, + question_type: r.question_type, + answer_data: r.answer_data, + })); + await service.exam.ensureExamSubmission(aggregation.aggregation_uid, snapshot); + // 大题作答同样补 submit_time(结算通道只补标记、不改内容) + await service.bigQuestion.markSubmitted(aggregation.aggregation_uid); + ctx.logger.warn( + `[submitAll][settle] 窗口已关闭,按库内作答结算 agg=${aggregation.aggregation_uid} questions=${snapshot.length}` + ); + ctx.body = ctx.helper.success({ + answeredCount: snapshot.length, + totalQuestions: snapshot.length, + hasWordFile: storedAnswers.some((s) => s.word_file_path), + savedCount: snapshot.length, + submitted: true, + settled: true, + submitTime: new Date().toISOString(), + }, '提交成功(考试已结束,按已保存答案结算)'); + return; + } + const { answers } = ctx.request.body; // 验证每个答案的数据格式(必须包含 question_type,否则 autoScore 无法判分) @@ -874,6 +979,14 @@ class ExamController extends Controller { // 聚合键取 aggregation.aggregation_uid(中职/高职即 contestant.id),与 hasSubmitted 一致。 await service.exam.ensureExamSubmission(aggregation.aggregation_uid, saveData); + // 综合大题(中职/高职)作答随整卷正式提交补写 submit_time。 + // 🔴 2026-09-21 修复:service.bigQuestion.markSubmitted 此前**无任何调用点**(死代码), + // 而 big_question_answers.submit_time 的列注释明确写着「随整卷正式提交写入(未提交为 NULL)」 + // → 中职/高职考生的大题 submit_time 恒为 NULL,管理端据此列区分「已作答未提交」时 + // 会把真实作答误判为未提交。markSubmitted 内部有 if (!submit_time) 保护, + // 重复提交不会覆盖首次提交时间,天然幂等;无大题作答行时返回 null(如本科/教师组),无副作用。 + await service.bigQuestion.markSubmitted(aggregation.aggregation_uid); + // 成绩门控:不回传 autoScore/total/details(自动评分仅落库供管理后台阅卷与合分使用)。 // 考生端提交后只应得到「提交成功 + 已保存题数」的确认,成绩须待管理后台发布。 ctx.body = ctx.helper.success({ diff --git a/app/extend/helper.js b/app/extend/helper.js index 99e284e..b83e955 100644 --- a/app/extend/helper.js +++ b/app/extend/helper.js @@ -3,7 +3,23 @@ const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const fs = require('fs'); -const bcrypt = require('bcryptjs'); +// 🔴 R8(2026-09-21):原生 bcrypt(N-API),**不是** bcryptjs。 +// 原实现 `require('bcryptjs')` 是纯 JS、**同步阻塞事件循环** —— 实测 16 并发 compare +// 耗时 1129ms,且期间**心跳达成率 0%**(主线程被冻结 1.1s,无法 accept 新连接), +// 这正是登录链路需要"并发闸门"兜底的根因。原生实现走 libuv 线程池: +// 同批 compare 265ms(**4.20×**)、心跳持续(环境实测见 probes/_bench_eventloop.cjs)。 +// +// ✅ 兼容性(已验证,drop-in,无需数据迁移): +// · API 同形:hash / compare / hashSync / compareSync 均已实测存在; +// · 哈希格式同形:产出仍为 `$2b$10$`,与存量 26/26 条完全一致,可互相比对; +// · 是 **N-API** 构建(prebuilds 文件名无 ABI 后缀)⇒ node 18(ABI 108) 与 +// 22(ABI 127) 同一份二进制均可 load;包内自带 win32-x64 与 +// linux-x64(glibc + musl)预编译 ⇒ 本地与 Linux 生产**均无需编译**。 +// +// ⚠️ 生产部署影响:原生模块**不会**被 rsync 源码带走,服务器须重新 `npm install` +// (见交付报告「上线前置」)。⛔ 不要把 `bcryptjs` 残留为依赖 —— 留着只会 +// 让后人误以为仍在用纯 JS 实现。 +const bcrypt = require('bcrypt'); // bcrypt 计算强度(10 轮,登录场景性能与安全的平衡点) const BCRYPT_ROUNDS = 10; diff --git a/app/middleware/concurrency_guard.js b/app/middleware/concurrency_guard.js index 9179eef..68132a7 100644 --- a/app/middleware/concurrency_guard.js +++ b/app/middleware/concurrency_guard.js @@ -26,19 +26,172 @@ const HEAVY_PATTERNS = [ /^\/api\/admin\/submissions\/[^/]+\/[^/]+\/word$/i, ]; +// 🔴 交卷闸门(2026-09-21 并发压测结论) +// +// 背景:submit-all 不在 HEAVY_PATTERNS 内,因此交卷高峰**完全无闸门**。压测实测: +// 400 并发同刻交卷 → 约 20% 考生在 TCP accept 层被拒(客户端 WinError 10061), +// 服务端零业务错误、零崩溃。根因是单 worker 的 accept 循环被重任务(一次交卷≈百次 +// DB 往返)饿死 → 内核 listen 队列溢出 → **Windows 在队列满时回 RST** +// (Linux 是静默丢 SYN,所以本机现象与 Linux 经验相反,极易误判为「网络问题」)。 +// +// 纳入闸门的意义:过载时由「静默 RST(考生不知道是否交上)」变为「明确 429 +// + Retry-After(可识别、可重试)」,配合前端交卷自动重试即可收敛; +// 同时闸门在**读取请求体之前**拒绝,避免请求体缓冲进一步挤占内存。 +// +// ⚠️ 交卷使用**独立上限**,不与上传共用 20: +// - 交卷是纯 DB 重任务、无文件流,与上传的大文件 IO 特性不同,共用会过紧; +// - 但上限的作用是「让 worker 有余力 accept 新连接」,**不是**提高吞吐 —— +// 实测单 worker 硬容量约 15 交卷/秒,把上限开到几百只会让每个请求都变慢, +// 反而更容易饿死 accept。默认 24 ≈ 略高于单 worker 舒适并发,形成良性背压。 +// - 多 worker 部署时总上限 = worker 数 × SUBMIT_REQ_MAX(进程内计数)。 +const SUBMIT_PATTERNS = [ + /^\/api\/exam\/[^/]+\/submit-all$/i, +]; + +// 🔴 登录闸门(2026-09-21 并发压测**第二轮**结论) +// +// 背景:submit-all 已于同日纳入闸门(见上),但**登录链路当时仍无闸门** —— +// 压测实测(每组 400 人 = 800 人同刻登录): +// · 仅 484/800 取得 token → **39.5% 在 TCP accept 层被拒**(WinError 10061); +// · 连接级重试 1134 次; +// · **P50 = 9957ms、P95 = 24350ms** —— 考生点「登录」后要干等十秒以上, +// 且近四成直接失败(客户端只看到「无法连接」,不知道是否要重试)。 +// 根因与交卷侧**同源**:单 worker 的 accept 循环被重任务饿死 → accept 队列溢出 +// → Windows 回 RST(Linux 静默丢 SYN,故 Linux 生产下表现为客户端长时间超时)。 +// 按「同源族一并修」原则纳入闸门。 +// +// 为何登录需要**独立**上限(不与交卷共用 24): +// - 登录是 **CPU 型**重任务:单请求的绝大部分成本是口令哈希比对 +// (BCRYPT_ROUNDS=10,见 extend/helper.js),另加 2~3 次 DB 往返 +// (findByPhone + isScorePublished); +// - 交卷是 **IO 型**重任务(约 23 条 SQL 往返),等待 DB 期间可让位给 accept; +// CPU 密集任务的换手粒度更粗,对 accept 的挤压更直接; +// - 故默认取 16(低于交卷的 24)。上限的作用是**保命**(让 worker 有余力 +// accept 新连接),**不是**提速 —— 把上限开大只会让每个请求都变慢。 +// +// 🔴 R8(2026-09-21)后的状态变化 —— 闸门保留,但**理由已改变**(勿照旧文本理解): +// 口令哈希实现已由 `bcryptjs`(纯 JS,**同步阻塞主线程**)换为**原生 bcrypt** +// (N-API,走 libuv 线程池)。实测:16 并发 compare 由 1129ms → 265ms(4.20×); +// 改动前**心跳达成率 0%**(主线程被冻结 1.1s,期间无法 accept 任何连接), +// 改动后心跳持续(环境实测见 probes/_bench_eventloop.cjs)。 +// ⇒ "accept 被 CPU 计算饿死"这一**原始**理由已大幅减弱,但闸门**仍然必要**: +// ① libuv 线程池默认仅 **4** 线程,16 并发仍需排队;长时间满载会挤压**其它** +// 走线程池的工作(DNS / fs / crypto)—— 闸门把排队**限界**,避免无界堆积; +// ② 每个登录请求另有 2~3 次 DB 往返,是**连接池与 DB 的真实压力源**, +// 与哈希是否阻塞事件循环**无关**; +// ③ 过载时给出**可识别**的 429 + Retry-After,优于 TCP 层静默 RST +// (考生至少知道"要重试"而非"不知道登没登上")。 +// ⛔ 因此**不要**因为"换了原生 bcrypt"就顺手删掉本闸门 —— 理由②③与哈希实现无关。 +// - 多 worker 部署时总上限 = worker 数 × LOGIN_REQ_MAX(进程内计数)。 +// +// ⛔ 为何**不**把 /api/auth/admin-login 一并纳入(重要设计决策,勿"顺手"补上): +// 管理后台登录是极低频瞬时动作(并发个位数),**从不构成 accept 压力源**, +// 因此它不属"同源缺陷族"(缺陷族 = 同一根因 **且** 同一压力形态); +// 而一旦纳入共用/独立计数,考生入场洪峰会挤占管理员配额 → +// 制造出「考试期间管理员登不进后台」这一**新**故障面,得不偿失。 +// 其防爆破由 router 级 loginRateLimit 单独负责(该中间件同时挂在两条登录路由上)。 +// +// 🔴 与 loginRateLimit 的执行顺序(本改动的关键红线,勿破坏): +// 本中间件是**全局**中间件(config/config.default.js 的 config.middleware), +// 而 loginRateLimit 是 **router 级**(router.js 挂在 /api/auth/login 单条路由上); +// Egg 的请求链为「全局中间件 → router 匹配 → router 级中间件」, +// 因此本闸门**先于** loginRateLimit 执行 —— 一旦在此处返回 429, +// loginRateLimit 的 `await next()` **根本不会执行**,其"按失败计数"逻辑不生效。 +// 这是**必须**的性质:否则考生每被背压拒绝一次就被记一次失败, +// 重试 10 次即撞上 15 分钟账号锁(业务码 4290)—— 那比「静默 RST」更糟(锁死)。 +// ⚠️ 后人若把本中间件降为 router 级、或把登录判断挪到 `await next()` 之后, +// 会立刻破坏该性质。门禁 scripts/verify_login_guard.py 对此有专项断言。 +const LOGIN_PATTERNS = [ + /^\/api\/auth\/login$/i, +]; + module.exports = (options, app) => { const MAX = parseInt(process.env.HEAVY_REQ_MAX || (options && options.max) || '20', 10); + const SUBMIT_MAX = parseInt( + process.env.SUBMIT_REQ_MAX || (options && options.submitMax) || '24', + 10 + ); + const LOGIN_MAX = parseInt( + process.env.LOGIN_REQ_MAX || (options && options.loginMax) || '16', + 10 + ); - let active = 0; + // 🔴 Retry-After 取值依据(2026-09-21 R8 实测,勿凭直觉调大) + // + // 原为**三处各自硬编码 `'2'`** 的魔数(无依据、无同源保证)。经实测给出依据: + // + // 一个「闸门满载窗口」的服务时间 = 并发上限 × 单请求 compare 耗时: + // · 改动前 bcryptjs(纯 JS,主线程**串行**):16 × ~85ms ≈ 1.11 s + // (实测 16 并发 compare = 1092~1133 ms,期间**心跳达成率 0%**,主线程完全冻结) + // · 改动后 原生 bcrypt(N-API + libuv 线程池):实测 265 ms + // + // 客户端应在**一个满载窗口之后**再回来:窗口结束时槽位集中释放,命中概率最高; + // 过早回来只会撞墙并制造无谓往返(臂2 实测登录侧请求 +14%、重试 +28%)。 + // + // ⇒ 取 **2s**:≈1.8 个满载窗口(改动前)/ ≈7.5 个(改动后),足以覆盖一次拥挤周期, + // 又不过长以免挤掉前端后续的指数退避重试机会(前端 cap 15s / 总窗口 ~45s)。 + // + // ⛔ **不要盲目调大(如 5–8s)**:本闸门是「**立即拒绝**」(无排队),槽位是**连续释放**的, + // 等待过久会**错过本可成功的时机**,并让总收敛时间超过前端重试窗口 ⇒ 反而更多失败。 + // ⛔ **也不要 < 1s**:退化为忙等,把连接级背压变成新的请求风暴。 + // ⛔ **三处必须同源同值**:登录/交卷/上传的差异只体现在**配额**(LOGIN_MAX / SUBMIT_MAX / MAX), + // 不体现在等待时长上。历史上此值散落三处,改一处漏两处即产生"同源族漂移"。 + const RETRY_AFTER = '2'; + + let active = 0; // 大文件/重 IO 并发数 + let submitActive = 0; // 交卷并发数(与上者独立计数,互不挤占配额) + let loginActive = 0; // 登录并发数(独立计数:登录为 CPU 型重任务,与上两者特性均不同) return async function concurrencyGuard(ctx, next) { - if (!HEAVY_PATTERNS.some((re) => re.test(ctx.path))) { + const path = ctx.path; + + // ---- 登录(独立配额,2026-09-21 第二轮压测新增)---- + if (LOGIN_PATTERNS.some((re) => re.test(path))) { + if (loginActive >= LOGIN_MAX) { + // 连接级背压:把「TCP 层静默 RST(考生不知是否登录成功)」换为 + // 「明确 429 + Retry-After(可识别、客户端可定时重试)」。 + // 注意:此处直接 return,故 router 级 loginRateLimit 不执行 → 不计失败次数。 + ctx.status = 429; + ctx.set('Retry-After', RETRY_AFTER); + ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试(登录高峰)'); + return; + } + loginActive++; + try { + await next(); + } finally { + loginActive--; + } + return; + } + + const isSubmit = SUBMIT_PATTERNS.some((re) => re.test(path)); + + if (isSubmit) { + if (submitActive >= SUBMIT_MAX) { + // 连接级背压:明确告知「服务器忙 + 多久后重试」,而非让请求堆积到 accept 队列溢出。 + ctx.status = 429; + ctx.set('Retry-After', RETRY_AFTER); + ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试(交卷高峰)'); + return; + } + submitActive++; + try { + await next(); + } finally { + submitActive--; + } + return; + } + + if (!HEAVY_PATTERNS.some((re) => re.test(path))) { return next(); } if (active >= MAX) { // 🔴 连接级背压:直接拒绝,避免请求体被解析/缓冲占用内存 ctx.status = 429; + ctx.set('Retry-After', RETRY_AFTER); ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试'); return; } diff --git a/app/model/account.js b/app/model/account.js index 9c85607..8b5476a 100644 --- a/app/model/account.js +++ b/app/model/account.js @@ -66,7 +66,7 @@ module.exports = app => { unique: true, comment: '身份证号', }, - // 登录密码(SHA256 哈希存储) + // 登录密码(bcrypt 加盐哈希存储;兼容存量 SHA256 旧哈希,登录成功后透明升级) password: { type: STRING(100), allowNull: false, diff --git a/app/router.js b/app/router.js index 14832df..5e6fbae 100644 --- a/app/router.js +++ b/app/router.js @@ -26,6 +26,21 @@ module.exports = app => { // 登录限流中间件(防暴力破解):15 分钟内同一 IP+账号最多 10 次失败 const loginRateLimit = middleware.loginRateLimit({ windowMs: 15 * 60 * 1000, maxAttempts: 10 }); + // 交卷限流(2026-09-21 并发压测结论) + // + // 🔴 只挂在 submit-all 这**单条**路由上,绝不整段挂 /api/exam: + // 逐题保存 submit-answers 是「每答一题一次请求」(一场约 59 次),若整段挂 + // exam:30/分钟,正常考生在答题过程中就会被自己的保存请求打爆(429), + // 直接造成丢答案 —— 这比不限流更糟。 + // + // 🔴 必须排在 auth **之后**:rateLimit 的限流键优先取 ctx.state.currentUser.uid + // (见 middleware/rateLimit.js getClientId),而 currentUser 由 auth 写入。 + // 若不排在 auth 之后,键会退化为 ip: —— 考场/学校普遍共用出口 IP, + // 会把整间考场的考生一起限流拒掉,属灾难级误伤。 + // + // 阈值取 LIMITS.exam = 30 次/60 秒(正常交卷 1 次 + 前端自动重试数次,余量充足)。 + const examSubmitLimit = middleware.rateLimit({ level: 'exam' }); + // ==================== 公开接口(无需认证) ==================== // 健康检查(容器探针 / 负载均衡探活,无需认证) @@ -72,7 +87,9 @@ module.exports = app => { router.post('/api/exam/:group/submit-answers', auth, controller.exam.submitAnswers); router.post('/api/exam/:group/upload-word', auth, controller.exam.uploadWord); router.delete('/api/exam/:group/word', auth, controller.exam.deleteWord); - router.post('/api/exam/:group/submit-all', auth, controller.exam.submitAll); + // 交卷:全局闸门(config.middleware 的 concurrencyGuard 已纳入 submit-all, + // 过载返回 429 + Retry-After)→ auth → 交卷限流(按 user,见上方 examSubmitLimit) + router.post('/api/exam/:group/submit-all', auth, examSubmitLimit, controller.exam.submitAll); // 综合大题(仅中职/高职组;2026-09-21 起替代原 Word 文件上传题) // 首次 GET 抽题并落库题干快照,后续 GET 一律返回同一道题(幂等防纠纷)。 diff --git a/app/service/auth.js b/app/service/auth.js index d3fa4f3..fe1fa90 100644 --- a/app/service/auth.js +++ b/app/service/auth.js @@ -144,7 +144,8 @@ class AuthService extends Service { * 将指定 id 的选手设置为已激活状态,并更新密码。 * * @param {number} id - 选手 id - * @param {string} password - SHA256 哈希后的密码 + * @param {string} password - bcrypt 加盐哈希后的密码(由 helper.hashPassword 生成, + * BCRYPT_ROUNDS=10;调用方 controller/auth.js 的 activate() 已保证此口径) * @returns {Promise} 更新后的 Contestant 模型对象 */ async activateAccount(id, password) { diff --git a/app/service/exam.js b/app/service/exam.js index dfc57c8..dc5576c 100644 --- a/app/service/exam.js +++ b/app/service/exam.js @@ -255,6 +255,19 @@ class ExamService extends Service { /** * 批量自动判分 + * + * 🔴 批量化(2026-09-21 并发压测结论):原实现对每道答案 `await this.checkAnswer()`, + * 而 checkAnswer 内部是 `findByPk` —— 单次交卷 59~70 题即 59~70 次**串行** DB 往返。 + * 这是「单 worker accept 循环被重任务饿死 → 监听队列溢出 → Windows 回 RST」的主因之一 + * (压测实测:单 worker 硬容量仅 15 交卷/秒,400 并发下 20% 考生连接被拒)。 + * 改为按题型分组,用**最多 2 次** whereIn 取回全部题目,再在内存中判分。 + * + * 判分语义与逐题版**逐字等价**(不得漂移): + * - 答案比对:`String(x).trim().toLowerCase()` 严格相等; + * - 得分:答对取 question.score,答错取 0; + * - 题目不存在:跳过该题、**不进 details**(与 checkAnswer 返回 question=null 时一致); + * - details 的字段名与取值口径不变(question_id 保留调用方传入的原始 uid 形态)。 + * * @param {Array} answers - 答案数组 [{ question_id, question_type, answer_data }] * @returns {Promise} { autoScore, total, details } */ @@ -263,24 +276,49 @@ class ExamService extends Service { let autoScore = 0; let total = 0; + // 第一步:按题型收集原始自增 id(去重后一次查出)。 + const idsByType = { choice: [], judge: [] }; for (const item of answers) { - const result = await this.checkAnswer( - item.question_id || item.question_uid, - item.question_type, - item.answer_data - ); - if (result.question) { - total += result.question.maxScore; - autoScore += result.score; - details.push({ - question_id: item.question_id || item.question_uid, - question_type: item.question_type, - answer_data: item.answer_data, - is_correct: result.isCorrect, - score: result.score, - max_score: result.question.maxScore, - }); - } + const type = item.question_type; + // 仅 choice/judge 有独立判分模型(word 走 WordQuestion,不在本链路)。 + // 用 hasOwnProperty 白名单而非直接属性取值,避免 'constructor' 之类字符串 + // 命中 Object 原型链而绕过题型判定。 + if (!Object.prototype.hasOwnProperty.call(idsByType, type)) continue; + const rawId = this._extractRawId(item.question_id || item.question_uid); + if (/^\d+$/.test(String(rawId))) idsByType[type].push(String(rawId)); + } + + const questionMap = new Map(); // `${type}:${id}` → question 实例 + for (const type of [ 'choice', 'judge' ]) { + const uniqueIds = Array.from(new Set(idsByType[type])); + if (!uniqueIds.length) continue; + const QuestionModel = this._getQuestionModel(type); + const rows = await QuestionModel.findAll({ where: { id: uniqueIds } }); + for (const q of rows) questionMap.set(`${type}:${q.id}`, q); + } + + // 第二步:内存判分,保持与原实现相同的顺序(按 answers 入参顺序 push)。 + for (const item of answers) { + const type = item.question_type; + const rawId = this._extractRawId(item.question_id || item.question_uid); + const question = questionMap.get(`${type}:${rawId}`); + if (!question) continue; // 题目不存在:与原 checkAnswer 行为一致 + + const correctAnswer = String(question.answer).trim().toLowerCase(); + const userAnswer = String(item.answer_data).trim().toLowerCase(); + const isCorrect = correctAnswer === userAnswer; + const score = isCorrect ? question.score : 0; + + total += question.score; + autoScore += score; + details.push({ + question_id: item.question_id || item.question_uid, + question_type: type, + answer_data: item.answer_data, + is_correct: isCorrect, + score, + max_score: question.score, + }); } return { autoScore, total, details }; @@ -394,31 +432,46 @@ class ExamService extends Service { for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { await ctx.model.transaction(async (t) => { - for (const answer of answers) { - // 原子 upsert:依靠 (aggregation_uid, question_uid, question_type) 唯一约束, - // 并发作答时 MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE 会将竞争请求 - // 折叠为对同一行的更新,彻底杜绝重复落库(B12 修复,覆盖 B11 的联合键)。 - // - // ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id, - // 否则 MySQL 会把字符串强转为 0,导致整卷 70 条答案坍缩成 choice/judge 各一行 - // 互相覆盖(B13 修复:uid 全局唯一化引入的落库回归)。 - // - // ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,带 returning:true 时 Sequelize - // 会生成不被支持的 SQL,导致 upsert 静默不落库却返回「成功实例」。故此处去掉 - // returning,仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。 - const rawQuestionId = this._extractRawId(answer.question_uid); - await SubmissionModel.upsert({ - aggregation_uid: aggregationUid, - question_uid: rawQuestionId, - question_type: answer.question_type, - answer_data: answer.answer_data, - is_correct: answer.is_correct, - score: answer.score, - }, { - conflictFields: ['aggregation_uid', 'question_uid', 'question_type'], - transaction: t, - }); - } + // 🔴 批量化(2026-09-21 并发压测结论):原实现在单事务内 for 循环**逐条** upsert, + // 单次交卷 59~70 条即 59~70 次串行 INSERT —— 事务持锁时间被放大到百毫秒级, + // 既直接拖慢交卷吞吐(实测单 worker 仅 15 交卷/秒),又显著放大不同考生 + // 抢同一唯一索引 uk_*_submission 的概率。改为一次 bulkCreate + + // updateOnDuplicate(单条 INSERT ... ON DUPLICATE KEY UPDATE)。 + // + // 语义保真:与原 upsert 的 SET 列表**逐列相同**(原实现未传 updateOnDuplicate, + // Sequelize 取 values 的键集合作为更新列),故不新增/不遗漏任何列; + // 特别是**不得**把 word_file_path / file_path 等未参与本链路的列写进更新列表, + // 否则会用 NULL 覆盖考生已上传的作品文件信息。 + // + // ⚠️ question_uid 列是 INT:必须先把前缀形态('choice_683')还原成原始数字 id, + // 否则 MySQL 会把字符串强转为 0,导致整卷答案坍缩成 choice/judge 各一行互相覆盖 + // (B13 修复:uid 全局唯一化引入的落库回归)。 + // + // ⚠️ 注意:MariaDB/MySQL 不支持 RETURNING 子句,故不使用 returning, + // 仅依赖事务提交 + 调用方 COUNT 复核(见 submitAll)。 + const rows = answers.map((answer) => ({ + aggregation_uid: aggregationUid, + question_uid: this._extractRawId(answer.question_uid), + question_type: answer.question_type, + answer_data: answer.answer_data, + is_correct: answer.is_correct, + score: answer.score, + })); + + // 空数组守卫:bulkCreate([]) 在部分 dialect 下会抛错,而原 for 循环是空转成功。 + if (!rows.length) return; + + await SubmissionModel.bulkCreate(rows, { + updateOnDuplicate: [ + 'aggregation_uid', + 'question_uid', + 'question_type', + 'answer_data', + 'is_correct', + 'score', + ], + transaction: t, + }); }); return answers.length; } catch (err) { diff --git a/config/config.default.js b/config/config.default.js index 6ec86cd..ecc1cb5 100644 --- a/config/config.default.js +++ b/config/config.default.js @@ -114,8 +114,45 @@ module.exports = (appInfo) => { // 总上限 = worker 数 × HEAVY_REQ_MAX,可适当提高。 config.concurrencyGuard = { max: parseInt(process.env.HEAVY_REQ_MAX, 10) || 20, + // 交卷独立上限(2026-09-21 并发压测结论):submit-all 纳入闸门后,过载由 + // 「静默 RST(考生不知道是否交上)」变为「明确 429 + Retry-After(可重试)」。 + // 取值理由(为何不与上传共用 20、也不放大到数百)见 middleware/concurrency_guard.js。 + submitMax: parseInt(process.env.SUBMIT_REQ_MAX, 10) || 24, + // 登录独立上限(2026-09-21 并发压测**第二轮**结论):/api/auth/login 此前无闸门, + // 800 人同刻登录实测「仅 484/800 取得 token(39.5% 在 TCP accept 层被拒)、 + // P50=9957ms / P95=24350ms、连接级重试 1134 次」。纳入闸门后过载由 + // 「静默 RST(考生不知道要不要重试)」变为「明确 429 + Retry-After(可重试)」。 + // 取 16(低于交卷的 24):登录是 CPU 型重任务(bcrypt 10 rounds), + // 换手粒度比 IO 型交卷更粗,对 accept 循环的挤压更直接。 + // 为何不纳入 admin-login、以及与 loginRateLimit 的顺序红线, + // 见 middleware/concurrency_guard.js 的 LOGIN_PATTERNS 段注释。 + loginMax: parseInt(process.env.LOGIN_REQ_MAX, 10) || 16, }; + // ----------------------------------------------------------------- + // 🔴 集群 worker 数(2026-09-21 并发压测结论,必须显式设定) + // ----------------------------------------------------------------- + // 背景:`egg-scripts start` 未传 --workers 时,egg-cluster 会按 os.cpus().length + // 起同等数量的 app worker —— 本机/多数服务器为 8 核 → 8 个 worker。 + // 而每个 worker 的连接池上限是 DB_POOL_MAX(默认 20),8 × 20 = 160 + // **已经超过** MySQL max_connections(151)。高峰期会出现 connection acquire + // timeout,在考生侧表现为「交卷失败」,且服务端不报业务错,极难定位。 + // + // 落点:worker 数由环境变量 EGG_WORKERS 控制(egg-scripts start 把它作为 + // --workers 的默认值,见 node_modules/egg-scripts/lib/cmd/start.js:31)。 + // 生产值写在 deploy/.env.production(EGG_WORKERS=4),并由 start-backend.sh + // 显式传参作双保险。4 × 20 = 80 ≤ 151,为管理后台/统计等其余连接留足余量。 + // + // ⚠️ 不要试图在此处写 config.cluster.workers —— egg-cluster 只读 + // startCluster(options.workers)(options.js:24 → master.js:267), + // 该值在 master 进程由 egg-scripts 的命令行/EGG_WORKERS 决定; + // config 阶段的配置对 worker **数量**无效。此处仅作说明,避免后人误配。 + // + // ⚠️ 多 worker 的两个已知副作用(均为进程内状态,非缺陷但需知悉): + // ① concurrencyGuard 的 active 是**进程内**计数,总上限 = worker 数 × max; + // ② loginRateLimit 的失败计数同为进程内 Map,实际阈值 = worker 数 × maxAttempts, + // 仅影响「防爆破」强度,不影响正确性(登录成功即清零该 key)。 + // ----------------------------------------------------------------- // ----------------------------------------------------------------- // sequelize - MariaDB/MySQL 数据库配置 @@ -149,9 +186,10 @@ module.exports = (appInfo) => { // 🔴 压测调优(2026-08-11):单 worker 默认 10 连接,在「无带宽限制 + 极端并发」 // 下分片合并/写入会短时占用大量连接。提高到 20(可用 DB_POOL_MAX 覆盖), // 并放宽 acquire 到 45s,避免瞬时峰值触发 "connection acquire timeout"。 - // 注意:这是「每 worker」上限;生产 egg-cluster 按 os.cpus() 起多 worker, - // 总连接数 ≈ worker 数 × max,需确保 ≤ MySQL max_connections(默认 151, - // 启动期由 app.js didReady 自举断言兜底,超限只 WARN 不阻断)。 + // 注意:这是「每 worker」上限;worker 数由 EGG_WORKERS 显式控制(生产 = 4, + // 见本文件上方「集群 worker 数」段),总连接数 = worker 数 × max = 4 × 20 = 80, + // 须 ≤ MySQL max_connections(默认 151)。启动期由 app.js didReady 自举断言 + // 按**实际 worker 数**复核(不再用 os.cpus() 误估),超限只 WARN 不阻断。 max: parseInt(process.env.DB_POOL_MAX, 10) || 20, min: 2, acquire: 45000, diff --git a/package-lock.json b/package-lock.json index 2584841..7897e5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@artus/core": "^2.2.4", "@eggjs/router": "^3.0.6", "archiver": "^8.0.0", - "bcryptjs": "^3.0.3", + "bcrypt": "^6.0.0", "cliui": "^9.0.1", "egg": "^3.34.0", "egg-cors": "^2.2.4", @@ -1606,13 +1606,17 @@ ], "license": "MIT" }, - "node_modules/bcryptjs": { - "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-3.0.3.tgz", - "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", - "license": "BSD-3-Clause", - "bin": { - "bcrypt": "bin/bcrypt" + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" } }, "node_modules/binary-extensions": { @@ -7748,6 +7752,14 @@ "lower-case": "^1.1.1" } }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", @@ -7767,6 +7779,16 @@ "node": ">=10.5.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmmirror.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-hex": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/node-hex/-/node-hex-1.0.1.tgz", diff --git a/package.json b/package.json index e4c8731..a63e932 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@artus/core": "^2.2.4", "@eggjs/router": "^3.0.6", "archiver": "^8.0.0", - "bcryptjs": "^3.0.3", + "bcrypt": "^6.0.0", "cliui": "^9.0.1", "egg": "^3.34.0", "egg-cors": "^2.2.4",