Files
competition-website/database/migrations/021-unique-submission-indexes.js

100 lines
4.2 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
/**
* 迁移 021:为答题提交表增加唯一约束,杜绝并发作答重复落库(Bug #12 / B12)
*
* 背景(B12):原 saveAnswers 使用「先 findOne 再 create」的非原子写法,且
* (aggregation_uid, question_uid, question_type) 仅为普通索引。同一用户并发提交
* 时,多个请求都 find 不到记录,于是各自 create,导致同一题被插入多行
* (实测:3 路并发产生 57 行 / 20 题,exam_submissions 出现 3 条同选手记录)。
* 重复行会污染 answeredCount、判分明细与成绩恢复,属数据一致性缺陷。
*
* 修复:
* 1) zhongzhi_submissions / gaozhi_submissions 的复合索引升级为唯一索引
* uk_{table}_submission (aggregation_uid, question_uid, question_type);
* service.saveAnswers 改用 upsert(conflictFields=[...]),由 MySQL 的
* INSERT ... ON DUPLICATE KEY UPDATE 在并发下原子折叠为单行更新。
* 2) exam_submissions 的 contestant_id 索引升级为唯一索引
* uk_exam_submission_contestant,配合 service 中并发安全的
* _ensureExamSubmissionSafe,保证每选手仅一条提交记录。
*
* 注意:up/down 均先去重(保留最小 id)再加减索引,确保幂等且不会因历史脏数据失败。
*/
module.exports = {
up: async (queryInterface) => {
// ---- 去重:同 (aggregation_uid, question_uid, question_type) 仅留最小 id ----
await queryInterface.sequelize.query(`
DELETE t FROM zhongzhi_submissions t
WHERE t.id NOT IN (
SELECT min_id FROM (
SELECT MIN(id) AS min_id FROM zhongzhi_submissions
GROUP BY aggregation_uid, question_uid, question_type
) AS keep
)
`);
await queryInterface.sequelize.query(`
DELETE t FROM gaozhi_submissions t
WHERE t.id NOT IN (
SELECT min_id FROM (
SELECT MIN(id) AS min_id FROM gaozhi_submissions
GROUP BY aggregation_uid, question_uid, question_type
) AS keep
)
`);
// exam_submissions:每 contestant_id 仅留最小 id
await queryInterface.sequelize.query(`
DELETE t FROM exam_submissions t
WHERE t.id NOT IN (
SELECT min_id FROM (
SELECT MIN(id) AS min_id FROM exam_submissions
GROUP BY contestant_id
) AS keep
)
`);
// ---- zhongzhi ----
await queryInterface.removeIndex('zhongzhi_submissions', 'idx_zhongzhi_submissions_agg_qtype').catch(() => {});
await queryInterface.addIndex('zhongzhi_submissions', {
name: 'uk_zhongzhi_submission',
unique: true,
fields: ['aggregation_uid', 'question_uid', 'question_type'],
}).catch(() => {});
// ---- gaozhi ----
await queryInterface.removeIndex('gaozhi_submissions', 'idx_gaozhi_submissions_agg_qtype').catch(() => {});
await queryInterface.addIndex('gaozhi_submissions', {
name: 'uk_gaozhi_submission',
unique: true,
fields: ['aggregation_uid', 'question_uid', 'question_type'],
}).catch(() => {});
// ---- exam_submissions ----
await queryInterface.removeIndex('exam_submissions', 'idx_exam_submissions_contestant_id').catch(() => {});
await queryInterface.addIndex('exam_submissions', {
name: 'uk_exam_submission_contestant',
unique: true,
fields: ['contestant_id'],
}).catch(() => {});
},
down: async (queryInterface) => {
await queryInterface.removeIndex('zhongzhi_submissions', 'uk_zhongzhi_submission').catch(() => {});
await queryInterface.addIndex('zhongzhi_submissions', {
name: 'idx_zhongzhi_submissions_agg_qtype',
fields: ['aggregation_uid', 'question_uid', 'question_type'],
});
await queryInterface.removeIndex('gaozhi_submissions', 'uk_gaozhi_submission').catch(() => {});
await queryInterface.addIndex('gaozhi_submissions', {
name: 'idx_gaozhi_submissions_agg_qtype',
fields: ['aggregation_uid', 'question_uid', 'question_type'],
});
await queryInterface.removeIndex('exam_submissions', 'uk_exam_submission_contestant').catch(() => {});
await queryInterface.addIndex('exam_submissions', {
name: 'idx_exam_submissions_contestant_id',
fields: ['contestant_id'],
});
},
};