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

@@ -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, '设置比赛时间失败');
}