diff --git a/app/controller/admin.js b/app/controller/admin.js index 1b47bf3..a7edc6e 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -170,7 +170,9 @@ class AdminController extends Controller { // 清理临时文件 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) { ctx.logger.error('Excel 导入失败:', err); const msg = (err && err.message) ? err.message : '未知错误'; diff --git a/app/service/admin.js b/app/service/admin.js index c84adf5..bfedabc 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -199,6 +199,35 @@ class AdminService extends Service { 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)。 // 优先使用显式入参;未传时回退到运行模式:测试环境导入默认归类为测试数据, // 生产环境导入默认归类为生产数据(差异最小化,调用方无需区分环境)。 @@ -223,6 +252,9 @@ class AdminService extends Service { imported: 0, skipped: 0, teamsCreated: 0, + // 非「参赛选手」身份被主动忽略的行数(业务规则过滤,非异常,不计入 skipped/errors) + ignored: 0, + ignoredBy: {}, errors: [], }; @@ -255,8 +287,10 @@ class AdminService extends Service { } catch (e) { /* ignore */ } for (let i = 0; i < rows.length; i++) { const row = rows[i]; - const teamIdRaw = String(row['队伍编号'] || row['team_id'] || row['team_number'] || '').trim(); - const group = normalizeGroup(row['组别'] || row['group']); + const teamIdRaw = pick(row, '队伍编号'); + const group = normalizeGroup(pick(row, '组别')); + // 只为参赛选手建队:报名人/指导教师/领队不参与建队,避免留下无人参赛的空团队 + if (!isImportableIdentity(pick(row, '身份'))) continue; if (!teamIdRaw) continue; const cacheKey = teamIdRaw + '|' + group; if (teamUidCache[cacheKey] !== undefined) continue; @@ -276,24 +310,36 @@ class AdminService extends Service { }); 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++) { const row = rows[i]; 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(); try { - const idNumber = String(row['身份证号'] || row['id_number'] || '').trim(); - const name = String(row['姓名'] || row['name'] || '').trim(); - const phone = String(row['手机号'] || row['phone'] || '').trim(); - const group = normalizeGroup(row['组别'] || row['group']); + const idNumber = pick(row, '身份证号'); + const name = pick(row, '姓名'); + const phone = pick(row, '手机号'); + const group = normalizeGroup(pick(row, '组别')); // 可选:队伍编号(人类可读,如 BENKE-01)。不填 = 单人参赛(不建团队、不触发团队锁) - const teamIdRaw = String(row['队伍编号'] || row['team_id'] || row['team_number'] || '').trim(); - // 可选:身份(队长/队员),默认队员 - const identity = String(row['身份'] || row['identity'] || '').trim() || '队员'; + const teamIdRaw = pick(row, '队伍编号'); + // 聚合记录的身份口径与作品/答题列表保持一致(作品列表统一记为「选手」) + const identity = '选手'; if (!idNumber) { result.errors.push({ row: rowIndex, message: '身份证号为空' }); @@ -308,24 +354,29 @@ class AdminService extends Service { continue; } - // 检查身份证号是否已存在 + // 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 team_uid(G1 锁前提) + let teamUid = null; + if (teamIdRaw) { + teamUid = teamUidCache[teamIdRaw + '|' + group]; + } + + // 重复判定:身份证号全局唯一,且业务上同一人不会分配两个身份证号 + // ⇒ 身份证号相同即同一人(无需「队伍+姓名+手机号」组合键;同名不同证者各自导入)。 + // 消息中附带「队伍 / 姓名 / 手机号」,便于人工在同名人员中定位到具体是哪一条。 const existing = await ctx.model.Contestant.findOne({ where: { id_number: idNumber }, transaction: t, }); if (existing) { - result.errors.push({ row: rowIndex, message: `身份证号 ${idNumber} 已存在` }); + result.errors.push({ + row: rowIndex, + message: `身份证号 ${idNumber} 已存在(队伍 ${teamIdRaw || '(单人参赛)'} / ${name} / ${phone || '(无手机号)'})`, + }); result.skipped++; await t.rollback(); continue; } - // 解析团队归属(如有):取自第一遍预建的缓存,确保同队同 team_uid(G1 锁前提) - let teamUid = null; - if (teamIdRaw) { - teamUid = teamUidCache[teamIdRaw + '|' + group]; - } - // 生成密码:测试数据统一使用「默认口令」的 bcrypt 哈希;生产数据沿用 // 「身份证后6位 SHA256」派生(保持原行为,不暴露统一弱口令)。 const password = importAsTest @@ -336,7 +387,7 @@ class AdminService extends Service { const contestant = await ctx.model.Contestant.create({ id_number: idNumber, name, - phone: phone || null, + phone: phone || '', group: group || 'zhongzhi', password, activated: false,