新增教师名册只读接口
教师名册来自开放平台全量同步,只用于检索与选人(教师不建登录账号,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,可重复执行
This commit is contained in:
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师名册控制器(只读)。
|
||||||
|
*
|
||||||
|
* <p>教师名册来自开放平台全量同步,只用于检索与选人,教师本身不建登录账号(见 ADR-0001)。
|
||||||
|
* 因此这里没有认领类接口,也没有可写字段。
|
||||||
|
*
|
||||||
|
* <p>手机号按 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<PageResult<GxmuTeacher>> 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<GxmuTeacher> wrapper = buildWrapper(collegeId, jgh, xm, status);
|
||||||
|
Page<GxmuTeacher> pageParam = new Page<>(page, limit);
|
||||||
|
IPage<GxmuTeacher> result = teacherMapper.selectPage(pageParam, wrapper);
|
||||||
|
List<GxmuTeacher> 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<List<GxmuTeacher>> export(
|
||||||
|
@RequestParam(value = "collegeId", required = false) Integer collegeId,
|
||||||
|
@RequestParam(value = "status", required = false) Integer status) {
|
||||||
|
List<GxmuTeacher> records = teacherMapper.selectList(buildWrapper(collegeId, null, null, status));
|
||||||
|
hideSensitive(records);
|
||||||
|
enrich(records);
|
||||||
|
return success(records);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LambdaQueryWrapper<GxmuTeacher> buildWrapper(Integer collegeId, String jgh, String xm, Integer status) {
|
||||||
|
LambdaQueryWrapper<GxmuTeacher> 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)。
|
||||||
|
*
|
||||||
|
* <p>包级可见是为了让单元测试能直接钉住这条规则。
|
||||||
|
*/
|
||||||
|
static void hideSensitive(List<GxmuTeacher> records) {
|
||||||
|
for (GxmuTeacher teacher : records) {
|
||||||
|
teacher.setSjh(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enrich(List<GxmuTeacher> records) {
|
||||||
|
if (records.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<Integer, String> orgNames = syncSupport.orgNameById();
|
||||||
|
for (GxmuTeacher teacher : records) {
|
||||||
|
if (teacher.getCollegeId() != null) {
|
||||||
|
teacher.setCollegeName(orgNames.get(teacher.getCollegeId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)。
|
||||||
|
*
|
||||||
|
* <p>教师手机号是落盘但「不进任何列表接口」的字段。名册页是本项目里第一个把
|
||||||
|
* {@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<GxmuTeacher> 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<GxmuTeacher> 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<GxmuTeacher> records = new ArrayList<>(Arrays.asList(sample("10001", null)));
|
||||||
|
|
||||||
|
GxmuTeacherController.hideSensitive(records);
|
||||||
|
|
||||||
|
assertNull(records.get(0).getSjh());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user