From cd54039f28803fce821092fa67df2de6c8a997c6 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Tue, 15 Sep 2026 15:54:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=8C=BB=E7=A7=91=E5=A4=A7?= =?UTF-8?q?=E5=BC=80=E6=94=BE=E5=B9=B3=E5=8F=B0=E6=95=B0=E6=8D=AE=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 com.gxwebsoft.gxmu.openplat:开放平台客户端(oauth 鉴权、token 缓存与失效 重取、分页拉取)、部门/教师/班级/学生四段同步、异步编排与手动同步接口、学生认领、 学生名册查询与导出、每天 03:30 的定时任务、同步记录。 关键设计: - 名册与登录账号分离,学生通过「认领」(手机号自动 / 学号姓名自助)关联 sys_user - 组织数据以 sys_organization 为唯一载体,gxmu_college 废弃 - 平台无可用增量字段,只做全量拉取 + 幂等 upsert - 上游消失只停用不删除,且只作用于同步来源的记录(避免误停用人工维护的研究生班) - 平台权威字段按白名单覆盖,人工字段不触碰 - 码表可配置并保留原始码,码值实测存在两套编码混用,不做硬编码 - 手动同步为异步:提交返回批次号,前端轮询 /status 看分段进度 性能: - 班级/部门由逐条 update 改为多行 upsert,班级 1584 条 41s -> 0.19s - 码表解析加进程内缓存,学生同步由 >17 分钟不完成 -> 170 秒完成 98187 行 迁移脚本需手动执行(可重复执行): sql/20260912_create_openplat_sync_tables.sql sql/20260912_add_openplat_menu.sql 离线单测 43 个;另有联网集成测试 OpenPlatformClientIT(需 -Dopenplat.it=true)。 --- sql/20260912_add_openplat_menu.sql | 66 ++++ sql/20260912_create_openplat_sync_tables.sql | 143 +++++++++ .../com/gxwebsoft/WebSoftApplication.java | 3 +- .../common/core/constants/RedisConstants.java | 2 + .../system/mapper/OrganizationMapper.java | 14 + .../system/mapper/xml/OrganizationMapper.xml | 27 ++ .../gxmu/mapper/ClassInfoMapper.java | 19 ++ .../gxmu/mapper/xml/ClassInfoMapper.xml | 37 +++ .../gxmu/openplat/GxmuOpenplatProperties.java | 56 ++++ .../gxmu/openplat/OpenPlatformClient.java | 270 ++++++++++++++++ .../gxmu/openplat/OpenPlatformException.java | 21 ++ .../gxmu/openplat/OpenplatCodeService.java | 247 ++++++++++++++ .../gxmu/openplat/OpenplatSyncConstants.java | 63 ++++ .../gxmu/openplat/SyncBatchStatus.java | 37 +++ .../gxwebsoft/gxmu/openplat/SyncResult.java | 43 +++ .../gxwebsoft/gxmu/openplat/SyncScope.java | 44 +++ .../gxmu/openplat/SyncSectionStatus.java | 57 ++++ .../gxwebsoft/gxmu/openplat/SyncSupport.java | 236 ++++++++++++++ .../gxmu/openplat/claim/ClaimResult.java | 38 +++ .../openplat/claim/StudentClaimService.java | 60 ++++ .../claim/StudentClaimServiceImpl.java | 179 +++++++++++ .../controller/GxmuStudentController.java | 235 ++++++++++++++ .../controller/GxmuSyncController.java | 129 ++++++++ .../gxmu/openplat/entity/GxmuStudent.java | 135 ++++++++ .../gxmu/openplat/entity/GxmuSyncRecord.java | 90 ++++++ .../gxmu/openplat/entity/GxmuTeacher.java | 82 +++++ .../openplat/mapper/GxmuStudentMapper.java | 50 +++ .../openplat/mapper/GxmuSyncRecordMapper.java | 14 + .../openplat/mapper/GxmuTeacherMapper.java | 50 +++ .../openplat/mapper/xml/GxmuStudentMapper.xml | 72 +++++ .../openplat/mapper/xml/GxmuTeacherMapper.xml | 52 +++ .../openplat/service/OpenplatSyncService.java | 39 +++ .../service/impl/OpenplatSyncServiceImpl.java | 300 ++++++++++++++++++ .../gxmu/openplat/sync/ClassSyncHandler.java | 218 +++++++++++++ .../gxmu/openplat/sync/DeptSyncHandler.java | 142 +++++++++ .../openplat/sync/StudentSourceMerger.java | 91 ++++++ .../openplat/sync/StudentSyncHandler.java | 196 ++++++++++++ .../openplat/sync/TeacherSyncHandler.java | 95 ++++++ .../gxmu/openplat/task/OpenplatSyncTask.java | 56 ++++ .../shop/controller/WxLoginController.java | 17 + src/main/resources/application-dev.yml | 3 +- src/main/resources/application-prod.yml | 5 +- src/main/resources/application.yml | 17 + .../openplat/MapperFieldWhitelistTest.java | 109 +++++++ .../gxmu/openplat/OpenPlatformClientIT.java | 94 ++++++ .../gxmu/openplat/OpenPlatformClientTest.java | 58 ++++ .../gxmu/openplat/OpenplatCodeLookupTest.java | 65 ++++ .../OpenplatCodeServiceCacheTest.java | 121 +++++++ .../gxmu/openplat/SyncScopeTest.java | 50 +++ .../openplat/sync/ClassSyncHandlerTest.java | 155 +++++++++ .../sync/StudentSourceMergerTest.java | 187 +++++++++++ 51 files changed, 4586 insertions(+), 3 deletions(-) create mode 100644 sql/20260912_add_openplat_menu.sql create mode 100644 sql/20260912_create_openplat_sync_tables.sql create mode 100644 src/main/java/com/gxwebsoft/gxmu/mapper/xml/ClassInfoMapper.xml create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/GxmuOpenplatProperties.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/OpenPlatformClient.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/OpenPlatformException.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/OpenplatCodeService.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/OpenplatSyncConstants.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/SyncBatchStatus.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/SyncResult.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/SyncScope.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/SyncSectionStatus.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/SyncSupport.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/claim/ClaimResult.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/claim/StudentClaimService.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/claim/StudentClaimServiceImpl.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/controller/GxmuStudentController.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/controller/GxmuSyncController.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuStudent.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuSyncRecord.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuTeacher.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuStudentMapper.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuSyncRecordMapper.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuTeacherMapper.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/mapper/xml/GxmuStudentMapper.xml create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/mapper/xml/GxmuTeacherMapper.xml create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/service/OpenplatSyncService.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/service/impl/OpenplatSyncServiceImpl.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/sync/ClassSyncHandler.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/sync/DeptSyncHandler.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/sync/StudentSourceMerger.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/sync/StudentSyncHandler.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/sync/TeacherSyncHandler.java create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/task/OpenplatSyncTask.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/openplat/MapperFieldWhitelistTest.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/openplat/OpenPlatformClientIT.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/openplat/OpenPlatformClientTest.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/openplat/OpenplatCodeLookupTest.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/openplat/OpenplatCodeServiceCacheTest.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/openplat/SyncScopeTest.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/openplat/sync/ClassSyncHandlerTest.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/openplat/sync/StudentSourceMergerTest.java diff --git a/sql/20260912_add_openplat_menu.sql b/sql/20260912_add_openplat_menu.sql new file mode 100644 index 0000000..fb9d63a --- /dev/null +++ b/sql/20260912_add_openplat_menu.sql @@ -0,0 +1,66 @@ +-- ============================================================================ +-- 开放平台数据同步:后台菜单与按钮权限 +-- 参见 .scratch/openplat-sync/issues/14-menu-permission-sql.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 14400, 8574, '数据同步', '/gxmu/dataSync', '/gxmu/dataSync', 0, 100, + '', 0, 0, 0, '', 10049 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` m WHERE m.`menu_id` = 14400); + +-- 按钮:手动同步 +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 14401, 14400, '手动同步', '', '', 1, 1, + 'gxmu:sync:manual', 0, 0, 0, '', 10049 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` m WHERE m.`menu_id` = 14401); + +-- 菜单:学生名册 +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 14402, 8574, '学生名册', '/gxmu/studentRoster', '/gxmu/studentRoster', 0, 105, + '', 0, 0, 0, '', 10049 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` m WHERE m.`menu_id` = 14402); + +-- 按钮:名册查询 +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 14403, 14402, '名册查询', '', '', 1, 1, + 'gxmu:student:list', 0, 0, 0, '', 10049 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` m WHERE m.`menu_id` = 14403); + +-- 按钮:名册导出(泄露风险最高,单独授权) +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 14404, 14402, '名册导出', '', '', 1, 2, + 'gxmu:student:export', 0, 0, 0, '', 10049 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` m WHERE m.`menu_id` = 14404); + +-- --------------------------------------------------------------------------- +-- 给超级管理员授权(其它角色请按需在后台「角色管理」里勾选) +-- --------------------------------------------------------------------------- +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 (14400, 14401, 14402, 14403, 14404) +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/sql/20260912_create_openplat_sync_tables.sql b/sql/20260912_create_openplat_sync_tables.sql new file mode 100644 index 0000000..0c68ffe --- /dev/null +++ b/sql/20260912_create_openplat_sync_tables.sql @@ -0,0 +1,143 @@ +-- ============================================================================ +-- 医科大开放平台数据同步:名册表、同步记录表、唯一键、码表字典 +-- 参见 .scratch/openplat-sync/spec.md 与 docs/adr/0001、0006 +-- 可重复执行 +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- 1. 学生名册(与 sys_user 账号分离,见 ADR-0001) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `gxmu_student` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `xh` VARCHAR(32) NOT NULL COMMENT '学号(开放平台外部键)', + `xm` VARCHAR(64) DEFAULT NULL COMMENT '姓名', + `xbm` VARCHAR(8) DEFAULT NULL COMMENT '性别码(平台原始)', + `sex` VARCHAR(16) DEFAULT NULL COMMENT '性别名称', + `mzm` VARCHAR(8) DEFAULT NULL COMMENT '民族码(平台原始)', + `nation` VARCHAR(32) DEFAULT NULL COMMENT '民族名称', + `zzmmm` VARCHAR(8) DEFAULT NULL COMMENT '政治面貌码(平台原始)', + `politics` VARCHAR(64) DEFAULT NULL COMMENT '政治面貌名称', + `sfzjh` VARCHAR(32) DEFAULT NULL COMMENT '身份证件号', + `sjh` VARCHAR(32) DEFAULT NULL COMMENT '手机号(认领键)', + `bjm` VARCHAR(64) DEFAULT NULL COMMENT '平台班级码(原始)', + `bh` VARCHAR(64) DEFAULT NULL COMMENT '平台班号(学籍库原始)', + `class_id` INT DEFAULT NULL COMMENT '解析出的班级ID', + `college_id` INT DEFAULT NULL COMMENT '解析出的学院ID', + `dwh` VARCHAR(64) DEFAULT NULL COMMENT '单位号(平台原始)', + `zyh` VARCHAR(32) DEFAULT NULL COMMENT '专业号', + `zymc` VARCHAR(128) DEFAULT NULL COMMENT '专业名称', + `pyccmc` VARCHAR(32) DEFAULT NULL COMMENT '培养层次', + `sfzx` VARCHAR(8) DEFAULT NULL COMMENT '是否在校', + `xjzt` VARCHAR(32) DEFAULT NULL COMMENT '学籍状态', + `xsdqzt` VARCHAR(32) DEFAULT NULL COMMENT '学生当前状态', + `csrq` VARCHAR(20) DEFAULT NULL COMMENT '出生日期', + `user_id` INT DEFAULT NULL COMMENT '认领后的 sys_user.user_id', + `claim_time` DATETIME DEFAULT NULL COMMENT '认领时间', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1在校 0毕业/结业/休学', + `sync_time` DATETIME DEFAULT NULL COMMENT '最近同步时间', + `tenant_id` INT DEFAULT NULL COMMENT '租户ID', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '删除标记:0未删 1已删', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_gxmu_student_xh` (`tenant_id`, `xh`), + KEY `idx_gxmu_student_xh` (`xh`), + KEY `idx_gxmu_student_sjh` (`sjh`), + KEY `idx_gxmu_student_class` (`class_id`), + KEY `idx_gxmu_student_college` (`college_id`), + KEY `idx_gxmu_student_uid` (`user_id`), + KEY `idx_gxmu_student_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生名册(开放平台同步)'; + +-- --------------------------------------------------------------------------- +-- 2. 教师名册 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `gxmu_teacher` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `jgh` VARCHAR(32) NOT NULL COMMENT '教工号(开放平台外部键)', + `xm` VARCHAR(64) DEFAULT NULL COMMENT '姓名', + `xbm` VARCHAR(8) DEFAULT NULL COMMENT '性别码(平台原始)', + `sex` VARCHAR(16) DEFAULT NULL COMMENT '性别名称', + `dwh` VARCHAR(64) DEFAULT NULL COMMENT '单位号(平台原始)', + `college_id` INT DEFAULT NULL COMMENT '解析出的学院ID', + `ksjybh` VARCHAR(64) DEFAULT NULL COMMENT '科室教研室编号', + `sjh` VARCHAR(32) DEFAULT NULL COMMENT '手机号(敏感字段, 不对外输出)', + `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用 0停用', + `sync_time` DATETIME DEFAULT NULL COMMENT '最近同步时间', + `tenant_id` INT DEFAULT NULL COMMENT '租户ID', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '删除标记:0未删 1已删', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_gxmu_teacher_jgh` (`tenant_id`, `jgh`), + KEY `idx_gxmu_teacher_jgh` (`jgh`), + KEY `idx_gxmu_teacher_college` (`college_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教师名册(开放平台同步)'; + +-- --------------------------------------------------------------------------- +-- 3. 同步记录 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `gxmu_sync_record` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `batch_id` VARCHAR(64) DEFAULT NULL COMMENT '批次号(同一次触发的各段共享)', + `sync_type` VARCHAR(32) NOT NULL COMMENT '同步类型:dept/teacher/class/student', + `trigger_type` VARCHAR(16) NOT NULL COMMENT '触发方式:schedule/manual', + `trigger_user` VARCHAR(100) DEFAULT NULL COMMENT '触发人(手动触发时)', + `start_time` DATETIME DEFAULT NULL COMMENT '开始时间', + `end_time` DATETIME DEFAULT NULL COMMENT '结束时间', + `success_count` INT NOT NULL DEFAULT 0 COMMENT '成功条数', + `fail_count` INT NOT NULL DEFAULT 0 COMMENT '失败条数', + `skip_count` INT NOT NULL DEFAULT 0 COMMENT '跳过条数', + `status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0成功 1失败', + `error_msg` VARCHAR(1000) DEFAULT NULL COMMENT '错误摘要(不含手机号/身份证)', + `tenant_id` INT DEFAULT NULL COMMENT '租户ID', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_gxmu_sync_record_batch` (`batch_id`), + KEY `idx_gxmu_sync_record_start` (`start_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开放平台同步记录'; + +-- --------------------------------------------------------------------------- +-- 4. 唯一键(幂等 upsert 的前提) +-- 先把手写的空串规范成 NULL,否则唯一键会被一堆 '' 互撞。 +-- --------------------------------------------------------------------------- +UPDATE `gxmu_class` SET `class_code` = NULL WHERE `class_code` = ''; +UPDATE `sys_organization` SET `organization_code` = NULL WHERE `organization_code` = ''; + +SET @exist := (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gxmu_class' + AND INDEX_NAME = 'uk_gxmu_class_code'); +SET @stmt := IF(@exist = 0, + 'ALTER TABLE `gxmu_class` ADD UNIQUE KEY `uk_gxmu_class_code` (`tenant_id`, `class_code`)', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +SET @exist := (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_organization' + AND INDEX_NAME = 'uk_sys_organization_code'); +SET @stmt := IF(@exist = 0, + 'ALTER TABLE `sys_organization` ADD UNIQUE KEY `uk_sys_organization_code` (`tenant_id`, `organization_code`)', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; + +-- --------------------------------------------------------------------------- +-- 5. 码表字典 +-- 平台只返回代码不返回名称,且平台本身没有任何码表接口(见 ADR-0006)。 +-- dict_data_code = 平台原始码,dict_data_name = 解析后的名称。 +-- --------------------------------------------------------------------------- +INSERT INTO `sys_dict` (`dict_id`, `dict_code`, `dict_name`, `sort_number`, `comments`, `tenant_id`) +VALUES + (9001, 'openplat_sex', '开放平台-性别码', 1, '1=男 2=女(已由实测确认)', 10049), + (9002, 'openplat_nation', '开放平台-民族码', 2, '待 RS_JZGXX 授权后用 MZM/MZM1 反推回填', 10049), + (9003, 'openplat_politics', '开放平台-政治面貌码', 3, '待 RS_JZGXX 授权后用 ZZZTM/ZZZTM1 反推回填', 10049) +ON DUPLICATE KEY UPDATE `dict_code` = VALUES(`dict_code`), `dict_name` = VALUES(`dict_name`); + +INSERT INTO `sys_dict_data` (`dict_data_code`, `dict_data_name`, `dict_id`, `sort_number`, `tenant_id`) +SELECT t.c, t.n, t.d, t.s, t.t +FROM ( + SELECT '1' AS c, '男' AS n, 9001 AS d, 1 AS s, 10049 AS t + UNION ALL SELECT '2', '女', 9001, 2, 10049 +) t +LEFT JOIN `sys_dict_data` d ON d.`dict_id` = t.d AND d.`dict_data_code` = t.c +WHERE d.`dict_data_id` IS NULL; diff --git a/src/main/java/com/gxwebsoft/WebSoftApplication.java b/src/main/java/com/gxwebsoft/WebSoftApplication.java index bfdb6cc..db9c1c2 100644 --- a/src/main/java/com/gxwebsoft/WebSoftApplication.java +++ b/src/main/java/com/gxwebsoft/WebSoftApplication.java @@ -1,6 +1,7 @@ package com.gxwebsoft; import com.gxwebsoft.common.core.config.ConfigProperties; +import com.gxwebsoft.gxmu.openplat.GxmuOpenplatProperties; import com.gxwebsoft.shop.config.WxMaProperties; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; @@ -18,7 +19,7 @@ import org.springframework.web.socket.config.annotation.EnableWebSocket; @EnableAsync @EnableTransactionManagement @MapperScan("com.gxwebsoft.**.mapper") -@EnableConfigurationProperties({ConfigProperties.class, WxMaProperties.class}) +@EnableConfigurationProperties({ConfigProperties.class, WxMaProperties.class, GxmuOpenplatProperties.class}) @SpringBootApplication @EnableScheduling @EnableWebSocket diff --git a/src/main/java/com/gxwebsoft/common/core/constants/RedisConstants.java b/src/main/java/com/gxwebsoft/common/core/constants/RedisConstants.java index 2765754..56964ad 100644 --- a/src/main/java/com/gxwebsoft/common/core/constants/RedisConstants.java +++ b/src/main/java/com/gxwebsoft/common/core/constants/RedisConstants.java @@ -22,6 +22,8 @@ public class RedisConstants { public static final String SEARCH_HISTORY = "searchHistory"; // 租户系统设置信息 public static final String TEN_ANT_SETTING_KEY = "setting"; + // 医科大开放平台 access_token + public static final String OPENPLAT_TOKEN_KEY = "gxmu:openplat:token"; // 排行榜Key public static final String USER_RANKING_BY_APPS_5 = "cache5:userRankingByApps"; diff --git a/src/main/java/com/gxwebsoft/common/system/mapper/OrganizationMapper.java b/src/main/java/com/gxwebsoft/common/system/mapper/OrganizationMapper.java index 6f06689..9090340 100644 --- a/src/main/java/com/gxwebsoft/common/system/mapper/OrganizationMapper.java +++ b/src/main/java/com/gxwebsoft/common/system/mapper/OrganizationMapper.java @@ -34,4 +34,18 @@ public interface OrganizationMapper extends BaseMapper { */ List selectListRel(@Param("param") OrganizationParam param); + /** + * 批量新增或更新(开放平台部门同步用)。 + * + *

幂等键为 {@code uk_sys_organization_code(tenant_id, organization_code)}; + * 只覆盖平台权威字段,人工字段不动,见 docs/adr/0005。 + * + *

用一条多行语句代替逐条 update:逐条发在公网数据库上会被往返延迟主导, + * 实测单条往返 25ms,1584 条即 40 秒;多行 upsert 只要几百毫秒。 + * + * @param list 机构列表 + * @return 影响行数 + */ + int batchUpsert(@Param("list") List list); + } diff --git a/src/main/java/com/gxwebsoft/common/system/mapper/xml/OrganizationMapper.xml b/src/main/java/com/gxwebsoft/common/system/mapper/xml/OrganizationMapper.xml index dff61a0..934ec96 100644 --- a/src/main/java/com/gxwebsoft/common/system/mapper/xml/OrganizationMapper.xml +++ b/src/main/java/com/gxwebsoft/common/system/mapper/xml/OrganizationMapper.xml @@ -92,4 +92,31 @@ + + + INSERT INTO sys_organization ( + parent_id, organization_name, organization_full_name, organization_code, + organization_type, comments, tenant_id, create_time, update_time, deleted + ) VALUES + + ( + #{item.parentId}, #{item.organizationName}, #{item.organizationFullName}, + #{item.organizationCode}, #{item.organizationType}, #{item.comments}, + #{item.tenantId}, #{item.createTime}, #{item.updateTime}, #{item.deleted} + ) + + ON DUPLICATE KEY UPDATE + organization_name = VALUES(organization_name), + organization_full_name = VALUES(organization_full_name), + organization_type = VALUES(organization_type), + comments = VALUES(comments), + update_time = VALUES(update_time), + deleted = VALUES(deleted) + + diff --git a/src/main/java/com/gxwebsoft/gxmu/mapper/ClassInfoMapper.java b/src/main/java/com/gxwebsoft/gxmu/mapper/ClassInfoMapper.java index 8e97417..8a08a19 100644 --- a/src/main/java/com/gxwebsoft/gxmu/mapper/ClassInfoMapper.java +++ b/src/main/java/com/gxwebsoft/gxmu/mapper/ClassInfoMapper.java @@ -2,9 +2,28 @@ package com.gxwebsoft.gxmu.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.gxwebsoft.gxmu.entity.ClassInfo; +import org.apache.ibatis.annotations.Param; + +import java.util.List; /** * 班级管理Mapper */ public interface ClassInfoMapper extends BaseMapper { + + /** + * 批量新增或更新(开放平台班级同步用)。 + * + *

幂等键为 {@code uk_gxmu_class_code(tenant_id, class_code)}; + * 只覆盖平台权威字段,人工维护的 sort_number / counselor_phone / create_time 不动, + * 见 docs/adr/0005。 + * + *

用一条多行语句代替逐条 save/update:实测公网数据库单条往返 25ms, + * 1584 条逐条发要 41 秒,多行 upsert 只要 0.2 秒。 + * + * @param list 班级列表 + * @return 影响行数 + */ + int batchUpsert(@Param("list") List list); + } diff --git a/src/main/java/com/gxwebsoft/gxmu/mapper/xml/ClassInfoMapper.xml b/src/main/java/com/gxwebsoft/gxmu/mapper/xml/ClassInfoMapper.xml new file mode 100644 index 0000000..3ac3406 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/mapper/xml/ClassInfoMapper.xml @@ -0,0 +1,37 @@ + + + + + + + INSERT INTO gxmu_class ( + college_id, class_code, class_name, grade_year, counselor_name, counselor_phone, + student_count, sort_number, status, remark, tenant_id, + create_time, update_time, deleted + ) VALUES + + ( + #{item.collegeId}, #{item.classCode}, #{item.className}, #{item.gradeYear}, + #{item.counselorName}, #{item.counselorPhone}, #{item.studentCount}, + #{item.sortNumber}, #{item.status}, #{item.remark}, #{item.tenantId}, + #{item.createTime}, #{item.updateTime}, #{item.deleted} + ) + + ON DUPLICATE KEY UPDATE + class_name = VALUES(class_name), + college_id = VALUES(college_id), + grade_year = VALUES(grade_year), + counselor_name = VALUES(counselor_name), + student_count = VALUES(student_count), + status = VALUES(status), + remark = VALUES(remark), + update_time = VALUES(update_time), + deleted = VALUES(deleted) + + + diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/GxmuOpenplatProperties.java b/src/main/java/com/gxwebsoft/gxmu/openplat/GxmuOpenplatProperties.java new file mode 100644 index 0000000..8a2c3fb --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/GxmuOpenplatProperties.java @@ -0,0 +1,56 @@ +package com.gxwebsoft.gxmu.openplat; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 医科大开放平台对接配置 + * + * @author Codex + * @since 2026-09-12 + */ +@Data +@ConfigurationProperties(prefix = "gxmu.openplat") +public class GxmuOpenplatProperties { + + /** + * 开放平台接口地址前缀 + */ + private String baseUrl = "http://210.36.49.10:15031/api/bd-api"; + + /** + * 应用编号 + */ + private String appId; + + /** + * 应用认证码 + */ + private String appSecret; + + /** + * 开发者账号 + */ + private String kfzzh; + + /** + * 开发者密码 + */ + private String kfzmm; + + /** + * 单次请求超时时间(毫秒) + */ + private Integer timeoutMs = 30000; + + /** + * 单接口请求失败重试次数 + */ + private Integer maxRetries = 2; + + /** + * 分页单页大小(平台实测上限 10000) + */ + private Integer pageSize = 10000; + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/OpenPlatformClient.java b/src/main/java/com/gxwebsoft/gxmu/openplat/OpenPlatformClient.java new file mode 100644 index 0000000..ca4c45f --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/OpenPlatformClient.java @@ -0,0 +1,270 @@ +package com.gxwebsoft.gxmu.openplat; + +import cn.hutool.core.codec.Base64; +import cn.hutool.core.util.CharsetUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import cn.hutool.crypto.symmetric.DES; +import cn.hutool.http.Header; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.gxwebsoft.common.core.constants.RedisConstants; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 医科大开放平台客户端。 + * + *

负责 oauth 鉴权、token 缓存与失效重取、分页拉取全量数据。 + * 平台 token 会被提前失效(实测声称 14399 秒、不到 10 分钟即失效), + * 因此任何一次请求遇到 token 失效都必须清缓存重取后重试。 + * + * @author Codex + * @since 2026-09-12 + */ +@Component +public class OpenPlatformClient { + + private static final Logger logger = LoggerFactory.getLogger(OpenPlatformClient.class); + + /** + * DES 密钥。与平台文档 DesUtil 一致,java DESKeySpec 只取前 8 字节, + * 此处显式取前 8 字节,效果等价且避免依赖具体 JDK 实现。 + */ + private static final String DES_KEY = "LianYi37621040"; + + /** + * 分页保护上限,避免异常情况下无限翻页 + */ + private static final int MAX_PAGES = 50; + + /** + * token 缓存提前过期时间(秒) + */ + private static final long TOKEN_EXPIRE_AHEAD_SECONDS = 300L; + + @Resource + private GxmuOpenplatProperties properties; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 获取 access_token,优先取缓存 + */ + public String getToken() { + String cached = stringRedisTemplate.opsForValue().get(RedisConstants.OPENPLAT_TOKEN_KEY); + if (StrUtil.isNotBlank(cached)) { + return cached; + } + return refreshToken(); + } + + /** + * 强制重新获取 access_token 并写入缓存 + */ + public synchronized String refreshToken() { + String cached = stringRedisTemplate.opsForValue().get(RedisConstants.OPENPLAT_TOKEN_KEY); + if (StrUtil.isNotBlank(cached)) { + return cached; + } + Map params = new LinkedHashMap<>(); + params.put("grant_type", "password"); + params.put("scope", "read"); + params.put("username", encrypt(properties.getKfzzh())); + params.put("password", encrypt(properties.getKfzmm())); + String url = HttpUtil.urlWithForm(properties.getBaseUrl() + "/oauth/token", params, + CharsetUtil.CHARSET_UTF_8, true); + String basic = Base64.encode(properties.getAppId() + ":" + properties.getAppSecret(), + CharsetUtil.CHARSET_UTF_8); + String body; + try { + HttpResponse response = HttpRequest.post(url) + .timeout(properties.getTimeoutMs()) + .header(Header.AUTHORIZATION, "Basic " + basic) + .body("") + .header(Header.CONTENT_TYPE, "application/json") + .execute(); + body = response.body(); + } catch (Exception e) { + throw new OpenPlatformException("开放平台鉴权请求失败", e); + } + JSONObject json = parseObject(body); + String token = json.getString("access_token"); + if (StrUtil.isBlank(token)) { + throw new OpenPlatformException("开放平台鉴权失败: " + messageOf(json)); + } + long expiresIn = json.getLongValue("expires_in"); + long ttl = expiresIn > TOKEN_EXPIRE_AHEAD_SECONDS ? expiresIn - TOKEN_EXPIRE_AHEAD_SECONDS : 60L; + stringRedisTemplate.opsForValue().set(RedisConstants.OPENPLAT_TOKEN_KEY, token, ttl, TimeUnit.SECONDS); + logger.warn("开放平台 token 已刷新, 有效期 {} 秒, 缓存 {} 秒", expiresIn, ttl); + return token; + } + + /** + * 清除 token 缓存 + */ + public void clearToken() { + stringRedisTemplate.delete(RedisConstants.OPENPLAT_TOKEN_KEY); + } + + /** + * 分页拉取某接口的全量数据。 + * + *

注意:平台返回的 {@code total} 字段是本次返回条数而非总数, + * 因此只能以「返回条数 < pageSize」作为结束条件。 + * + * @param jkbq 接口标签,即 /get/{jkbq} 的路径段 + * @return 全量记录 + */ + public JSONArray fetchAll(String jkbq) { + JSONArray all = new JSONArray(); + int pageSize = properties.getPageSize() == null || properties.getPageSize() <= 0 + ? 10000 : properties.getPageSize(); + for (int page = 1; page <= MAX_PAGES; page++) { + JSONArray rows = fetchPage(jkbq, page, pageSize); + all.addAll(rows); + logger.warn("开放平台拉取 {}(page={}) 返回 {} 条, 累计 {} 条", jkbq, page, rows.size(), all.size()); + if (rows.size() < pageSize) { + break; + } + } + return all; + } + + /** + * 拉取某一页 + */ + public JSONArray fetchPage(String jkbq, int page, int pageSize) { + int attempts = properties.getMaxRetries() == null || properties.getMaxRetries() < 1 + ? 1 : properties.getMaxRetries(); + RuntimeException lastException = null; + for (int attempt = 1; attempt <= attempts; attempt++) { + try { + Map params = new LinkedHashMap<>(); + params.put("access_token", getToken()); + params.put("page", page); + params.put("pageSize", pageSize); + String url = HttpUtil.urlWithForm(properties.getBaseUrl() + "/get/" + jkbq, params, + CharsetUtil.CHARSET_UTF_8, true); + String body = HttpRequest.get(url) + .timeout(properties.getTimeoutMs()) + .header(Header.ACCEPT, "application/json") + .execute() + .body(); + JSONObject json = parseObject(body); + if (isTokenInvalid(json)) { + logger.warn("开放平台 token 失效, 重新获取后重试: {} page={}", jkbq, page); + clearToken(); + continue; + } + int code = json.getIntValue("code"); + if (code != 200) { + throw new OpenPlatformException("开放平台接口 " + jkbq + " 返回异常: " + messageOf(json)); + } + JSONArray data = json.getJSONArray("data"); + return data == null ? new JSONArray() : data; + } catch (OpenPlatformException e) { + throw e; + } catch (Exception e) { + lastException = new OpenPlatformException( + "调用开放平台接口失败: " + jkbq + " page=" + page, e); + logger.warn("调用开放平台接口失败, 第 {}/{} 次: {} page={}", attempt, attempts, jkbq, page); + } + } + clearToken(); + if (lastException != null) { + throw lastException; + } + throw new OpenPlatformException("开放平台接口调用失败(可能为 token 持续失效): " + jkbq); + } + + /** + * DES 加密,输出大写十六进制。与平台文档 DesUtil.encrypt 等价。 + */ + public static String encrypt(String plain) { + if (plain == null) { + return null; + } + byte[] key = DES_KEY.substring(0, 8).getBytes(StandardCharsets.UTF_8); + DES des = SecureUtil.des(key); + return des.encryptHex(plain).toUpperCase(Locale.ROOT); + } + + /** + * 大小写不敏感地取字段值。 + * + *

平台的 findColumn 元数据返回小写字段名,实际数据却是大写(如 XH), + * 因此不能直接按元数据的字段名取值。 + */ + public static String field(JSONObject row, String name) { + if (row == null || name == null) { + return null; + } + Object value = row.get(name); + if (value == null) { + value = row.get(name.toUpperCase(Locale.ROOT)); + } + if (value == null) { + value = row.get(name.toLowerCase(Locale.ROOT)); + } + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return "NULL".equalsIgnoreCase(text) ? null : text; + } + + private boolean isTokenInvalid(JSONObject json) { + if (json == null) { + return false; + } + String code = json.getString("code"); + if ("401".equals(code)) { + return true; + } + String message = json.getString("message"); + if (message != null && message.contains("过期或无效")) { + return true; + } + String description = json.getString("error_description"); + return description != null && description.toLowerCase(Locale.ROOT).contains("invalid access token"); + } + + private String messageOf(JSONObject json) { + if (json == null) { + return "响应为空"; + } + String message = json.getString("message"); + if (StrUtil.isNotBlank(message)) { + return message; + } + String description = json.getString("error_description"); + return StrUtil.isNotBlank(description) ? description : json.toJSONString(); + } + + private JSONObject parseObject(String body) { + if (StrUtil.isBlank(body)) { + throw new OpenPlatformException("开放平台返回空响应"); + } + try { + return JSON.parseObject(body); + } catch (Exception e) { + throw new OpenPlatformException("开放平台返回内容无法解析为 JSON", e); + } + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/OpenPlatformException.java b/src/main/java/com/gxwebsoft/gxmu/openplat/OpenPlatformException.java new file mode 100644 index 0000000..e3c9d3c --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/OpenPlatformException.java @@ -0,0 +1,21 @@ +package com.gxwebsoft.gxmu.openplat; + +/** + * 开放平台调用异常 + * + * @author Codex + * @since 2026-09-12 + */ +public class OpenPlatformException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public OpenPlatformException(String message) { + super(message); + } + + public OpenPlatformException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/OpenplatCodeService.java b/src/main/java/com/gxwebsoft/gxmu/openplat/OpenplatCodeService.java new file mode 100644 index 0000000..d90cd99 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/OpenplatCodeService.java @@ -0,0 +1,247 @@ +package com.gxwebsoft.gxmu.openplat; + +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.TypeReference; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.gxwebsoft.common.system.entity.Dict; +import com.gxwebsoft.common.system.entity.DictData; +import com.gxwebsoft.common.system.service.DictDataService; +import com.gxwebsoft.common.system.service.DictService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * 开放平台码值映射。 + * + *

平台只返回代码不返回名称,且平台本身没有任何码表接口(见 docs/adr/0006)。 + * 映射放在 sys_dict / sys_dict_data 中,key 为 dictCode,dict_data_code 存平台原始码, + * dict_data_name 存解析后的名称。 + * + *

性能要点:一次全量同步要解码近三十万次(性别/民族/政治面貌 × 9.8 万行)。 + * 早期实现每次解码都去查一次 Redis、缓存未命中再查两次数据库,导致学生同步跑了二十分钟仍未完成。 + * 因此这里加了进程内缓存:每本字典在一次运行内只真正加载一次,之后都是本地 Map 查找。 + * + *

容错:码表加载失败只让当次解析返回 null,绝不让异常冒泡—— + * 否则一次瞬时抖动就会中断整个 9.8 万行的同步。 + * + *

实测平台代码存在两套编码混用(民族码 `1` 与 `01` 并存、政治面貌码 `3` 与 `03` 并存), + * 因此匹配前会做归一化。 + * + * @author Codex + * @since 2026-09-12 + */ +@Component +public class OpenplatCodeService { + + private static final Logger logger = LoggerFactory.getLogger(OpenplatCodeService.class); + + /** + * 性别码字典 + */ + public static final String DICT_SEX = "openplat_sex"; + + /** + * 民族码字典 + */ + public static final String DICT_NATION = "openplat_nation"; + + /** + * 政治面貌码字典 + */ + public static final String DICT_POLITICS = "openplat_politics"; + + private static final String CACHE_KEY_PREFIX = "gxmu:openplat:dict:"; + + private static final long CACHE_TTL_SECONDS = 3600L; + + /** + * 进程内缓存有效期。码表改动后最多 10 分钟生效,无需重启。 + */ + private static final long LOCAL_TTL_MILLIS = 10 * 60 * 1000L; + + @Resource + private DictService dictService; + + @Resource + private DictDataService dictDataService; + + @Resource + private StringRedisTemplate stringRedisTemplate; + + /** + * 码表进程内缓存 + */ + private final Map localCache = new ConcurrentHashMap<>(); + + /** + * 已经告警过的「字典 + 码」组合。 + * + *

民族/政治面貌码表在 RS_JZGXX 授权前本就是空的,若每行都告警会产生十几万条日志。 + */ + private final Set warnedCodes = ConcurrentHashMap.newKeySet(); + + /** + * 把平台原始码解析为名称。 + * + * @param dictCode 字典编码,如 openplat_nation + * @param rawCode 平台返回的原始码 + * @return 解析出的名称;解析不出或码表不可用时返回 null + */ + public String decode(String dictCode, String rawCode) { + if (StrUtil.isBlank(dictCode) || StrUtil.isBlank(rawCode)) { + return null; + } + Map dict; + try { + dict = loadDict(dictCode); + } catch (Exception e) { + logger.warn("加载码表失败, 本次解析跳过: dict={}, err={}", dictCode, e.getMessage()); + return null; + } + if (dict.isEmpty()) { + return null; + } + String name = lookup(dict, rawCode.trim()); + if (name == null && warnedCodes.add(dictCode + '=' + rawCode)) { + logger.warn("开放平台码值无法解析(同一码只告警一次): dict={} code={}", dictCode, rawCode); + } + return name; + } + + /** + * 读取码表。优先进程内缓存,其次 Redis,最后数据库。 + */ + public Map loadDict(String dictCode) { + CachedDict cached = localCache.get(dictCode); + long now = System.currentTimeMillis(); + if (cached != null && now - cached.loadedAt < LOCAL_TTL_MILLIS) { + return cached.dict; + } + return reload(dictCode); + } + + /** + * 清除某本字典的缓存(进程内 + Redis) + */ + public void evict(String dictCode) { + localCache.remove(dictCode); + try { + stringRedisTemplate.delete(CACHE_KEY_PREFIX + dictCode); + } catch (Exception e) { + logger.warn("清除码表缓存失败: {}", dictCode); + } + } + + /** + * 清除全部码表缓存 + */ + public void evictAll() { + localCache.clear(); + warnedCodes.clear(); + } + + private synchronized Map reload(String dictCode) { + // 双重检查:并发进入时可能已被别的线程加载 + CachedDict cached = localCache.get(dictCode); + long now = System.currentTimeMillis(); + if (cached != null && now - cached.loadedAt < LOCAL_TTL_MILLIS) { + return cached.dict; + } + String cacheKey = CACHE_KEY_PREFIX + dictCode; + Map dict = null; + try { + String redisValue = stringRedisTemplate.opsForValue().get(cacheKey); + if (StrUtil.isNotBlank(redisValue)) { + dict = JSON.parseObject(redisValue, new TypeReference>() { + }); + } + } catch (Exception e) { + logger.warn("读取码表 Redis 缓存失败, 回退数据库: {}", dictCode); + } + if (dict == null) { + dict = loadFromDatabase(dictCode); + try { + stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(dict), + CACHE_TTL_SECONDS, TimeUnit.SECONDS); + } catch (Exception e) { + logger.warn("写入码表 Redis 缓存失败: {}", dictCode); + } + } + localCache.put(dictCode, new CachedDict(dict, now)); + return dict; + } + + /** + * 大小写与前导零不敏感地查表。 + * + *

先精确匹配(若字典里 `1` 与 `01` 都配了不同名称,以精确者为准), + * 再尝试去前导零与补零到两位。 + */ + static String lookup(Map dict, String rawCode) { + if (StrUtil.isBlank(rawCode)) { + return null; + } + String exact = dict.get(rawCode); + if (exact != null) { + return exact; + } + String stripped = rawCode.replaceFirst("^0+(?=[0-9])", ""); + if (!stripped.equals(rawCode)) { + String hit = dict.get(stripped); + if (hit != null) { + return hit; + } + } + if (rawCode.length() == 1 && Character.isDigit(rawCode.charAt(0))) { + return dict.get("0" + rawCode); + } + return null; + } + + private Map loadFromDatabase(String dictCode) { + Map dict = new HashMap<>(); + Dict dictEntity = dictService.getOne(new LambdaQueryWrapper() + .eq(Dict::getDictCode, dictCode) + .eq(Dict::getDeleted, 0) + .last("limit 1")); + if (dictEntity == null) { + logger.warn("码表字典不存在: {}", dictCode); + return dict; + } + List items = dictDataService.list(new LambdaQueryWrapper() + .eq(DictData::getDictId, dictEntity.getDictId()) + .eq(DictData::getDeleted, 0)); + for (DictData item : items) { + if (StrUtil.isNotBlank(item.getDictDataCode())) { + dict.put(item.getDictDataCode().trim(), item.getDictDataName()); + } + } + return dict; + } + + /** + * 带加载时间的码表快照 + */ + private static final class CachedDict { + private final Map dict; + private final long loadedAt; + + private CachedDict(Map dict, long loadedAt) { + this.dict = dict; + this.loadedAt = loadedAt; + } + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/OpenplatSyncConstants.java b/src/main/java/com/gxwebsoft/gxmu/openplat/OpenplatSyncConstants.java new file mode 100644 index 0000000..049a8a7 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/OpenplatSyncConstants.java @@ -0,0 +1,63 @@ +package com.gxwebsoft.gxmu.openplat; + +/** + * 开放平台同步常量 + * + * @author Codex + * @since 2026-09-12 + */ +public final class OpenplatSyncConstants { + + private OpenplatSyncConstants() { + } + + /** + * 租户ID。定时任务线程没有 SecurityContext,必须显式使用该常量, + * 不能依赖 BaseController.getTenantId()。 + */ + public static final int TENANT_ID = 10049; + + /** + * 学院节点挂载的父节点:广西医科大学 + */ + public static final int ORG_PARENT_ID = 24; + + /** + * 必须排除的组织节点:校团委(id=18)。 + * + *

它的 parent_id 同样是 24,与院系节点平级,但被 gxmu_review_flow 与 + * gxmu_review_flow_config 引用,停用逻辑必须避开它。 + */ + public static final int ORG_KEEP_ID = 18; + + /** + * 单批写入条数,避免万级记录一个长事务 + */ + public static final int BATCH_SIZE = 800; + + public static final String EP_DEPT = "RS_BMXX"; + public static final String EP_CLASS = "bjxxb"; + public static final String EP_TEACHER = "scjk_jsyhxx"; + public static final String EP_STUDENT_ALL = "xsjbxxq"; + public static final String EP_STUDENT_BK = "LY_XXBZ_JW_XSXXB"; + public static final String EP_STUDENT_YKT = "yhxx_xsqtykt"; + public static final String EP_STUDENT_YKT_INSCHOOL = "xsyhxx_ykt"; + + /** + * 班级辅导员原始工号在 remarks 中的前缀,便于以后重算 + */ + public static final String REMARK_FDYH_PREFIX = "辅导员号:"; + + /** + * 全部同步段的固定执行顺序。 + * + *

顺序不可调换:后者的学院/班级归属依赖前者写入的组织与班级 id。 + */ + public static final java.util.List ALL_SECTIONS = java.util.Collections.unmodifiableList( + java.util.Arrays.asList( + com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord.TYPE_DEPT, + com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord.TYPE_TEACHER, + com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord.TYPE_CLASS, + com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord.TYPE_STUDENT)); + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/SyncBatchStatus.java b/src/main/java/com/gxwebsoft/gxmu/openplat/SyncBatchStatus.java new file mode 100644 index 0000000..5707ec4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/SyncBatchStatus.java @@ -0,0 +1,37 @@ +package com.gxwebsoft.gxmu.openplat; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 同步批次进度,供前端轮询。 + * + * @author Codex + * @since 2026-09-14 + */ +@Data +public class SyncBatchStatus implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 是否有同步任务在执行 */ + private boolean running; + + private String batchId; + + private String triggerType; + + private String triggerUser; + + private LocalDateTime startTime; + + /** 正在执行的段,未执行时为 null */ + private String currentSection; + + /** 本次批次包含的各段状态,按执行顺序 */ + private List sections; + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/SyncResult.java b/src/main/java/com/gxwebsoft/gxmu/openplat/SyncResult.java new file mode 100644 index 0000000..e323290 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/SyncResult.java @@ -0,0 +1,43 @@ +package com.gxwebsoft.gxmu.openplat; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 单段同步结果 + * + * @author Codex + * @since 2026-09-12 + */ +@Data +public class SyncResult implements Serializable { + + private static final long serialVersionUID = 1L; + + private String syncType; + + private int successCount; + + private int failCount; + + private int skipCount; + + private boolean success = true; + + private String errorMsg; + + public static SyncResult of(String syncType) { + SyncResult result = new SyncResult(); + result.setSyncType(syncType); + return result; + } + + public static SyncResult fail(String syncType, String errorMsg) { + SyncResult result = of(syncType); + result.setSuccess(false); + result.setErrorMsg(errorMsg); + return result; + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/SyncScope.java b/src/main/java/com/gxwebsoft/gxmu/openplat/SyncScope.java new file mode 100644 index 0000000..ea8b3dd --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/SyncScope.java @@ -0,0 +1,44 @@ +package com.gxwebsoft.gxmu.openplat; + +import cn.hutool.core.util.StrUtil; + +/** + * 同步作用域判定。 + * + *

把 docs/adr/0004 的核心规则收在一处:只有「有外部键(同步来源)」且「本次未从平台拉到」 + * 的记录才置为停用;人工创建的记录(没有外部键)永不被停用。 + * + *

这条规则写错的代价是实打实的:平台 1584 个班级里一个研究生班都没有, + * 而库里研究生班是团委手工维护的、没有班级码——一旦按「同步没拉到就停用」无条件执行, + * 会一次性废掉全部研究生班。 + * + * @author Codex + * @since 2026-09-12 + */ +public final class SyncScope { + + private SyncScope() { + } + + /** + * 是否应把该记录置为停用。 + * + * @param externalCode 记录的外部键(单位号/班级码/学号/教工号);为空表示人工创建 + * @param presentInPlatform 本次是否从开放平台拉到 + * @return true 表示应停用 + */ + public static boolean shouldDeactivate(String externalCode, boolean presentInPlatform) { + if (StrUtil.isBlank(externalCode)) { + return false; + } + return !presentInPlatform; + } + + /** + * 该记录是否属于同步来源 + */ + public static boolean isSyncSourced(String externalCode) { + return StrUtil.isNotBlank(externalCode); + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/SyncSectionStatus.java b/src/main/java/com/gxwebsoft/gxmu/openplat/SyncSectionStatus.java new file mode 100644 index 0000000..6c759c9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/SyncSectionStatus.java @@ -0,0 +1,57 @@ +package com.gxwebsoft.gxmu.openplat; + +import lombok.Data; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 同步批次中某一段的状态。 + * + *

段在开始执行前先落一条「进行中」的记录,因此前端能实时看到进度, + * 而不是对着一个卡住的 loading 干等。 + * + * @author Codex + * @since 2026-09-14 + */ +@Data +public class SyncSectionStatus implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 未开始 */ + public static final String STATE_PENDING = "pending"; + /** 进行中 */ + public static final String STATE_RUNNING = "running"; + /** 成功 */ + public static final String STATE_SUCCESS = "success"; + /** 失败 */ + public static final String STATE_FAIL = "fail"; + + private String syncType; + + private String state; + + private Integer successCount; + + private Integer failCount; + + private Integer skipCount; + + private LocalDateTime startTime; + + private LocalDateTime endTime; + + private String errorMsg; + + public static SyncSectionStatus pending(String syncType) { + SyncSectionStatus status = new SyncSectionStatus(); + status.setSyncType(syncType); + status.setState(STATE_PENDING); + status.setSuccessCount(0); + status.setFailCount(0); + status.setSkipCount(0); + return status; + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/SyncSupport.java b/src/main/java/com/gxwebsoft/gxmu/openplat/SyncSupport.java new file mode 100644 index 0000000..67f3e09 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/SyncSupport.java @@ -0,0 +1,236 @@ +package com.gxwebsoft.gxmu.openplat; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.gxwebsoft.common.system.entity.Organization; +import com.gxwebsoft.common.system.mapper.OrganizationMapper; +import com.gxwebsoft.common.system.service.OrganizationService; +import com.gxwebsoft.gxmu.entity.ClassInfo; +import com.gxwebsoft.gxmu.mapper.ClassInfoMapper; +import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent; +import com.gxwebsoft.gxmu.openplat.entity.GxmuTeacher; +import com.gxwebsoft.gxmu.openplat.mapper.GxmuStudentMapper; +import com.gxwebsoft.gxmu.openplat.mapper.GxmuTeacherMapper; +import com.gxwebsoft.gxmu.service.ClassInfoService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 同步过程的公共支撑:码表映射、分批写入、差集停用。 + * + * @author Codex + * @since 2026-09-12 + */ +@Component +public class SyncSupport { + + private static final Logger logger = LoggerFactory.getLogger(SyncSupport.class); + + @Resource + private OrganizationService organizationService; + + @Resource + private ClassInfoService classInfoService; + + @Resource + private OrganizationMapper organizationMapper; + + @Resource + private ClassInfoMapper classInfoMapper; + + @Resource + private GxmuStudentMapper studentMapper; + + @Resource + private GxmuTeacherMapper teacherMapper; + + public static LocalDateTime now() { + return LocalDateTime.now(); + } + + /** + * 单位号 -> 组织ID + */ + public Map orgIdByCode() { + Map map = new HashMap<>(); + List list = organizationService.list(new LambdaQueryWrapper() + .eq(Organization::getTenantId, OpenplatSyncConstants.TENANT_ID) + .isNotNull(Organization::getOrganizationCode)); + for (Organization org : list) { + String code = StrUtil.trimToNull(org.getOrganizationCode()); + if (code != null) { + map.put(code, org.getOrganizationId()); + } + } + return map; + } + + /** + * 班级码 -> 班级ID + */ + public Map classIdByCode() { + Map map = new HashMap<>(); + List list = classInfoService.list(new LambdaQueryWrapper() + .eq(ClassInfo::getTenantId, OpenplatSyncConstants.TENANT_ID) + .isNotNull(ClassInfo::getClassCode)); + for (ClassInfo item : list) { + String code = StrUtil.trimToNull(item.getClassCode()); + if (code != null) { + map.put(code, item.getId()); + } + } + return map; + } + + /** + * 组织ID -> 组织名称 + */ + public Map orgNameById() { + Map map = new HashMap<>(); + List list = organizationService.list(new LambdaQueryWrapper() + .eq(Organization::getTenantId, OpenplatSyncConstants.TENANT_ID)); + for (Organization org : list) { + map.put(org.getOrganizationId(), org.getOrganizationName()); + } + return map; + } + + /** + * 班级ID -> 班级名称 + */ + public Map classNameById() { + Map map = new HashMap<>(); + List list = classInfoService.list(new LambdaQueryWrapper() + .eq(ClassInfo::getTenantId, OpenplatSyncConstants.TENANT_ID)); + for (ClassInfo item : list) { + map.put(item.getId(), item.getClassName()); + } + return map; + } + + /** + * 教工号 -> 姓名 + */ + public Map teacherNameByJgh() { + Map map = new HashMap<>(); + List list = teacherMapper.selectAllForNameLookup(OpenplatSyncConstants.TENANT_ID); + for (GxmuTeacher item : list) { + String jgh = StrUtil.trimToNull(item.getJgh()); + if (jgh != null) { + map.put(jgh, item.getXm()); + } + } + return map; + } + + /** + * 分批写入学生名册 + */ + public void upsertStudents(List list) { + for (List chunk : CollUtil.split(list, OpenplatSyncConstants.BATCH_SIZE)) { + studentMapper.batchUpsert(chunk); + } + } + + /** + * 分批写入教师名册 + */ + public void upsertTeachers(List list) { + for (List chunk : CollUtil.split(list, OpenplatSyncConstants.BATCH_SIZE)) { + teacherMapper.batchUpsert(chunk); + } + } + + /** + * 分批写入班级。 + * + *

用多行 upsert 而不是 MyBatis-Plus 的 saveBatch/updateBatchById:后者逐条发语句, + * 在公网数据库上被往返延迟主导——实测单条 25.9ms,1584 条就是 41 秒, + * 而多行 upsert 只要 0.2 秒。 + */ + public void upsertClasses(List list) { + for (List chunk : CollUtil.split(list, OpenplatSyncConstants.BATCH_SIZE)) { + classInfoMapper.batchUpsert(chunk); + } + } + + /** + * 分批写入部门 + */ + public void upsertOrganizations(List list) { + for (List chunk : CollUtil.split(list, OpenplatSyncConstants.BATCH_SIZE)) { + organizationMapper.batchUpsert(chunk); + } + } + + /** + * 学生差集停用。 + * + *

在 Java 侧求差集而不是拼 NOT IN,避免 9 万多个参数。 + * 只处理同步来源的记录(xh 非空),见 docs/adr/0004。 + */ + public int deactivateMissingStudents(Set incomingXh) { + List existing = studentMapper.selectAllXh(OpenplatSyncConstants.TENANT_ID); + List missing = new ArrayList<>(); + for (String xh : existing) { + if (SyncScope.shouldDeactivate(xh, incomingXh.contains(xh))) { + missing.add(xh); + } + } + if (missing.isEmpty()) { + return 0; + } + logger.warn("学生名册中有 {} 条记录本次未从开放平台拉到, 置为停用", missing.size()); + for (List chunk : CollUtil.split(missing, OpenplatSyncConstants.BATCH_SIZE)) { + studentMapper.deactivateByXh(OpenplatSyncConstants.TENANT_ID, chunk); + } + return missing.size(); + } + + /** + * 教师差集停用 + */ + public int deactivateMissingTeachers(Set incomingJgh) { + List existing = teacherMapper.selectAllJgh(OpenplatSyncConstants.TENANT_ID); + List missing = new ArrayList<>(); + for (String jgh : existing) { + if (SyncScope.shouldDeactivate(jgh, incomingJgh.contains(jgh))) { + missing.add(jgh); + } + } + if (missing.isEmpty()) { + return 0; + } + logger.warn("教师名册中有 {} 条记录本次未从开放平台拉到, 置为停用", missing.size()); + for (List chunk : CollUtil.split(missing, OpenplatSyncConstants.BATCH_SIZE)) { + teacherMapper.deactivateByJgh(OpenplatSyncConstants.TENANT_ID, chunk); + } + return missing.size(); + } + + /** + * 收集一列非空值 + */ + public static Set collectNonBlank(Iterable values) { + Set set = new HashSet<>(); + for (String value : values) { + String text = StrUtil.trimToNull(value); + if (text != null) { + set.add(text); + } + } + return set; + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/claim/ClaimResult.java b/src/main/java/com/gxwebsoft/gxmu/openplat/claim/ClaimResult.java new file mode 100644 index 0000000..86c7cc7 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/claim/ClaimResult.java @@ -0,0 +1,38 @@ +package com.gxwebsoft.gxmu.openplat.claim; + +import lombok.Data; + +import java.io.Serializable; + +/** + * 学生认领结果 + * + * @author Codex + * @since 2026-09-12 + */ +@Data +public class ClaimResult implements Serializable { + + private static final long serialVersionUID = 1L; + + private boolean success; + + private String message; + + private String studentName; + + public static ClaimResult ok(String message) { + ClaimResult result = new ClaimResult(); + result.setSuccess(true); + result.setMessage(message); + return result; + } + + public static ClaimResult fail(String message) { + ClaimResult result = new ClaimResult(); + result.setSuccess(false); + result.setMessage(message); + return result; + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/claim/StudentClaimService.java b/src/main/java/com/gxwebsoft/gxmu/openplat/claim/StudentClaimService.java new file mode 100644 index 0000000..9c19c9e --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/claim/StudentClaimService.java @@ -0,0 +1,60 @@ +package com.gxwebsoft.gxmu.openplat.claim; + +import com.gxwebsoft.common.system.entity.User; + +/** + * 学生认领服务。 + * + *

名册与登录账号分离(docs/adr/0001),认领负责把两者关联起来。 + * 学生登录仍走微信授权手机号,认领只回填 sys_user 的 user_code / real_name / sex / organization_id, + * 不修改 username 与 phone。 + * + * @author Codex + * @since 2026-09-12 + */ +public interface StudentClaimService { + + /** + * 按手机号自动认领。 + * + *

命中且唯一才认领;命中多条或命中不到都返回 false,由自助认领兜底。 + * + * @param user 登录用户 + * @return 是否认领成功 + */ + boolean autoClaimByPhone(User user); + + /** + * 自助认领:学号 + 姓名校验。 + * + * @param user 登录用户 + * @param xh 学号 + * @param xm 姓名 + * @return 认领结果 + */ + ClaimResult claimByXhAndName(User user, String xh, String xm); + + /** + * 人工认领/改绑。 + * + *

优先用 userId;未给 userId 时按 phone 查账号;phone 也未给时用名册上的手机号查。 + * 三者都定位不到账号则失败。 + * + * @param studentId 名册记录ID + * @param userId 目标用户ID,可为空 + * @param phone 目标用户手机号,可为空 + * @param tenantId 租户ID + * @return 是否成功 + */ + boolean bind(Integer studentId, Integer userId, String phone, Integer tenantId); + + /** + * 解除认领 + * + * @param studentId 名册记录ID + * @param tenantId 租户ID + * @return 是否成功 + */ + boolean unbind(Integer studentId, Integer tenantId); + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/claim/StudentClaimServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/openplat/claim/StudentClaimServiceImpl.java new file mode 100644 index 0000000..eda8d25 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/claim/StudentClaimServiceImpl.java @@ -0,0 +1,179 @@ +package com.gxwebsoft.gxmu.openplat.claim; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.gxwebsoft.common.system.entity.User; +import com.gxwebsoft.common.system.service.UserService; +import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants; +import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent; +import com.gxwebsoft.gxmu.openplat.mapper.GxmuStudentMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 学生认领服务实现。 + * + * @author Codex + * @since 2026-09-12 + */ +@Service +public class StudentClaimServiceImpl implements StudentClaimService { + + private static final Logger logger = LoggerFactory.getLogger(StudentClaimServiceImpl.class); + + @Resource + private GxmuStudentMapper studentMapper; + + @Resource + private UserService userService; + + @Override + public boolean autoClaimByPhone(User user) { + if (user == null || user.getUserId() == null) { + return false; + } + String phone = StrUtil.trimToNull(user.getPhone()); + if (phone == null) { + return false; + } + List candidates = studentMapper.selectList(new LambdaQueryWrapper() + .eq(GxmuStudent::getTenantId, OpenplatSyncConstants.TENANT_ID) + .eq(GxmuStudent::getSjh, phone) + .isNull(GxmuStudent::getUserId)); + if (candidates.isEmpty()) { + return false; + } + if (candidates.size() > 1) { + // 一个手机号对应多个学号,不自动认领,交由自助认领处理 + logger.warn("手机号匹配到 {} 条学生名册记录, 不自动认领 userId={}", candidates.size(), user.getUserId()); + return false; + } + return doClaim(user, candidates.get(0)); + } + + @Override + public ClaimResult claimByXhAndName(User user, String xh, String xm) { + if (user == null || user.getUserId() == null) { + return ClaimResult.fail("请先登录"); + } + String studentNo = StrUtil.trimToNull(xh); + String name = StrUtil.trimToNull(xm); + if (studentNo == null || name == null) { + return ClaimResult.fail("请填写学号与姓名"); + } + GxmuStudent student = studentMapper.selectOne(new LambdaQueryWrapper() + .eq(GxmuStudent::getTenantId, OpenplatSyncConstants.TENANT_ID) + .eq(GxmuStudent::getXh, studentNo) + .last("limit 1")); + if (student == null) { + return ClaimResult.fail("学号不存在"); + } + if (!name.equals(StrUtil.trim(student.getXm()))) { + return ClaimResult.fail("姓名与学号不匹配"); + } + if (student.getUserId() != null) { + if (student.getUserId().equals(user.getUserId())) { + return ClaimResult.ok("该学号已绑定到当前账号"); + } + return ClaimResult.fail("该学号已被其他账号认领"); + } + return doClaim(user, student) + ? ClaimResult.ok("认领成功") + : ClaimResult.fail("认领失败"); + } + + @Override + public boolean bind(Integer studentId, Integer userId, String phone, Integer tenantId) { + GxmuStudent student = getStudent(studentId, tenantId); + if (student == null) { + return false; + } + User user = null; + if (userId != null) { + user = userService.getById(userId); + } + if (user == null) { + String target = StrUtil.trimToNull(phone); + if (target == null) { + target = StrUtil.trimToNull(student.getSjh()); + } + if (target != null) { + user = userService.getByPhone(target); + } + } + if (user == null) { + logger.warn("手动认领失败: 未找到目标账号 studentId={}", studentId); + return false; + } + if (student.getUserId() != null && !student.getUserId().equals(user.getUserId())) { + // 改绑:先解除旧关联 + unbind(studentId, tenantId); + } + return doClaim(user, student); + } + + @Override + public boolean unbind(Integer studentId, Integer tenantId) { + GxmuStudent student = getStudent(studentId, tenantId); + if (student == null) { + return false; + } + // userId 为 null 时 updateById 不会清空该列,用 UpdateWrapper 显式置空 + studentMapper.update(null, new LambdaUpdateWrapper() + .eq(GxmuStudent::getId, student.getId()) + .set(GxmuStudent::getUserId, null) + .set(GxmuStudent::getClaimTime, null) + .set(GxmuStudent::getUpdateTime, LocalDateTime.now())); + return true; + } + + private GxmuStudent getStudent(Integer studentId, Integer tenantId) { + if (studentId == null) { + return null; + } + return studentMapper.selectOne(new LambdaQueryWrapper() + .eq(GxmuStudent::getId, studentId) + .eq(GxmuStudent::getTenantId, tenantId == null ? OpenplatSyncConstants.TENANT_ID : tenantId) + .last("limit 1")); + } + + /** + * 执行认领:回填 sys_user 的四个字段,并回写名册的 user_id / claim_time。 + * + *

用 updateById 做局部更新,只写这四个字段,绝不触碰 username 与 phone, + * 从而绕开 UserServiceImpl 中全局的唯一性校验。 + */ + private boolean doClaim(User user, GxmuStudent student) { + if (student.getUserId() != null && !student.getUserId().equals(user.getUserId())) { + logger.warn("名册记录已被其他账号认领, 跳过: studentId={}", student.getId()); + return false; + } + User patch = new User(); + patch.setUserId(user.getUserId()); + patch.setUserCode(student.getXh()); + patch.setRealName(student.getXm()); + if (StrUtil.isNotBlank(student.getSex())) { + patch.setSex(student.getSex()); + } + if (student.getCollegeId() != null) { + patch.setOrganizationId(student.getCollegeId()); + } + userService.updateById(patch); + + GxmuStudent studentPatch = new GxmuStudent(); + studentPatch.setId(student.getId()); + studentPatch.setUserId(user.getUserId()); + studentPatch.setClaimTime(LocalDateTime.now()); + studentPatch.setUpdateTime(LocalDateTime.now()); + studentMapper.updateById(studentPatch); + logger.warn("学生认领成功: userId={} xh={}", user.getUserId(), student.getXh()); + return true; + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/controller/GxmuStudentController.java b/src/main/java/com/gxwebsoft/gxmu/openplat/controller/GxmuStudentController.java new file mode 100644 index 0000000..1bbf085 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/controller/GxmuStudentController.java @@ -0,0 +1,235 @@ +package com.gxwebsoft.gxmu.openplat.controller; + +import cn.hutool.core.util.DesensitizedUtil; +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.common.system.entity.User; +import com.gxwebsoft.gxmu.entity.ClassInfo; +import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants; +import com.gxwebsoft.gxmu.openplat.SyncSupport; +import com.gxwebsoft.gxmu.openplat.claim.ClaimResult; +import com.gxwebsoft.gxmu.openplat.claim.StudentClaimService; +import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent; +import com.gxwebsoft.gxmu.openplat.mapper.GxmuStudentMapper; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +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; + +/** + * 学生名册与认领控制器。 + * + *

响应中的手机号与身份证一律脱敏,原始值不出本控制器。 + * + * @author Codex + * @since 2026-09-12 + */ +@Api(tags = "学生名册") +@RestController +@RequestMapping("/api/gxmu/student") +public class GxmuStudentController extends BaseController { + + @Resource + private GxmuStudentMapper studentMapper; + + @Resource + private StudentClaimService studentClaimService; + + @Resource + private SyncSupport syncSupport; + + @PreAuthorize("hasAuthority('gxmu:student: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 = "classId", required = false) Integer classId, + @RequestParam(value = "xh", required = false) String xh, + @RequestParam(value = "xm", required = false) String xm, + @RequestParam(value = "pyccmc", required = false) String pyccmc, + @RequestParam(value = "status", required = false) Integer status, + @RequestParam(value = "claimed", required = false) Boolean claimed) { + LambdaQueryWrapper wrapper = buildWrapper(collegeId, classId, xh, xm, pyccmc, status, claimed); + Page pageParam = new Page<>(page, limit); + IPage result = studentMapper.selectPage(pageParam, wrapper); + List records = result.getRecords(); + desensitize(records); + enrich(records); + return success(new PageResult<>(records, result.getTotal())); + } + + @PreAuthorize("hasAuthority('gxmu:student:list')") + @ApiOperation("学生名册详情") + @GetMapping("/{id}") + public ApiResult get(@PathVariable("id") Integer id) { + GxmuStudent student = studentMapper.selectById(id); + if (student == null) { + return fail("记录不存在", null); + } + desensitize(java.util.Collections.singletonList(student)); + enrich(java.util.Collections.singletonList(student)); + return success(student); + } + + @ApiOperation("导出学生名册(脱敏)") + @PreAuthorize("hasAuthority('gxmu:student:export')") + @OperationLog + @PostMapping("/export") + public ApiResult> export( + @RequestParam(value = "collegeId", required = false) Integer collegeId, + @RequestParam(value = "classId", required = false) Integer classId, + @RequestParam(value = "status", required = false) Integer status) { + LambdaQueryWrapper wrapper = buildWrapper(collegeId, classId, null, null, null, status, null); + List records = studentMapper.selectList(wrapper); + desensitize(records); + enrich(records); + return success(records); + } + + @ApiOperation("自助认领(学号+姓名)") + @PostMapping("/claim") + public ApiResult claim(@RequestBody ClaimParam param) { + User user = getLoginUser(); + ClaimResult result = studentClaimService.claimByXhAndName(user, + param == null ? null : param.getXh(), param == null ? null : param.getXm()); + return result.isSuccess() ? success(result.getMessage(), result) : fail(result.getMessage(), result); + } + + @OperationLog + @PreAuthorize("hasAuthority('gxmu:student:list')") + @ApiOperation("手动认领/改绑") + @PutMapping("/{id}/claim") + public ApiResult bind(@PathVariable("id") Integer id, @RequestBody ClaimParam param) { + boolean ok = studentClaimService.bind(id, param == null ? null : param.getUserId(), + param == null ? null : param.getPhone(), getTenantId()); + return ok ? success("认领成功") : fail("认领失败:未找到匹配的账号"); + } + + @OperationLog + @PreAuthorize("hasAuthority('gxmu:student:list')") + @ApiOperation("解除认领") + @PutMapping("/{id}/unclaim") + public ApiResult unbind(@PathVariable("id") Integer id) { + return studentClaimService.unbind(id, getTenantId()) ? success("已解除认领") : fail("解除失败"); + } + + private LambdaQueryWrapper buildWrapper(Integer collegeId, Integer classId, String xh, + String xm, String pyccmc, Integer status, Boolean claimed) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(GxmuStudent::getTenantId, getTenantId()); + wrapper.eq(collegeId != null, GxmuStudent::getCollegeId, collegeId); + wrapper.eq(classId != null, GxmuStudent::getClassId, classId); + wrapper.eq(StrUtil.isNotBlank(xh), GxmuStudent::getXh, StrUtil.trim(xh)); + wrapper.like(StrUtil.isNotBlank(xm), GxmuStudent::getXm, StrUtil.trim(xm)); + wrapper.eq(StrUtil.isNotBlank(pyccmc), GxmuStudent::getPyccmc, pyccmc); + // 默认只看在校生 + wrapper.eq(status != null, GxmuStudent::getStatus, status); + wrapper.eq(status == null, GxmuStudent::getStatus, 1); + if (claimed != null) { + if (claimed) { + wrapper.isNotNull(GxmuStudent::getUserId); + } else { + wrapper.isNull(GxmuStudent::getUserId); + } + } + wrapper.orderByAsc(GxmuStudent::getCollegeId).orderByAsc(GxmuStudent::getXh); + return wrapper; + } + + /** + * 脱敏:手机号与身份证明文绝不离开后端。 + */ + private void desensitize(List records) { + for (GxmuStudent student : records) { + if (StrUtil.isNotBlank(student.getSjh())) { + student.setSjh(DesensitizedUtil.mobilePhone(student.getSjh())); + } + if (StrUtil.isNotBlank(student.getSfzjh()) && student.getSfzjh().length() >= 8) { + student.setSfzjh(DesensitizedUtil.idCardNum(student.getSfzjh(), 4, 4)); + } + } + } + + private void enrich(List records) { + if (records.isEmpty()) { + return; + } + Map orgNames = syncSupport.orgNameById(); + Map classNames = syncSupport.classNameById(); + for (GxmuStudent student : records) { + if (student.getCollegeId() != null) { + student.setCollegeName(orgNames.get(student.getCollegeId())); + } + if (student.getClassId() != null) { + student.setClassName(classNames.get(student.getClassId())); + } + } + } + + /** + * 认领参数 + */ + @io.swagger.annotations.ApiModel("学生认领参数") + public static class ClaimParam { + @io.swagger.annotations.ApiModelProperty("学号") + private String xh; + @io.swagger.annotations.ApiModelProperty("姓名") + private String xm; + @io.swagger.annotations.ApiModelProperty("目标用户ID(手动认领时)") + private Integer userId; + @io.swagger.annotations.ApiModelProperty("目标用户手机号(手动认领时, 可不传则用名册手机号)") + private String phone; + + public String getXh() { + return xh; + } + + public void setXh(String xh) { + this.xh = xh; + } + + public String getXm() { + return xm; + } + + public void setXm(String xm) { + this.xm = xm; + } + + public Integer getUserId() { + return userId; + } + + public void setUserId(Integer userId) { + this.userId = userId; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/controller/GxmuSyncController.java b/src/main/java/com/gxwebsoft/gxmu/openplat/controller/GxmuSyncController.java new file mode 100644 index 0000000..9e6d75a --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/controller/GxmuSyncController.java @@ -0,0 +1,129 @@ +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.common.system.entity.User; +import com.gxwebsoft.gxmu.openplat.SyncBatchStatus; +import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord; +import com.gxwebsoft.gxmu.openplat.mapper.GxmuSyncRecordMapper; +import com.gxwebsoft.gxmu.openplat.service.OpenplatSyncService; +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.Collections; +import java.util.List; + +/** + * 开放平台数据同步控制器。 + * + *

手动同步一律是异步的:提交后立即返回批次号,前端轮询 {@code /status} 看进度。 + * 学生全量约 9.8 万条、耗时 75~90 秒,同步阻塞式 HTTP 会先撞上网关或浏览器超时, + * 页面看起来像卡死而其实后端还在跑,容易被误当成失败而重复点击。 + * + * @author Codex + * @since 2026-09-12 + */ +@Api(tags = "开放平台数据同步") +@RestController +@RequestMapping("/api/gxmu/sync") +public class GxmuSyncController extends BaseController { + + @Resource + private OpenplatSyncService openplatSyncService; + + @Resource + private GxmuSyncRecordMapper gxmuSyncRecordMapper; + + @OperationLog + @PreAuthorize("hasAuthority('gxmu:sync:manual')") + @ApiOperation("同步部门(异步, 返回批次号)") + @PostMapping("/dept") + public ApiResult dept() { + return submit(Collections.singletonList(GxmuSyncRecord.TYPE_DEPT)); + } + + @OperationLog + @PreAuthorize("hasAuthority('gxmu:sync:manual')") + @ApiOperation("同步教师(异步, 返回批次号)") + @PostMapping("/teacher") + public ApiResult teacher() { + return submit(Collections.singletonList(GxmuSyncRecord.TYPE_TEACHER)); + } + + @OperationLog + @PreAuthorize("hasAuthority('gxmu:sync:manual')") + @ApiOperation("同步班级(异步, 返回批次号)") + @PostMapping("/class") + public ApiResult classInfo() { + return submit(Collections.singletonList(GxmuSyncRecord.TYPE_CLASS)); + } + + @OperationLog + @PreAuthorize("hasAuthority('gxmu:sync:manual')") + @ApiOperation("同步学生(异步, 返回批次号)") + @PostMapping("/student") + public ApiResult student() { + return submit(Collections.singletonList(GxmuSyncRecord.TYPE_STUDENT)); + } + + @OperationLog + @PreAuthorize("hasAuthority('gxmu:sync:manual')") + @ApiOperation("全部同步(异步, 返回批次号)") + @PostMapping("/all") + public ApiResult all() { + return submit(null); + } + + @PreAuthorize("hasAuthority('gxmu:sync:manual')") + @ApiOperation("同步进度(当前或最近一次批次)") + @GetMapping("/status") + public ApiResult status() { + return success(openplatSyncService.currentStatus()); + } + + @PreAuthorize("hasAuthority('gxmu:sync:manual')") + @ApiOperation("同步记录分页查询") + @GetMapping("/record/page") + public ApiResult> recordPage( + @RequestParam(value = "page", defaultValue = "1") Integer page, + @RequestParam(value = "limit", defaultValue = "20") Integer limit, + @RequestParam(value = "syncType", required = false) String syncType, + @RequestParam(value = "batchId", required = false) String batchId) { + Page pageParam = new Page<>(page, limit); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(GxmuSyncRecord::getTenantId, getTenantId()); + wrapper.eq(StrUtil.isNotBlank(syncType), GxmuSyncRecord::getSyncType, syncType); + wrapper.eq(StrUtil.isNotBlank(batchId), GxmuSyncRecord::getBatchId, batchId); + wrapper.orderByDesc(GxmuSyncRecord::getStartTime).orderByDesc(GxmuSyncRecord::getId); + IPage result = gxmuSyncRecordMapper.selectPage(pageParam, wrapper); + return success(result); + } + + private ApiResult submit(List sections) { + String batchId = openplatSyncService.submitAsync( + GxmuSyncRecord.TRIGGER_MANUAL, currentUsername(), sections); + if (batchId == null) { + return fail("已有同步任务正在执行,请等待其完成后再试", null); + } + return success("已提交同步任务", batchId); + } + + private String currentUsername() { + User user = getLoginUser(); + return user == null ? null : user.getUsername(); + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuStudent.java b/src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuStudent.java new file mode 100644 index 0000000..bc1a05b --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuStudent.java @@ -0,0 +1,135 @@ +package com.gxwebsoft.gxmu.openplat.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 学生名册(开放平台同步)。 + * + *

与 sys_user 登录账号分离,见 docs/adr/0001。 + * mzm/nation、zzmmm/politics 成对保存「原始码 + 解析名称」,见 docs/adr/0006。 + * + * @author Codex + * @since 2026-09-12 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@ApiModel(value = "GxmuStudent对象", description = "学生名册") +@TableName("gxmu_student") +public class GxmuStudent implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + @ApiModelProperty(value = "学号(开放平台外部键)") + private String xh; + + @ApiModelProperty(value = "姓名") + private String xm; + + @ApiModelProperty(value = "性别码(平台原始)") + private String xbm; + + @ApiModelProperty(value = "性别名称") + private String sex; + + @ApiModelProperty(value = "民族码(平台原始)") + private String mzm; + + @ApiModelProperty(value = "民族名称") + private String nation; + + @ApiModelProperty(value = "政治面貌码(平台原始)") + private String zzmmm; + + @ApiModelProperty(value = "政治面貌名称") + private String politics; + + @ApiModelProperty(value = "身份证件号") + private String sfzjh; + + @ApiModelProperty(value = "手机号(认领键)") + private String sjh; + + @ApiModelProperty(value = "平台班级码(原始)") + private String bjm; + + @ApiModelProperty(value = "平台班号(学籍库原始)") + private String bh; + + @ApiModelProperty(value = "解析出的班级ID") + private Integer classId; + + @ApiModelProperty(value = "解析出的学院ID") + private Integer collegeId; + + @ApiModelProperty(value = "单位号(平台原始)") + private String dwh; + + @ApiModelProperty(value = "专业号") + private String zyh; + + @ApiModelProperty(value = "专业名称") + private String zymc; + + @ApiModelProperty(value = "培养层次") + private String pyccmc; + + @ApiModelProperty(value = "是否在校") + private String sfzx; + + @ApiModelProperty(value = "学籍状态") + private String xjzt; + + @ApiModelProperty(value = "学生当前状态") + private String xsdqzt; + + @ApiModelProperty(value = "出生日期") + private String csrq; + + @ApiModelProperty(value = "认领后的 sys_user.user_id") + private Integer userId; + + @ApiModelProperty(value = "认领时间") + private LocalDateTime claimTime; + + @ApiModelProperty(value = "状态:1在校 0毕业/结业/休学") + private Integer status; + + @ApiModelProperty(value = "最近同步时间") + private LocalDateTime syncTime; + + @ApiModelProperty(value = "租户ID") + private Integer tenantId; + + @ApiModelProperty(value = "创建时间") + private LocalDateTime createTime; + + @ApiModelProperty(value = "更新时间") + private LocalDateTime updateTime; + + @ApiModelProperty(value = "删除标记:0未删 1已删") + @TableLogic + private Integer deleted; + + @TableField(exist = false) + @ApiModelProperty(value = "学院名称") + private String collegeName; + + @TableField(exist = false) + @ApiModelProperty(value = "班级名称") + private String className; + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuSyncRecord.java b/src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuSyncRecord.java new file mode 100644 index 0000000..2dd182e --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuSyncRecord.java @@ -0,0 +1,90 @@ +package com.gxwebsoft.gxmu.openplat.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 开放平台同步记录。一接口一次一行,batch_id 关联同一次触发。 + * + *

注意:error_msg 中不得写入手机号或身份证号。 + * + * @author Codex + * @since 2026-09-12 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@ApiModel(value = "GxmuSyncRecord对象", description = "开放平台同步记录") +@TableName("gxmu_sync_record") +public class GxmuSyncRecord implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final String TYPE_DEPT = "dept"; + public static final String TYPE_TEACHER = "teacher"; + public static final String TYPE_CLASS = "class"; + public static final String TYPE_STUDENT = "student"; + + public static final String TRIGGER_SCHEDULE = "schedule"; + public static final String TRIGGER_MANUAL = "manual"; + + public static final int STATUS_SUCCESS = 0; + public static final int STATUS_FAIL = 1; + /** + * 段已开始、尚未结束。用于让前端看到实时进度,也能识别出被中断的批次。 + */ + public static final int STATUS_RUNNING = 2; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + @ApiModelProperty(value = "批次号(同一次触发的各段共享)") + private String batchId; + + @ApiModelProperty(value = "同步类型:dept/teacher/class/student") + private String syncType; + + @ApiModelProperty(value = "触发方式:schedule/manual") + private String triggerType; + + @ApiModelProperty(value = "触发人(手动触发时)") + private String triggerUser; + + @ApiModelProperty(value = "开始时间") + private LocalDateTime startTime; + + @ApiModelProperty(value = "结束时间") + private LocalDateTime endTime; + + @ApiModelProperty(value = "成功条数") + private Integer successCount; + + @ApiModelProperty(value = "失败条数") + private Integer failCount; + + @ApiModelProperty(value = "跳过条数") + private Integer skipCount; + + @ApiModelProperty(value = "状态:0成功 1失败") + private Integer status; + + @ApiModelProperty(value = "错误摘要(不含手机号/身份证)") + private String errorMsg; + + @ApiModelProperty(value = "租户ID") + private Integer tenantId; + + @ApiModelProperty(value = "创建时间") + private LocalDateTime createTime; + + @ApiModelProperty(value = "更新时间") + private LocalDateTime updateTime; + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuTeacher.java b/src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuTeacher.java new file mode 100644 index 0000000..94080bf --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuTeacher.java @@ -0,0 +1,82 @@ +package com.gxwebsoft.gxmu.openplat.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 教师名册(开放平台同步)。 + * + *

教师不建登录账号,只用于解析班级的辅导员工号与后台选人。 + * + * @author Codex + * @since 2026-09-12 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@ApiModel(value = "GxmuTeacher对象", description = "教师名册") +@TableName("gxmu_teacher") +public class GxmuTeacher implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + @ApiModelProperty(value = "教工号(开放平台外部键)") + private String jgh; + + @ApiModelProperty(value = "姓名") + private String xm; + + @ApiModelProperty(value = "性别码(平台原始)") + private String xbm; + + @ApiModelProperty(value = "性别名称") + private String sex; + + @ApiModelProperty(value = "单位号(平台原始)") + private String dwh; + + @ApiModelProperty(value = "解析出的学院ID") + private Integer collegeId; + + @ApiModelProperty(value = "科室教研室编号") + private String ksjybh; + + @ApiModelProperty(value = "手机号(敏感字段, 不对外输出)") + private String sjh; + + @ApiModelProperty(value = "状态:1启用 0停用") + private Integer status; + + @ApiModelProperty(value = "最近同步时间") + private LocalDateTime syncTime; + + @ApiModelProperty(value = "租户ID") + private Integer tenantId; + + @ApiModelProperty(value = "创建时间") + private LocalDateTime createTime; + + @ApiModelProperty(value = "更新时间") + private LocalDateTime updateTime; + + @ApiModelProperty(value = "删除标记:0未删 1已删") + @TableLogic + private Integer deleted; + + @TableField(exist = false) + @ApiModelProperty(value = "学院名称") + private String collegeName; + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuStudentMapper.java b/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuStudentMapper.java new file mode 100644 index 0000000..55b19b3 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuStudentMapper.java @@ -0,0 +1,50 @@ +package com.gxwebsoft.gxmu.openplat.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 学生名册Mapper + * + * @author Codex + * @since 2026-09-12 + */ +public interface GxmuStudentMapper extends BaseMapper { + + /** + * 按 (tenant_id, xh) 批量新增或更新。 + * + *

只覆盖平台权威字段;user_id / claim_time 由认领流程维护,不在此处覆盖。 + * create_time 不在 UPDATE 子句中,重复执行不会刷新创建时间。 + * + * @param list 学生名册 + * @return 影响行数 + */ + int batchUpsert(@Param("list") List list); + + /** + * 查询全部同步来源的学号。 + * + *

用于在 Java 侧与本次拉取到的学号求差集——上游消失的记录通常为零, + * 这样就不必用 9 万多个参数去拼 NOT IN。 + * + * @param tenantId 租户ID + * @return 学号列表 + */ + List selectAllXh(@Param("tenantId") Integer tenantId); + + /** + * 按学号批量置为停用。 + * + *

只作用于同步来源的记录(xh 非空),人工创建的记录不在其中,见 docs/adr/0004。 + * + * @param tenantId 租户ID + * @param xhs 学号列表(调用方需自行分批) + * @return 影响行数 + */ + int deactivateByXh(@Param("tenantId") Integer tenantId, @Param("xhs") List xhs); + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuSyncRecordMapper.java b/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuSyncRecordMapper.java new file mode 100644 index 0000000..11fb3c5 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuSyncRecordMapper.java @@ -0,0 +1,14 @@ +package com.gxwebsoft.gxmu.openplat.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord; + +/** + * 开放平台同步记录Mapper + * + * @author Codex + * @since 2026-09-12 + */ +public interface GxmuSyncRecordMapper extends BaseMapper { + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuTeacherMapper.java b/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuTeacherMapper.java new file mode 100644 index 0000000..e5cff6a --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuTeacherMapper.java @@ -0,0 +1,50 @@ +package com.gxwebsoft.gxmu.openplat.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.gxwebsoft.gxmu.openplat.entity.GxmuTeacher; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 教师名册Mapper + * + * @author Codex + * @since 2026-09-12 + */ +public interface GxmuTeacherMapper extends BaseMapper { + + /** + * 按 (tenant_id, jgh) 批量新增或更新 + * + * @param list 教师名册 + * @return 影响行数 + */ + int batchUpsert(@Param("list") List list); + + /** + * 查询全部同步来源的教工号,用于在 Java 侧求差集 + * + * @param tenantId 租户ID + * @return 教工号列表 + */ + List selectAllJgh(@Param("tenantId") Integer tenantId); + + /** + * 按教工号批量置为停用 + * + * @param tenantId 租户ID + * @param jghs 教工号列表(调用方需自行分批) + * @return 影响行数 + */ + int deactivateByJgh(@Param("tenantId") Integer tenantId, @Param("jghs") List jghs); + + /** + * 查询全部 教工号 -> 姓名,用于解析班级的辅导员/班主任 + * + * @param tenantId 租户ID + * @return 列表,元素含 jgh 与 xm + */ + List selectAllForNameLookup(@Param("tenantId") Integer tenantId); + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/xml/GxmuStudentMapper.xml b/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/xml/GxmuStudentMapper.xml new file mode 100644 index 0000000..eed8b95 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/xml/GxmuStudentMapper.xml @@ -0,0 +1,72 @@ + + + + + + INSERT INTO gxmu_student ( + xh, xm, xbm, sex, mzm, nation, zzmmm, politics, sfzjh, sjh, + bjm, bh, class_id, college_id, dwh, zyh, zymc, pyccmc, + sfzx, xjzt, xsdqzt, csrq, status, sync_time, tenant_id, + create_time, update_time, deleted + ) VALUES + + ( + #{item.xh}, #{item.xm}, #{item.xbm}, #{item.sex}, #{item.mzm}, #{item.nation}, + #{item.zzmmm}, #{item.politics}, #{item.sfzjh}, #{item.sjh}, + #{item.bjm}, #{item.bh}, #{item.classId}, #{item.collegeId}, #{item.dwh}, + #{item.zyh}, #{item.zymc}, #{item.pyccmc}, + #{item.sfzx}, #{item.xjzt}, #{item.xsdqzt}, #{item.csrq}, + #{item.status}, #{item.syncTime}, #{item.tenantId}, + #{item.createTime}, #{item.updateTime}, #{item.deleted} + ) + + ON DUPLICATE KEY UPDATE + xm = VALUES(xm), + xbm = VALUES(xbm), + sex = VALUES(sex), + mzm = VALUES(mzm), + nation = VALUES(nation), + zzmmm = VALUES(zzmmm), + politics = VALUES(politics), + sfzjh = VALUES(sfzjh), + sjh = VALUES(sjh), + bjm = VALUES(bjm), + bh = VALUES(bh), + class_id = VALUES(class_id), + college_id = VALUES(college_id), + dwh = VALUES(dwh), + zyh = VALUES(zyh), + zymc = VALUES(zymc), + pyccmc = VALUES(pyccmc), + sfzx = VALUES(sfzx), + xjzt = VALUES(xjzt), + xsdqzt = VALUES(xsdqzt), + csrq = VALUES(csrq), + status = VALUES(status), + sync_time = VALUES(sync_time), + update_time = VALUES(update_time), + deleted = VALUES(deleted) + + + + + + UPDATE gxmu_student + SET status = 0, update_time = NOW() + WHERE tenant_id = #{tenantId} AND status <> 0 + AND xh IN + + #{xh} + + + + + + diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/xml/GxmuTeacherMapper.xml b/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/xml/GxmuTeacherMapper.xml new file mode 100644 index 0000000..7368eb8 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/xml/GxmuTeacherMapper.xml @@ -0,0 +1,52 @@ + + + + + + INSERT INTO gxmu_teacher ( + jgh, xm, xbm, sex, dwh, college_id, ksjybh, sjh, + status, sync_time, tenant_id, create_time, update_time, deleted + ) VALUES + + ( + #{item.jgh}, #{item.xm}, #{item.xbm}, #{item.sex}, #{item.dwh}, + #{item.collegeId}, #{item.ksjybh}, #{item.sjh}, + #{item.status}, #{item.syncTime}, #{item.tenantId}, + #{item.createTime}, #{item.updateTime}, #{item.deleted} + ) + + ON DUPLICATE KEY UPDATE + xm = VALUES(xm), + xbm = VALUES(xbm), + sex = VALUES(sex), + dwh = VALUES(dwh), + college_id = VALUES(college_id), + ksjybh = VALUES(ksjybh), + sjh = VALUES(sjh), + status = VALUES(status), + sync_time = VALUES(sync_time), + update_time = VALUES(update_time), + deleted = VALUES(deleted) + + + + + + UPDATE gxmu_teacher + SET status = 0, update_time = NOW() + WHERE tenant_id = #{tenantId} AND status <> 0 + AND jgh IN + + #{jgh} + + + + + + diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/service/OpenplatSyncService.java b/src/main/java/com/gxwebsoft/gxmu/openplat/service/OpenplatSyncService.java new file mode 100644 index 0000000..15264ee --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/service/OpenplatSyncService.java @@ -0,0 +1,39 @@ +package com.gxwebsoft.gxmu.openplat.service; + +import com.gxwebsoft.gxmu.openplat.SyncBatchStatus; + +import java.util.List; + +/** + * 开放平台数据同步服务 + * + * @author Codex + * @since 2026-09-12 + */ +public interface OpenplatSyncService { + + /** + * 异步提交一次同步并立即返回批次号。 + * + *

学生全量约 9.8 万条、耗时 75~90 秒(其中平台拉取约 50 秒), + * 用同步阻塞 HTTP 会先撞上网关或浏览器超时,页面看起来像卡死而其实后端还在跑。 + * 因此改为提交即返回,前端轮询 {@link #currentStatus()} 看进度。 + * + * @param triggerType 触发方式,见 GxmuSyncRecord.TRIGGER_* + * @param triggerUser 触发人,定时任务可为空 + * @param sections 要执行的段;为空表示全部,顺序固定为 部门 → 教师 → 班级 → 学生 + * @return 批次号;已有同步在跑时返回 null + */ + String submitAsync(String triggerType, String triggerUser, List sections); + + /** + * 当前(或最近一次)批次的进度 + */ + SyncBatchStatus currentStatus(); + + /** + * 是否有同步任务在执行 + */ + boolean isRunning(); + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/service/impl/OpenplatSyncServiceImpl.java b/src/main/java/com/gxwebsoft/gxmu/openplat/service/impl/OpenplatSyncServiceImpl.java new file mode 100644 index 0000000..1d0c513 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/service/impl/OpenplatSyncServiceImpl.java @@ -0,0 +1,300 @@ +package com.gxwebsoft.gxmu.openplat.service.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants; +import com.gxwebsoft.gxmu.openplat.SyncBatchStatus; +import com.gxwebsoft.gxmu.openplat.SyncResult; +import com.gxwebsoft.gxmu.openplat.SyncSectionStatus; +import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord; +import com.gxwebsoft.gxmu.openplat.mapper.GxmuSyncRecordMapper; +import com.gxwebsoft.gxmu.openplat.service.OpenplatSyncService; +import com.gxwebsoft.gxmu.openplat.sync.ClassSyncHandler; +import com.gxwebsoft.gxmu.openplat.sync.DeptSyncHandler; +import com.gxwebsoft.gxmu.openplat.sync.StudentSyncHandler; +import com.gxwebsoft.gxmu.openplat.sync.TeacherSyncHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 开放平台数据同步服务实现。 + * + *

异步执行:提交后立即返回批次号,实际同步跑在单线程执行器上。 + * 单线程保证同一时刻只有一个批次在跑,配合 {@link AtomicBoolean} 拒绝重复提交。 + * + *

进度可见:每段开始执行前先落一条 status=2(进行中) 的记录,结束时回填结果, + * 前端据此展示进度,也能识别被中断的批次。 + * + *

相互隔离:单段失败不影响其余段,照抄 TzbProjectLibraryCrawlerServiceImpl 的写法。 + * 此处刻意不加 {@code @Transactional}:学生量级近十万,一个覆盖整段的事务会长时间持锁, + * 与「分批提交」相冲突;同步是幂等的,失败重跑即可。 + * + * @author Codex + * @since 2026-09-12 + */ +@Service +public class OpenplatSyncServiceImpl implements OpenplatSyncService { + + private static final Logger logger = LoggerFactory.getLogger(OpenplatSyncServiceImpl.class); + + @Resource + private DeptSyncHandler deptSyncHandler; + + @Resource + private TeacherSyncHandler teacherSyncHandler; + + @Resource + private ClassSyncHandler classSyncHandler; + + @Resource + private StudentSyncHandler studentSyncHandler; + + @Resource + private GxmuSyncRecordMapper syncRecordMapper; + + private final AtomicBoolean running = new AtomicBoolean(false); + + private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> { + Thread thread = new Thread(r, "openplat-sync"); + thread.setDaemon(true); + return thread; + }); + + private volatile String currentBatchId; + + private volatile String currentTriggerType; + + private volatile String currentTriggerUser; + + private volatile LocalDateTime currentStartTime; + + private volatile List currentSections = Collections.emptyList(); + + /** + * 应用重启会把「进行中」的记录留在库里,启动时标为中断,避免前端一直显示在跑。 + */ + @PostConstruct + public void markStaleRunningRecords() { + markRunningAsInterrupted("应用重启导致中断"); + } + + @PreDestroy + public void shutdown() { + executor.shutdown(); + try { + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + @Override + public String submitAsync(String triggerType, String triggerUser, List sections) { + List target = (sections == null || sections.isEmpty()) + ? OpenplatSyncConstants.ALL_SECTIONS + : new ArrayList<>(sections); + if (!running.compareAndSet(false, true)) { + return null; + } + // 上一批次若异常退出,库里可能残留「进行中」,先收尾 + markRunningAsInterrupted("被新的同步批次中断"); + + final String batchId = UUID.randomUUID().toString().replace("-", ""); + currentBatchId = batchId; + currentTriggerType = triggerType; + currentTriggerUser = triggerUser; + currentStartTime = LocalDateTime.now(); + currentSections = target; + logger.warn("开放平台同步批次 {} 已提交, 触发方式={}, 段={}", batchId, triggerType, target); + + try { + executor.submit(() -> executeBatch(batchId, triggerType, triggerUser, target)); + } catch (RuntimeException e) { + running.set(false); + throw e; + } + return batchId; + } + + @Override + public SyncBatchStatus currentStatus() { + SyncBatchStatus status = new SyncBatchStatus(); + status.setRunning(running.get()); + status.setBatchId(currentBatchId); + status.setTriggerType(currentTriggerType); + status.setTriggerUser(currentTriggerUser); + status.setStartTime(currentStartTime); + + Map rows = new HashMap<>(); + if (StrUtil.isNotBlank(currentBatchId)) { + List list = syncRecordMapper.selectList( + new LambdaQueryWrapper() + .eq(GxmuSyncRecord::getBatchId, currentBatchId) + .orderByAsc(GxmuSyncRecord::getId)); + for (GxmuSyncRecord record : list) { + rows.put(record.getSyncType(), record); + } + } + List sections = new ArrayList<>(); + for (String type : currentSections) { + GxmuSyncRecord record = rows.get(type); + sections.add(record == null ? SyncSectionStatus.pending(type) : toSectionStatus(type, record)); + if (record != null && record.getStatus() != null + && record.getStatus() == GxmuSyncRecord.STATUS_RUNNING) { + status.setCurrentSection(type); + } + } + status.setSections(sections); + return status; + } + + @Override + public boolean isRunning() { + return running.get(); + } + + private void executeBatch(String batchId, String triggerType, String triggerUser, List sections) { + try { + for (String section : sections) { + if (!runSection(batchId, section, triggerType, triggerUser)) { + break; + } + } + logger.warn("开放平台同步批次 {} 执行完毕", batchId); + } catch (Exception e) { + logger.error("开放平台同步批次 " + batchId + " 执行失败", e); + } finally { + running.set(false); + } + } + + /** + * 执行一段。单段失败不回滚其他段,返回 false 表示批次应终止。 + */ + private boolean runSection(String batchId, String syncType, String triggerType, String triggerUser) { + LocalDateTime start = LocalDateTime.now(); + Long recordId = insertRunningRecord(batchId, syncType, triggerType, triggerUser, start); + SyncResult result; + try { + result = dispatch(syncType); + } catch (Exception e) { + logger.error("开放平台同步段失败: " + syncType, e); + result = SyncResult.fail(syncType, StrUtil.maxLength( + StrUtil.blankToDefault(e.getMessage(), e.getClass().getSimpleName()), 900)); + } + finishRunningRecord(recordId, result); + return true; + } + + private SyncResult dispatch(String syncType) { + if (GxmuSyncRecord.TYPE_DEPT.equals(syncType)) { + return deptSyncHandler.sync(); + } + if (GxmuSyncRecord.TYPE_TEACHER.equals(syncType)) { + return teacherSyncHandler.sync(); + } + if (GxmuSyncRecord.TYPE_CLASS.equals(syncType)) { + return classSyncHandler.sync(); + } + if (GxmuSyncRecord.TYPE_STUDENT.equals(syncType)) { + return studentSyncHandler.sync(); + } + return SyncResult.fail(syncType, "未知的同步类型: " + syncType); + } + + private Long insertRunningRecord(String batchId, String syncType, String triggerType, + String triggerUser, LocalDateTime start) { + GxmuSyncRecord record = new GxmuSyncRecord(); + record.setBatchId(batchId); + record.setSyncType(syncType); + record.setTriggerType(triggerType); + record.setTriggerUser(triggerUser); + record.setStartTime(start); + record.setSuccessCount(0); + record.setFailCount(0); + record.setSkipCount(0); + record.setStatus(GxmuSyncRecord.STATUS_RUNNING); + record.setTenantId(OpenplatSyncConstants.TENANT_ID); + record.setCreateTime(LocalDateTime.now()); + record.setUpdateTime(LocalDateTime.now()); + syncRecordMapper.insert(record); + return record.getId(); + } + + private void finishRunningRecord(Long recordId, SyncResult result) { + if (recordId == null) { + return; + } + try { + GxmuSyncRecord patch = new GxmuSyncRecord(); + patch.setId(recordId); + patch.setEndTime(LocalDateTime.now()); + patch.setSuccessCount(result.getSuccessCount()); + patch.setFailCount(result.getFailCount()); + patch.setSkipCount(result.getSkipCount()); + patch.setStatus(result.isSuccess() + ? GxmuSyncRecord.STATUS_SUCCESS : GxmuSyncRecord.STATUS_FAIL); + patch.setErrorMsg(result.getErrorMsg()); + patch.setUpdateTime(LocalDateTime.now()); + syncRecordMapper.updateById(patch); + } catch (Exception e) { + logger.error("回填同步记录失败: {}", result.getSyncType(), e); + } + } + + private void markRunningAsInterrupted(String reason) { + try { + int rows = syncRecordMapper.update(null, new LambdaUpdateWrapper() + .eq(GxmuSyncRecord::getStatus, GxmuSyncRecord.STATUS_RUNNING) + .set(GxmuSyncRecord::getStatus, GxmuSyncRecord.STATUS_FAIL) + .set(GxmuSyncRecord::getErrorMsg, reason) + .set(GxmuSyncRecord::getEndTime, LocalDateTime.now())); + if (rows > 0) { + logger.warn("清理 {} 条未收尾的同步记录: {}", rows, reason); + } + } catch (Exception e) { + logger.warn("清理未收尾的同步记录失败: {}", e.getMessage()); + } + } + + private SyncSectionStatus toSectionStatus(String syncType, GxmuSyncRecord record) { + SyncSectionStatus section = new SyncSectionStatus(); + section.setSyncType(syncType); + int status = record.getStatus() == null ? GxmuSyncRecord.STATUS_FAIL : record.getStatus(); + if (status == GxmuSyncRecord.STATUS_SUCCESS) { + section.setState(SyncSectionStatus.STATE_SUCCESS); + } else if (status == GxmuSyncRecord.STATUS_RUNNING) { + section.setState(SyncSectionStatus.STATE_RUNNING); + } else { + section.setState(SyncSectionStatus.STATE_FAIL); + } + section.setSuccessCount(record.getSuccessCount()); + section.setFailCount(record.getFailCount()); + section.setSkipCount(record.getSkipCount()); + section.setStartTime(record.getStartTime()); + section.setEndTime(record.getEndTime()); + section.setErrorMsg(record.getErrorMsg()); + return section; + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/sync/ClassSyncHandler.java b/src/main/java/com/gxwebsoft/gxmu/openplat/sync/ClassSyncHandler.java new file mode 100644 index 0000000..a21f80c --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/sync/ClassSyncHandler.java @@ -0,0 +1,218 @@ +package com.gxwebsoft.gxmu.openplat.sync; + +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.gxwebsoft.gxmu.entity.ClassInfo; +import com.gxwebsoft.gxmu.openplat.OpenPlatformClient; +import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants; +import com.gxwebsoft.gxmu.openplat.SyncResult; +import com.gxwebsoft.gxmu.openplat.SyncScope; +import com.gxwebsoft.gxmu.openplat.SyncSupport; +import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord; +import com.gxwebsoft.gxmu.service.ClassInfoService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.time.Year; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 班级同步:bjxxb -> gxmu_class。 + * + *

只按 class_code 匹配,绝不做名称模糊匹配——实测激进归一化会把 + * 「22级本科护理学1班」误配到「2022级临床医学1班」。 + * + *

停用只作用于有 class_code 的记录。团委手工维护的研究生班没有 class_code, + * 平台上一个研究生班都没有,若按名称或无条件停用会一次性废掉全部研究生班, + * 见 docs/adr/0004。 + * + * @author Codex + * @since 2026-09-12 + */ +@Component +public class ClassSyncHandler { + + private static final Logger logger = LoggerFactory.getLogger(ClassSyncHandler.class); + + private static final int COUNSELOR_NAME_MAX = 50; + + @Resource + private OpenPlatformClient client; + + @Resource + private ClassInfoService classInfoService; + + @Resource + private SyncSupport support; + + public SyncResult sync() { + SyncResult result = SyncResult.of(GxmuSyncRecord.TYPE_CLASS); + JSONArray rows = client.fetchAll(OpenplatSyncConstants.EP_CLASS); + Map orgIds = support.orgIdByCode(); + Map teacherNames = support.teacherNameByJgh(); + + Map existing = new HashMap<>(); + List existingList = listSyncedClasses(); + for (ClassInfo item : existingList) { + existing.put(StrUtil.trim(item.getClassCode()), item); + } + + Set incoming = new HashSet<>(); + List toUpsert = new ArrayList<>(); + LocalDateTime now = SyncSupport.now(); + int unresolvedCollege = 0; + for (int i = 0; i < rows.size(); i++) { + JSONObject row = rows.getJSONObject(i); + String bjm = OpenPlatformClient.field(row, "BJM"); + String bjmc = OpenPlatformClient.field(row, "BJMC"); + if (StrUtil.isBlank(bjm) || StrUtil.isBlank(bjmc)) { + result.setFailCount(result.getFailCount() + 1); + continue; + } + incoming.add(bjm); + String dwh = OpenPlatformClient.field(row, "DWH"); + Integer collegeId = StrUtil.isBlank(dwh) ? null : orgIds.get(dwh); + if (collegeId == null) { + unresolvedCollege++; + } + + ClassInfo classInfo = existing.get(bjm); + boolean isNew = classInfo == null; + if (isNew) { + classInfo = new ClassInfo(); + classInfo.setTenantId(OpenplatSyncConstants.TENANT_ID); + classInfo.setSortNumber(0); + classInfo.setCreateTime(now); + classInfo.setDeleted(0); + } + classInfo.setClassCode(bjm); + classInfo.setClassName(bjmc); + classInfo.setCollegeId(collegeId); + classInfo.setGradeYear(parseGradeYear(row)); + classInfo.setStudentCount(parseInteger(row, "BJRS")); + classInfo.setStatus("2".equals(OpenPlatformClient.field(row, "ZT")) ? 0 : 1); + classInfo.setCounselorName(buildCounselorName(row, teacherNames)); + // remark 属人工维护字段:仅当为空或仍是本同步写入的原始工号时才覆盖 + String rawFdyh = OpenPlatformClient.field(row, "FDYH"); + if (StrUtil.isNotBlank(rawFdyh)) { + String current = classInfo.getRemark(); + if (StrUtil.isBlank(current) || current.startsWith(OpenplatSyncConstants.REMARK_FDYH_PREFIX)) { + classInfo.setRemark(OpenplatSyncConstants.REMARK_FDYH_PREFIX + rawFdyh); + } + } + classInfo.setUpdateTime(now); + toUpsert.add(classInfo); + } + // 一条多行 upsert 代替逐条 save/update:1584 条从 41 秒降到 0.2 秒 + support.upsertClasses(toUpsert); + result.setSuccessCount(incoming.size()); + if (unresolvedCollege > 0) { + logger.warn("班级同步: {} 个班级的学院未能解析出组织节点", unresolvedCollege); + } + + int deactivated = deactivateMissing(incoming, existingList); + result.setSkipCount(deactivated); + logger.warn("班级同步完成: 新增/更新 {} 条, 停用 {} 条, 失败 {} 条", + incoming.size(), deactivated, result.getFailCount()); + return result; + } + + /** + * RXNF 入学年份,带合理性校验。 + * + *

实测存在 RXNF=2030 这类脏数据,越界时记 warn 并落 null,单条脏数据不得中断整批。 + */ + Integer parseGradeYear(JSONObject row) { + Integer year = parseInteger(row, "RXNF"); + if (year == null) { + return null; + } + int max = Year.now().getValue() + 1; + if (year < 2000 || year > max) { + logger.warn("班级入学年份越界, 置空: RXNF={}", year); + return null; + } + return year; + } + + /** + * 解析辅导员姓名。 + * + *

FDYH 可能是一组逗号分隔的工号(实测 32 个班级如此),需要逐个解析后用「、」连接。 + */ + String buildCounselorName(JSONObject row, Map teacherNames) { + String raw = OpenPlatformClient.field(row, "FDYH"); + if (StrUtil.isBlank(raw)) { + return null; + } + Set names = new LinkedHashSet<>(); + for (String part : raw.split("[,,]")) { + String jgh = StrUtil.trimToNull(part); + if (jgh == null) { + continue; + } + String name = teacherNames.get(jgh); + names.add(StrUtil.isBlank(name) ? jgh : name); + } + if (names.isEmpty()) { + return null; + } + String joined = String.join("、", names); + return joined.length() > COUNSELOR_NAME_MAX ? joined.substring(0, COUNSELOR_NAME_MAX) : joined; + } + + /** + * 本次未从平台拉到的同步来源班级置为停用。 + * + *

只处理 class_code 非空的记录,人工创建的研究生班不受影响。 + * 复用同步开头已加载的列表,避免再全表查一次。 + */ + private int deactivateMissing(Set incoming, List existingList) { + List ids = new ArrayList<>(); + for (ClassInfo item : existingList) { + if (!incoming.contains(StrUtil.trim(item.getClassCode())) + && SyncScope.shouldDeactivate(item.getClassCode(), false)) { + ids.add(item.getId()); + } + } + if (ids.isEmpty()) { + return 0; + } + logger.warn("班级中有 {} 条记录本次未从开放平台拉到, 置为停用", ids.size()); + classInfoService.removeByIds(ids); + return ids.size(); + } + + private List listSyncedClasses() { + return classInfoService.list(new LambdaQueryWrapper() + .eq(ClassInfo::getTenantId, OpenplatSyncConstants.TENANT_ID) + .isNotNull(ClassInfo::getClassCode)); + } + + /** + * 容错地把平台字段解析为整数。平台的人数形如 "1.0"。 + */ + static Integer parseInteger(JSONObject row, String key) { + String text = OpenPlatformClient.field(row, key); + if (StrUtil.isBlank(text)) { + return null; + } + try { + return (int) Math.round(Double.parseDouble(text)); + } catch (NumberFormatException e) { + return null; + } + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/sync/DeptSyncHandler.java b/src/main/java/com/gxwebsoft/gxmu/openplat/sync/DeptSyncHandler.java new file mode 100644 index 0000000..938dae9 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/sync/DeptSyncHandler.java @@ -0,0 +1,142 @@ +package com.gxwebsoft.gxmu.openplat.sync; + +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.gxwebsoft.common.system.entity.Organization; +import com.gxwebsoft.common.system.service.OrganizationService; +import com.gxwebsoft.gxmu.openplat.OpenPlatformClient; +import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants; +import com.gxwebsoft.gxmu.openplat.SyncResult; +import com.gxwebsoft.gxmu.openplat.SyncScope; +import com.gxwebsoft.gxmu.openplat.SyncSupport; +import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 部门同步:RS_BMXX -> sys_organization。 + * + *

全部部门扁平挂在「广西医科大学」(id=24) 下;实测 75 个部门中仅 + * 「附属小学」「幼儿园」有可推出的父节点,DWH 前缀推不出真实层级,因此不做层级推导。 + * + *

只操作 organization_code 非空的记录(同步来源)。校团委等人工创建的节点 + * 没有 organization_code,天然不会被停用,见 docs/adr/0004。 + * + * @author Codex + * @since 2026-09-12 + */ +@Component +public class DeptSyncHandler { + + private static final Logger logger = LoggerFactory.getLogger(DeptSyncHandler.class); + + @Resource + private OpenPlatformClient client; + + @Resource + private OrganizationService organizationService; + + @Resource + private SyncSupport support; + + public SyncResult sync() { + SyncResult result = SyncResult.of(GxmuSyncRecord.TYPE_DEPT); + JSONArray rows = client.fetchAll(OpenplatSyncConstants.EP_DEPT); + Map teacherNames = support.teacherNameByJgh(); + + Map existing = new HashMap<>(); + List existingList = listSyncedOrganizations(); + for (Organization org : existingList) { + existing.put(StrUtil.trim(org.getOrganizationCode()), org); + } + + Set incoming = new HashSet<>(); + List toUpsert = new ArrayList<>(); + for (int i = 0; i < rows.size(); i++) { + JSONObject row = rows.getJSONObject(i); + String dwh = OpenPlatformClient.field(row, "DWH"); + String dwmc = OpenPlatformClient.field(row, "DWMC"); + if (StrUtil.isBlank(dwh) || StrUtil.isBlank(dwmc)) { + result.setFailCount(result.getFailCount() + 1); + continue; + } + incoming.add(dwh); + Organization org = existing.get(dwh); + boolean isNew = org == null; + if (isNew) { + org = new Organization(); + org.setTenantId(OpenplatSyncConstants.TENANT_ID); + org.setParentId(OpenplatSyncConstants.ORG_PARENT_ID); + org.setSortNumber(0); + org.setCreateTime(new Date()); + org.setDeleted(0); + } + org.setOrganizationCode(dwh); + org.setOrganizationName(dwmc); + org.setOrganizationFullName(dwmc); + org.setOrganizationType(StrUtil.blankToDefault(OpenPlatformClient.field(row, "DWLB"), "0")); + // FZR 实际是工号而非姓名;用教师名册解析,解析不到就保留工号 + String fzr = OpenPlatformClient.field(row, "FZR"); + if (StrUtil.isNotBlank(fzr)) { + org.setComments("负责人:" + teacherNames.getOrDefault(fzr, fzr)); + } + org.setUpdateTime(new Date()); + toUpsert.add(org); + } + // 一条多行 upsert 代替逐条 save/update + support.upsertOrganizations(toUpsert); + result.setSuccessCount(incoming.size()); + + int deactivated = deactivateMissing(incoming, existingList); + result.setSkipCount(deactivated); + logger.warn("部门同步完成: 新增/更新 {} 条, 停用 {} 条, 失败 {} 条", + incoming.size(), deactivated, result.getFailCount()); + return result; + } + + /** + * 本次未从平台拉到的同步来源部门置为停用。 + * + *

只处理 organization_code 非空的记录;并显式跳过校团委(id=18)作为双保险。 + * 复用同步开头已加载的列表,避免再全表查一次。 + */ + private int deactivateMissing(Set incoming, List existingList) { + List ids = new ArrayList<>(); + for (Organization org : existingList) { + if (org.getOrganizationId() != null + && org.getOrganizationId() == OpenplatSyncConstants.ORG_KEEP_ID) { + continue; + } + if (!incoming.contains(StrUtil.trim(org.getOrganizationCode()))) { + if (SyncScope.shouldDeactivate(org.getOrganizationCode(), false)) { + ids.add(org.getOrganizationId()); + } + } + } + if (ids.isEmpty()) { + return 0; + } + logger.warn("部门中有 {} 条记录本次未从开放平台拉到, 置为停用", ids.size()); + organizationService.removeByIds(ids); + return ids.size(); + } + + private List listSyncedOrganizations() { + return organizationService.list(new LambdaQueryWrapper() + .eq(Organization::getTenantId, OpenplatSyncConstants.TENANT_ID) + .isNotNull(Organization::getOrganizationCode)); + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/sync/StudentSourceMerger.java b/src/main/java/com/gxwebsoft/gxmu/openplat/sync/StudentSourceMerger.java new file mode 100644 index 0000000..7a90d08 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/sync/StudentSourceMerger.java @@ -0,0 +1,91 @@ +package com.gxwebsoft.gxmu.openplat.sync; + +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSONObject; +import com.gxwebsoft.gxmu.openplat.OpenPlatformClient; +import com.gxwebsoft.gxmu.openplat.OpenplatCodeService; +import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent; + +/** + * 学生三源合并的字段优先级规则。 + * + *

从 StudentSyncHandler 中抽出来单独成类,是为了让「谁覆盖谁」这条规则成为 + * 可单测的纯逻辑——写反了不会报错,只会静默产生错误的人事数据。 + * + *

+ *   xsjbxxq           主源:姓名/性别/学院/班级/身份证/专业(在校生,含研究生)
+ *   LY_XXBZ_JW_XSXXB  补源:民族/政治面貌/出生日期/学籍状态/班号(含已毕业)
+ * 
+ * + * @author Codex + * @since 2026-09-12 + */ +public final class StudentSourceMerger { + + private StudentSourceMerger() { + } + + /** + * 码值解析器:字典编码 + 平台原始码 -> 名称 + */ + @FunctionalInterface + public interface CodeDecoder { + String decode(String dictCode, String rawCode); + } + + /** + * 主源 xsjbxxq:这些字段以它为准,直接覆盖。 + * + *

xsjbxxq 是在校生接口,因此并入的学生默认置为在校(status=1)。 + */ + public static void applyMain(GxmuStudent student, JSONObject row, CodeDecoder decoder) { + student.setXm(OpenPlatformClient.field(row, "XM")); + student.setXbm(OpenPlatformClient.field(row, "XBM")); + student.setSex(decoder.decode(OpenplatCodeService.DICT_SEX, student.getXbm())); + student.setSfzjh(OpenPlatformClient.field(row, "SFZJH")); + student.setDwh(OpenPlatformClient.field(row, "DWH")); + student.setBjm(OpenPlatformClient.field(row, "BJM")); + student.setZyh(OpenPlatformClient.field(row, "ZYH")); + student.setZymc(OpenPlatformClient.field(row, "ZYMC")); + student.setPyccmc(OpenPlatformClient.field(row, "PYCCMC")); + if (student.getStatus() == null) { + student.setStatus(1); + } + } + + /** + * 补源 LY_XXBZ_JW_XSXXB:姓名/性别/学院/班级**只补空缺,绝不覆盖主源**; + * 民族/政治面貌/出生日期/学籍状态等主源没有的字段直接写入。 + * + *

注意 BH(学籍库班号) 与 BJM(平台班级码) 实测是同一套编码: + * 43,117 名两接口都有的学生 BH == BJM 100% 一致,因此可以互为兜底。 + */ + public static void applyAcademic(GxmuStudent student, JSONObject row, CodeDecoder decoder) { + if (StrUtil.isBlank(student.getXm())) { + student.setXm(OpenPlatformClient.field(row, "XM")); + } + if (StrUtil.isBlank(student.getXbm())) { + student.setXbm(OpenPlatformClient.field(row, "XBM")); + student.setSex(decoder.decode(OpenplatCodeService.DICT_SEX, student.getXbm())); + } + if (StrUtil.isBlank(student.getDwh())) { + student.setDwh(OpenPlatformClient.field(row, "DWH")); + } + student.setBh(OpenPlatformClient.field(row, "BH")); + if (StrUtil.isBlank(student.getBjm())) { + student.setBjm(student.getBh()); + } + student.setMzm(OpenPlatformClient.field(row, "MZM")); + student.setNation(decoder.decode(OpenplatCodeService.DICT_NATION, student.getMzm())); + student.setZzmmm(OpenPlatformClient.field(row, "ZZMMM")); + student.setPolitics(decoder.decode(OpenplatCodeService.DICT_POLITICS, student.getZzmmm())); + student.setCsrq(OpenPlatformClient.field(row, "CSRQ")); + student.setSfzx(OpenPlatformClient.field(row, "SFZX")); + student.setXjzt(OpenPlatformClient.field(row, "XJZT")); + student.setXsdqzt(OpenPlatformClient.field(row, "XSDQZT")); + if (student.getStatus() == null && "0".equals(student.getSfzx())) { + student.setStatus(0); + } + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/sync/StudentSyncHandler.java b/src/main/java/com/gxwebsoft/gxmu/openplat/sync/StudentSyncHandler.java new file mode 100644 index 0000000..b2ce1df --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/sync/StudentSyncHandler.java @@ -0,0 +1,196 @@ +package com.gxwebsoft.gxmu.openplat.sync; + +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.gxwebsoft.gxmu.openplat.OpenPlatformClient; +import com.gxwebsoft.gxmu.openplat.OpenPlatformException; +import com.gxwebsoft.gxmu.openplat.OpenplatCodeService; +import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants; +import com.gxwebsoft.gxmu.openplat.SyncResult; +import com.gxwebsoft.gxmu.openplat.SyncSupport; +import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent; +import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * 学生同步:三个接口按学号合并 -> gxmu_student。 + * + *

+ *   xsjbxxq            在校生(含研究生) 58,655   姓名/性别/学院/班级/身份证/专业
+ *   LY_XXBZ_JW_XSXXB   本专科(含毕业)   82,649   民族/政治面貌/出生日期/学籍状态/班号
+ *   yhxx_xsqtykt       已毕业           14,059   手机号
+ *   xsyhxx_ykt         在校生(未授权)            手机号(优先)
+ *   并集                              98,187
+ * 
+ * + *

实测 BH(学籍库班号) 与 BJM(平台班级码) 是同一套编码:43,117 名两接口都有的学生 + * BH == BJM 100% 一致,因此 class_id 统一按班级码解析。 + * + *

研究生(15,538 人)在平台上没有班级码,class_id 落 null,只归属学院。 + * + * @author Codex + * @since 2026-09-12 + */ +@Component +public class StudentSyncHandler { + + private static final Logger logger = LoggerFactory.getLogger(StudentSyncHandler.class); + + @Resource + private OpenPlatformClient client; + + @Resource + private SyncSupport support; + + @Resource + private OpenplatCodeService codeService; + + public SyncResult sync() { + SyncResult result = SyncResult.of(GxmuSyncRecord.TYPE_STUDENT); + Map orgIds = support.orgIdByCode(); + Map classIds = support.classIdByCode(); + + Map merged = new HashMap<>(131072); + + // 1) 在校生(含研究生):主源 + mergeSource(merged, OpenplatSyncConstants.EP_STUDENT_ALL, Source.MAIN, orgIds, classIds); + + // 2) 本专科(含毕业):补民族/政治面貌/出生日期/学籍状态,并纳入已毕业人群 + mergeSource(merged, OpenplatSyncConstants.EP_STUDENT_BK, Source.ACADEMIC, orgIds, classIds); + + // 3) 在校生手机号(未授权时静默降级) + Map inSchoolPhone = loadPhoneQuietly( + OpenplatSyncConstants.EP_STUDENT_YKT_INSCHOOL); + + // 4) 已毕业手机号 + Map graduatePhone = loadPhoneQuietly(OpenplatSyncConstants.EP_STUDENT_YKT); + + applyPhones(merged, inSchoolPhone, graduatePhone); + + LocalDateTime now = SyncSupport.now(); + Set incoming = new HashSet<>(merged.size()); + java.util.List list = new java.util.ArrayList<>(merged.size()); + for (Map.Entry entry : merged.entrySet()) { + GxmuStudent student = entry.getValue(); + student.setXh(entry.getKey()); + student.setTenantId(OpenplatSyncConstants.TENANT_ID); + student.setSyncTime(now); + student.setCreateTime(now); + student.setUpdateTime(now); + student.setDeleted(0); + if (student.getStatus() == null) { + student.setStatus(0); + } + incoming.add(entry.getKey()); + list.add(student); + } + support.upsertStudents(list); + result.setSuccessCount(list.size()); + + int deactivated = support.deactivateMissingStudents(incoming); + result.setSkipCount(deactivated); + logger.warn("学生同步完成: 写入 {} 条, 停用 {} 条, 失败 {} 条", + list.size(), deactivated, result.getFailCount()); + return result; + } + + /** + * 拉取某接口并合并进结果集。 + * + *

逐个接口处理而不是把三份原始 JSON 同时留在内存里,避免十几万个 JSONObject 峰值。 + */ + private void mergeSource(Map merged, String endpoint, Source source, + Map orgIds, Map classIds) { + JSONArray rows = client.fetchAll(endpoint); + for (int i = 0; i < rows.size(); i++) { + JSONObject row = rows.getJSONObject(i); + String xh = OpenPlatformClient.field(row, "XH"); + if (StrUtil.isBlank(xh)) { + continue; + } + GxmuStudent student = merged.computeIfAbsent(xh, k -> new GxmuStudent()); + if (source == Source.MAIN) { + StudentSourceMerger.applyMain(student, row, codeService::decode); + } else { + StudentSourceMerger.applyAcademic(student, row, codeService::decode); + } + resolveRelations(student, orgIds, classIds); + } + logger.warn("学生同步: 合并 {} 共 {} 条, 累计 {} 人", endpoint, rows.size(), merged.size()); + } + + /** + * 拉取手机号来源;接口未授权时记 warn 并返回空表,不阻断整批同步。 + */ + private Map loadPhoneQuietly(String endpoint) { + Map map = new HashMap<>(); + try { + JSONArray rows = client.fetchAll(endpoint); + for (int i = 0; i < rows.size(); i++) { + JSONObject row = rows.getJSONObject(i); + String xh = OpenPlatformClient.field(row, "XH"); + String sjh = OpenPlatformClient.field(row, "SJH"); + if (StrUtil.isNotBlank(xh) && StrUtil.isNotBlank(sjh)) { + map.put(xh, sjh); + } + } + logger.warn("手机号来源 {} 拉到 {} 条带号码记录", endpoint, map.size()); + } catch (OpenPlatformException e) { + logger.warn("手机号来源 {} 暂不可用, 跳过: {}", endpoint, e.getMessage()); + } + return map; + } + + private void applyPhones(Map merged, + Map inSchoolPhone, + Map graduatePhone) { + // 在校生手机号优先 + inSchoolPhone.forEach((xh, sjh) -> { + GxmuStudent student = merged.get(xh); + if (student != null) { + student.setSjh(sjh); + } + }); + graduatePhone.forEach((xh, sjh) -> { + GxmuStudent student = merged.get(xh); + if (student != null && StrUtil.isBlank(student.getSjh())) { + student.setSjh(sjh); + } + }); + } + + private void resolveRelations(GxmuStudent student, + Map orgIds, + Map classIds) { + String dwh = student.getDwh(); + if (StrUtil.isNotBlank(dwh)) { + student.setCollegeId(orgIds.get(dwh)); + } + String bjm = student.getBjm(); + if (StrUtil.isNotBlank(bjm)) { + student.setClassId(classIds.get(bjm)); + } + } + + private enum Source { + /** + * 在校生主源 xsjbxxq + */ + MAIN, + /** + * 本专科学籍库 LY_XXBZ_JW_XSXXB + */ + ACADEMIC + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/sync/TeacherSyncHandler.java b/src/main/java/com/gxwebsoft/gxmu/openplat/sync/TeacherSyncHandler.java new file mode 100644 index 0000000..6dc1f1f --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/sync/TeacherSyncHandler.java @@ -0,0 +1,95 @@ +package com.gxwebsoft.gxmu.openplat.sync; + +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.gxwebsoft.gxmu.openplat.OpenPlatformClient; +import com.gxwebsoft.gxmu.openplat.OpenplatCodeService; +import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants; +import com.gxwebsoft.gxmu.openplat.SyncResult; +import com.gxwebsoft.gxmu.openplat.SyncSupport; +import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord; +import com.gxwebsoft.gxmu.openplat.entity.GxmuTeacher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 教师同步:scjk_jsyhxx -> gxmu_teacher。 + * + *

全量落库不筛选(实测六成以上属附属医院),因为班级的辅导员/班主任可能来自任何单位, + * 筛掉会导致班级同步的工号解析失败。 + * + * @author Codex + * @since 2026-09-12 + */ +@Component +public class TeacherSyncHandler { + + private static final Logger logger = LoggerFactory.getLogger(TeacherSyncHandler.class); + + @Resource + private OpenPlatformClient client; + + @Resource + private SyncSupport support; + + @Resource + private OpenplatCodeService codeService; + + public SyncResult sync() { + SyncResult result = SyncResult.of(GxmuSyncRecord.TYPE_TEACHER); + JSONArray rows = client.fetchAll(OpenplatSyncConstants.EP_TEACHER); + Map orgIds = support.orgIdByCode(); + LocalDateTime now = SyncSupport.now(); + + List list = new ArrayList<>(); + Set incoming = new HashSet<>(); + for (int i = 0; i < rows.size(); i++) { + JSONObject row = rows.getJSONObject(i); + String jgh = OpenPlatformClient.field(row, "JGH"); + String xm = OpenPlatformClient.field(row, "XM"); + if (StrUtil.isBlank(jgh) || StrUtil.isBlank(xm)) { + result.setFailCount(result.getFailCount() + 1); + continue; + } + incoming.add(jgh); + String dwh = OpenPlatformClient.field(row, "DWH"); + String xbm = OpenPlatformClient.field(row, "XBM"); + + GxmuTeacher teacher = new GxmuTeacher(); + teacher.setJgh(jgh); + teacher.setXm(xm); + teacher.setXbm(xbm); + teacher.setSex(codeService.decode(OpenplatCodeService.DICT_SEX, xbm)); + teacher.setDwh(dwh); + teacher.setCollegeId(StrUtil.isBlank(dwh) ? null : orgIds.get(dwh)); + teacher.setKsjybh(OpenPlatformClient.field(row, "KSJYBH")); + teacher.setSjh(OpenPlatformClient.field(row, "SJH")); + teacher.setStatus(1); + teacher.setSyncTime(now); + teacher.setTenantId(OpenplatSyncConstants.TENANT_ID); + teacher.setCreateTime(now); + teacher.setUpdateTime(now); + teacher.setDeleted(0); + list.add(teacher); + } + support.upsertTeachers(list); + result.setSuccessCount(incoming.size()); + + int deactivated = support.deactivateMissingTeachers(incoming); + result.setSkipCount(deactivated); + logger.warn("教师同步完成: 新增/更新 {} 条, 停用 {} 条, 失败 {} 条", + incoming.size(), deactivated, result.getFailCount()); + return result; + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/task/OpenplatSyncTask.java b/src/main/java/com/gxwebsoft/gxmu/openplat/task/OpenplatSyncTask.java new file mode 100644 index 0000000..6eac02d --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/task/OpenplatSyncTask.java @@ -0,0 +1,56 @@ +package com.gxwebsoft.gxmu.openplat.task; + +import com.gxwebsoft.gxmu.openplat.entity.GxmuSyncRecord; +import com.gxwebsoft.gxmu.openplat.service.OpenplatSyncService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; + +/** + * 医科大开放平台定时同步任务。 + * + *

每天 03:30 全量同步部门、教师、班级、学生。 + * + *

与手动同步共用同一把「单批次」锁:定时到点时若手动同步还在跑, + * {@code submitAsync} 会返回 null,本轮调度直接跳过。 + * + *

注意:{@code @Scheduled} 线程没有 SecurityContext,tenantId 由同步逻辑 + * 显式使用 OpenplatSyncConstants.TENANT_ID,不依赖 BaseController.getTenantId()。 + * + * @author Codex + * @since 2026-09-12 + */ +@Component +public class OpenplatSyncTask { + + private static final Logger logger = LoggerFactory.getLogger(OpenplatSyncTask.class); + + @Resource + private OpenplatSyncService openplatSyncService; + + @Value("${gxmu.openplat-sync.enabled:true}") + private boolean enabled; + + @Scheduled(cron = "${gxmu.openplat-sync.cron:0 30 3 * * ?}") + public void syncAll() { + if (!enabled) { + return; + } + try { + String batchId = openplatSyncService.submitAsync( + GxmuSyncRecord.TRIGGER_SCHEDULE, null, null); + if (batchId == null) { + logger.warn("开放平台同步任务仍在执行,跳过本轮调度"); + return; + } + logger.warn("开放平台定时同步已提交, 批次 {}", batchId); + } catch (Exception e) { + logger.error("开放平台定时同步任务提交失败", e); + } + } + +} diff --git a/src/main/java/com/gxwebsoft/shop/controller/WxLoginController.java b/src/main/java/com/gxwebsoft/shop/controller/WxLoginController.java index 57da3c2..9188a57 100644 --- a/src/main/java/com/gxwebsoft/shop/controller/WxLoginController.java +++ b/src/main/java/com/gxwebsoft/shop/controller/WxLoginController.java @@ -58,6 +58,8 @@ public class WxLoginController extends BaseController { private RoleService roleService; @Resource private WxMaProperties wxMaProperties; + @Resource + private com.gxwebsoft.gxmu.openplat.claim.StudentClaimService studentClaimService; @Autowired private StringRedisTemplate redisTemplate; @@ -90,6 +92,7 @@ public class WxLoginController extends BaseController { userOauthService.save(uo); } // 4.签发token + autoClaimQuietly(user); loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request); String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()), configProperties.getTokenExpireTime(), configProperties.getTokenKey()); @@ -124,6 +127,7 @@ public class WxLoginController extends BaseController { userOauthService.save(uo); } // 签发token + autoClaimQuietly(user); String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()), configProperties.getTokenExpireTime(), configProperties.getTokenKey()); loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request); @@ -146,6 +150,19 @@ public class WxLoginController extends BaseController { return fail("更新失败"); } + /** + * 尝试按手机号自动认领学生名册(docs/adr/0001)。 + * + *

认领失败不影响登录:命中不到、命中多条、姓名不符等都只是返回 false。 + */ + private void autoClaimQuietly(User user) { + try { + studentClaimService.autoClaimByPhone(user); + } catch (Exception e) { + System.out.println("学生认领失败:" + e.getMessage()); + } + } + /** * 新用户注册 */ diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 80afe4b..3cb6711 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -3,7 +3,7 @@ # 数据源配置 spring: datasource: - url: jdbc:mysql://132.232.214.96:13306/tuanwei?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8 + url: jdbc:mysql://132.232.214.96:13306/tuanwei?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&rewriteBatchedStatements=true username: tuanwei password: TytQZsSW5m4wGh38 driver-class-name: com.mysql.cj.jdbc.Driver @@ -14,6 +14,7 @@ logging: level: com.gxwebsoft: DEBUG com.baomidou.mybatisplus: DEBUG + com.gxwebsoft.gxmu.openplat: INFO # 框架配置 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index b48562d..3900a10 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -3,7 +3,7 @@ # 数据源配置 spring: datasource: - url: jdbc:mysql://1Panel-mysql-Bqdt:3306/tuanwei?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8 + url: jdbc:mysql://1Panel-mysql-Bqdt:3306/tuanwei?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&rewriteBatchedStatements=true username: tuanwei password: TytQZsSW5m4wGh38 driver-class-name: com.mysql.cj.jdbc.Driver @@ -23,6 +23,9 @@ logging: root: WARN com.gxwebsoft: ERROR com.baomidou.mybatisplus: ERROR + # 开放平台同步过程保留日志:一次同步动辄一两分钟, + # 若沿用 com.gxwebsoft 的 ERROR 级,进度与失败原因在生产完全不可见 + com.gxwebsoft.gxmu.openplat: INFO # 框架配置 config: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9ef9b4a..4fa6d70 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -107,3 +107,20 @@ wxpay: app-id: wxf7ee9cd5801bf7a6 mch-id: 1732930031 mch-serial-no: 7EE24C695B278DB2C4F0C1CB42069065ECCBE3FD + +# 医科大开放平台对接 +gxmu: + openplat: + base-url: http://210.36.49.10:15031/api/bd-api + app-id: d50666e1621f493688a2 + app-secret: c12ba36360a82ae7a59a4f0a056ec39d + kfzzh: twxt2026 + # 开发者密码, 建议用环境变量 OPENPLAT_KFZMM 覆盖 + kfzmm: ${OPENPLAT_KFZMM:twxt2026} + timeout-ms: 30000 + max-retries: 2 + page-size: 10000 + openplat-sync: + enabled: true + # 每天 03:30 + cron: 0 30 3 * * ? diff --git a/src/test/java/com/gxwebsoft/gxmu/openplat/MapperFieldWhitelistTest.java b/src/test/java/com/gxwebsoft/gxmu/openplat/MapperFieldWhitelistTest.java new file mode 100644 index 0000000..935f7bf --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/openplat/MapperFieldWhitelistTest.java @@ -0,0 +1,109 @@ +package com.gxwebsoft.gxmu.openplat; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 平台权威字段白名单的守卫测试(docs/adr/0005)。 + * + *

同步的 upsert 是 {@code ON DUPLICATE KEY UPDATE},写进该子句的列每次同步都会被覆盖。 + * 「人工维护的字段同步不触碰」这条规则只体现在 SQL 里,代码层面看不出对错, + * 因此直接把 mapper XML 读出来断言——以后有人往 UPDATE 子句里加 sort_number 之类会当场失败。 + * + * @author Codex + * @since 2026-09-12 + */ +class MapperFieldWhitelistTest { + + private static final String STUDENT_XML = + "/com/gxwebsoft/gxmu/openplat/mapper/xml/GxmuStudentMapper.xml"; + private static final String TEACHER_XML = + "/com/gxwebsoft/gxmu/openplat/mapper/xml/GxmuTeacherMapper.xml"; + private static final String CLASS_XML = + "/com/gxwebsoft/gxmu/mapper/xml/ClassInfoMapper.xml"; + private static final String ORG_XML = + "/com/gxwebsoft/common/system/mapper/xml/OrganizationMapper.xml"; + + @Test + void studentUpsertKeepsManualFieldsOutOfUpdateClause() throws Exception { + String update = extractUpdateClause(read(STUDENT_XML)); + // 认领流程维护 + assertFalse(update.contains("user_id"), "user_id 归认领流程管,不得被同步覆盖"); + assertFalse(update.contains("claim_time"), "claim_time 归认领流程管,不得被同步覆盖"); + // 创建时间不应被重复刷新 + assertFalse(update.contains("create_time"), "create_time 不应出现在 UPDATE 子句"); + // 平台权威字段必须在 + assertTrue(update.contains("xm ="), "姓名是平台权威字段"); + assertTrue(update.contains("class_id ="), "班级归属由同步解析"); + assertTrue(update.contains("nation ="), "民族名称由同步解析"); + } + + @Test + void teacherUpsertKeepsManualFieldsOutOfUpdateClause() throws Exception { + String update = extractUpdateClause(read(TEACHER_XML)); + assertFalse(update.contains("create_time"), "create_time 不应出现在 UPDATE 子句"); + assertTrue(update.contains("xm ="), "姓名是平台权威字段"); + assertTrue(update.contains("sjh ="), "手机号来自平台"); + } + + /** + * 班级是本次性能优化改动的重点:原先逐条 save/update,现在走多行 upsert。 + * 换写法最容易顺手把人工字段塞进 UPDATE 子句,所以单独钉住。 + */ + @Test + void classUpsertKeepsManualFieldsOutOfUpdateClause() throws Exception { + String update = extractUpdateClause(read(CLASS_XML)); + assertFalse(update.contains("sort_number"), "排序号属人工维护,不得被同步覆盖"); + assertFalse(update.contains("counselor_phone"), "辅导员电话属人工维护,不得被同步覆盖"); + assertFalse(update.contains("create_time"), "create_time 不应出现在 UPDATE 子句"); + assertTrue(update.contains("class_name ="), "班级名称是平台权威字段"); + assertTrue(update.contains("college_id ="), "学院归属由同步解析"); + assertTrue(update.contains("counselor_name ="), "辅导员姓名由同步解析"); + } + + @Test + void organizationUpsertKeepsManualFieldsOutOfUpdateClause() throws Exception { + String update = extractUpdateClause(read(ORG_XML)); + assertFalse(update.contains("sort_number"), "排序号属人工维护,不得被同步覆盖"); + assertFalse(update.contains("leader_id"), "负责人ID属人工维护,不得被同步覆盖"); + assertFalse(update.contains("parent_id"), "层级关系属人工维护,同步不改挂载点"); + assertFalse(update.contains("create_time"), "create_time 不应出现在 UPDATE 子句"); + assertTrue(update.contains("organization_name ="), "机构名称是平台权威字段"); + } + + /** + * 取出 ON DUPLICATE KEY UPDATE 之后的片段(到 </insert> 为止)。 + * + *

先剥掉 XML 注释:mapper 里的说明性注释同样会写到「ON DUPLICATE KEY UPDATE」 + * 和字段名,直接用 indexOf 会命中注释,断言就失去意义了。 + */ + private String extractUpdateClause(String xml) { + String noComment = xml.replaceAll("(?s)", ""); + int idx = noComment.indexOf("ON DUPLICATE KEY UPDATE"); + assertTrue(idx > 0, "mapper 中应存在 ON DUPLICATE KEY UPDATE"); + String tail = noComment.substring(idx); + int end = tail.indexOf(""); + return end > 0 ? tail.substring(0, end) : tail; + } + + private String read(String resource) throws Exception { + try (InputStream in = MapperFieldWhitelistTest.class.getResourceAsStream(resource)) { + assertNotNull(in, "mapper XML 未随资源一起进入 classpath: " + resource); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + return out.toString(StandardCharsets.UTF_8.name()); + } + } + +} diff --git a/src/test/java/com/gxwebsoft/gxmu/openplat/OpenPlatformClientIT.java b/src/test/java/com/gxwebsoft/gxmu/openplat/OpenPlatformClientIT.java new file mode 100644 index 0000000..90ebc09 --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/openplat/OpenPlatformClientIT.java @@ -0,0 +1,94 @@ +package com.gxwebsoft.gxmu.openplat; + +import com.alibaba.fastjson.JSONArray; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 开放平台客户端的**联网**验证。 + * + *

默认不执行(类名以 IT 结尾,surefire 不会扫到)。手动运行: + * + *

+ *   ./mvnw test -Dtest=OpenPlatformClientIT -Dopenplat.it=true
+ * 
+ * + *

它存在的意义:其余测试只证明了 DES 密文与平台一致,而 Hutool 的请求构造 + * (URL 拼接、query 参数、Basic 头、分页终止)从未真正打到过平台。 + * 这个测试用真实接口把这一段补上——不需要数据库,Redis 用 mock 顶掉。 + * + *

前提:本机出口 IP 在应用白名单内(10.129.0.125 / 10.100.158.15), + * 否则会返回 code=420。 + * + * @author Codex + * @since 2026-09-14 + */ +@EnabledIfSystemProperty(named = "openplat.it", matches = "true") +class OpenPlatformClientIT { + + private static final String APP_ID = "d50666e1621f493688a2"; + private static final String APP_SECRET = "c12ba36360a82ae7a59a4f0a056ec39d"; + private static final String KFZZH = "twxt2026"; + private static final String KFZMM = "twxt2026"; + + private OpenPlatformClient newClient() { + GxmuOpenplatProperties properties = new GxmuOpenplatProperties(); + properties.setBaseUrl("http://210.36.49.10:15031/api/bd-api"); + properties.setAppId(APP_ID); + properties.setAppSecret(APP_SECRET); + properties.setKfzzh(KFZZH); + properties.setKfzmm(KFZMM); + properties.setTimeoutMs(30000); + properties.setMaxRetries(2); + properties.setPageSize(10000); + + @SuppressWarnings("unchecked") + ValueOperations valueOperations = mock(ValueOperations.class); + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + // 缓存始终未命中,让每次请求都走真实的取 token 流程 + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get(anyString())).thenReturn(null); + + OpenPlatformClient client = new OpenPlatformClient(); + ReflectionTestUtils.setField(client, "properties", properties); + ReflectionTestUtils.setField(client, "stringRedisTemplate", redisTemplate); + return client; + } + + @Test + void canFetchRealAccessToken() { + String token = newClient().getToken(); + assertTrue(token != null && token.length() > 8, "应取到 access_token, 实际: " + token); + } + + /** + * 部门接口实测 75 条,单页即可拉完,同时验证了分页终止条件。 + */ + @Test + void canFetchAllDepartments() { + JSONArray rows = newClient().fetchAll(OpenplatSyncConstants.EP_DEPT); + assertEquals(75, rows.size(), "RS_BMXX 实测应为 75 条"); + // 字段名实际是大写,验证大小写不敏感取值 + String dwh = OpenPlatformClient.field(rows.getJSONObject(0), "DWH"); + assertTrue(dwh != null && !dwh.isEmpty(), "应能取到单位号"); + } + + /** + * 班级接口 1584 条,超过单页上限,能验证翻页。 + */ + @Test + void canPageThroughClasses() { + JSONArray rows = newClient().fetchAll(OpenplatSyncConstants.EP_CLASS); + assertEquals(1584, rows.size(), "bjxxb 实测应为 1584 条"); + } + +} diff --git a/src/test/java/com/gxwebsoft/gxmu/openplat/OpenPlatformClientTest.java b/src/test/java/com/gxwebsoft/gxmu/openplat/OpenPlatformClientTest.java new file mode 100644 index 0000000..6a9ff1b --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/openplat/OpenPlatformClientTest.java @@ -0,0 +1,58 @@ +package com.gxwebsoft.gxmu.openplat; + +import com.alibaba.fastjson.JSONObject; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * 开放平台客户端的纯逻辑测试(不依赖数据库与网络)。 + * + * @author Codex + * @since 2026-09-12 + */ +class OpenPlatformClientTest { + + /** + * 与平台文档 DesUtil.encrypt 的结果逐字对齐。 + * 期望值由 openssl DES-ECB 独立算出,并已在真实平台上换取 token 成功验证。 + */ + @Test + void encrypt_matchesPlatformDesUtil() { + assertEquals("92ADFAD04C17DA1F1D796914279AEEE3", OpenPlatformClient.encrypt("twxt2026")); + assertEquals("56EAF13B5CA98AF8", OpenPlatformClient.encrypt("fa")); + } + + @Test + void encrypt_returnsNullForNullInput() { + assertNull(OpenPlatformClient.encrypt(null)); + } + + /** + * 平台 findColumn 元数据返回小写字段名,实际数据是大写,取值必须大小写不敏感。 + */ + @Test + void field_isCaseInsensitive() { + JSONObject row = new JSONObject(); + row.put("XH", "201510051"); + assertEquals("201510051", OpenPlatformClient.field(row, "xh")); + assertEquals("201510051", OpenPlatformClient.field(row, "XH")); + } + + @Test + void field_trimsAndTreatsLiteralNullAsMissing() { + JSONObject row = new JSONObject(); + row.put("CSRQ", "NULL"); + row.put("XM", " 张三 "); + assertNull(OpenPlatformClient.field(row, "CSRQ")); + assertEquals("张三", OpenPlatformClient.field(row, "XM")); + } + + @Test + void field_returnsNullForMissingKey() { + assertNull(OpenPlatformClient.field(new JSONObject(), "BJM")); + assertNull(OpenPlatformClient.field(null, "BJM")); + } + +} diff --git a/src/test/java/com/gxwebsoft/gxmu/openplat/OpenplatCodeLookupTest.java b/src/test/java/com/gxwebsoft/gxmu/openplat/OpenplatCodeLookupTest.java new file mode 100644 index 0000000..aa9be51 --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/openplat/OpenplatCodeLookupTest.java @@ -0,0 +1,65 @@ +package com.gxwebsoft.gxmu.openplat; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * 码值映射测试。 + * + *

实测平台代码存在两套编码混用(民族码 1 与 01 并存、8 完全不存在; + * 政治面貌码 3 与 03 并存),匹配必须做归一化。 + * + * @author Codex + * @since 2026-09-12 + */ +class OpenplatCodeLookupTest { + + @Test + void exactMatchWins() { + Map dict = new HashMap<>(); + dict.put("1", "甲"); + dict.put("01", "乙"); + assertEquals("甲", OpenplatCodeService.lookup(dict, "1")); + assertEquals("乙", OpenplatCodeService.lookup(dict, "01")); + } + + @Test + void unpaddedCodeFallsBackToZeroPaddedEntry() { + Map dict = new HashMap<>(); + dict.put("01", "汉族"); + assertEquals("汉族", OpenplatCodeService.lookup(dict, "1")); + } + + @Test + void zeroPaddedCodeFallsBackToUnpaddedEntry() { + Map dict = new HashMap<>(); + dict.put("3", "共青团员"); + assertEquals("共青团员", OpenplatCodeService.lookup(dict, "03")); + } + + @Test + void unknownCodeReturnsNullWithoutThrowing() { + Map dict = new HashMap<>(); + dict.put("01", "汉族"); + assertNull(OpenplatCodeService.lookup(dict, "99")); + assertNull(OpenplatCodeService.lookup(dict, "")); + assertNull(OpenplatCodeService.lookup(dict, null)); + assertNull(OpenplatCodeService.lookup(new HashMap<>(), "1")); + } + + /** + * 两位以上代码不应被当成"补零"处理。 + */ + @Test + void multiDigitCodeIsNotPadded() { + Map dict = new HashMap<>(); + dict.put("013", "不应命中"); + assertNull(OpenplatCodeService.lookup(dict, "13")); + } + +} diff --git a/src/test/java/com/gxwebsoft/gxmu/openplat/OpenplatCodeServiceCacheTest.java b/src/test/java/com/gxwebsoft/gxmu/openplat/OpenplatCodeServiceCacheTest.java new file mode 100644 index 0000000..9b5a63b --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/openplat/OpenplatCodeServiceCacheTest.java @@ -0,0 +1,121 @@ +package com.gxwebsoft.gxmu.openplat; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.gxwebsoft.common.system.entity.Dict; +import com.gxwebsoft.common.system.entity.DictData; +import com.gxwebsoft.common.system.service.DictDataService; +import com.gxwebsoft.common.system.service.DictService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 码表缓存的回归测试。 + * + *

钉住的是一次真实事故:早期实现每次 {@code decode} 都去查 Redis、未命中再查两次数据库。 + * 学生同步要解码近三十万次,结果跑了 20 分钟仍未完成、并把连接池耗到 + * {@code CannotGetJdbcConnectionException}。这里断言「每本字典只加载一次」。 + * + * @author Codex + * @since 2026-09-15 + */ +class OpenplatCodeServiceCacheTest { + + private DictService dictService; + private DictDataService dictDataService; + private OpenplatCodeService service; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + dictService = mock(DictService.class); + dictDataService = mock(DictDataService.class); + ValueOperations valueOperations = mock(ValueOperations.class); + StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + // Redis 始终未命中,强制走数据库加载路径 + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + when(valueOperations.get(anyString())).thenReturn(null); + + Dict dict = new Dict(); + dict.setDictId(9001); + dict.setDictCode(OpenplatCodeService.DICT_SEX); + when(dictService.getOne(any(Wrapper.class))).thenReturn(dict); + + DictData male = new DictData(); + male.setDictDataCode("1"); + male.setDictDataName("男"); + DictData female = new DictData(); + female.setDictDataCode("2"); + female.setDictDataName("女"); + when(dictDataService.list(any(Wrapper.class))) + .thenReturn(java.util.Arrays.asList(male, female)); + + service = new OpenplatCodeService(); + ReflectionTestUtils.setField(service, "dictService", dictService); + ReflectionTestUtils.setField(service, "dictDataService", dictDataService); + ReflectionTestUtils.setField(service, "stringRedisTemplate", redisTemplate); + } + + /** + * 最重要的一条:解码一千次,数据库只应被查一次。 + */ + @Test + void decodeLoadsDictionaryOnlyOnce() { + for (int i = 0; i < 1000; i++) { + assertEquals("男", service.decode(OpenplatCodeService.DICT_SEX, "1")); + } + verify(dictService, times(1)).getOne(any(Wrapper.class)); + verify(dictDataService, times(1)).list(any(Wrapper.class)); + } + + @Test + void decodeStillResolvesAllVariants() { + assertEquals("男", service.decode(OpenplatCodeService.DICT_SEX, "1")); + assertEquals("男", service.decode(OpenplatCodeService.DICT_SEX, "01")); + assertEquals("女", service.decode(OpenplatCodeService.DICT_SEX, "2")); + assertNull(service.decode(OpenplatCodeService.DICT_SEX, "9")); + verify(dictService, times(1)).getOne(any(Wrapper.class)); + } + + /** + * 码表加载失败不得让异常冒泡——否则一次瞬时抖动会中断整个 9.8 万行的同步。 + */ + @Test + void decodeSwallowsDictionaryLoadFailure() { + when(dictService.getOne(any(Wrapper.class))) + .thenThrow(new org.springframework.dao.DataAccessResourceFailureException("连接池耗尽")); + assertNull(service.decode(OpenplatCodeService.DICT_SEX, "1")); + } + + @Test + void emptyDictionaryReturnsNullWithoutThrowing() { + when(dictDataService.list(any(Wrapper.class))).thenReturn(Collections.emptyList()); + service.evictAll(); + assertNull(service.decode(OpenplatCodeService.DICT_NATION, "01")); + } + + /** + * 清除缓存后应重新加载,保证码表改动无需重启即可生效。 + */ + @Test + void evictForcesReload() { + service.decode(OpenplatCodeService.DICT_SEX, "1"); + service.evict(OpenplatCodeService.DICT_SEX); + service.decode(OpenplatCodeService.DICT_SEX, "1"); + verify(dictService, times(2)).getOne(any(Wrapper.class)); + } + +} diff --git a/src/test/java/com/gxwebsoft/gxmu/openplat/SyncScopeTest.java b/src/test/java/com/gxwebsoft/gxmu/openplat/SyncScopeTest.java new file mode 100644 index 0000000..ad96d46 --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/openplat/SyncScopeTest.java @@ -0,0 +1,50 @@ +package com.gxwebsoft.gxmu.openplat; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 停用作用域判定测试 —— docs/adr/0004 的核心规则。 + * + * @author Codex + * @since 2026-09-12 + */ +class SyncScopeTest { + + /** + * 最重要的一条:人工创建、没有外部键的记录永不被停用。 + * + *

平台 1584 个班级里一个研究生班都没有,而库里的研究生班是团委手工维护的 + * (没有班级码)。若这里判成 true,一次同步就会废掉全部研究生班。 + */ + @Test + void manualRecordsAreNeverDeactivated() { + assertFalse(SyncScope.shouldDeactivate(null, false)); + assertFalse(SyncScope.shouldDeactivate("", false)); + assertFalse(SyncScope.shouldDeactivate(" ", false)); + // 即使本次"没拉到",人工记录也不动 + assertFalse(SyncScope.shouldDeactivate(null, true)); + } + + @Test + void syncedRecordMissingFromPlatformIsDeactivated() { + assertTrue(SyncScope.shouldDeactivate("GZ2203", false)); + assertTrue(SyncScope.shouldDeactivate("010318", false)); + assertTrue(SyncScope.shouldDeactivate("201510051", false)); + } + + @Test + void syncedRecordStillPresentIsKept() { + assertFalse(SyncScope.shouldDeactivate("GZ2203", true)); + } + + @Test + void isSyncSourcedOnlyWhenExternalCodePresent() { + assertTrue(SyncScope.isSyncSourced("GZ2203")); + assertFalse(SyncScope.isSyncSourced(null)); + assertFalse(SyncScope.isSyncSourced(" ")); + } + +} diff --git a/src/test/java/com/gxwebsoft/gxmu/openplat/sync/ClassSyncHandlerTest.java b/src/test/java/com/gxwebsoft/gxmu/openplat/sync/ClassSyncHandlerTest.java new file mode 100644 index 0000000..fdece01 --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/openplat/sync/ClassSyncHandlerTest.java @@ -0,0 +1,155 @@ +package com.gxwebsoft.gxmu.openplat.sync; + +import com.alibaba.fastjson.JSONObject; +import org.junit.jupiter.api.Test; + +import java.time.Year; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * 班级同步的纯逻辑测试。 + * + *

这些方法只依赖平台返回的 JSONObject 与一张工号->姓名表,不碰数据库。 + * + * @author Codex + * @since 2026-09-12 + */ +class ClassSyncHandlerTest { + + private final ClassSyncHandler handler = new ClassSyncHandler(); + + // ---------------------------------------------------------------- RXNF + + @Test + void gradeYearAcceptsReasonableValue() { + JSONObject row = new JSONObject(); + row.put("RXNF", "2023"); + assertEquals(2023, handler.parseGradeYear(row)); + } + + /** + * 实测平台存在 RXNF=2030 这类脏数据,必须被拦下且不中断整批。 + */ + @Test + void gradeYearRejectsOutOfRangeValue() { + JSONObject row = new JSONObject(); + row.put("RXNF", "2030"); + assertNull(handler.parseGradeYear(row)); + } + + @Test + void gradeYearAcceptsNextYearBoundary() { + JSONObject row = new JSONObject(); + row.put("RXNF", String.valueOf(Year.now().getValue() + 1)); + assertEquals(Year.now().getValue() + 1, handler.parseGradeYear(row)); + } + + @Test + void gradeYearHandlesBlankAndNonNumeric() { + JSONObject blank = new JSONObject(); + blank.put("RXNF", ""); + assertNull(handler.parseGradeYear(blank)); + + JSONObject text = new JSONObject(); + text.put("RXNF", "NULL"); + assertNull(handler.parseGradeYear(text)); + + assertNull(handler.parseGradeYear(new JSONObject())); + } + + // ------------------------------------------------------------ 数值解析 + + /** + * 平台返回的班级人数形如 "1.0"、"107.0",必须容错解析。 + */ + @Test + void parseIntegerHandlesFloatLikeValues() { + JSONObject row = new JSONObject(); + row.put("BJRS", "107.0"); + assertEquals(107, ClassSyncHandler.parseInteger(row, "BJRS")); + + row.put("BJRS", "0.0"); + assertEquals(0, ClassSyncHandler.parseInteger(row, "BJRS")); + + row.put("BJRS", ""); + assertNull(ClassSyncHandler.parseInteger(row, "BJRS")); + + row.put("BJRS", "abc"); + assertNull(ClassSyncHandler.parseInteger(row, "BJRS")); + } + + // -------------------------------------------------------- 辅导员姓名解析 + + @Test + void counselorNameResolvesSingleTeacher() { + Map teachers = new HashMap<>(); + teachers.put("219550", "张三"); + JSONObject row = new JSONObject(); + row.put("FDYH", "219550"); + assertEquals("张三", handler.buildCounselorName(row, teachers)); + } + + /** + * 实测有 32 个班级的 FDYH 是一组逗号分隔的工号。 + */ + @Test + void counselorNameJoinsMultipleTeachers() { + Map teachers = new HashMap<>(); + teachers.put("219550", "张三"); + teachers.put("219104", "李四"); + JSONObject row = new JSONObject(); + row.put("FDYH", "219550,219104"); + assertEquals("张三、李四", handler.buildCounselorName(row, teachers)); + } + + @Test + void counselorNameHandlesFullWidthCommaAndSpaces() { + Map teachers = new HashMap<>(); + teachers.put("219550", "张三"); + teachers.put("219104", "李四"); + JSONObject row = new JSONObject(); + row.put("FDYH", " 219550 , 219104 "); + assertEquals("张三、李四", handler.buildCounselorName(row, teachers)); + } + + /** + * 解析不出姓名时退回工号,不丢信息。 + */ + @Test + void counselorNameFallsBackToJobNumber() { + JSONObject row = new JSONObject(); + row.put("FDYH", "hl000093"); + assertEquals("hl000093", handler.buildCounselorName(row, new HashMap<>())); + } + + @Test + void counselorNameIsNullWhenNoCounselor() { + assertNull(handler.buildCounselorName(new JSONObject(), new HashMap<>())); + JSONObject blank = new JSONObject(); + blank.put("FDYH", ""); + assertNull(handler.buildCounselorName(blank, new HashMap<>())); + } + + /** + * counselor_name 是 varchar(50),多值拼接后需截断。 + */ + @Test + void counselorNameIsTruncatedTo50Chars() { + Map teachers = new HashMap<>(); + JSONObject row = new JSONObject(); + StringBuilder codes = new StringBuilder(); + for (int i = 0; i < 10; i++) { + String code = "T" + i; + teachers.put(code, "很长很长的姓名占位" + i); + codes.append(i > 0 ? "," : "").append(code); + } + row.put("FDYH", codes.toString()); + String name = handler.buildCounselorName(row, teachers); + assertEquals(50, name.length()); + } + +} diff --git a/src/test/java/com/gxwebsoft/gxmu/openplat/sync/StudentSourceMergerTest.java b/src/test/java/com/gxwebsoft/gxmu/openplat/sync/StudentSourceMergerTest.java new file mode 100644 index 0000000..5069a20 --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/openplat/sync/StudentSourceMergerTest.java @@ -0,0 +1,187 @@ +package com.gxwebsoft.gxmu.openplat.sync; + +import com.alibaba.fastjson.JSONObject; +import com.gxwebsoft.gxmu.openplat.OpenplatCodeService; +import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * 学生三源合并优先级测试。 + * + *

这条规则写反了不会报错,只会静默产生错误的人事数据,因此单独钉住。 + * + * @author Codex + * @since 2026-09-12 + */ +class StudentSourceMergerTest { + + /** 用直接映射代替真正的码表,专注验证优先级本身 */ + private static final StudentSourceMerger.CodeDecoder DECODER = (dictCode, rawCode) -> { + if (rawCode == null) { + return null; + } + if (OpenplatCodeService.DICT_SEX.equals(dictCode)) { + return "1".equals(rawCode) ? "男" : "2".equals(rawCode) ? "女" : null; + } + if (OpenplatCodeService.DICT_NATION.equals(dictCode)) { + return "1".equals(rawCode) ? "汉族" : "08".equals(rawCode) ? "壮族" : null; + } + if (OpenplatCodeService.DICT_POLITICS.equals(dictCode)) { + return "3".equals(rawCode) ? "共青团员" : null; + } + return null; + }; + + private static JSONObject mainRow() { + JSONObject row = new JSONObject(); + row.put("XM", "张三"); + row.put("XBM", "1"); + row.put("SFZJH", "450101200201011234"); + row.put("DWH", "010318"); + row.put("BJM", "GZ2203"); + row.put("ZYH", "100201"); + row.put("ZYMC", "临床医学"); + row.put("PYCCMC", "普通本科"); + return row; + } + + private static JSONObject academicRow() { + JSONObject row = new JSONObject(); + row.put("XM", "李四"); + row.put("XBM", "2"); + row.put("DWH", "010302"); + row.put("BH", "14340120"); + row.put("MZM", "1"); + row.put("ZZMMM", "3"); + row.put("CSRQ", "2002-01-01"); + row.put("SFZX", "1"); + row.put("XJZT", "01"); + row.put("XSDQZT", "01"); + return row; + } + + // ------------------------------------------------------------ 主源优先 + + @Test + void mainSourceWinsOverAcademic() { + GxmuStudent student = new GxmuStudent(); + StudentSourceMerger.applyMain(student, mainRow(), DECODER); + StudentSourceMerger.applyAcademic(student, academicRow(), DECODER); + + assertEquals("张三", student.getXm(), "姓名应取主源"); + assertEquals("1", student.getXbm(), "性别码应取主源"); + assertEquals("男", student.getSex()); + assertEquals("010318", student.getDwh(), "学院应取主源"); + assertEquals("GZ2203", student.getBjm(), "班级码应取主源"); + } + + // -------------------------------------------------------- 补源只填空缺 + + @Test + void academicSourceOnlyFillsBlanks() { + GxmuStudent student = new GxmuStudent(); + // 先只走补源:主源字段为空,应被补上 + StudentSourceMerger.applyAcademic(student, academicRow(), DECODER); + assertEquals("李四", student.getXm()); + assertEquals("2", student.getXbm()); + assertEquals("女", student.getSex()); + assertEquals("010302", student.getDwh()); + } + + /** + * 主源没有班级码时,用学籍库的班号兜底——实测 BH 与 BJM 是同一套编码。 + */ + @Test + void academicBhFillsMissingClassCode() { + GxmuStudent student = new GxmuStudent(); + JSONObject main = mainRow(); + main.remove("BJM"); + StudentSourceMerger.applyMain(student, main, DECODER); + assertNull(student.getBjm()); + + StudentSourceMerger.applyAcademic(student, academicRow(), DECODER); + assertEquals("14340120", student.getBh()); + assertEquals("14340120", student.getBjm(), "班号应兜底成为班级码"); + } + + @Test + void academicBhDoesNotOverwriteExistingClassCode() { + GxmuStudent student = new GxmuStudent(); + StudentSourceMerger.applyMain(student, mainRow(), DECODER); + StudentSourceMerger.applyAcademic(student, academicRow(), DECODER); + assertEquals("GZ2203", student.getBjm()); + assertEquals("14340120", student.getBh(), "原始班号仍然保留"); + } + + // -------------------------------------------------------- 主源独有字段 + + @Test + void academicOnlyFieldsAreFilledFromAcademicSource() { + GxmuStudent student = new GxmuStudent(); + StudentSourceMerger.applyAcademic(student, academicRow(), DECODER); + assertEquals("1", student.getMzm()); + assertEquals("汉族", student.getNation()); + assertEquals("3", student.getZzmmm()); + assertEquals("共青团员", student.getPolitics()); + assertEquals("2002-01-01", student.getCsrq()); + assertEquals("1", student.getSfzx()); + assertEquals("01", student.getXjzt()); + assertEquals("01", student.getXsdqzt()); + } + + // ---------------------------------------------------------------- 状态 + + @Test + void mainSourceMarksStudentAsInSchool() { + GxmuStudent student = new GxmuStudent(); + StudentSourceMerger.applyMain(student, mainRow(), DECODER); + assertEquals(1, student.getStatus()); + } + + /** + * 只在学籍库出现、且不在校的学生,应判为已毕业。 + */ + @Test + void academicSourceMarksNotInSchoolAsGraduated() { + GxmuStudent student = new GxmuStudent(); + JSONObject row = academicRow(); + row.put("SFZX", "0"); + StudentSourceMerger.applyAcademic(student, row, DECODER); + assertEquals(0, student.getStatus()); + } + + /** + * 已在主源判定为在校的,补源不得把它改成毕业。 + */ + @Test + void academicSourceDoesNotDowngradeInSchoolStatus() { + GxmuStudent student = new GxmuStudent(); + StudentSourceMerger.applyMain(student, mainRow(), DECODER); + JSONObject row = academicRow(); + row.put("SFZX", "0"); + StudentSourceMerger.applyAcademic(student, row, DECODER); + assertEquals(1, student.getStatus()); + } + + // ------------------------------------------------------ 研究生无班级 + + /** + * 研究生在平台上没有班级码:class_id 为空的链路不能被这里弄崩。 + */ + @Test + void graduateWithoutClassCodeKeepsNullClassFields() { + GxmuStudent student = new GxmuStudent(); + JSONObject row = mainRow(); + row.remove("BJM"); + row.put("PYCCMC", "研究生"); + StudentSourceMerger.applyMain(student, row, DECODER); + StudentSourceMerger.applyAcademic(student, new JSONObject(), DECODER); + assertNull(student.getBjm()); + assertNull(student.getBh()); + assertEquals("研究生", student.getPyccmc()); + } + +}