From de719d8ec6a821c27d4b314ad36a4c58cf32eaa0 Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Wed, 21 Oct 2026 13:44:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=97=B6=E9=97=B4=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=8A=A0=E5=85=A5=E5=80=BC=E7=BA=A7=E4=B9=90=E8=A7=82?= =?UTF-8?q?=E9=94=81=20+=20=E5=B9=B6=E5=8F=91=E5=86=B2=E7=AA=81=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=204090=EF=BC=88B=E2=91=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 场景:两个管理员同时打开「时间设置」,甲先保存,乙手里的表单已过期, 乙再保存就会静默覆盖甲的结果 —— 此前 setTimes 直接 upsert,无任何检测。 - service/admin.js:setTimes(times, { expected }) 先**全量校验**、全部通过后才写 (边校验边 upsert 会留下"部分生效"的半成品)。判据选值级比对而非时间戳: competition_times.updated_at 没有 ON UPDATE CURRENT_TIMESTAMP,其刷新依赖 Sequelize 对 default 字面量的处理,语义不确定 ⇒ 建在它上面等于建在流沙上。 比对前归一到秒级,避免毫秒尾差造成恒定假冲突。 - controller/admin.js:冲突返回 **200 + code=4090**(⛔ 5xx —— 按 500 返回会让前端 拦截器提示"服务器错误",把"你刷新一下"掩盖成"系统挂了")。 - 不传 expectedTimes ⇒ 退回旧行为,不破坏既有调用方。 --- app/controller/admin.js | 12 +++++++- app/service/admin.js | 65 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/app/controller/admin.js b/app/controller/admin.js index fc1c154..8334f26 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -591,9 +591,19 @@ class AdminController extends Controller { } } - await service.admin.setTimes(times); + // B② 乐观锁:客户端可回传它读到的旧值(expectedTimes);与库里现值不一致 + // 即判冲突,避免后保存者静默覆盖先保存者的结果。不传 ⇒ 退回旧行为。 + const expectedTimes = (ctx.request.body && ctx.request.body.expectedTimes) || null; + await service.admin.setTimes(times, { expected: expectedTimes }); ctx.body = ctx.helper.success(null, '比赛时间设置成功'); } catch (err) { + // 🔴 并发冲突是业务性保护,不是 500:必须「200 + 业务码」。 + // 按 500 返回会让前端拦截器提示「服务器错误」,掩盖真实原因。 + if (err && err.name === 'TimesConflictError') { + ctx.logger.warn('[setTimes] 时间配置并发冲突: %j', err.conflicts); + ctx.body = ctx.helper.error(4090, '比赛时间已被他人修改,请刷新后重试'); + return; + } ctx.logger.error('设置比赛时间失败:', err); ctx.body = ctx.helper.error(500, '设置比赛时间失败'); } diff --git a/app/service/admin.js b/app/service/admin.js index 3082fb6..c6bfd28 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -29,6 +29,27 @@ const { inspectIdNumber } = require('../lib/idCard'); const OBJECTIVE_SCORE_PER_QUESTION = 1; const OBJECTIVE_TYPES = ['choice', 'judge']; +/** + * 时间配置并发冲突(B② 乐观锁)。 + * + * 场景:两个管理员同时打开「时间设置」,甲先保存,乙手里的表单已过期;乙再保存 + * 就会**静默覆盖**甲的结果。此前无任何检测 —— `setTimes` 直接 upsert。 + * + * 判据选「值级比对」而非时间戳:`competition_times.updated_at` 没有 + * `ON UPDATE CURRENT_TIMESTAMP`,其刷新完全依赖 Sequelize upsert 对 default + * 字面量的处理,语义不确定 ⇒ 建在它上面等于建在流沙上。值级比对只看 + * 「客户端读到的 start/end/deadline」与「库里现值」是否一致,确定、可测、零 DDL。 + * + * ⚠️ 只在**调用方显式传 expected** 时启用:不传 ⇒ 退回旧行为,不破坏既有调用方。 + */ +class TimesConflictError extends Error { + constructor(conflicts) { + super('比赛时间已被他人修改,请刷新后重试'); + this.name = 'TimesConflictError'; + this.conflicts = conflicts; + } +} + /** * 批量解析「小组编号」标签(R178 · 成绩管理「小组编号」列) * @@ -2059,9 +2080,51 @@ answeredCount, * 批量设置比赛时间 * @param {Array} times - 时间配置数组 [{ group, startTime, endTime, deadline }] */ - async setTimes(times) { + async setTimes(times, options = {}) { const { ctx } = this; + // ---- B② 乐观锁:先全量校验,全部通过后再写 ---- + // ⛔ 顺序不可反:若边校验边 upsert,冲突组之后的组可能已经落库, + // 留下「部分生效」的半成品状态,比直接失败更难收拾。 + const expected = options && options.expected; + if (expected && typeof expected === 'object') { + const rows = await ctx.model.CompetitionTime.findAll({ + attributes: ['group', 'start_time', 'end_time', 'deadline'], + raw: true, + }); + const curMap = new Map(rows.map(r => [r.group, r])); + // 归一到「秒级毫秒数」:与前端 'YYYY-MM-DDTHH:mm:ss' 的精度一致, + // 避免毫秒尾差造成恒定假冲突。 + const norm = (v) => { + if (v === null || v === undefined || v === '') return null; + const t = new Date(v).getTime(); + return Number.isNaN(t) ? null : Math.floor(t / 1000); + }; + const conflicts = []; + for (const t of times) { + const exp = expected[t.group]; + if (!exp) continue; + const cur = curMap.get(t.group) || {}; + const fields = [['start', 'start_time'], ['end', 'end_time'], ['deadline', 'deadline']]; + for (const pair of fields) { + const k = pair[0]; + const col = pair[1]; + if (!Object.prototype.hasOwnProperty.call(exp, k)) continue; + const want = norm(exp[k]); + const now = norm(cur[col]); + if (want !== now) { + conflicts.push({ + group: t.group, + field: k, + expected: exp[k] == null ? null : exp[k], + current: cur[col] == null ? null : cur[col], + }); + } + } + } + if (conflicts.length > 0) throw new TimesConflictError(conflicts); + } + for (const timeConfig of times) { const { group, startTime, endTime, deadline } = timeConfig; // 客观题抽取数量:缺省 50/20;数量不足时考试抽题会按题库实际数量自动封顶