feat(admin): 报名表导入补列名别名 + 只导参赛选手 + 判重口径(R32)
- COLUMN_ALIAS/pick():兼容上游列名(身份证号码/全名/队伍ID),上游导出表免改表头 - 只导身份=参赛选手:其余计入 result.ignored/ignoredBy(≠skipped),建团队同步过滤 - 判重改以身份证号为准(同证即同人);错误消息附「队伍/姓名/手机号」便于同名定位 - 修 teamUidCache 复用库内团队时写入 maxTeamUid 而非真实 uid 的 bug - phone 空值写 ''(列 NOT NULL) - 实测:1225 行 -> 成功 591 / 跳过 3(身份证非18位)/ 忽略 631 / 建队 297
This commit is contained in:
@@ -170,7 +170,9 @@ class AdminController extends Controller {
|
|||||||
// 清理临时文件
|
// 清理临时文件
|
||||||
try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ }
|
try { fs.unlinkSync(tempFilePath); } catch (e) { /* ignore */ }
|
||||||
|
|
||||||
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条`);
|
// 非参赛选手行按业务规则主动忽略(报名表含报名人/指导教师/领队),在提示中单列以免被误读为失败
|
||||||
|
const ignoredTip = result.ignored ? `,忽略非参赛选手 ${result.ignored} 条` : '';
|
||||||
|
ctx.body = ctx.helper.success(result, `导入完成:成功 ${result.imported} 条,跳过 ${result.skipped} 条${ignoredTip}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ctx.logger.error('Excel 导入失败:', err);
|
ctx.logger.error('Excel 导入失败:', err);
|
||||||
const msg = (err && err.message) ? err.message : '未知错误';
|
const msg = (err && err.message) ? err.message : '未知错误';
|
||||||
|
|||||||
@@ -199,6 +199,35 @@ class AdminService extends Service {
|
|||||||
return GROUP_ALIAS[s] || s;
|
return GROUP_ALIAS[s] || s;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 列名别名归一:上游赛事系统导出的「报名信息表」与后端内部列名契约不一致
|
||||||
|
// (「身份证号码」vs「身份证号」、「全名」vs「姓名」、「队伍ID」vs「队伍编号」),
|
||||||
|
// 直接导入会因取不到列而把整表判为「身份证号为空」全量跳过(R31 实测 0/1225)。
|
||||||
|
// 数组顺序即优先级;取首个非空值,使上游导出表无需人工改列名即可导入。
|
||||||
|
const COLUMN_ALIAS = {
|
||||||
|
身份证号: ['身份证号', '身份证号码', '身份证', 'id_number'],
|
||||||
|
姓名: ['姓名', '全名', 'name'],
|
||||||
|
队伍编号: ['队伍编号', '队伍ID', '队伍id', 'team_id', 'team_number'],
|
||||||
|
手机号: ['手机号', '手机号码', '联系电话', 'phone'],
|
||||||
|
组别: ['组别', 'group'],
|
||||||
|
身份: ['身份', 'identity'],
|
||||||
|
};
|
||||||
|
// 按别名顺序取首个非空值(统一 trim,返回字符串)
|
||||||
|
const pick = (row, key) => {
|
||||||
|
const aliases = COLUMN_ALIAS[key] || [key];
|
||||||
|
for (const k of aliases) {
|
||||||
|
const v = row[k];
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '') return String(v).trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 只导入「参赛选手」:报名审核表同一队伍内含报名人 / 指导教师(专家、教练) / 团队支持(领队) 等身份,
|
||||||
|
// 这些身份不建立参赛账号(不参与考试与作品提交)。归一化去空白后精确匹配,
|
||||||
|
// 避免因上游表格中的空白字符差异导致漏导。
|
||||||
|
const IMPORT_IDENTITY = '参赛选手';
|
||||||
|
const normalizeIdentity = (s) => String(s || '').replace(/\s+/g, '');
|
||||||
|
const isImportableIdentity = (s) => normalizeIdentity(s) === IMPORT_IDENTITY;
|
||||||
|
|
||||||
// 是否将本批次标记为测试数据(test_flag=1)。
|
// 是否将本批次标记为测试数据(test_flag=1)。
|
||||||
// 优先使用显式入参;未传时回退到运行模式:测试环境导入默认归类为测试数据,
|
// 优先使用显式入参;未传时回退到运行模式:测试环境导入默认归类为测试数据,
|
||||||
// 生产环境导入默认归类为生产数据(差异最小化,调用方无需区分环境)。
|
// 生产环境导入默认归类为生产数据(差异最小化,调用方无需区分环境)。
|
||||||
@@ -223,6 +252,9 @@ class AdminService extends Service {
|
|||||||
imported: 0,
|
imported: 0,
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
teamsCreated: 0,
|
teamsCreated: 0,
|
||||||
|
// 非「参赛选手」身份被主动忽略的行数(业务规则过滤,非异常,不计入 skipped/errors)
|
||||||
|
ignored: 0,
|
||||||
|
ignoredBy: {},
|
||||||
errors: [],
|
errors: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -255,8 +287,10 @@ class AdminService extends Service {
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const row = rows[i];
|
const row = rows[i];
|
||||||
const teamIdRaw = String(row['队伍编号'] || row['team_id'] || row['team_number'] || '').trim();
|
const teamIdRaw = pick(row, '队伍编号');
|
||||||
const group = normalizeGroup(row['组别'] || row['group']);
|
const group = normalizeGroup(pick(row, '组别'));
|
||||||
|
// 只为参赛选手建队:报名人/指导教师/领队不参与建队,避免留下无人参赛的空团队
|
||||||
|
if (!isImportableIdentity(pick(row, '身份'))) continue;
|
||||||
if (!teamIdRaw) continue;
|
if (!teamIdRaw) continue;
|
||||||
const cacheKey = teamIdRaw + '|' + group;
|
const cacheKey = teamIdRaw + '|' + group;
|
||||||
if (teamUidCache[cacheKey] !== undefined) continue;
|
if (teamUidCache[cacheKey] !== undefined) continue;
|
||||||
@@ -276,24 +310,36 @@ class AdminService extends Service {
|
|||||||
});
|
});
|
||||||
result.teamsCreated++;
|
result.teamsCreated++;
|
||||||
}
|
}
|
||||||
teamUidCache[cacheKey] = maxTeamUid;
|
// 写入团队自身的 team_uid:新建时为刚生成的 uid,库内复用时为真实 uid。
|
||||||
|
// (旧代码固定写 maxTeamUid,复用库内已存在团队时会把后续选手挂到错误的团队 uid)
|
||||||
|
teamUidCache[cacheKey] = team.get('team_uid');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const row = rows[i];
|
const row = rows[i];
|
||||||
const rowIndex = i + 2;
|
const rowIndex = i + 2;
|
||||||
|
|
||||||
|
// 身份过滤(事务之外):只导入参赛选手,其余身份计入 ignored 明细后跳过。
|
||||||
|
// 置于事务外可避免为被忽略行白开事务(本表约一半行属于此类)。
|
||||||
|
const identityRaw = pick(row, '身份');
|
||||||
|
if (!isImportableIdentity(identityRaw)) {
|
||||||
|
result.ignored++;
|
||||||
|
const label = identityRaw || '(空)';
|
||||||
|
result.ignoredBy[label] = (result.ignoredBy[label] || 0) + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 整行事务:队伍 + 选手 + 聚合记录要么一起成功,要么整体回滚
|
// 整行事务:队伍 + 选手 + 聚合记录要么一起成功,要么整体回滚
|
||||||
const t = await ctx.model.transaction();
|
const t = await ctx.model.transaction();
|
||||||
try {
|
try {
|
||||||
const idNumber = String(row['身份证号'] || row['id_number'] || '').trim();
|
const idNumber = pick(row, '身份证号');
|
||||||
const name = String(row['姓名'] || row['name'] || '').trim();
|
const name = pick(row, '姓名');
|
||||||
const phone = String(row['手机号'] || row['phone'] || '').trim();
|
const phone = pick(row, '手机号');
|
||||||
const group = normalizeGroup(row['组别'] || row['group']);
|
const group = normalizeGroup(pick(row, '组别'));
|
||||||
// 可选:队伍编号(人类可读,如 BENKE-01)。不填 = 单人参赛(不建团队、不触发团队锁)
|
// 可选:队伍编号(人类可读,如 BENKE-01)。不填 = 单人参赛(不建团队、不触发团队锁)
|
||||||
const teamIdRaw = String(row['队伍编号'] || row['team_id'] || row['team_number'] || '').trim();
|
const teamIdRaw = pick(row, '队伍编号');
|
||||||
// 可选:身份(队长/队员),默认队员
|
// 聚合记录的身份口径与作品/答题列表保持一致(作品列表统一记为「选手」)
|
||||||
const identity = String(row['身份'] || row['identity'] || '').trim() || '队员';
|
const identity = '选手';
|
||||||
|
|
||||||
if (!idNumber) {
|
if (!idNumber) {
|
||||||
result.errors.push({ row: rowIndex, message: '身份证号为空' });
|
result.errors.push({ row: rowIndex, message: '身份证号为空' });
|
||||||
@@ -308,24 +354,29 @@ class AdminService extends Service {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查身份证号是否已存在
|
// 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 team_uid(G1 锁前提)
|
||||||
|
let teamUid = null;
|
||||||
|
if (teamIdRaw) {
|
||||||
|
teamUid = teamUidCache[teamIdRaw + '|' + group];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重复判定:身份证号全局唯一,且业务上同一人不会分配两个身份证号
|
||||||
|
// ⇒ 身份证号相同即同一人(无需「队伍+姓名+手机号」组合键;同名不同证者各自导入)。
|
||||||
|
// 消息中附带「队伍 / 姓名 / 手机号」,便于人工在同名人员中定位到具体是哪一条。
|
||||||
const existing = await ctx.model.Contestant.findOne({
|
const existing = await ctx.model.Contestant.findOne({
|
||||||
where: { id_number: idNumber },
|
where: { id_number: idNumber },
|
||||||
transaction: t,
|
transaction: t,
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
result.errors.push({ row: rowIndex, message: `身份证号 ${idNumber} 已存在` });
|
result.errors.push({
|
||||||
|
row: rowIndex,
|
||||||
|
message: `身份证号 ${idNumber} 已存在(队伍 ${teamIdRaw || '(单人参赛)'} / ${name} / ${phone || '(无手机号)'})`,
|
||||||
|
});
|
||||||
result.skipped++;
|
result.skipped++;
|
||||||
await t.rollback();
|
await t.rollback();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 team_uid(G1 锁前提)
|
|
||||||
let teamUid = null;
|
|
||||||
if (teamIdRaw) {
|
|
||||||
teamUid = teamUidCache[teamIdRaw + '|' + group];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成密码:测试数据统一使用「默认口令」的 bcrypt 哈希;生产数据沿用
|
// 生成密码:测试数据统一使用「默认口令」的 bcrypt 哈希;生产数据沿用
|
||||||
// 「身份证后6位 SHA256」派生(保持原行为,不暴露统一弱口令)。
|
// 「身份证后6位 SHA256」派生(保持原行为,不暴露统一弱口令)。
|
||||||
const password = importAsTest
|
const password = importAsTest
|
||||||
@@ -336,7 +387,7 @@ class AdminService extends Service {
|
|||||||
const contestant = await ctx.model.Contestant.create({
|
const contestant = await ctx.model.Contestant.create({
|
||||||
id_number: idNumber,
|
id_number: idNumber,
|
||||||
name,
|
name,
|
||||||
phone: phone || null,
|
phone: phone || '',
|
||||||
group: group || 'zhongzhi',
|
group: group || 'zhongzhi',
|
||||||
password,
|
password,
|
||||||
activated: false,
|
activated: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user