接入医科大开放平台数据同步
新增 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)。
This commit is contained in:
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.gxwebsoft;
|
package com.gxwebsoft;
|
||||||
|
|
||||||
import com.gxwebsoft.common.core.config.ConfigProperties;
|
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||||
|
import com.gxwebsoft.gxmu.openplat.GxmuOpenplatProperties;
|
||||||
import com.gxwebsoft.shop.config.WxMaProperties;
|
import com.gxwebsoft.shop.config.WxMaProperties;
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
@@ -18,7 +19,7 @@ import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
|||||||
@EnableAsync
|
@EnableAsync
|
||||||
@EnableTransactionManagement
|
@EnableTransactionManagement
|
||||||
@MapperScan("com.gxwebsoft.**.mapper")
|
@MapperScan("com.gxwebsoft.**.mapper")
|
||||||
@EnableConfigurationProperties({ConfigProperties.class, WxMaProperties.class})
|
@EnableConfigurationProperties({ConfigProperties.class, WxMaProperties.class, GxmuOpenplatProperties.class})
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@EnableWebSocket
|
@EnableWebSocket
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ public class RedisConstants {
|
|||||||
public static final String SEARCH_HISTORY = "searchHistory";
|
public static final String SEARCH_HISTORY = "searchHistory";
|
||||||
// 租户系统设置信息
|
// 租户系统设置信息
|
||||||
public static final String TEN_ANT_SETTING_KEY = "setting";
|
public static final String TEN_ANT_SETTING_KEY = "setting";
|
||||||
|
// 医科大开放平台 access_token
|
||||||
|
public static final String OPENPLAT_TOKEN_KEY = "gxmu:openplat:token";
|
||||||
// 排行榜Key
|
// 排行榜Key
|
||||||
public static final String USER_RANKING_BY_APPS_5 = "cache5:userRankingByApps";
|
public static final String USER_RANKING_BY_APPS_5 = "cache5:userRankingByApps";
|
||||||
|
|
||||||
|
|||||||
@@ -34,4 +34,18 @@ public interface OrganizationMapper extends BaseMapper<Organization> {
|
|||||||
*/
|
*/
|
||||||
List<Organization> selectListRel(@Param("param") OrganizationParam param);
|
List<Organization> selectListRel(@Param("param") OrganizationParam param);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量新增或更新(开放平台部门同步用)。
|
||||||
|
*
|
||||||
|
* <p>幂等键为 {@code uk_sys_organization_code(tenant_id, organization_code)};
|
||||||
|
* 只覆盖平台权威字段,人工字段不动,见 docs/adr/0005。
|
||||||
|
*
|
||||||
|
* <p>用一条多行语句代替逐条 update:逐条发在公网数据库上会被往返延迟主导,
|
||||||
|
* 实测单条往返 25ms,1584 条即 40 秒;多行 upsert 只要几百毫秒。
|
||||||
|
*
|
||||||
|
* @param list 机构列表
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int batchUpsert(@Param("list") List<Organization> list);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,4 +92,31 @@
|
|||||||
<include refid="selectSql"></include>
|
<include refid="selectSql"></include>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
开放平台部门同步的批量写入。
|
||||||
|
ON DUPLICATE KEY UPDATE 里只列「平台权威字段」(docs/adr/0005):
|
||||||
|
sort_number / leader_id / province / city / region / parent_id 属人工维护,同步不触碰;
|
||||||
|
create_time 不重复刷新。幂等键是 uk_sys_organization_code(tenant_id, organization_code)。
|
||||||
|
-->
|
||||||
|
<insert id="batchUpsert">
|
||||||
|
INSERT INTO sys_organization (
|
||||||
|
parent_id, organization_name, organization_full_name, organization_code,
|
||||||
|
organization_type, comments, tenant_id, create_time, update_time, deleted
|
||||||
|
) VALUES
|
||||||
|
<foreach collection="list" item="item" separator=",">
|
||||||
|
(
|
||||||
|
#{item.parentId}, #{item.organizationName}, #{item.organizationFullName},
|
||||||
|
#{item.organizationCode}, #{item.organizationType}, #{item.comments},
|
||||||
|
#{item.tenantId}, #{item.createTime}, #{item.updateTime}, #{item.deleted}
|
||||||
|
)
|
||||||
|
</foreach>
|
||||||
|
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)
|
||||||
|
</insert>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -2,9 +2,28 @@ package com.gxwebsoft.gxmu.mapper;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.gxwebsoft.gxmu.entity.ClassInfo;
|
import com.gxwebsoft.gxmu.entity.ClassInfo;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 班级管理Mapper
|
* 班级管理Mapper
|
||||||
*/
|
*/
|
||||||
public interface ClassInfoMapper extends BaseMapper<ClassInfo> {
|
public interface ClassInfoMapper extends BaseMapper<ClassInfo> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量新增或更新(开放平台班级同步用)。
|
||||||
|
*
|
||||||
|
* <p>幂等键为 {@code uk_gxmu_class_code(tenant_id, class_code)};
|
||||||
|
* 只覆盖平台权威字段,人工维护的 sort_number / counselor_phone / create_time 不动,
|
||||||
|
* 见 docs/adr/0005。
|
||||||
|
*
|
||||||
|
* <p>用一条多行语句代替逐条 save/update:实测公网数据库单条往返 25ms,
|
||||||
|
* 1584 条逐条发要 41 秒,多行 upsert 只要 0.2 秒。
|
||||||
|
*
|
||||||
|
* @param list 班级列表
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int batchUpsert(@Param("list") List<ClassInfo> list);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.gxwebsoft.gxmu.mapper.ClassInfoMapper">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
开放平台班级同步的批量写入。
|
||||||
|
ON DUPLICATE KEY UPDATE 里只列「平台权威字段」(docs/adr/0005):
|
||||||
|
sort_number / counselor_phone 属人工维护,同步不触碰;create_time 不重复刷新。
|
||||||
|
幂等键是 uk_gxmu_class_code(tenant_id, class_code)。
|
||||||
|
-->
|
||||||
|
<insert id="batchUpsert">
|
||||||
|
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
|
||||||
|
<foreach collection="list" item="item" separator=",">
|
||||||
|
(
|
||||||
|
#{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}
|
||||||
|
)
|
||||||
|
</foreach>
|
||||||
|
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)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医科大开放平台客户端。
|
||||||
|
*
|
||||||
|
* <p>负责 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<String, Object> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页拉取某接口的全量数据。
|
||||||
|
*
|
||||||
|
* <p>注意:平台返回的 {@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<String, Object> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 大小写不敏感地取字段值。
|
||||||
|
*
|
||||||
|
* <p>平台的 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台码值映射。
|
||||||
|
*
|
||||||
|
* <p>平台只返回代码不返回名称,且平台本身没有任何码表接口(见 docs/adr/0006)。
|
||||||
|
* 映射放在 sys_dict / sys_dict_data 中,key 为 dictCode,dict_data_code 存平台原始码,
|
||||||
|
* dict_data_name 存解析后的名称。
|
||||||
|
*
|
||||||
|
* <p><b>性能要点</b>:一次全量同步要解码近三十万次(性别/民族/政治面貌 × 9.8 万行)。
|
||||||
|
* 早期实现每次解码都去查一次 Redis、缓存未命中再查两次数据库,导致学生同步跑了二十分钟仍未完成。
|
||||||
|
* 因此这里加了<b>进程内缓存</b>:每本字典在一次运行内只真正加载一次,之后都是本地 Map 查找。
|
||||||
|
*
|
||||||
|
* <p><b>容错</b>:码表加载失败只让当次解析返回 null,绝不让异常冒泡——
|
||||||
|
* 否则一次瞬时抖动就会中断整个 9.8 万行的同步。
|
||||||
|
*
|
||||||
|
* <p>实测平台代码存在两套编码混用(民族码 `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<String, CachedDict> localCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已经告警过的「字典 + 码」组合。
|
||||||
|
*
|
||||||
|
* <p>民族/政治面貌码表在 RS_JZGXX 授权前本就是空的,若每行都告警会产生十几万条日志。
|
||||||
|
*/
|
||||||
|
private final Set<String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> dict = null;
|
||||||
|
try {
|
||||||
|
String redisValue = stringRedisTemplate.opsForValue().get(cacheKey);
|
||||||
|
if (StrUtil.isNotBlank(redisValue)) {
|
||||||
|
dict = JSON.parseObject(redisValue, new TypeReference<LinkedHashMap<String, String>>() {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 大小写与前导零不敏感地查表。
|
||||||
|
*
|
||||||
|
* <p>先精确匹配(若字典里 `1` 与 `01` 都配了不同名称,以精确者为准),
|
||||||
|
* 再尝试去前导零与补零到两位。
|
||||||
|
*/
|
||||||
|
static String lookup(Map<String, String> 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<String, String> loadFromDatabase(String dictCode) {
|
||||||
|
Map<String, String> dict = new HashMap<>();
|
||||||
|
Dict dictEntity = dictService.getOne(new LambdaQueryWrapper<Dict>()
|
||||||
|
.eq(Dict::getDictCode, dictCode)
|
||||||
|
.eq(Dict::getDeleted, 0)
|
||||||
|
.last("limit 1"));
|
||||||
|
if (dictEntity == null) {
|
||||||
|
logger.warn("码表字典不存在: {}", dictCode);
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
List<DictData> items = dictDataService.list(new LambdaQueryWrapper<DictData>()
|
||||||
|
.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<String, String> dict;
|
||||||
|
private final long loadedAt;
|
||||||
|
|
||||||
|
private CachedDict(Map<String, String> dict, long loadedAt) {
|
||||||
|
this.dict = dict;
|
||||||
|
this.loadedAt = loadedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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)。
|
||||||
|
*
|
||||||
|
* <p>它的 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 = "辅导员号:";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部同步段的固定执行顺序。
|
||||||
|
*
|
||||||
|
* <p>顺序不可调换:后者的学院/班级归属依赖前者写入的组织与班级 id。
|
||||||
|
*/
|
||||||
|
public static final java.util.List<String> 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));
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<SyncSectionStatus> sections;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.gxwebsoft.gxmu.openplat;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步作用域判定。
|
||||||
|
*
|
||||||
|
* <p>把 docs/adr/0004 的核心规则收在一处:<b>只有「有外部键(同步来源)」且「本次未从平台拉到」
|
||||||
|
* 的记录才置为停用</b>;人工创建的记录(没有外部键)永不被停用。
|
||||||
|
*
|
||||||
|
* <p>这条规则写错的代价是实打实的:平台 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.gxwebsoft.gxmu.openplat;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步批次中某一段的状态。
|
||||||
|
*
|
||||||
|
* <p>段在开始执行前先落一条「进行中」的记录,因此前端能实时看到进度,
|
||||||
|
* 而不是对着一个卡住的 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<String, Integer> orgIdByCode() {
|
||||||
|
Map<String, Integer> map = new HashMap<>();
|
||||||
|
List<Organization> list = organizationService.list(new LambdaQueryWrapper<Organization>()
|
||||||
|
.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<String, Integer> classIdByCode() {
|
||||||
|
Map<String, Integer> map = new HashMap<>();
|
||||||
|
List<ClassInfo> list = classInfoService.list(new LambdaQueryWrapper<ClassInfo>()
|
||||||
|
.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<Integer, String> orgNameById() {
|
||||||
|
Map<Integer, String> map = new HashMap<>();
|
||||||
|
List<Organization> list = organizationService.list(new LambdaQueryWrapper<Organization>()
|
||||||
|
.eq(Organization::getTenantId, OpenplatSyncConstants.TENANT_ID));
|
||||||
|
for (Organization org : list) {
|
||||||
|
map.put(org.getOrganizationId(), org.getOrganizationName());
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级ID -> 班级名称
|
||||||
|
*/
|
||||||
|
public Map<Integer, String> classNameById() {
|
||||||
|
Map<Integer, String> map = new HashMap<>();
|
||||||
|
List<ClassInfo> list = classInfoService.list(new LambdaQueryWrapper<ClassInfo>()
|
||||||
|
.eq(ClassInfo::getTenantId, OpenplatSyncConstants.TENANT_ID));
|
||||||
|
for (ClassInfo item : list) {
|
||||||
|
map.put(item.getId(), item.getClassName());
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教工号 -> 姓名
|
||||||
|
*/
|
||||||
|
public Map<String, String> teacherNameByJgh() {
|
||||||
|
Map<String, String> map = new HashMap<>();
|
||||||
|
List<GxmuTeacher> 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<GxmuStudent> list) {
|
||||||
|
for (List<GxmuStudent> chunk : CollUtil.split(list, OpenplatSyncConstants.BATCH_SIZE)) {
|
||||||
|
studentMapper.batchUpsert(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分批写入教师名册
|
||||||
|
*/
|
||||||
|
public void upsertTeachers(List<GxmuTeacher> list) {
|
||||||
|
for (List<GxmuTeacher> chunk : CollUtil.split(list, OpenplatSyncConstants.BATCH_SIZE)) {
|
||||||
|
teacherMapper.batchUpsert(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分批写入班级。
|
||||||
|
*
|
||||||
|
* <p>用多行 upsert 而不是 MyBatis-Plus 的 saveBatch/updateBatchById:后者逐条发语句,
|
||||||
|
* 在公网数据库上被往返延迟主导——实测单条 25.9ms,1584 条就是 41 秒,
|
||||||
|
* 而多行 upsert 只要 0.2 秒。
|
||||||
|
*/
|
||||||
|
public void upsertClasses(List<ClassInfo> list) {
|
||||||
|
for (List<ClassInfo> chunk : CollUtil.split(list, OpenplatSyncConstants.BATCH_SIZE)) {
|
||||||
|
classInfoMapper.batchUpsert(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分批写入部门
|
||||||
|
*/
|
||||||
|
public void upsertOrganizations(List<Organization> list) {
|
||||||
|
for (List<Organization> chunk : CollUtil.split(list, OpenplatSyncConstants.BATCH_SIZE)) {
|
||||||
|
organizationMapper.batchUpsert(chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生差集停用。
|
||||||
|
*
|
||||||
|
* <p>在 Java 侧求差集而不是拼 NOT IN,避免 9 万多个参数。
|
||||||
|
* 只处理同步来源的记录(xh 非空),见 docs/adr/0004。
|
||||||
|
*/
|
||||||
|
public int deactivateMissingStudents(Set<String> incomingXh) {
|
||||||
|
List<String> existing = studentMapper.selectAllXh(OpenplatSyncConstants.TENANT_ID);
|
||||||
|
List<String> 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<String> chunk : CollUtil.split(missing, OpenplatSyncConstants.BATCH_SIZE)) {
|
||||||
|
studentMapper.deactivateByXh(OpenplatSyncConstants.TENANT_ID, chunk);
|
||||||
|
}
|
||||||
|
return missing.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师差集停用
|
||||||
|
*/
|
||||||
|
public int deactivateMissingTeachers(Set<String> incomingJgh) {
|
||||||
|
List<String> existing = teacherMapper.selectAllJgh(OpenplatSyncConstants.TENANT_ID);
|
||||||
|
List<String> 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<String> chunk : CollUtil.split(missing, OpenplatSyncConstants.BATCH_SIZE)) {
|
||||||
|
teacherMapper.deactivateByJgh(OpenplatSyncConstants.TENANT_ID, chunk);
|
||||||
|
}
|
||||||
|
return missing.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集一列非空值
|
||||||
|
*/
|
||||||
|
public static Set<String> collectNonBlank(Iterable<String> values) {
|
||||||
|
Set<String> set = new HashSet<>();
|
||||||
|
for (String value : values) {
|
||||||
|
String text = StrUtil.trimToNull(value);
|
||||||
|
if (text != null) {
|
||||||
|
set.add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.gxwebsoft.gxmu.openplat.claim;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生认领服务。
|
||||||
|
*
|
||||||
|
* <p>名册与登录账号分离(docs/adr/0001),认领负责把两者关联起来。
|
||||||
|
* 学生登录仍走微信授权手机号,认领只回填 sys_user 的 user_code / real_name / sex / organization_id,
|
||||||
|
* <b>不修改 username 与 phone</b>。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-09-12
|
||||||
|
*/
|
||||||
|
public interface StudentClaimService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按手机号自动认领。
|
||||||
|
*
|
||||||
|
* <p>命中且唯一才认领;命中多条或命中不到都返回 false,由自助认领兜底。
|
||||||
|
*
|
||||||
|
* @param user 登录用户
|
||||||
|
* @return 是否认领成功
|
||||||
|
*/
|
||||||
|
boolean autoClaimByPhone(User user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自助认领:学号 + 姓名校验。
|
||||||
|
*
|
||||||
|
* @param user 登录用户
|
||||||
|
* @param xh 学号
|
||||||
|
* @param xm 姓名
|
||||||
|
* @return 认领结果
|
||||||
|
*/
|
||||||
|
ClaimResult claimByXhAndName(User user, String xh, String xm);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人工认领/改绑。
|
||||||
|
*
|
||||||
|
* <p>优先用 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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<GxmuStudent> candidates = studentMapper.selectList(new LambdaQueryWrapper<GxmuStudent>()
|
||||||
|
.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<GxmuStudent>()
|
||||||
|
.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<GxmuStudent>()
|
||||||
|
.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<GxmuStudent>()
|
||||||
|
.eq(GxmuStudent::getId, studentId)
|
||||||
|
.eq(GxmuStudent::getTenantId, tenantId == null ? OpenplatSyncConstants.TENANT_ID : tenantId)
|
||||||
|
.last("limit 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行认领:回填 sys_user 的四个字段,并回写名册的 user_id / claim_time。
|
||||||
|
*
|
||||||
|
* <p>用 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生名册与认领控制器。
|
||||||
|
*
|
||||||
|
* <p>响应中的手机号与身份证一律脱敏,原始值不出本控制器。
|
||||||
|
*
|
||||||
|
* @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<PageResult<GxmuStudent>> 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<GxmuStudent> wrapper = buildWrapper(collegeId, classId, xh, xm, pyccmc, status, claimed);
|
||||||
|
Page<GxmuStudent> pageParam = new Page<>(page, limit);
|
||||||
|
IPage<GxmuStudent> result = studentMapper.selectPage(pageParam, wrapper);
|
||||||
|
List<GxmuStudent> records = result.getRecords();
|
||||||
|
desensitize(records);
|
||||||
|
enrich(records);
|
||||||
|
return success(new PageResult<>(records, result.getTotal()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('gxmu:student:list')")
|
||||||
|
@ApiOperation("学生名册详情")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ApiResult<GxmuStudent> 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<List<GxmuStudent>> export(
|
||||||
|
@RequestParam(value = "collegeId", required = false) Integer collegeId,
|
||||||
|
@RequestParam(value = "classId", required = false) Integer classId,
|
||||||
|
@RequestParam(value = "status", required = false) Integer status) {
|
||||||
|
LambdaQueryWrapper<GxmuStudent> wrapper = buildWrapper(collegeId, classId, null, null, null, status, null);
|
||||||
|
List<GxmuStudent> records = studentMapper.selectList(wrapper);
|
||||||
|
desensitize(records);
|
||||||
|
enrich(records);
|
||||||
|
return success(records);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiOperation("自助认领(学号+姓名)")
|
||||||
|
@PostMapping("/claim")
|
||||||
|
public ApiResult<ClaimResult> 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<GxmuStudent> buildWrapper(Integer collegeId, Integer classId, String xh,
|
||||||
|
String xm, String pyccmc, Integer status, Boolean claimed) {
|
||||||
|
LambdaQueryWrapper<GxmuStudent> 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<GxmuStudent> 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<GxmuStudent> records) {
|
||||||
|
if (records.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<Integer, String> orgNames = syncSupport.orgNameById();
|
||||||
|
Map<Integer, String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台数据同步控制器。
|
||||||
|
*
|
||||||
|
* <p>手动同步一律是<b>异步</b>的:提交后立即返回批次号,前端轮询 {@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<String> dept() {
|
||||||
|
return submit(Collections.singletonList(GxmuSyncRecord.TYPE_DEPT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog
|
||||||
|
@PreAuthorize("hasAuthority('gxmu:sync:manual')")
|
||||||
|
@ApiOperation("同步教师(异步, 返回批次号)")
|
||||||
|
@PostMapping("/teacher")
|
||||||
|
public ApiResult<String> teacher() {
|
||||||
|
return submit(Collections.singletonList(GxmuSyncRecord.TYPE_TEACHER));
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog
|
||||||
|
@PreAuthorize("hasAuthority('gxmu:sync:manual')")
|
||||||
|
@ApiOperation("同步班级(异步, 返回批次号)")
|
||||||
|
@PostMapping("/class")
|
||||||
|
public ApiResult<String> classInfo() {
|
||||||
|
return submit(Collections.singletonList(GxmuSyncRecord.TYPE_CLASS));
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog
|
||||||
|
@PreAuthorize("hasAuthority('gxmu:sync:manual')")
|
||||||
|
@ApiOperation("同步学生(异步, 返回批次号)")
|
||||||
|
@PostMapping("/student")
|
||||||
|
public ApiResult<String> student() {
|
||||||
|
return submit(Collections.singletonList(GxmuSyncRecord.TYPE_STUDENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
@OperationLog
|
||||||
|
@PreAuthorize("hasAuthority('gxmu:sync:manual')")
|
||||||
|
@ApiOperation("全部同步(异步, 返回批次号)")
|
||||||
|
@PostMapping("/all")
|
||||||
|
public ApiResult<String> all() {
|
||||||
|
return submit(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('gxmu:sync:manual')")
|
||||||
|
@ApiOperation("同步进度(当前或最近一次批次)")
|
||||||
|
@GetMapping("/status")
|
||||||
|
public ApiResult<SyncBatchStatus> status() {
|
||||||
|
return success(openplatSyncService.currentStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('gxmu:sync:manual')")
|
||||||
|
@ApiOperation("同步记录分页查询")
|
||||||
|
@GetMapping("/record/page")
|
||||||
|
public ApiResult<PageResult<GxmuSyncRecord>> 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<GxmuSyncRecord> pageParam = new Page<>(page, limit);
|
||||||
|
LambdaQueryWrapper<GxmuSyncRecord> 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<GxmuSyncRecord> result = gxmuSyncRecordMapper.selectPage(pageParam, wrapper);
|
||||||
|
return success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ApiResult<String> submit(List<String> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生名册(开放平台同步)。
|
||||||
|
*
|
||||||
|
* <p>与 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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 关联同一次触发。
|
||||||
|
*
|
||||||
|
* <p>注意: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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师名册(开放平台同步)。
|
||||||
|
*
|
||||||
|
* <p>教师不建登录账号,只用于解析班级的辅导员工号与后台选人。
|
||||||
|
*
|
||||||
|
* @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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<GxmuStudent> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 (tenant_id, xh) 批量新增或更新。
|
||||||
|
*
|
||||||
|
* <p>只覆盖平台权威字段;user_id / claim_time 由认领流程维护,不在此处覆盖。
|
||||||
|
* create_time 不在 UPDATE 子句中,重复执行不会刷新创建时间。
|
||||||
|
*
|
||||||
|
* @param list 学生名册
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int batchUpsert(@Param("list") List<GxmuStudent> list);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询全部同步来源的学号。
|
||||||
|
*
|
||||||
|
* <p>用于在 Java 侧与本次拉取到的学号求差集——上游消失的记录通常为零,
|
||||||
|
* 这样就不必用 9 万多个参数去拼 NOT IN。
|
||||||
|
*
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
* @return 学号列表
|
||||||
|
*/
|
||||||
|
List<String> selectAllXh(@Param("tenantId") Integer tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按学号批量置为停用。
|
||||||
|
*
|
||||||
|
* <p>只作用于同步来源的记录(xh 非空),人工创建的记录不在其中,见 docs/adr/0004。
|
||||||
|
*
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
* @param xhs 学号列表(调用方需自行分批)
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int deactivateByXh(@Param("tenantId") Integer tenantId, @Param("xhs") List<String> xhs);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<GxmuSyncRecord> {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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<GxmuTeacher> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 (tenant_id, jgh) 批量新增或更新
|
||||||
|
*
|
||||||
|
* @param list 教师名册
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int batchUpsert(@Param("list") List<GxmuTeacher> list);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询全部同步来源的教工号,用于在 Java 侧求差集
|
||||||
|
*
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
* @return 教工号列表
|
||||||
|
*/
|
||||||
|
List<String> selectAllJgh(@Param("tenantId") Integer tenantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按教工号批量置为停用
|
||||||
|
*
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
* @param jghs 教工号列表(调用方需自行分批)
|
||||||
|
* @return 影响行数
|
||||||
|
*/
|
||||||
|
int deactivateByJgh(@Param("tenantId") Integer tenantId, @Param("jghs") List<String> jghs);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询全部 教工号 -> 姓名,用于解析班级的辅导员/班主任
|
||||||
|
*
|
||||||
|
* @param tenantId 租户ID
|
||||||
|
* @return 列表,元素含 jgh 与 xm
|
||||||
|
*/
|
||||||
|
List<GxmuTeacher> selectAllForNameLookup(@Param("tenantId") Integer tenantId);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.gxwebsoft.gxmu.openplat.mapper.GxmuStudentMapper">
|
||||||
|
|
||||||
|
<insert id="batchUpsert">
|
||||||
|
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
|
||||||
|
<foreach collection="list" item="item" separator=",">
|
||||||
|
(
|
||||||
|
#{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}
|
||||||
|
)
|
||||||
|
</foreach>
|
||||||
|
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)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="selectAllXh" resultType="java.lang.String">
|
||||||
|
SELECT xh FROM gxmu_student
|
||||||
|
WHERE tenant_id = #{tenantId} AND xh IS NOT NULL AND xh <> ''
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="deactivateByXh">
|
||||||
|
UPDATE gxmu_student
|
||||||
|
SET status = 0, update_time = NOW()
|
||||||
|
WHERE tenant_id = #{tenantId} AND status <> 0
|
||||||
|
AND xh IN
|
||||||
|
<foreach collection="xhs" item="xh" open="(" separator="," close=")">
|
||||||
|
#{xh}
|
||||||
|
</foreach>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="selectClassIdByCode" resultType="java.lang.Integer">
|
||||||
|
SELECT id FROM gxmu_class
|
||||||
|
WHERE tenant_id = #{tenantId} AND class_code = #{classCode} AND deleted = 0
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.gxwebsoft.gxmu.openplat.mapper.GxmuTeacherMapper">
|
||||||
|
|
||||||
|
<insert id="batchUpsert">
|
||||||
|
INSERT INTO gxmu_teacher (
|
||||||
|
jgh, xm, xbm, sex, dwh, college_id, ksjybh, sjh,
|
||||||
|
status, sync_time, tenant_id, create_time, update_time, deleted
|
||||||
|
) VALUES
|
||||||
|
<foreach collection="list" item="item" separator=",">
|
||||||
|
(
|
||||||
|
#{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}
|
||||||
|
)
|
||||||
|
</foreach>
|
||||||
|
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)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="selectAllJgh" resultType="java.lang.String">
|
||||||
|
SELECT jgh FROM gxmu_teacher
|
||||||
|
WHERE tenant_id = #{tenantId} AND jgh IS NOT NULL AND jgh <> ''
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<update id="deactivateByJgh">
|
||||||
|
UPDATE gxmu_teacher
|
||||||
|
SET status = 0, update_time = NOW()
|
||||||
|
WHERE tenant_id = #{tenantId} AND status <> 0
|
||||||
|
AND jgh IN
|
||||||
|
<foreach collection="jghs" item="jgh" open="(" separator="," close=")">
|
||||||
|
#{jgh}
|
||||||
|
</foreach>
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="selectAllForNameLookup" resultType="com.gxwebsoft.gxmu.openplat.entity.GxmuTeacher">
|
||||||
|
SELECT jgh, xm FROM gxmu_teacher
|
||||||
|
WHERE tenant_id = #{tenantId} AND deleted = 0 AND jgh IS NOT NULL
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -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 {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步提交一次同步并立即返回批次号。
|
||||||
|
*
|
||||||
|
* <p>学生全量约 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<String> sections);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前(或最近一次)批次的进度
|
||||||
|
*/
|
||||||
|
SyncBatchStatus currentStatus();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否有同步任务在执行
|
||||||
|
*/
|
||||||
|
boolean isRunning();
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台数据同步服务实现。
|
||||||
|
*
|
||||||
|
* <p><b>异步执行</b>:提交后立即返回批次号,实际同步跑在单线程执行器上。
|
||||||
|
* 单线程保证同一时刻只有一个批次在跑,配合 {@link AtomicBoolean} 拒绝重复提交。
|
||||||
|
*
|
||||||
|
* <p><b>进度可见</b>:每段开始执行前先落一条 status=2(进行中) 的记录,结束时回填结果,
|
||||||
|
* 前端据此展示进度,也能识别被中断的批次。
|
||||||
|
*
|
||||||
|
* <p><b>相互隔离</b>:单段失败不影响其余段,照抄 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<String> 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<String> sections) {
|
||||||
|
List<String> 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<String, GxmuSyncRecord> rows = new HashMap<>();
|
||||||
|
if (StrUtil.isNotBlank(currentBatchId)) {
|
||||||
|
List<GxmuSyncRecord> list = syncRecordMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<GxmuSyncRecord>()
|
||||||
|
.eq(GxmuSyncRecord::getBatchId, currentBatchId)
|
||||||
|
.orderByAsc(GxmuSyncRecord::getId));
|
||||||
|
for (GxmuSyncRecord record : list) {
|
||||||
|
rows.put(record.getSyncType(), record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<SyncSectionStatus> 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<String> 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<GxmuSyncRecord>()
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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。
|
||||||
|
*
|
||||||
|
* <p>只按 class_code 匹配,<b>绝不做名称模糊匹配</b>——实测激进归一化会把
|
||||||
|
* 「22级本科护理学1班」误配到「2022级临床医学1班」。
|
||||||
|
*
|
||||||
|
* <p>停用只作用于有 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<String, Integer> orgIds = support.orgIdByCode();
|
||||||
|
Map<String, String> teacherNames = support.teacherNameByJgh();
|
||||||
|
|
||||||
|
Map<String, ClassInfo> existing = new HashMap<>();
|
||||||
|
List<ClassInfo> existingList = listSyncedClasses();
|
||||||
|
for (ClassInfo item : existingList) {
|
||||||
|
existing.put(StrUtil.trim(item.getClassCode()), item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> incoming = new HashSet<>();
|
||||||
|
List<ClassInfo> 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 入学年份,带合理性校验。
|
||||||
|
*
|
||||||
|
* <p>实测存在 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析辅导员姓名。
|
||||||
|
*
|
||||||
|
* <p>FDYH 可能是一组逗号分隔的工号(实测 32 个班级如此),需要逐个解析后用「、」连接。
|
||||||
|
*/
|
||||||
|
String buildCounselorName(JSONObject row, Map<String, String> teacherNames) {
|
||||||
|
String raw = OpenPlatformClient.field(row, "FDYH");
|
||||||
|
if (StrUtil.isBlank(raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Set<String> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本次未从平台拉到的同步来源班级置为停用。
|
||||||
|
*
|
||||||
|
* <p>只处理 class_code 非空的记录,人工创建的研究生班不受影响。
|
||||||
|
* 复用同步开头已加载的列表,避免再全表查一次。
|
||||||
|
*/
|
||||||
|
private int deactivateMissing(Set<String> incoming, List<ClassInfo> existingList) {
|
||||||
|
List<Integer> 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<ClassInfo> listSyncedClasses() {
|
||||||
|
return classInfoService.list(new LambdaQueryWrapper<ClassInfo>()
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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。
|
||||||
|
*
|
||||||
|
* <p>全部部门扁平挂在「广西医科大学」(id=24) 下;实测 75 个部门中仅
|
||||||
|
* 「附属小学」「幼儿园」有可推出的父节点,DWH 前缀推不出真实层级,因此不做层级推导。
|
||||||
|
*
|
||||||
|
* <p>只操作 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<String, String> teacherNames = support.teacherNameByJgh();
|
||||||
|
|
||||||
|
Map<String, Organization> existing = new HashMap<>();
|
||||||
|
List<Organization> existingList = listSyncedOrganizations();
|
||||||
|
for (Organization org : existingList) {
|
||||||
|
existing.put(StrUtil.trim(org.getOrganizationCode()), org);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> incoming = new HashSet<>();
|
||||||
|
List<Organization> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本次未从平台拉到的同步来源部门置为停用。
|
||||||
|
*
|
||||||
|
* <p>只处理 organization_code 非空的记录;并显式跳过校团委(id=18)作为双保险。
|
||||||
|
* 复用同步开头已加载的列表,避免再全表查一次。
|
||||||
|
*/
|
||||||
|
private int deactivateMissing(Set<String> incoming, List<Organization> existingList) {
|
||||||
|
List<Integer> 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<Organization> listSyncedOrganizations() {
|
||||||
|
return organizationService.list(new LambdaQueryWrapper<Organization>()
|
||||||
|
.eq(Organization::getTenantId, OpenplatSyncConstants.TENANT_ID)
|
||||||
|
.isNotNull(Organization::getOrganizationCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生三源合并的字段优先级规则。
|
||||||
|
*
|
||||||
|
* <p>从 StudentSyncHandler 中抽出来单独成类,是为了让「谁覆盖谁」这条规则成为
|
||||||
|
* 可单测的纯逻辑——写反了不会报错,只会静默产生错误的人事数据。
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* xsjbxxq 主源:姓名/性别/学院/班级/身份证/专业(在校生,含研究生)
|
||||||
|
* LY_XXBZ_JW_XSXXB 补源:民族/政治面貌/出生日期/学籍状态/班号(含已毕业)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-09-12
|
||||||
|
*/
|
||||||
|
public final class StudentSourceMerger {
|
||||||
|
|
||||||
|
private StudentSourceMerger() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 码值解析器:字典编码 + 平台原始码 -> 名称
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface CodeDecoder {
|
||||||
|
String decode(String dictCode, String rawCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主源 xsjbxxq:这些字段以它为准,直接覆盖。
|
||||||
|
*
|
||||||
|
* <p>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:姓名/性别/学院/班级**只补空缺,绝不覆盖主源**;
|
||||||
|
* 民族/政治面貌/出生日期/学籍状态等主源没有的字段直接写入。
|
||||||
|
*
|
||||||
|
* <p>注意 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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。
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* xsjbxxq 在校生(含研究生) 58,655 姓名/性别/学院/班级/身份证/专业
|
||||||
|
* LY_XXBZ_JW_XSXXB 本专科(含毕业) 82,649 民族/政治面貌/出生日期/学籍状态/班号
|
||||||
|
* yhxx_xsqtykt 已毕业 14,059 手机号
|
||||||
|
* xsyhxx_ykt 在校生(未授权) 手机号(优先)
|
||||||
|
* 并集 98,187
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>实测 BH(学籍库班号) 与 BJM(平台班级码) 是同一套编码:43,117 名两接口都有的学生
|
||||||
|
* BH == BJM 100% 一致,因此 class_id 统一按班级码解析。
|
||||||
|
*
|
||||||
|
* <p>研究生(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<String, Integer> orgIds = support.orgIdByCode();
|
||||||
|
Map<String, Integer> classIds = support.classIdByCode();
|
||||||
|
|
||||||
|
Map<String, GxmuStudent> 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<String, String> inSchoolPhone = loadPhoneQuietly(
|
||||||
|
OpenplatSyncConstants.EP_STUDENT_YKT_INSCHOOL);
|
||||||
|
|
||||||
|
// 4) 已毕业手机号
|
||||||
|
Map<String, String> graduatePhone = loadPhoneQuietly(OpenplatSyncConstants.EP_STUDENT_YKT);
|
||||||
|
|
||||||
|
applyPhones(merged, inSchoolPhone, graduatePhone);
|
||||||
|
|
||||||
|
LocalDateTime now = SyncSupport.now();
|
||||||
|
Set<String> incoming = new HashSet<>(merged.size());
|
||||||
|
java.util.List<GxmuStudent> list = new java.util.ArrayList<>(merged.size());
|
||||||
|
for (Map.Entry<String, GxmuStudent> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取某接口并合并进结果集。
|
||||||
|
*
|
||||||
|
* <p>逐个接口处理而不是把三份原始 JSON 同时留在内存里,避免十几万个 JSONObject 峰值。
|
||||||
|
*/
|
||||||
|
private void mergeSource(Map<String, GxmuStudent> merged, String endpoint, Source source,
|
||||||
|
Map<String, Integer> orgIds, Map<String, Integer> 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<String, String> loadPhoneQuietly(String endpoint) {
|
||||||
|
Map<String, String> 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<String, GxmuStudent> merged,
|
||||||
|
Map<String, String> inSchoolPhone,
|
||||||
|
Map<String, String> 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<String, Integer> orgIds,
|
||||||
|
Map<String, Integer> 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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。
|
||||||
|
*
|
||||||
|
* <p>全量落库不筛选(实测六成以上属附属医院),因为班级的辅导员/班主任可能来自任何单位,
|
||||||
|
* 筛掉会导致班级同步的工号解析失败。
|
||||||
|
*
|
||||||
|
* @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<String, Integer> orgIds = support.orgIdByCode();
|
||||||
|
LocalDateTime now = SyncSupport.now();
|
||||||
|
|
||||||
|
List<GxmuTeacher> list = new ArrayList<>();
|
||||||
|
Set<String> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医科大开放平台定时同步任务。
|
||||||
|
*
|
||||||
|
* <p>每天 03:30 全量同步部门、教师、班级、学生。
|
||||||
|
*
|
||||||
|
* <p>与手动同步共用同一把「单批次」锁:定时到点时若手动同步还在跑,
|
||||||
|
* {@code submitAsync} 会返回 null,本轮调度直接跳过。
|
||||||
|
*
|
||||||
|
* <p>注意:{@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -58,6 +58,8 @@ public class WxLoginController extends BaseController {
|
|||||||
private RoleService roleService;
|
private RoleService roleService;
|
||||||
@Resource
|
@Resource
|
||||||
private WxMaProperties wxMaProperties;
|
private WxMaProperties wxMaProperties;
|
||||||
|
@Resource
|
||||||
|
private com.gxwebsoft.gxmu.openplat.claim.StudentClaimService studentClaimService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private StringRedisTemplate redisTemplate;
|
private StringRedisTemplate redisTemplate;
|
||||||
@@ -90,6 +92,7 @@ public class WxLoginController extends BaseController {
|
|||||||
userOauthService.save(uo);
|
userOauthService.save(uo);
|
||||||
}
|
}
|
||||||
// 4.签发token
|
// 4.签发token
|
||||||
|
autoClaimQuietly(user);
|
||||||
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
|
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
|
||||||
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
||||||
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
||||||
@@ -124,6 +127,7 @@ public class WxLoginController extends BaseController {
|
|||||||
userOauthService.save(uo);
|
userOauthService.save(uo);
|
||||||
}
|
}
|
||||||
// 签发token
|
// 签发token
|
||||||
|
autoClaimQuietly(user);
|
||||||
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
String access_token = JwtUtil.buildToken(new JwtSubject(user.getUsername(), user.getTenantId()),
|
||||||
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
configProperties.getTokenExpireTime(), configProperties.getTokenKey());
|
||||||
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
|
loginRecordService.saveAsync(user.getUsername(), LoginRecord.TYPE_LOGIN, null, user.getTenantId(), request);
|
||||||
@@ -146,6 +150,19 @@ public class WxLoginController extends BaseController {
|
|||||||
return fail("更新失败");
|
return fail("更新失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试按手机号自动认领学生名册(docs/adr/0001)。
|
||||||
|
*
|
||||||
|
* <p>认领失败不影响登录:命中不到、命中多条、姓名不符等都只是返回 false。
|
||||||
|
*/
|
||||||
|
private void autoClaimQuietly(User user) {
|
||||||
|
try {
|
||||||
|
studentClaimService.autoClaimByPhone(user);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("学生认领失败:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 新用户注册
|
* 新用户注册
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# 数据源配置
|
# 数据源配置
|
||||||
spring:
|
spring:
|
||||||
datasource:
|
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
|
username: tuanwei
|
||||||
password: TytQZsSW5m4wGh38
|
password: TytQZsSW5m4wGh38
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
@@ -14,6 +14,7 @@ logging:
|
|||||||
level:
|
level:
|
||||||
com.gxwebsoft: DEBUG
|
com.gxwebsoft: DEBUG
|
||||||
com.baomidou.mybatisplus: DEBUG
|
com.baomidou.mybatisplus: DEBUG
|
||||||
|
com.gxwebsoft.gxmu.openplat: INFO
|
||||||
|
|
||||||
|
|
||||||
# 框架配置
|
# 框架配置
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# 数据源配置
|
# 数据源配置
|
||||||
spring:
|
spring:
|
||||||
datasource:
|
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
|
username: tuanwei
|
||||||
password: TytQZsSW5m4wGh38
|
password: TytQZsSW5m4wGh38
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
@@ -23,6 +23,9 @@ logging:
|
|||||||
root: WARN
|
root: WARN
|
||||||
com.gxwebsoft: ERROR
|
com.gxwebsoft: ERROR
|
||||||
com.baomidou.mybatisplus: ERROR
|
com.baomidou.mybatisplus: ERROR
|
||||||
|
# 开放平台同步过程保留日志:一次同步动辄一两分钟,
|
||||||
|
# 若沿用 com.gxwebsoft 的 ERROR 级,进度与失败原因在生产完全不可见
|
||||||
|
com.gxwebsoft.gxmu.openplat: INFO
|
||||||
|
|
||||||
# 框架配置
|
# 框架配置
|
||||||
config:
|
config:
|
||||||
|
|||||||
@@ -107,3 +107,20 @@ wxpay:
|
|||||||
app-id: wxf7ee9cd5801bf7a6
|
app-id: wxf7ee9cd5801bf7a6
|
||||||
mch-id: 1732930031
|
mch-id: 1732930031
|
||||||
mch-serial-no: 7EE24C695B278DB2C4F0C1CB42069065ECCBE3FD
|
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 * * ?
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
*
|
||||||
|
* <p>同步的 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> 为止)。
|
||||||
|
*
|
||||||
|
* <p>先剥掉 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("</insert>");
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台客户端的**联网**验证。
|
||||||
|
*
|
||||||
|
* <p>默认<b>不执行</b>(类名以 IT 结尾,surefire 不会扫到)。手动运行:
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* ./mvnw test -Dtest=OpenPlatformClientIT -Dopenplat.it=true
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>它存在的意义:其余测试只证明了 DES 密文与平台一致,而 Hutool 的请求构造
|
||||||
|
* (URL 拼接、query 参数、Basic 头、分页终止)从未真正打到过平台。
|
||||||
|
* 这个测试用真实接口把这一段补上——不需要数据库,Redis 用 mock 顶掉。
|
||||||
|
*
|
||||||
|
* <p>前提:本机出口 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<String, String> 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 条");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 码值映射测试。
|
||||||
|
*
|
||||||
|
* <p>实测平台代码存在两套编码混用(民族码 1 与 01 并存、8 完全不存在;
|
||||||
|
* 政治面貌码 3 与 03 并存),匹配必须做归一化。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-09-12
|
||||||
|
*/
|
||||||
|
class OpenplatCodeLookupTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exactMatchWins() {
|
||||||
|
Map<String, String> dict = new HashMap<>();
|
||||||
|
dict.put("1", "甲");
|
||||||
|
dict.put("01", "乙");
|
||||||
|
assertEquals("甲", OpenplatCodeService.lookup(dict, "1"));
|
||||||
|
assertEquals("乙", OpenplatCodeService.lookup(dict, "01"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unpaddedCodeFallsBackToZeroPaddedEntry() {
|
||||||
|
Map<String, String> dict = new HashMap<>();
|
||||||
|
dict.put("01", "汉族");
|
||||||
|
assertEquals("汉族", OpenplatCodeService.lookup(dict, "1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void zeroPaddedCodeFallsBackToUnpaddedEntry() {
|
||||||
|
Map<String, String> dict = new HashMap<>();
|
||||||
|
dict.put("3", "共青团员");
|
||||||
|
assertEquals("共青团员", OpenplatCodeService.lookup(dict, "03"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownCodeReturnsNullWithoutThrowing() {
|
||||||
|
Map<String, String> 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<String, String> dict = new HashMap<>();
|
||||||
|
dict.put("013", "不应命中");
|
||||||
|
assertNull(OpenplatCodeService.lookup(dict, "13"));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 码表缓存的回归测试。
|
||||||
|
*
|
||||||
|
* <p>钉住的是一次真实事故:早期实现每次 {@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<String, String> 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最重要的一条:人工创建、没有外部键的记录永不被停用。
|
||||||
|
*
|
||||||
|
* <p>平台 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(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级同步的纯逻辑测试。
|
||||||
|
*
|
||||||
|
* <p>这些方法只依赖平台返回的 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生三源合并优先级测试。
|
||||||
|
*
|
||||||
|
* <p>这条规则写反了不会报错,只会静默产生错误的人事数据,因此单独钉住。
|
||||||
|
*
|
||||||
|
* @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());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user