feat(admin): 选手列表补齐「小组编号/所属学校」,小组视图汇总改双口径

R90 人工测试反馈三问题修复(方案 1 + 方案 3-A):

- getAccounts:新增 teamUid / teamId / teamNumber / schoolName
  · 与 getTeams 同源口径(小组键 = 组别 group + 队伍号 teamUid;
    学校取自 aggregation.organization_cn)
  · 2 条批量 SQL(Aggregation / Team 各一次)替代逐行查询,避免 N+1
  · 既有字段一律不变
- getTeams:summary 新增 memberActivatedInPartial / filteredTotal /
  filteredMemberTotal / filteredMemberActivated
  · 既有 6 个汇总字段语义保持「筛选前全集」不变
  · 同步修正 JSDoc 中陈旧的 allSummary 描述

生产已部署并复验:线上接口 63 项断言全部 PASS,
且与服务器侧 MySQL 只读交叉复核一致。
This commit is contained in:
2026-10-09 15:27:37 +08:00
parent c101b15e2e
commit 26a29b2214

View File

@@ -113,6 +113,11 @@ class AdminService extends Service {
/** /**
* 获取分页账号列表 * 获取分页账号列表
*
* R90 增补:每行附带 `teamUid / teamId / teamNumber / schoolName`(与 getTeams 同源口径),
* 供「选手管理 → 选手列表」与「小组视图」相互对照(人工测试反馈:选手列表缺小组编号列)。
* 既有字段一律不变。
*
* @param {Object} options - 查询选项 { group, keyword, activated, page, pageSize, startDate, endDate } * @param {Object} options - 查询选项 { group, keyword, activated, page, pageSize, startDate, endDate }
* @returns {Promise<Object>} { list, total, page, pageSize } * @returns {Promise<Object>} { list, total, page, pageSize }
*/ */
@@ -166,21 +171,71 @@ class AdminService extends Service {
limit: pageSize, limit: pageSize,
}); });
// ---- R90:补齐「小组编号 / 所属学校」(与 getTeams 同源口径,供选手列表对照)----
// 口径(勿另立第二套):
// · 小组键 = (组别 group, 队伍号 teamUid),⛔ 不可单用 teamUid(跨组重号)
// · teamUid 解析优先级:aggregation.team_uid(报名表聚合,权威) → contestants.team_id(兜底)
// · schoolName 取自 aggregation.organization_cn
// 批量查询(2 条 SQL)而非逐行查,避免 N+1。
const Op = ctx.model.Sequelize.Op;
const pageIds = rows.map(r => r.id);
const aggTeamUid = new Map(); // contestant.id → teamUid
const aggSchool = new Map(); // contestant.id → 学校名
if (pageIds.length > 0) {
const aggRows = await ctx.model.Aggregation.findAll({
where: { account_uid: { [Op.in]: pageIds } },
attributes: ['account_uid', 'team_uid', 'organization_cn'],
raw: true,
});
for (const a of aggRows) {
if (a.team_uid !== null && a.team_uid !== undefined && !aggTeamUid.has(a.account_uid)) {
aggTeamUid.set(a.account_uid, a.team_uid);
}
const sch = typeof a.organization_cn === 'string' ? a.organization_cn.trim() : '';
if (sch && !aggSchool.has(a.account_uid)) aggSchool.set(a.account_uid, sch);
}
}
// 队伍元信息(人类可读队号 team_id / team_number),按 (group, teamUid) 建索引
const resolveTeamUid = row => {
if (aggTeamUid.has(row.id)) return aggTeamUid.get(row.id);
return row.team_id === null || row.team_id === undefined ? null : row.team_id;
};
const teamUids = Array.from(new Set(rows.map(resolveTeamUid).filter(u => u !== null)));
const teamMeta = new Map();
if (teamUids.length > 0) {
const teamRows = await ctx.model.Team.findAll({
where: { team_uid: { [Op.in]: teamUids } },
attributes: ['team_uid', 'group_name', 'team_id', 'team_number'],
raw: true,
});
for (const t of teamRows) teamMeta.set(t.group_name + '#' + t.team_uid, t);
}
// 转换为前端期望的字段格式 // 转换为前端期望的字段格式
const list = rows.map(row => ({ const list = rows.map(row => {
uid: row.id, const teamUid = resolveTeamUid(row);
name: row.name, const meta = teamUid === null ? null : (teamMeta.get(row.group + '#' + teamUid) || null);
name_cn: row.name, return {
idNumber: row.id_number, uid: row.id,
id_number: row.id_number, name: row.name,
phone: row.phone, name_cn: row.name,
group: row.group, idNumber: row.id_number,
activated: row.activated, id_number: row.id_number,
score: row.score, phone: row.phone,
test_flag: row.test_flag, group: row.group,
createdAt: row.created_at, activated: row.activated,
created_at: row.created_at, score: row.score,
})); test_flag: row.test_flag,
createdAt: row.created_at,
created_at: row.created_at,
// R90 新增(既有字段一律不变)
teamUid,
teamId: meta ? meta.team_id : null,
teamNumber: meta ? (meta.team_number || meta.team_id) : null,
schoolName: aggSchool.get(row.id) || '',
};
});
return { return {
list, list,
@@ -212,7 +267,9 @@ class AdminService extends Service {
* 某小组若成员全为测试数据,则该小组整体不出现(这正是「生产口径」想要的)。 * 某小组若成员全为测试数据,则该小组整体不出现(这正是「生产口径」想要的)。
* *
* @param {Object} options - { group?, activated?, includeTest? } * @param {Object} options - { group?, activated?, includeTest? }
* @returns {Promise<Object>} { list, summary, allSummary, filters } * @returns {Promise<Object>} { list, summary, filteredTotal, filters }
* summary 为**筛选前全集**口径,含 total/activated/partial/inactive/memberTotal/memberActivated
* 以及 R90 增补的 memberActivatedInPartial / filteredTotal / filteredMemberTotal / filteredMemberActivated
*/ */
async getTeams(options = {}) { async getTeams(options = {}) {
const { ctx } = this; const { ctx } = this;
@@ -340,10 +397,26 @@ class AdminService extends Service {
list = all.filter(t => t.activated === false); list = all.filter(t => t.activated === false);
} }
// R90:给前端「双口径」汇总用(人工测试反馈:筛选后小组数 3 与成员激活数 4 看似矛盾)。
// 既有 6 个字段语义一律不变(仍为**筛选前全集**口径),仅新增下列字段:
// · memberActivatedInPartial —— 「部分激活」组内已激活的成员数(∅=恒为全集口径)。
// 它是「成员激活总数 − 筛选出的已激活组成员数」差额的来源,正是消除歧义的关键量。
// · filteredTotal / filteredMemberTotal / filteredMemberActivated —— **当前筛选后**口径。
const allSummary = summarize(all);
const filteredSummary = summarize(list);
return { return {
list, list,
summary: {
...allSummary,
memberActivatedInPartial: all
.filter(t => t.status === 'partial')
.reduce((s, t) => s + t.activatedCount, 0),
filteredTotal: filteredSummary.total,
filteredMemberTotal: filteredSummary.memberTotal,
filteredMemberActivated: filteredSummary.memberActivated,
},
// summary 作用于**筛选前**的全集(给总览用);filteredTotal 说明筛选后条数 // summary 作用于**筛选前**的全集(给总览用);filteredTotal 说明筛选后条数
summary: summarize(all),
filteredTotal: list.length, filteredTotal: list.length,
filters: { group: group || '', activated: activated === undefined ? '' : activated, includeTest }, filters: { group: group || '', activated: activated === undefined ? '' : activated, includeTest },
}; };