From 26a29b22144eca1141aad7f2e848af2ce54c763f Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Fri, 9 Oct 2026 15:27:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E9=80=89=E6=89=8B=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E8=A1=A5=E9=BD=90=E3=80=8C=E5=B0=8F=E7=BB=84=E7=BC=96?= =?UTF-8?q?=E5=8F=B7/=E6=89=80=E5=B1=9E=E5=AD=A6=E6=A0=A1=E3=80=8D?= =?UTF-8?q?=EF=BC=8C=E5=B0=8F=E7=BB=84=E8=A7=86=E5=9B=BE=E6=B1=87=E6=80=BB?= =?UTF-8?q?=E6=94=B9=E5=8F=8C=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 只读交叉复核一致。 --- app/service/admin.js | 105 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 89 insertions(+), 16 deletions(-) diff --git a/app/service/admin.js b/app/service/admin.js index 974d752..573c7e7 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -113,6 +113,11 @@ class AdminService extends Service { /** * 获取分页账号列表 + * + * R90 增补:每行附带 `teamUid / teamId / teamNumber / schoolName`(与 getTeams 同源口径), + * 供「选手管理 → 选手列表」与「小组视图」相互对照(人工测试反馈:选手列表缺小组编号列)。 + * 既有字段一律不变。 + * * @param {Object} options - 查询选项 { group, keyword, activated, page, pageSize, startDate, endDate } * @returns {Promise} { list, total, page, pageSize } */ @@ -166,21 +171,71 @@ class AdminService extends Service { 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 => ({ - uid: row.id, - name: row.name, - name_cn: row.name, - idNumber: row.id_number, - id_number: row.id_number, - phone: row.phone, - group: row.group, - activated: row.activated, - score: row.score, - test_flag: row.test_flag, - createdAt: row.created_at, - created_at: row.created_at, - })); + const list = rows.map(row => { + const teamUid = resolveTeamUid(row); + const meta = teamUid === null ? null : (teamMeta.get(row.group + '#' + teamUid) || null); + return { + uid: row.id, + name: row.name, + name_cn: row.name, + idNumber: row.id_number, + id_number: row.id_number, + phone: row.phone, + group: row.group, + activated: row.activated, + 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 { list, @@ -212,7 +267,9 @@ class AdminService extends Service { * 某小组若成员全为测试数据,则该小组整体不出现(这正是「生产口径」想要的)。 * * @param {Object} options - { group?, activated?, includeTest? } - * @returns {Promise} { list, summary, allSummary, filters } + * @returns {Promise} { list, summary, filteredTotal, filters } + * summary 为**筛选前全集**口径,含 total/activated/partial/inactive/memberTotal/memberActivated + * 以及 R90 增补的 memberActivatedInPartial / filteredTotal / filteredMemberTotal / filteredMemberActivated */ async getTeams(options = {}) { const { ctx } = this; @@ -340,10 +397,26 @@ class AdminService extends Service { list = all.filter(t => t.activated === false); } + // R90:给前端「双口径」汇总用(人工测试反馈:筛选后小组数 3 与成员激活数 4 看似矛盾)。 + // 既有 6 个字段语义一律不变(仍为**筛选前全集**口径),仅新增下列字段: + // · memberActivatedInPartial —— 「部分激活」组内已激活的成员数(∅=恒为全集口径)。 + // 它是「成员激活总数 − 筛选出的已激活组成员数」差额的来源,正是消除歧义的关键量。 + // · filteredTotal / filteredMemberTotal / filteredMemberActivated —— **当前筛选后**口径。 + const allSummary = summarize(all); + const filteredSummary = summarize(list); + return { 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: summarize(all), filteredTotal: list.length, filters: { group: group || '', activated: activated === undefined ? '' : activated, includeTest }, };