From 853f3c3f5b5ea1c215ec00ed49bad440a95bce43 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Tue, 15 Sep 2026 17:21:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=95=99=E5=B8=88=E5=90=8D?= =?UTF-8?q?=E5=86=8C=E5=8F=AA=E8=AF=BB=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 教师名册来自开放平台全量同步,只用于检索与选人(教师不建登录账号,ADR-0001), 因此不提供认领类接口,也没有可写字段。 - GET /api/gxmu/teacher/page:学院/教工号/姓名/状态筛选,默认只返回启用 - POST /api/gxmu/teacher/export:导出用列表,带操作日志 - 权限拆成 gxmu:teacher:list 与 gxmu:teacher:export - 手机号按 spec §5.2 落盘但不进任何列表接口,返回前一律清空(hideSensitive) - 菜单与按钮权限见 sql/20260915_add_teacher_roster_menu.sql,可重复执行 --- sql/20260915_add_teacher_roster_menu.sql | 50 ++++++++ .../controller/GxmuTeacherController.java | 118 ++++++++++++++++++ .../controller/GxmuTeacherControllerTest.java | 84 +++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 sql/20260915_add_teacher_roster_menu.sql create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/controller/GxmuTeacherController.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/openplat/controller/GxmuTeacherControllerTest.java diff --git a/sql/20260915_add_teacher_roster_menu.sql b/sql/20260915_add_teacher_roster_menu.sql new file mode 100644 index 0000000..7cc928f --- /dev/null +++ b/sql/20260915_add_teacher_roster_menu.sql @@ -0,0 +1,50 @@ +-- ============================================================================ +-- 教师名册:后台菜单与按钮权限 +-- 参见 .scratch/openplat-sync/issues/19-admin-teacher-roster.md +-- 可重复执行 +-- +-- 说明:后台菜单是动态从 sys_menu 读取的(GET /auth/user -> formatMenus -> addRoute)。 +-- menu_type=1 的记录不生成路由,只作按钮权限。 +-- path 与 component 填相同值,组件按 import.meta.glob('/src/views/**/index.vue') 解析。 +-- +-- 教师名册只是查看(分页 + 导出),没有任何写操作,因此按钮只有查询与导出两个。 +-- ============================================================================ + +-- 菜单:教师名册 +INSERT INTO `sys_menu` + (`menu_id`, `parent_id`, `title`, `path`, `component`, `menu_type`, `sort_number`, + `authority`, `hide`, `app_id`, `deleted`, `merchant_code`, `tenant_id`) +SELECT 14405, 8574, '教师名册', '/gxmu/teacherRoster', '/gxmu/teacherRoster', 0, 106, + '', 0, 0, 0, '', 10049 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` m WHERE m.`menu_id` = 14405); + +-- 按钮:名册查询 +INSERT INTO `sys_menu` + (`menu_id`, `parent_id`, `title`, `path`, `component`, `menu_type`, `sort_number`, + `authority`, `hide`, `app_id`, `deleted`, `merchant_code`, `tenant_id`) +SELECT 14406, 14405, '名册查询', '', '', 1, 1, + 'gxmu:teacher:list', 0, 0, 0, '', 10049 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` m WHERE m.`menu_id` = 14406); + +-- 按钮:名册导出(泄露风险最高,单独授权) +INSERT INTO `sys_menu` + (`menu_id`, `parent_id`, `title`, `path`, `component`, `menu_type`, `sort_number`, + `authority`, `hide`, `app_id`, `deleted`, `merchant_code`, `tenant_id`) +SELECT 14407, 14405, '名册导出', '', '', 1, 2, + 'gxmu:teacher:export', 0, 0, 0, '', 10049 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` m WHERE m.`menu_id` = 14407); + +-- --------------------------------------------------------------------------- +-- 给超级管理员授权(其它角色请按需在后台「角色管理」里勾选) +-- --------------------------------------------------------------------------- +INSERT INTO `sys_role_menu` (`role_id`, `menu_id`, `tenant_id`) +SELECT r.`role_id`, m.`menu_id`, 10049 +FROM `sys_role` r +JOIN `sys_menu` m ON m.`menu_id` IN (14405, 14406, 14407) +LEFT JOIN `sys_role_menu` rm ON rm.`role_id` = r.`role_id` AND rm.`menu_id` = m.`menu_id` +WHERE r.`role_code` = 'superAdmin' + AND r.`tenant_id` = 10049 + AND rm.`id` IS NULL; diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/controller/GxmuTeacherController.java b/src/main/java/com/gxwebsoft/gxmu/openplat/controller/GxmuTeacherController.java new file mode 100644 index 0000000..b8e0092 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/controller/GxmuTeacherController.java @@ -0,0 +1,118 @@ +package com.gxwebsoft.gxmu.openplat.controller; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.gxwebsoft.common.core.annotation.OperationLog; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.core.web.PageResult; +import com.gxwebsoft.gxmu.openplat.SyncSupport; +import com.gxwebsoft.gxmu.openplat.entity.GxmuTeacher; +import com.gxwebsoft.gxmu.openplat.mapper.GxmuTeacherMapper; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import java.util.List; +import java.util.Map; + +/** + * 教师名册控制器(只读)。 + * + *

教师名册来自开放平台全量同步,只用于检索与选人,教师本身不建登录账号(见 ADR-0001)。 + * 因此这里没有认领类接口,也没有可写字段。 + * + *

手机号按 spec §5.2「落盘但不进任何列表接口」处理:{@link #hideSensitive} 在返回前一律清空, + * 明文不出本控制器。 + * + * @author Codex + * @since 2026-09-15 + */ +@Api(tags = "教师名册") +@RestController +@RequestMapping("/api/gxmu/teacher") +public class GxmuTeacherController extends BaseController { + + @Resource + private GxmuTeacherMapper teacherMapper; + + @Resource + private SyncSupport syncSupport; + + @PreAuthorize("hasAuthority('gxmu:teacher:list')") + @ApiOperation("教师名册分页查询") + @GetMapping("/page") + public ApiResult> page( + @RequestParam(value = "page", defaultValue = "1") Integer page, + @RequestParam(value = "limit", defaultValue = "20") Integer limit, + @RequestParam(value = "collegeId", required = false) Integer collegeId, + @RequestParam(value = "jgh", required = false) String jgh, + @RequestParam(value = "xm", required = false) String xm, + @RequestParam(value = "status", required = false) Integer status) { + LambdaQueryWrapper wrapper = buildWrapper(collegeId, jgh, xm, status); + Page pageParam = new Page<>(page, limit); + IPage result = teacherMapper.selectPage(pageParam, wrapper); + List records = result.getRecords(); + hideSensitive(records); + enrich(records); + return success(new PageResult<>(records, result.getTotal())); + } + + @ApiOperation("导出教师名册(不含手机号)") + @PreAuthorize("hasAuthority('gxmu:teacher:export')") + @OperationLog + @PostMapping("/export") + public ApiResult> export( + @RequestParam(value = "collegeId", required = false) Integer collegeId, + @RequestParam(value = "status", required = false) Integer status) { + List records = teacherMapper.selectList(buildWrapper(collegeId, null, null, status)); + hideSensitive(records); + enrich(records); + return success(records); + } + + private LambdaQueryWrapper buildWrapper(Integer collegeId, String jgh, String xm, Integer status) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(GxmuTeacher::getTenantId, getTenantId()); + wrapper.eq(collegeId != null, GxmuTeacher::getCollegeId, collegeId); + wrapper.eq(StrUtil.isNotBlank(jgh), GxmuTeacher::getJgh, StrUtil.trim(jgh)); + wrapper.like(StrUtil.isNotBlank(xm), GxmuTeacher::getXm, StrUtil.trim(xm)); + // 默认只看启用(停用=上游已消失,见 ADR-0004,需要时显式查 status=0) + wrapper.eq(status != null, GxmuTeacher::getStatus, status); + wrapper.eq(status == null, GxmuTeacher::getStatus, 1); + wrapper.orderByAsc(GxmuTeacher::getCollegeId).orderByAsc(GxmuTeacher::getJgh); + return wrapper; + } + + /** + * 手机号绝不随响应返回:置空而不是脱敏,教师名册页也不需要这个字段(spec §5.2)。 + * + *

包级可见是为了让单元测试能直接钉住这条规则。 + */ + static void hideSensitive(List records) { + for (GxmuTeacher teacher : records) { + teacher.setSjh(null); + } + } + + private void enrich(List records) { + if (records.isEmpty()) { + return; + } + Map orgNames = syncSupport.orgNameById(); + for (GxmuTeacher teacher : records) { + if (teacher.getCollegeId() != null) { + teacher.setCollegeName(orgNames.get(teacher.getCollegeId())); + } + } + } + +} diff --git a/src/test/java/com/gxwebsoft/gxmu/openplat/controller/GxmuTeacherControllerTest.java b/src/test/java/com/gxwebsoft/gxmu/openplat/controller/GxmuTeacherControllerTest.java new file mode 100644 index 0000000..014cd23 --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/openplat/controller/GxmuTeacherControllerTest.java @@ -0,0 +1,84 @@ +package com.gxwebsoft.gxmu.openplat.controller; + +import com.gxwebsoft.gxmu.openplat.entity.GxmuTeacher; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * 教师名册的敏感字段守卫测试(spec §5.2)。 + * + *

教师手机号是落盘但「不进任何列表接口」的字段。名册页是本项目里第一个把 + * {@link GxmuTeacher} 直接序列化出去的地方,一旦漏掉清空,1.7 万条手机号就会随 + * 分页接口泄露——这类事故从代码评审里看不出来,所以直接钉在测试里。 + * + * @author Codex + * @since 2026-09-15 + */ +class GxmuTeacherControllerTest { + + private static GxmuTeacher sample(String jgh, String sjh) { + GxmuTeacher teacher = new GxmuTeacher(); + teacher.setJgh(jgh); + teacher.setXm("张三"); + teacher.setXbm("1"); + teacher.setSex("男"); + teacher.setDwh("010318"); + teacher.setCollegeId(60); + teacher.setKsjybh("01031801"); + teacher.setSjh(sjh); + teacher.setStatus(1); + return teacher; + } + + @Test + void phoneNumberNeverSurvives() { + List records = new ArrayList<>(Arrays.asList( + sample("10001", "13800000000"), + sample("10002", "13900000000"))); + + GxmuTeacherController.hideSensitive(records); + + for (GxmuTeacher teacher : records) { + assertNull(teacher.getSjh(), "手机号必须清空后再返回"); + } + } + + /** + * 清空的是手机号这一列,不是整行——列表页要靠其余字段渲染。 + */ + @Test + void otherFieldsAreUntouched() { + List records = new ArrayList<>(Arrays.asList(sample("10001", "13800000000"))); + + GxmuTeacherController.hideSensitive(records); + + GxmuTeacher teacher = records.get(0); + assertEquals("10001", teacher.getJgh()); + assertEquals("张三", teacher.getXm()); + assertEquals("男", teacher.getSex()); + assertEquals(Integer.valueOf(60), teacher.getCollegeId()); + assertEquals("01031801", teacher.getKsjybh()); + assertEquals(Integer.valueOf(1), teacher.getStatus()); + } + + @Test + void emptyListIsFine() { + GxmuTeacherController.hideSensitive(new ArrayList<>()); + } + + @Test + void nullPhoneStaysNull() { + List records = new ArrayList<>(Arrays.asList(sample("10001", null))); + + GxmuTeacherController.hideSensitive(records); + + assertNull(records.get(0).getSjh()); + } + +}