From c101b15e2e79c4c85149b91e76c30656f8e3c734 Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Fri, 9 Oct 2026 09:33:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=E5=B0=8F?= =?UTF-8?q?=E7=BB=84=E6=BF=80=E6=B4=BB=E7=8A=B6=E6=80=81=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=20/api/admin/teams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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(鉴权在位) --- app/controller/admin.js | 24 ++++++ app/model/aggregation.js | 19 +++++ app/router.js | 3 + app/service/admin.js | 175 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+) diff --git a/app/controller/admin.js b/app/controller/admin.js index 27600d2..2ec7cea 100644 --- a/app/controller/admin.js +++ b/app/controller/admin.js @@ -126,6 +126,30 @@ class AdminController extends Controller { } } + /** + * 获取「小组(团队)激活状态」清单(R84 续六新增) + * GET /api/admin/teams?group=xxx&activated=true|false&includeTest=0|1 + * + * 需求:「选手管理」中查看已激活/未激活小组,**必须携带小组所属学校名称**。 + * 口径(键 = 组别 + 队伍号;成员 = aggregation.team_uid ∪ contestants.team_id; + * 学校 = aggregation.organization_cn;激活 = 全组成员均已激活)详见 + * `service/admin.js#getTeams` 的方法头注释 —— 那是唯一真相源。 + */ + async teams() { + const { ctx, service } = this; + if (!this._ensureAdmin()) return; + + try { + // ⚠️ 筛选条件必须**整体透传**(同 accounts():曾因只解构部分参数导致前端筛选静默失效) + const { group, activated, includeTest } = ctx.query; + const result = await service.admin.getTeams({ group, activated, includeTest }); + ctx.body = ctx.helper.success(result, '获取小组列表成功'); + } catch (err) { + ctx.logger.error('获取小组列表失败:', err); + ctx.body = ctx.helper.error(500, '获取小组列表失败'); + } + } + /** * 从 Excel 文件导入账号数据 * POST /api/admin/import-excel diff --git a/app/model/aggregation.js b/app/model/aggregation.js index cf22d98..7b19e47 100644 --- a/app/model/aggregation.js +++ b/app/model/aggregation.js @@ -78,6 +78,25 @@ module.exports = app => { defaultValue: null, comment: '驳回原因', }, + // 所属单位 / 学校名称(报名表原始来源,R84 续六新增声明) + // ⚠️ 该列在运行库中**一直存在**(`SHOW COLUMNS FROM aggregation` 可见), + // 只是模型漏声明 ⇒ 任何 `attributes: ['organization_cn']` 查询都会 + // 被 Sequelize 当作未知列。此处补齐仅为让模型与运行库一致, + // 声明为 allowNull:true,不改变既有写入路径的语义。 + organization_cn: { + type: STRING(200), + allowNull: true, + defaultValue: null, + comment: '所属单位/学校名称', + }, + // 参赛组别(zhongzhi/gaozhi/benke/jiaoshi)—— 同为运行库既有列,模型漏声明。 + // ⚠️ `group` 是 MySQL 保留字,Sequelize 会自动加反引号;手写 SQL 时须自行反引号。 + group: { + type: STRING(20), + allowNull: true, + defaultValue: null, + comment: '参赛组别', + }, // 记录创建时间 created_at: { type: DATE, diff --git a/app/router.js b/app/router.js index 4ffde52..5a13119 100644 --- a/app/router.js +++ b/app/router.js @@ -115,6 +115,9 @@ module.exports = app => { // 账号管理 router.get('/api/admin/accounts', adminAuth, controller.admin.accounts); + // 小组(团队)激活状态清单(R84 续六):查看已激活 / 未激活小组,并携带所属学校名称。 + // ⚠️ 路径段固定、无通配,与既有路由无匹配冲突。 + router.get('/api/admin/teams', adminAuth, controller.admin.teams); router.post('/api/admin/import-excel', adminAuth, controller.admin.importExcel); // 提交管理 diff --git a/app/service/admin.js b/app/service/admin.js index 53e3fc4..974d752 100644 --- a/app/service/admin.js +++ b/app/service/admin.js @@ -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} { 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++;