接入医科大开放平台数据同步

新增 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:
2026-09-15 15:54:49 +08:00
parent f379291e4f
commit cd54039f28
51 changed files with 4586 additions and 3 deletions
@@ -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 之后的片段(到 &lt;/insert&gt; 为止)。
*
* <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());
}
}