feat(admin): 新增小组激活状态清单接口 /api/admin/teams

- router:GET /api/admin/teams(adminAuth;固定路径段,与既有路由无通配冲突)

- controller:teams() 整体透传 group/activated/includeTest(防前端筛选静默失效)

- service:getTeams(),键 = 组别+队伍号;成员并集 aggregation.team_uid ∪ contestants.team_id;学校取 aggregation.organization_cn;激活 = 全组成员均已激活;附 summary 与 schoolConflict

- model/aggregation:补声明运行库既有列 organization_cn / group(此前模型漏声明 ⇒ 查询被当未知列)

线上验证:R86 已部署上线,公网 /api/admin/teams 返回 401(鉴权在位)
This commit is contained in:
2026-10-09 09:33:05 +08:00
parent 481de69e3d
commit c101b15e2e
4 changed files with 221 additions and 0 deletions

View File

@@ -190,6 +190,165 @@ class AdminService extends Service {
};
}
/**
* 获取「小组(团队)激活状态」清单(R84 续六新增 · 人工测试反馈需求)
*
* 需求原文:「在选手管理中加入一个查看已激活/未激活小组的功能,且必须携带小组所属学校名称」。
*
* 口径(本函数的**唯一真相源**,改动前请先读这里):
* - **小组的键 = (组别 group, 队伍号 teamUid)**。⛔ 绝不能用 teamUid 单独作键:
* 实测 `team_uid=1` 同时存在于 zhongzhi 与 benke(见 `service/exam.js` checkTeamLock
* 的跨组复用防护注释),单独用 teamUid 会把两个不同组的小组并成一个。
* - **成员来源为双来源并集**(与 checkTeamLock 保持一致):
* ① `aggregation.team_uid`(报名表聚合填充,**同时是学校名称的来源**)
* ② `contestants.team_id`(导入流程填充,兼容旧数据)
* 同一选手若两处都有归属,**只计一次**(以 aggregation 为准,避免重复计数)。
* - **学校名称**取自 `aggregation.organization_cn`。同一小组理论上同校;
* 若出现多校(异常数据),并列展示并置 `schoolConflict=true` 供排查,**不静默取一个**。
* - **激活判据**:`activated` = 组内**全部**成员 `contestants.activated=1`。
* 三个细分状态:`activated`(全激活)/ `partial`(部分激活)/ `inactive`(全未激活)。
* ⚠️ `partial` 在「已激活/未激活」二选一筛选里归入**未激活**(因为尚未全员可登录)。
* - **测试数据口径**:默认 `includeTest=false` ⇒ 只统计 `test_flag=0` 的成员;
* 某小组若成员全为测试数据,则该小组整体不出现(这正是「生产口径」想要的)。
*
* @param {Object} options - { group?, activated?, includeTest? }
* @returns {Promise<Object>} { list, summary, allSummary, filters }
*/
async getTeams(options = {}) {
const { ctx } = this;
const { group, activated } = options;
const includeTest = options.includeTest === true || options.includeTest === 'true' || options.includeTest === '1';
const GROUP_CN = { zhongzhi: '中职组', gaozhi: '高职组', benke: '本科组', jiaoshi: '教师组' };
// ---- 1) 成员(选手):只取本功能所需列,避免把 password 等带出来 ----
const contestantWhere = {};
if (group) contestantWhere.group = group;
if (!includeTest) contestantWhere.test_flag = 0;
const contestants = await ctx.model.Contestant.findAll({
where: contestantWhere,
attributes: ['id', 'group', 'team_id', 'activated'],
raw: true,
});
const contestantById = new Map();
for (const c of contestants) contestantById.set(c.id, c);
// ---- 2) 聚合报名(团队归属 + 学校名称)----
const aggs = await ctx.model.Aggregation.findAll({
attributes: ['account_uid', 'team_uid', 'organization_cn'],
raw: true,
});
// ---- 3) 团队元信息(人类可读队伍编号 team_id / team_number)----
const teamWhere = group ? { group_name: group } : {};
const teamRows = await ctx.model.Team.findAll({
where: teamWhere,
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
raw: true,
});
const teamMeta = new Map();
for (const t of teamRows) teamMeta.set(t.group_name + '#' + t.team_uid, t);
// ---- 4) 归集:(组别, 队伍号) → 成员 / 激活数 / 学校 ----
const buckets = new Map();
const keyOf = (g, uid) => g + '#' + uid;
const touch = (g, uid) => {
const k = keyOf(g, uid);
let b = buckets.get(k);
if (!b) {
b = { group: g, teamUid: uid, memberIds: new Set(), activatedCount: 0, schools: new Set() };
buckets.set(k, b);
}
return b;
};
// 4a) 来源一:aggregation.team_uid(先跑,作为「已归属」判定的基准)
const assigned = new Set();
for (const a of aggs) {
if (a.team_uid === null || a.team_uid === undefined) continue;
const c = contestantById.get(a.account_uid);
if (!c) continue; // 不在本次口径内(被组别/测试数据过滤掉)
const b = touch(c.group, a.team_uid);
if (!b.memberIds.has(c.id)) {
b.memberIds.add(c.id);
if (c.activated) b.activatedCount += 1;
}
assigned.add(c.id);
const sch = typeof a.organization_cn === 'string' ? a.organization_cn.trim() : '';
if (sch) b.schools.add(sch);
}
// 4b) 来源二:contestants.team_id(兜底;已由 aggregation 计过的选手跳过 ⇒ 防重复计数)
for (const c of contestants) {
if (c.team_id === null || c.team_id === undefined) continue;
if (assigned.has(c.id)) continue;
const b = touch(c.group, c.team_id);
if (!b.memberIds.has(c.id)) {
b.memberIds.add(c.id);
if (c.activated) b.activatedCount += 1;
}
}
// ---- 5) 组装 + 状态判定 ----
const all = [];
for (const b of buckets.values()) {
const memberCount = b.memberIds.size;
const activatedCount = b.activatedCount;
const meta = teamMeta.get(keyOf(b.group, b.teamUid)) || null;
const status = activatedCount === 0
? 'inactive'
: (activatedCount === memberCount ? 'activated' : 'partial');
all.push({
group: b.group,
groupName: GROUP_CN[b.group] || b.group,
teamUid: b.teamUid,
// teams 表可能没有对应行(旧数据只写了 aggregation)⇒ 回退为空,前端展示「—」
teamId: meta ? meta.team_id : null,
teamNumber: meta ? (meta.team_number || meta.team_id) : null,
schoolName: Array.from(b.schools).join(' / '),
schoolConflict: b.schools.size > 1,
memberCount,
activatedCount,
activated: memberCount > 0 && activatedCount === memberCount,
status,
});
}
// 稳定排序:组别 → 队伍号(数值升序;非数值队号排后面)
const GROUP_ORDER = { zhongzhi: 0, gaozhi: 1, benke: 2, jiaoshi: 3 };
all.sort((x, y) => {
const gx = GROUP_ORDER[x.group] === undefined ? 9 : GROUP_ORDER[x.group];
const gy = GROUP_ORDER[y.group] === undefined ? 9 : GROUP_ORDER[y.group];
if (gx !== gy) return gx - gy;
return (x.teamUid || 0) - (y.teamUid || 0);
});
const summarize = arr => ({
total: arr.length,
activated: arr.filter(t => t.status === 'activated').length,
partial: arr.filter(t => t.status === 'partial').length,
inactive: arr.filter(t => t.status === 'inactive').length,
memberTotal: arr.reduce((s, t) => s + t.memberCount, 0),
memberActivated: arr.reduce((s, t) => s + t.activatedCount, 0),
});
// 筛选(与 getAccounts 的 activated 归一化同理:前端 el-select 传字符串)
let list = all;
if ([true, 1, '1', 'true'].includes(activated)) {
list = all.filter(t => t.activated === true);
} else if ([false, 0, '0', 'false'].includes(activated)) {
list = all.filter(t => t.activated === false);
}
return {
list,
// summary 作用于**筛选前**的全集(给总览用);filteredTotal 说明筛选后条数
summary: summarize(all),
filteredTotal: list.length,
filters: { group: group || '', activated: activated === undefined ? '' : activated, includeTest },
};
}
// ==================== Excel 导入 ====================
/**
@@ -226,6 +385,12 @@ class AdminService extends Service {
手机号: ['手机号', '手机号码', '联系电话', 'phone'],
组别: ['组别', 'group'],
身份: ['身份', 'identity'],
// R84 续六新增:上游「报名信息表」确实带「学校/单位名称全称」列,但此前列名契约
// **有意忽略**该列 ⇒ 落库时丢失 ⇒ 生产库 `aggregation.organization_cn`
// 600 行全为空(2026-10-08 只读取证)⇒「小组所属学校」无从展示。
// 现补入契约,写入 `aggregation.organization_cn`(该列运行库既有,模型声明见
// `model/aggregation.js`)。列名以上游导出为准,另兼容几种常见写法以防改版。
学校: ['学校/单位名称全称', '学校/单位名称全称(英文)', '学校名称', '单位名称', '学校', 'organization_cn'],
};
// 按别名顺序取首个非空值(统一 trim,返回字符串)
const pick = (row, key) => {
@@ -356,6 +521,8 @@ class AdminService extends Service {
const teamIdRaw = pick(row, '队伍编号');
// 聚合记录的身份口径与作品/答题列表保持一致(作品列表统一记为「选手」)
const identity = '选手';
// R84 续六:学校 / 单位名称(上游「报名信息表」的「学校/单位名称全称」列)
const schoolName = pick(row, '学校');
if (!idNumber) {
result.errors.push({ row: rowIndex, message: '身份证号为空' });
@@ -425,8 +592,16 @@ class AdminService extends Service {
account_uid: contestant.id,
identity,
is_registrant: true,
// R84 续六:把「学校/单位名称全称」落库,供「选手管理 · 小组视图」展示所属学校。
// 取不到时写 null(而非 ''),与「未填写」语义一致。
organization_cn: schoolName || null,
}, { transaction: t });
}
// ⚠️ R84 续六注记(勿误以为「再导一次就能补学校」):
// 本函数在第 541-553 行已按 `id_number` 判重,**已存在的选手会整行 skip 并 rollback**,
// 根本走不到这里 ⇒ **重复导入不会回填历史行的学校**。
// 历史数据(生产 600 行 organization_cn 全空)必须走一次性回填工具:
// `scripts/backfill_organization_cn.py`(默认 dry-run,只填空、不覆盖)。
await t.commit();
result.imported++;