接入医科大开放平台数据同步
新增 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,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