Files
competition-website/app/lib/teacherBackfill.js
qubiaobiao 459f1adf6d feat(admin): R176-R180 公示表与新闻撤回 + 管理端多管理员权限(C 层)+ 交卷冻结
- R176 成绩公示表引擎(lib/publicitySheet.js + lib/assets/publicity-template.xlsx 官方模板基底)
  与指导教师回填(lib/teacherBackfill.js);赛项名称/编号改为配置项
- R177 新闻「发布 ↔ 撤回」互斥:撤回只置 is_draft=1,不删除数据
- R178 C 层 RBAC:lib/adminPermissions.js(13 权限点 / 70 条路由规则,唯一 SoT)
  + middleware/auth.js 三段判定(默认拒绝 · is_super 单点 · token_version)
  + controller/service/adminUser.js + lib/auditLog.js + model/adminAuditLog.js
- R178 B1 交卷冻结:service.exam.assertNotSubmitted(bizCode 4006 单一判据源)
- R178 B④ 提交口径拆分:formalSubmitted / submitted / answeredCount 三字段
- R178 成绩总览补小组编号:resolveTeamLabels(与选手管理同源,含兜底与三态)
- R181 生产迁移配套:admins +7 列、admin_audit_logs(scripts/migrate_r178_admin_rbac.py)
2026-10-20 11:06:29 +08:00

288 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* 指导教师回填 —— 纯判定层(R176)
*
* 背景:`contestants` 原本没有任何「指导教师」承载字段,而成绩公示表
* (`7-2026一带一路暨金砖大赛成绩公示表-参考模版.xlsx`)第 4 列必须填写。
* 报名表里其实有指导教师行,但导入流程按业务规则只导入「参赛选手」身份
* (`service/admin.js` 的 IMPORT_IDENTITY),教师行被计入 `ignored` 丢弃。
*
* 本模块只负责**从报名表二维数组里把指导教师找出来、按小组归并**,
* 不碰数据库、不碰文件、不依赖 Egg。落库由 `service/admin.js#backfillTeachers` 完成。
*
* 🔴 为什么不直接复用既有导入:`importContestants` 对已存在的身份证号一律 skip
* (`result.skipped++`),所以「重导一次」不会更新任何字段。回填必须是独立通路。
*
* 🔴 为什么必须带 group:`teams.team_uid` 仅组内唯一、可跨组重号(实测本科组也有
* team_uid=1),只按 teamId 归并会把别的组的教师挂到本组头上。
*/
/** 教师身份白名单(归一化:去掉所有空白字符后比对) */
const TEACHER_IDENTITIES = [
'指导教师',
'指导老师',
'指导专家',
'指导教师(专家)',
'指导教师(专家)',
'指导教师(教练)',
'指导教师(教练)',
'教练',
'导师',
];
/** 组别别名 → 系统 key。⚠️ 名字带 TEACHER_ 前缀,避免与 service/admin.js 的 GROUP_ALIAS 撞名 */
const TEACHER_GROUP_ALIAS = {
中职: 'zhongzhi',
中职组: 'zhongzhi',
高职: 'gaozhi',
高职组: 'gaozhi',
本科: 'benke',
本科组: 'benke',
教师: 'jiaoshi',
教师组: 'jiaoshi',
zhongzhi: 'zhongzhi',
gaozhi: 'gaozhi',
benke: 'benke',
jiaoshi: 'jiaoshi',
};
/** 报名表列名别名(与导入契约同源,但这里只取回填必需的 4 列) */
const TEACHER_COLUMN_ALIAS = {
name: ['姓名', '全名', 'name'],
teamId: ['队伍编号', '队伍ID', '队伍id', 'team_id', 'team_number'],
group: ['组别', 'group'],
identity: ['身份', 'identity'],
};
const ROW_STATUS = {
TEACHER: 'TEACHER',
NOT_TEACHER: 'NOT_TEACHER',
TEACHER_NO_TEAM: 'TEACHER_NO_TEAM',
TEACHER_NO_NAME: 'TEACHER_NO_NAME',
TEACHER_NO_GROUP: 'TEACHER_NO_GROUP',
BLANK_ROW: 'BLANK_ROW',
};
/**
* 去掉所有空白字符(含全角空格、不换行空格、零宽字符)。
* ⛔ 不能只 trim:上游表格里「指导 教师」「指导教师」混用很常见,
* 只 trim 会把前者判成非教师身份而整行丢掉。
*/
function squash(v) {
if (v === null || v === undefined) return '';
return String(v)
.replace(/[\u3000\u00a0\u200b\u200c\u200d\ufeff]/g, '')
.replace(/\s+/g, '');
}
/** 组别归一;无法识别时返回空串(调用方据此判定"组别缺失") */
function normalizeGroup(v) {
const s = squash(v);
if (!s) return '';
return TEACHER_GROUP_ALIAS[s] || TEACHER_GROUP_ALIAS[s.toLowerCase()] || '';
}
/** 是否教师身份。归一化后精确命中白名单才算,⛔ 不做包含匹配(避免「非指导教师」被误判) */
function isTeacherIdentity(v) {
const s = squash(v);
if (!s) return false;
return TEACHER_IDENTITIES.indexOf(s) >= 0;
}
/** 队伍编号归一:只 trim 首尾空白与零宽字符,⛔ 不折叠大小写(编号大小写不敏感会串组) */
function normalizeTeamId(v) {
if (v === null || v === undefined) return '';
return String(v).replace(/[\u200b\u200c\u200d\ufeff]/g, '').replace(/^[\s\u3000\u00a0]+|[\s\u3000\u00a0]+$/g, '');
}
/** 在给定行里按别名找列下标,返回 -1 表示未命中 */
function findColumn(row, aliases) {
if (!Array.isArray(row)) return -1;
for (let i = 0; i < row.length; i++) {
const cell = squash(row[i]);
if (!cell) continue;
for (const a of aliases) {
if (cell === squash(a)) return i;
}
}
return -1;
}
/**
* 解析报名表 AOA,提取「小组 → 指导教师姓名列表」。
*
* @param {Array<Array>} aoa 二维数组(xlsx sheet_to_json header:1 的产物)
* @param {Object} [opts]
* @param {number} [opts.maxHeaderScan=10] 表头最多向下扫描的行数
* @returns {{
* ok: boolean, code: string, message: string,
* teams: Array<{group: string, teamId: string, names: string[], rowNumbers: number[]}>,
* stats: Object, errors: Array<Object>
* }}
*/
function extractTeachers(aoa, opts) {
const maxHeaderScan = (opts && opts.maxHeaderScan) || 10;
const empty = { teams: [], stats: { rows: 0, teachers: 0, notTeachers: 0, blank: 0 }, errors: [] };
if (!Array.isArray(aoa) || aoa.length === 0) {
return Object.assign({ ok: false, code: 'EMPTY_FILE', message: '文件内容为空' }, empty);
}
// ---- 1) 定位表头 ----
let headerRowIdx = -1;
let colName = -1;
let colTeam = -1;
let colGroup = -1;
let colIdentity = -1;
for (let i = 0; i < Math.min(aoa.length, maxHeaderScan); i++) {
const r = aoa[i];
const cn = findColumn(r, TEACHER_COLUMN_ALIAS.name);
const ci = findColumn(r, TEACHER_COLUMN_ALIAS.identity);
if (cn >= 0 && ci >= 0) {
headerRowIdx = i;
colName = cn;
colIdentity = ci;
colTeam = findColumn(r, TEACHER_COLUMN_ALIAS.teamId);
colGroup = findColumn(r, TEACHER_COLUMN_ALIAS.group);
break;
}
}
if (headerRowIdx < 0) {
return Object.assign({
ok: false,
code: 'HEADER_NOT_FOUND',
message: '前 10 行内没有同时包含「姓名」与「身份」的表头,请确认上传的是报名表',
}, empty);
}
if (colTeam < 0) {
return Object.assign({
ok: false,
code: 'TEAM_COLUMN_MISSING',
message: '报名表缺少「队伍编号」列,无法把指导教师归到具体小组',
}, empty);
}
if (colGroup < 0) {
return Object.assign({
ok: false,
code: 'GROUP_COLUMN_MISSING',
message: '报名表缺少「组别」列,无法限定同组(team_uid 跨组重号,不能只按编号归并)',
}, empty);
}
// ---- 2) 逐行收集 ----
const stats = { rows: 0, teachers: 0, notTeachers: 0, blank: 0 };
const errors = [];
// key = group + '\u0000' + teamId,用不可见分隔符避免与编号里的字符冲突
const bucket = new Map();
for (let i = headerRowIdx + 1; i < aoa.length; i++) {
const r = aoa[i] || [];
stats.rows++;
const rowNumber = i + 1; // 1 基,与 Excel 行号一致
const nameRaw = colName >= 0 ? r[colName] : '';
const identityRaw = colIdentity >= 0 ? r[colIdentity] : '';
const allBlank = r.every(c => squash(c) === '');
if (allBlank) {
stats.blank++;
continue;
}
if (!isTeacherIdentity(identityRaw)) {
stats.notTeachers++;
continue;
}
const name = String(nameRaw === null || nameRaw === undefined ? '' : nameRaw).trim();
if (!name) {
stats.teachers++;
errors.push({ row: rowNumber, status: ROW_STATUS.TEACHER_NO_NAME, message: '身份为导师但姓名为空,已跳过' });
continue;
}
const group = normalizeGroup(colGroup >= 0 ? r[colGroup] : '');
if (!group) {
stats.teachers++;
errors.push({
row: rowNumber,
status: ROW_STATUS.TEACHER_NO_GROUP,
message: `指导教师「${name}」所在行组别为空或无法识别,已跳过(缺少 group 无法安全归组)`,
});
continue;
}
const teamId = normalizeTeamId(colTeam >= 0 ? r[colTeam] : '');
if (!teamId) {
stats.teachers++;
errors.push({
row: rowNumber,
status: ROW_STATUS.TEACHER_NO_TEAM,
message: `指导教师「${name}」所在行队伍编号为空,已跳过(无法定位到具体小组)`,
});
continue;
}
stats.teachers++;
const key = `${group}\u0000${teamId}`;
let item = bucket.get(key);
if (!item) {
item = { group, teamId, names: [], rowNumbers: [] };
bucket.set(key, item);
}
if (item.names.indexOf(name) < 0) item.names.push(name);
item.rowNumbers.push(rowNumber);
}
const teams = Array.from(bucket.values());
if (teams.length === 0) {
return Object.assign({
ok: false,
code: 'NO_TEACHER_FOUND',
message: stats.teachers === 0
? '报名表里没有身份为「指导教师」的行(请确认「身份」列的写法)'
: '识别到指导教师行,但都缺少组别或队伍编号,无法归组',
}, { stats, errors });
}
return Object.assign({ ok: true, code: 'OK', message: `识别到 ${teams.length} 个小组的指导教师` }, { teams, stats, errors });
}
/**
* 把同一小组的多个指导教师姓名合成一个展示串。
* ⛔ 去重保序:报名表里同一教师可能重复出现(同一人被登记两次),
* 直接 join 会得到「张三、张三」。
*/
function joinNames(names) {
if (!Array.isArray(names)) return '';
const seen = new Set();
const out = [];
for (const n of names) {
const s = String(n === null || n === undefined ? '' : n).trim();
if (!s || seen.has(s)) continue;
seen.add(s);
out.push(s);
}
return out.join('、');
}
/** 1 基数组是报名表原始行号,直接透出(调用方拼提示用) */
function formatRowNumbers(nums) {
if (!Array.isArray(nums) || nums.length === 0) return '';
return nums.join('、');
}
module.exports = {
TEACHER_IDENTITIES,
TEACHER_GROUP_ALIAS,
TEACHER_COLUMN_ALIAS,
ROW_STATUS,
squash,
normalizeGroup,
normalizeTeamId,
isTeacherIdentity,
findColumn,
extractTeachers,
joinNames,
formatRowNumbers,
};