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

新增 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,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;
}
}
}