feat(admin): 时间配置加入值级乐观锁 + 并发冲突返回 4090(B②)

场景:两个管理员同时打开「时间设置」,甲先保存,乙手里的表单已过期,
乙再保存就会静默覆盖甲的结果 —— 此前 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 ⇒ 退回旧行为,不破坏既有调用方。
This commit is contained in:
2026-10-21 13:44:31 +08:00
parent 459f1adf6d
commit de719d8ec6
2 changed files with 75 additions and 2 deletions

View File

@@ -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;数量不足时考试抽题会按题库实际数量自动封顶