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:
@@ -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, '设置比赛时间失败');
|
||||
}
|
||||
|
||||
@@ -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;数量不足时考试抽题会按题库实际数量自动封顶
|
||||
|
||||
Reference in New Issue
Block a user