Compare commits

..
12 Commits
Author SHA1 Message Date
wanghu e063e67822 修改教师
build / build-push (push) Successful in 2m17s
2026-10-28 11:22:50 +08:00
wanghu e0c9e14ab9 更新
build / build-push (push) Successful in 1m20s
2026-10-28 08:57:33 +08:00
wanghu 3fa52f21af 学年 学校 获取 年级
build / build-push (push) Successful in 1m12s
2026-10-21 14:43:27 +08:00
ops a0c6a27f55 fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:29 +00:00
wanghu cee411472a 同步班级学生
build / build-push (push) Successful in 1m4s
2026-10-21 08:47:21 +08:00
wanghu a669bb67ec 提交
build / build-push (push) Successful in 1m19s
2026-10-20 19:55:50 +08:00
wanghu 064c855a55 git错误
build / build-push (push) Successful in 1m12s
2026-10-20 14:29:10 +08:00
wanghu d633609306 依赖
build / build-push (push) Failing after 2m2s
2026-10-20 14:17:10 +08:00
wanghu 1c19e3d2ef 自定义组开放接口
build / build-push (push) Successful in 1m7s
2026-09-29 15:45:23 +08:00
wanghu aecab2fd91 自定义组开放接口
build / build-push (push) Failing after 48s
2026-09-29 11:03:02 +08:00
wanghu 9f3b49c30a 任教
build / build-push (push) Successful in 1m4s
2026-09-20 20:13:54 +08:00
wanghu 3e18856c0c 学科排序
build / build-push (push) Successful in 1m3s
2026-09-20 19:28:23 +08:00
33 changed files with 1482 additions and 29 deletions
+25 -10
View File
@@ -24,8 +24,17 @@ jobs:
image: docker:24
options: --network infra-infra-net
steps:
- name: 安装 git / curl(alpine)
run: apk add --no-cache git curl
# dl-cdn 在 CI 容器内被出口防火墙拒(apk 报 Permission denied,索引拉不下来即 curl no such package),
# 切国内镜像源安装;aliyun 不通再退 ustc,最后兜底官方源
- name: 安装 git / curl(alpine,国内镜像源)
run: |
set -e
for m in mirrors.aliyun.com mirrors.ustc.edu.cn dl-cdn.alpinelinux.org; do
sed -i "s#https://[^/]*#https://$m#" /etc/apk/repositories
echo ">> trying mirror: $m"
if apk add --no-cache git curl; then break; fi
done
git --version && curl --version | head -1
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -55,18 +64,24 @@ jobs:
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
run: |
set -e
docker rm -f mavenbuild 2>/dev/null || true
docker run -d --name mavenbuild \
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \
--network infra-infra-net \
-v /opt/docker/projman-m2:/root/.m2 \
-w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." mavenbuild:/build/
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package'
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
mkdir -p "${PWD}/target"
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f mavenbuild
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f "$BUILD_CONTAINER"
- name: 构建并推送镜像
run: |
@@ -0,0 +1,49 @@
## 根因
`TeainfoController.convertToTeaInfoVO`(TeainfoController.java:84-86)检查 `teacher.getGroupList()`,但它是一个 `@Transient` 字段,而 `getSchTeacherList` 查询链路(SchTeacherCacheService.java:36-44)只查 `sch_teacher` 本表、从不填充它。因此**每个教师都在第 86 行 `return null`**,`stream().map()` 不过滤 null,接口最终返回 `200 OK + [null, null, ...]`。`GetTeainfo` 和 `GetArrTeainfo` 共用同一转换方法,有一模一样的 bug。
## 修复方案
### 改动 1:TeainfoController.java(修复 null 元素)
1. **注入 `SchTeacherGroupRepository`**(新增 `@Autowired` 字段 + import:`SchTeacherGroupRepository`、`java.util.Map`、`java.util.Collections`、`java.util.Objects`)。
2. **新增私有方法 `fillGroupList(List<SchTeacher> teachers, Integer schId)`**:批量查询组关系并回填到每个教师的 `@Transient groupList` 字段,复用项目既有模式(SchTeacherService.java:96-98 的 `findByTeaIdIn` + `groupingBy`):
```java
private void fillGroupList(List<SchTeacher> teachers, Integer schId) {
if (teachers == null || teachers.isEmpty()) return;
List<Integer> teaIds = teachers.stream()
.map(SchTeacher::getTeaId)
.filter(Objects::nonNull)
.collect(Collectors.toList());
if (teaIds.isEmpty()) return;
List<SchTeacherGroup> relations = (schId != null)
? schTeacherGroupRepository.findByTeaIdInAndSchId(teaIds, schId)
: schTeacherGroupRepository.findByTeaIdIn(teaIds);
Map<Integer, List<SchTeacherGroup>> groupMap = relations.stream()
.filter(g -> g.getTeaId() != null)
.collect(Collectors.groupingBy(SchTeacherGroup::getTeaId));
teachers.forEach(t -> t.setGroupList(groupMap.getOrDefault(t.getTeaId(), Collections.emptyList())));
}
```
3. **三个接口调用处**:
- `getSchTeainfo`(:143-147):查询后调用 `fillGroupList(teachers, resolvedSchId)`(按学校过滤组关系,保证 GroupID/SchSerID 取的是本校分组),流上加 `.filter(Objects::nonNull)` 兜底。
- `getArrTeainfo`(:107-110):调用 `fillGroupList(teachers, null)`(无学校上下文,按教师ID查全部分组),流上加 `.filter(Objects::nonNull)`。
- `getTeainfo`(:52-55):`Optional.map` 内对单个教师调用 `fillGroupList(Collections.singletonList(teacher), null)`(Java 8,不用 `List.of`)。
### 改动 2:TeaInfoVO.java(锁定 JSON 键名)
每个字段加 `@JsonProperty("原始字段名")`(如 `@JsonProperty("SchSerID") private String SchSerID;`),与同包 SchInfoVO 的写法一致,确保输出键名为 `SchSerID`/`TeaIDCard`/`GroupID` 等原始大写形式,而不是 Jackson 默认推导的 `schSerID`/`teaIDCard`。新增 import `com.fasterxml.jackson.annotation.JsonProperty`。
## 保持现状(不在本次范围)
- `TeaNo` 映射的是 `sort` 排序号、其余 12 个字段(SchName、Photo 等)从不赋值恒为 null —— 属于字段映射契约问题,另行确认。
- `findTeaIdsBySchId` 不过滤 `transfer_status`(已转出教师也会出现在列表中)—— 与其他接口口径不一致,保持现状。
- 文件中历史遗留的乱码注释不动;新增代码 UTF-8 + CRLF 与文件一致。
## 验证
- `mvn -q compile` 编译通过。
- 如有条件,本地起服务后请求 `/oapi/OATea/GetSchTeainfo?schId=xxx`,确认返回教师数组(元素为对象而非 null,且键名为原始大写形式)。
+15
View File
@@ -204,6 +204,21 @@
<artifactId>yh-api-std</artifactId>
</dependency>
<!-- 前端运行时配置端点 GET /api/env-config(免登录,starter 自带 SSO 旁路过滤器):
数据源默认 FileEnvConfigSource 读 /app/config/env-config-{target}.json(projman 逐文件 bind mount)。
注意本端不在聚合包上,聚合里的 env-config 不会传递进来,必须显式引入 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-env-config-spring-boot-starter</artifactId>
</dependency>
<!-- 外观偏好同源读端点 GET /api/appearance(三态 proxy/local/off,默认 proxy 经 YschOpenCall
代理 UserHome /oapi/appearance)。平台件刻意不进聚合包(防本地同名控制器双注册),必须显式引入。
生效条件=@ConditionalOnBean(YschOpenCall),本端 yh.open.base-host 已配即满足 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-appearance-spring-boot-starter</artifactId>
</dependency>
<!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。
版本 3.5.1 已在本地 .m2 仓库。 -->
<dependency>
@@ -15,7 +15,11 @@ import java.util.Optional;
@Repository
public interface SchStudentRepository extends JpaRepository<SchStudent, Integer>, JpaSpecificationExecutor<SchStudent> {
Optional<SchStudent> findByStuId(Integer stuId);
/**
* stu_id 是用户中心用户ID(非本表主键),学生在多所学校各有一条就学记录,
* 同一 stu_id 可能存在多行,必须返回 List;返回 Optional 会在多行时抛 NonUniqueResultException
*/
List<SchStudent> findByStuId(Integer stuId);
List<SchStudent> findByStuIdIn(List<Integer> stuIds);
@@ -20,7 +20,11 @@ public interface SchTeacherRepository extends JpaRepository<SchTeacher, Integer>
List<SchTeacher> findByTeaIdIn(List<Integer> teaIds);
Optional<SchTeacher> findByTeaId(Integer teaId);
/**
* tea_id 是用户中心用户ID(非本表主键),教师在多所学校各有一条记录,
* 同一 tea_id 可能存在多行,必须返回 List;返回 Optional 会在多行时抛 NonUniqueResultException
*/
List<SchTeacher> findByTeaId(Integer teaId);
@Query(value = "SELECT DISTINCT t.* FROM sch_teacher t " +
@@ -16,7 +16,7 @@ import org.springframework.web.bind.annotation.*;
public class LogController extends BaseController {
@Autowired
private ILogService logService; // 使用接口1
private ILogService logService; // 使用接口
@GetMapping("systemSearch")
public PageVO<OperationLogRecord> systemSearch(@RequestParam(required = false) String keyword,
@@ -66,6 +66,7 @@ public class DbClassController {
row.put("SchSerID", schSerId);
row.put("TeaTel", teacher.getTel());
row.put("TeaName", teacher.getName());
row.put("TeaGender", teacher.getGender());
row.put("UserTeaSerID", teacher.getTeaId());
row.put("TeaIDCard", teacher.getIdCard());
row.put("TeaNo", teacher.getSort());
@@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import com.yunhai.bschclassmanage.Repository.*;
import com.yunhai.bschclassmanage.config.Config;
import com.yunhai.bschclassmanage.entity.*;
import com.yunhai.bschclassmanage.enums.TransferStatus;
import com.yunhai.bschclassmanage.service.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
@@ -20,7 +21,7 @@ import java.util.stream.Collectors;
public class TeaChesController {
@Autowired private SchClassTeachRepository teachingRepository;
@Autowired private SchInfoRepository schoolRepository;
@Autowired private ISchTeacherService teacherService;
@Autowired private SchTeacherGroupRepository teacherGroupRepository;
@Autowired private ISchClassService classService;
@Autowired private ISchDicSubjectService subjectService;
@@ -62,14 +63,14 @@ public class TeaChesController {
List<SchClassTeach> list = teachingRepository.findAll().stream().filter(t -> ids.contains(t.getSchId())).collect(Collectors.toList());
return ResponseEntity.ok(normalFiltered(list).stream().map(t -> OaCompatSupport.teaching(t, schoolSer(t.getSchId()))).collect(Collectors.toList()));
}
/** 只保留正常教师(status=1)、正常班级(未毕业)、启用学科(enable=true)的任教记录,按学校分别过滤 */
/** 只保留正常教师(sch_teacher_group 中 transfer_status=转入)、正常班级(未毕业)、启用学科(enable=true)的任教记录,按学校分别过滤 */
private List<SchClassTeach> normalFiltered(List<SchClassTeach> teaches) {
List<SchClassTeach> out = new ArrayList<>();
Map<Integer, List<SchClassTeach>> bySch = new HashMap<>();
for (SchClassTeach t : teaches) if (t.getSchId() != null) bySch.computeIfAbsent(t.getSchId(), k -> new ArrayList<>()).add(t);
for (Map.Entry<Integer, List<SchClassTeach>> e : bySch.entrySet()) {
Integer schId = e.getKey();
Set<Integer> teaIds = teacherService.getSchTeacherCacheList(schId).stream().filter(t -> t.getStatus() == 1).map(SchTeacher::getTeaId).collect(Collectors.toSet());
Set<Integer> teaIds = teacherGroupRepository.findBySchIdAndTransferStatus(schId, TransferStatus.IN.getId()).stream().map(SchTeacherGroup::getTeaId).collect(Collectors.toSet());
Set<Integer> classIds = classService.getSchAllClassCacheList(schId).stream().filter(c -> !Boolean.TRUE.equals(c.getGraduate()) && c.getClassId() != null).map(SchClass::getClassId).collect(Collectors.toSet());
Set<String> subCodes = subjectService.toEnableIdAndNameMap(schId, null).keySet();
for (SchClassTeach t : e.getValue()) if (teaIds.contains(t.getTeaId()) && classIds.contains(t.getClassId()) && t.getSubId() != null && subCodes.contains(t.getSubId())) out.add(t);
@@ -56,7 +56,8 @@ public class TeainfoController {
}
// 2. 鐩存帴浣跨敤 uid 鏌ヨ
return schTeacherRepository.findByTeaId(uid) // 鍋囪 findByTeaId 杩斿洖 Optional<SchTeacher>
return schTeacherRepository.findByTeaId(uid).stream()
.findFirst()
.map(teacher -> {
fillGroupList(Collections.singletonList(teacher), null);
return teacher;
@@ -160,7 +160,8 @@ public class UserInfoController {
return ResponseEntity.ok(new ArrayList<>());
}
Optional<SchTeacher> teacherOpt = schTeacherRepository.findByTeaId(userid);
// 同一 teaId/stuId 可能在多所学校各有记录,取第一条即可(不能按唯一查询)
Optional<SchTeacher> teacherOpt = schTeacherRepository.findByTeaId(userid).stream().findFirst();
if (teacherOpt.isPresent()) {
UserInfoVO vo = convertToUserInfoVO(teacherOpt.get());
if (vo != null) {
@@ -168,7 +169,7 @@ public class UserInfoController {
}
}
Optional<SchStudent> studentOpt = schStudentRepository.findByStuId(userid);
Optional<SchStudent> studentOpt = schStudentRepository.findByStuId(userid).stream().findFirst();
if (studentOpt.isPresent()) {
UserInfoVO vo = convertToUserInfoVO(studentOpt.get());
if (vo != null) {
@@ -5,7 +5,10 @@ import com.yunhai.bschclassmanage.Repository.SchInfoRepository;
import com.yunhai.bschclassmanage.config.Config;
import com.yunhai.bschclassmanage.entity.SchClass;
import com.yunhai.bschclassmanage.model.vo.sch.OapiClassVO;
import com.yunhai.bschclassmanage.model.vo.sch.OapiGradeNameVO;
import com.yunhai.bschclassmanage.service.IBaseSchsystemGradeService;
import com.yunhai.bschclassmanage.service.IBaseSchsystemService;
import com.yunhai.bschclassmanage.service.ISchBinSchsystemService;
import com.yunhai.bschclassmanage.service.ISchClassService;
import com.yunhai.bschclassmanage.service.ISchTeacherService;
import io.swagger.v3.oas.annotations.Operation;
@@ -33,6 +36,10 @@ public class OapiSchClassController {
private ISchTeacherService schTeacherService;
@Autowired
private IBaseSchsystemGradeService baseSchsystemGradeService;
@Autowired
private ISchBinSchsystemService schBinSchsystemService;
@Autowired
private IBaseSchsystemService baseSchsystemService;
@GetMapping("getClassInfo")
@Operation(summary = "获取班级信息", description = "按班级ID(班级组ID)查询班级信息,含班主任/副班主任ID与姓名;可传 schId 精确到学校")
@@ -77,6 +84,53 @@ public class OapiSchClassController {
return ResponseEntity.ok(toVOList(classes));
}
@GetMapping("getGradeName")
@Operation(summary = "获取年级名称", description = "按学校ID+入学年份+学段(1小学/2初中/3高中)查询学校年级字典名称(gradeNickname);"
+ "年份按当前学年与年级序换算(与班级导入同口径),无匹配学制/年级返回 null,由调用方自行降级")
public ResponseEntity<OapiGradeNameVO> getGradeName(@RequestParam("schId") Integer schId,
@RequestParam("year") Integer year,
@RequestParam(value = "stage", required = false) Integer stage) {
if (schId == null || year == null) {
return ResponseEntity.ok(null);
}
// 学段 → 学制:按学校各学制记录的学段名(小学/初中/高中)匹配;不传 stage 时不限学段
String stageName = stage == null ? null
: stage == 1 ? "小学" : stage == 2 ? "初中" : stage == 3 ? "高中" : null;
List<Integer> targetSystems = new ArrayList<>();
if (stageName == null) {
targetSystems.addAll(schBinSchsystemService.getSchBinSchsystemIds(schId));
} else {
for (Integer schsystemId : schBinSchsystemService.getSchBinSchsystemIds(schId)) {
try {
String name = baseSchsystemService.getAcademicStageName(schsystemId);
if (name != null && name.contains(stageName)) {
targetSystems.add(schsystemId);
}
} catch (Exception e) {
// 单个学制学段名缺失(学制未找到等)跳过,不影响其余学制
}
}
}
for (Integer schsystemId : targetSystems) {
// 年级→入学年份(当前学年-年级偏移,与班级导入 getNowYearGradeMap 同口径);再查字典别称
Map<Integer, Integer> yearMap = baseSchsystemGradeService.getNowYearGradeMap(schId, schsystemId);
if (yearMap == null || yearMap.isEmpty()) {
continue;
}
Map<Integer, String> nameMap = baseSchsystemGradeService.getGradeNameMap(schId, schsystemId, null);
for (Map.Entry<Integer, Integer> e : yearMap.entrySet()) {
if (year.equals(e.getValue()) && nameMap.get(e.getKey()) != null) {
OapiGradeNameVO vo = new OapiGradeNameVO();
vo.setGradeId(e.getKey());
vo.setGradeName(nameMap.get(e.getKey()));
vo.setYear(year);
return ResponseEntity.ok(vo);
}
}
}
return ResponseEntity.ok(null);
}
/** 批量转 VO:按学校分组后统一回填学校名/年级名/班主任姓名 */
private List<OapiClassVO> toVOList(List<SchClass> classes) {
if (classes == null || classes.isEmpty()) {
@@ -12,6 +12,9 @@ import com.yunhai.bschclassmanage.entity.SchTeacherCustomGroupInSubject;
import com.yunhai.bschclassmanage.entity.SchTeacherCustomGroupInTeacher;
import com.yunhai.bschclassmanage.enums.CustomGroupType;
import com.yunhai.bschclassmanage.model.oa.TeaTreeVO;
import com.yunhai.bschclassmanage.model.vo.SelectIntVO;
import com.yunhai.bschclassmanage.model.vo.SelectStrVO;
import com.yunhai.bschclassmanage.model.vo.sch.OapiGroupVO;
import com.yunhai.bschclassmanage.model.vo.sch.TeaCustomGroupTreeVO;
import com.yunhai.bschclassmanage.service.IBaseSchsystemGradeService;
import com.yunhai.bschclassmanage.service.IComSchTreeService;
@@ -74,7 +77,7 @@ public class OapiSchGroupController {
description = "不传 groupType 时返回三层:类型根节点(id=-1年级组/-2教研组/-3备课组)→ 组节点 → 教师叶子;"
+ "传 groupType(1年级组 2教研组 3备课组,教研组即教师学科组)时直接返回该类型组节点列表(两层)。"
+ "组节点携带组长、关联年级、关联学科;教师叶子携带 isLeader")
public ResponseEntity<List<TeaCustomGroupTreeVO>> getTeaCustomGroupTree(@RequestParam(value = "schId", required = false) Integer schId,
public ResponseEntity<List<TeaCustomGroupTreeVO>> getTeaCustomGroupTree(@RequestParam(value = "schId") Integer schId,
@RequestParam(value = "groupType", required = false) Integer groupType) {
if (schId == null) {
return ResponseEntity.ok(Collections.emptyList());
@@ -91,6 +94,98 @@ public class OapiSchGroupController {
return ResponseEntity.ok(buildTree(schId, groups, groupType != null));
}
@GetMapping("getTeaCustomGroupList")
@Operation(summary = "获取教师分组列表(组/组员/组长合一)",
description = "按学校查教师分组列表:组id、组名称、学科集合、年级集合、组员集合、组长集合;"
+ "groupType 可选(1年级组 2教研组 3备课组,不传返回全部类型)。"
+ "组长取关系表 group_type=1 的全部教师(取不到时兜底组记录 leader_id 字段),组员取 group_type=2 且排除组长")
public ResponseEntity<List<OapiGroupVO>> getTeaCustomGroupList(@RequestParam(value = "schId") Integer schId,
@RequestParam(value = "groupType", required = false) Integer groupType) {
if (schId == null) {
return ResponseEntity.ok(Collections.emptyList());
}
List<SchTeacherCustomGroup> groups = (groupType != null)
? customGroupRepository.findBySchIdAndGroupType(schId, groupType)
: customGroupRepository.findBySchId(schId);
if (groups == null || groups.isEmpty()) {
return ResponseEntity.ok(Collections.emptyList());
}
groups = groups.stream()
.sorted(Comparator.comparing(SchTeacherCustomGroup::getSort, Comparator.nullsLast(Integer::compareTo)))
.collect(Collectors.toList());
List<Integer> groupIds = groups.stream().map(SchTeacherCustomGroup::getId).collect(Collectors.toList());
Map<Integer, List<SchTeacherCustomGroupInGrade>> groupIdToGrades = groupInGradeRepository.findByCgGroupIdIn(groupIds).stream()
.collect(Collectors.groupingBy(SchTeacherCustomGroupInGrade::getCgGroupId));
Map<Integer, List<SchTeacherCustomGroupInSubject>> groupIdToSubjects = groupInSubjectRepository.findByCgGroupIdIn(groupIds).stream()
.collect(Collectors.groupingBy(SchTeacherCustomGroupInSubject::getCgGroupId));
// relationType:1组长 2组员
Map<Integer, List<SchTeacherCustomGroupInTeacher>> groupIdToTeachers = groupInTeacherRepository.findByCgGroupIdIn(groupIds).stream()
.filter(r -> r.getTeaId() != null)
.collect(Collectors.groupingBy(SchTeacherCustomGroupInTeacher::getCgGroupId));
Map<Integer, SchTeacher> teacherMap = schTeacherService.getSchTeacherCacheList(schId).stream()
.filter(t -> t.getTeaId() != null)
.collect(Collectors.toMap(SchTeacher::getTeaId, t -> t, (a, b) -> a));
Map<Integer, String> gradeNameMap = baseSchsystemGradeService.getGradeNameMap(schId, null, null);
Map<String, String> subjectNameMap = schDicSubjectService.toIdAndNameMap(schId, null);
List<OapiGroupVO> result = new ArrayList<>(groups.size());
for (SchTeacherCustomGroup group : groups) {
List<SchTeacherCustomGroupInTeacher> relations = groupIdToTeachers.getOrDefault(group.getId(), Collections.emptyList());
List<Integer> leaderIds = resolveLeaders(group, relations);
OapiGroupVO vo = new OapiGroupVO();
vo.setGroupId(group.getId());
vo.setGroupName(group.getGroupName());
vo.setGroupType(group.getGroupType());
vo.setGroupTypeName(group.getGroupType() == null ? null : CustomGroupType.nameById(group.getGroupType()));
vo.setSubjects(groupIdToSubjects.getOrDefault(group.getId(), Collections.emptyList()).stream()
.map(SchTeacherCustomGroupInSubject::getSubId)
.filter(Objects::nonNull)
.distinct()
.map(subId -> new SelectStrVO(subId, subjectNameMap.get(subId)))
.collect(Collectors.toList()));
vo.setGrades(groupIdToGrades.getOrDefault(group.getId(), Collections.emptyList()).stream()
.map(SchTeacherCustomGroupInGrade::getGradeId)
.filter(Objects::nonNull)
.distinct()
.map(gradeId -> new SelectIntVO(gradeId, gradeNameMap.get(gradeId)))
.collect(Collectors.toList()));
vo.setLeaders(toIdNameList(leaderIds, teacherMap));
vo.setMembers(toIdNameList(relations.stream()
.filter(r -> Integer.valueOf(2).equals(r.getGroupType()))
.map(SchTeacherCustomGroupInTeacher::getTeaId)
.filter(id -> !leaderIds.contains(id))
.distinct()
.collect(Collectors.toList()), teacherMap));
result.add(vo);
}
return ResponseEntity.ok(result);
}
/** 组长ID集合:关系表 group_type=1 的全部教师,取不到时兜底组记录 leader_id 字段(与 getSchCustomGroup 语义一致) */
private List<Integer> resolveLeaders(SchTeacherCustomGroup group, List<SchTeacherCustomGroupInTeacher> relations) {
List<Integer> leaderIds = relations.stream()
.filter(r -> Integer.valueOf(1).equals(r.getGroupType()))
.map(SchTeacherCustomGroupInTeacher::getTeaId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (leaderIds.isEmpty() && group.getLeaderId() != null) {
leaderIds = Collections.singletonList(group.getLeaderId());
}
return leaderIds;
}
/** 教师ID集合 → [{id,姓名}],按入参顺序;姓名取不到时与 getSchCustomGroup 一致回退"未知教师" */
private List<SelectIntVO> toIdNameList(List<Integer> teaIds, Map<Integer, SchTeacher> teacherMap) {
return teaIds.stream()
.map(teaId -> new SelectIntVO(teaId,
Optional.ofNullable(teacherMap.get(teaId)).map(SchTeacher::getName).orElse("未知教师")))
.collect(Collectors.toList());
}
/** 组装类型节点→组节点→教师叶子树(flatTypeRoot=true 时不建类型根,组节点直接作为顶层) */
private List<TeaCustomGroupTreeVO> buildTree(Integer schId, List<SchTeacherCustomGroup> groups, boolean flatTypeRoot) {
List<Integer> groupIds = groups.stream().map(SchTeacherCustomGroup::getId).collect(Collectors.toList());
@@ -44,7 +44,9 @@ public class OapiSchStudentController {
if (stuId == null) {
return ResponseEntity.ok(null);
}
return ResponseEntity.ok(schStudentRepository.findByStuId(stuId)
// 同一 stu_id 可能在多所学校各有记录,取第一条即可(不能按唯一查询)
return ResponseEntity.ok(schStudentRepository.findByStuId(stuId).stream()
.findFirst()
.map(stu -> toVOList(stu.getSchId(), Collections.singletonList(stu)).get(0))
.orElse(null));
}
@@ -0,0 +1,30 @@
package com.yunhai.bschclassmanage.controller.oapi.sch;
import com.yunhai.bschclassmanage.config.Config;
import com.yunhai.bschclassmanage.model.dto.TeacherBaseInfoDTO;
import com.yunhai.bschclassmanage.service.impl.teacher.SchTeacherBaseInfoService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@Tag(name = "OapiSchTeacher", description = "开放接口-教师基础信息")
@RestController
@RequestMapping(Config.OAPI + "/sch/")
public class OapiSchTeacherController {
@Autowired private SchTeacherBaseInfoService service;
@GetMapping("getTeacherBaseInfo")
@Operation(summary = "获取教师基础信息", description = "姓名、性别、身份证、手机;teaId 为平台用户ID")
public ResponseEntity<TeacherBaseInfoDTO> get(@RequestParam Integer schId, @RequestParam Integer teaId) {
return ResponseEntity.ok(service.get(schId, teaId));
}
@PostMapping("editTeacherBaseInfo")
@Operation(summary = "编辑教师基础信息并同步基础平台", description = "提交四项资料;手机、身份证可清空,性别0女1男")
public ResponseEntity<TeacherBaseInfoDTO> edit(@Valid @RequestBody TeacherBaseInfoDTO dto) {
return ResponseEntity.ok(service.edit(dto));
}
}
@@ -0,0 +1,52 @@
package com.yunhai.bschclassmanage.controller.oapi.sync;
import com.yunhai.bschclassmanage.annotation.SyncOpLog;
import com.yunhai.bschclassmanage.config.Config;
import com.yunhai.bschclassmanage.enums.SyncDataType;
import com.yunhai.bschclassmanage.model.tool.SyncClassDTO;
import com.yunhai.bschclassmanage.model.tool.SyncClassStudentDTO;
import com.yunhai.bschclassmanage.model.vo.sync.SyncClassResultVO;
import com.yunhai.bschclassmanage.model.vo.sync.SyncStudentResultVO;
import com.yunhai.bschclassmanage.service.ISyncClassService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 分班结果推送开放接口(/oapi/class/,调用方:分班管理 AcaClassAssign)。
* <p>与既有 /oapi/group、/oapi/user 同步接口不同:本控制器用强类型 DTO 入参 + 结果 VO 出参
* (推送方需要拿到 本地班号→平台classId 映射与逐生成败结果,void 返回无法承载),
* 风格对齐 /oapi/sch 只读接口。</p>
*/
@Slf4j
@Tag(name = "OapiSyncClass", description = "开放接口-分班结果推送(班级/班级学生)")
@RestController
@RequestMapping(Config.OAPI + "/class/")
@CrossOrigin(originPatterns = {"*"}, allowCredentials = "true")
public class SyncClassController {
@Autowired
private ISyncClassService syncClassService;
// 返回 ResponseEntity 绕过平台 ApiResponseAdvice 包装(对齐 /oapi/sch 只读接口:成功裸 VO,业务异常走 ValidationExceptionHandler 输出 {code,msg})
@SyncOpLog(SyncDataType.SYNC_ADD_CLASS)
@PostMapping("AddClass")
@Operation(summary = "添加班级(同步组)", description = "学生组→[学段组]→年级组(入学年份)→班级组(年份+两位班号)+sch_class;按 code 幂等,返回平台 classId 映射")
public ResponseEntity<SyncClassResultVO> AddClass(@RequestBody SyncClassDTO dto) {
return ResponseEntity.ok(syncClassService.addClass(dto));
}
@SyncOpLog(SyncDataType.SYNC_ADD_STU)
@PostMapping("AddClassStudent")
@Operation(summary = "添加班级学生(同步学生)", description = "用户中心建号+sch_student 落库;按 account/班内姓名幂等;默认聚合模式逐生返回成败,rollbackOnError=true 整批回滚")
public ResponseEntity<SyncStudentResultVO> AddClassStudent(@RequestBody SyncClassStudentDTO dto) {
return ResponseEntity.ok(syncClassService.addClassStudent(dto));
}
}
@@ -23,6 +23,9 @@ public enum SyncDataType
SYNC_MOVE_USER("Sync-MoveUser","用户移组"),
SYNC_USER("Sync-SyncUser","全量用户"),
SYNC_ADD_CLASS("Sync-AddClass","分班推送添加班级"),
SYNC_ADD_STU("Sync-AddClassStudent","分班推送添加班级学生"),
//推送
SEND_ADD_GROUP("Send-AddGroup","添加组"),
SEND_EDIT_GROUP("Send-EditGroup","编辑组"),
@@ -0,0 +1,22 @@
package com.yunhai.bschclassmanage.model.dto;
import lombok.Data;
import javax.validation.constraints.*;
/** 教师基础资料;teaId 为基础平台用户 ID,而非本地教师表主键。 */
@Data
public class TeacherBaseInfoDTO {
@NotNull @Min(1)
private Integer schId;
@NotNull @Min(1)
private Integer teaId;
@NotBlank @Size(max = 255)
private String name;
/** 0=女,1=男;null=未登记 */
@Min(0) @Max(1)
private Integer gender;
@Pattern(regexp = "^$|^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dXx]$", message = "身份证号码格式不正确")
private String idcard;
@Size(max = 255)
private String phone;
}
@@ -0,0 +1,42 @@
package com.yunhai.bschclassmanage.model.tool;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import java.util.List;
/**
* 分班结果推送-添加班级入参 DTO(POST /oapi/class/AddClass)。
* 调用方为分班管理(AcaClassAssign),同步组:学生组→[学段组]→年级组→班级组 + sch_class。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SyncClassDTO {
/** 学校id(=学校组id) */
private Integer schId;
/** 学段:AcademicStage 口径 1小学/2初中/3高中(分班方负责换算,非分班侧 2/3/4) */
private Integer stage;
/** 入学年份(=分班项目 year,如 2026)。显式传入,禁止用 NowYearTerm 推导(分班多在 6-8 月,会差一学年) */
private Integer enrollYear;
/** 班级列表 */
private List<ClassItem> classes;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class ClassItem {
/** 班号 1..N(=aca_assign_class.sort_order,决定 code 尾号与 sch_class.sort) */
private Integer sort;
/** 分班侧班名(可空;非空时写 cname 别名,name 统一按规则生成 sort+"班")。
* 分班侧口径(2026-10-21):仅语义班名(阳光班/苹果班)才传,序号生成的班名(1班/2班)不传,cname 留空 */
private String name;
/** 班主任(平台教师 UserTeaSerID,可空)→ first_teacher_id */
private Integer teacherId;
}
}
@@ -0,0 +1,47 @@
package com.yunhai.bschclassmanage.model.tool;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
import java.util.List;
/**
* 分班结果推送-添加班级学生入参 DTO(POST /oapi/class/AddClassStudent)。
* 用户中心建号 + sch_student 落库;默认聚合模式(逐生返回成败),rollbackOnError=true 走整批回滚。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SyncClassStudentDTO {
/** 学校id */
private Integer schId;
/** true=整批 all-or-nothing(任一失败回滚本批已建账号并整批报错);缺省 false=聚合模式 */
private Boolean rollbackOnError;
/** 学生列表(可跨班) */
private List<StudentItem> students;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class StudentItem {
/** 目标班级(班级组id,=AddClass 返回的 classId) */
private Integer classId;
/** 姓名(必填) */
private String name;
/** 性别:本接口口径 1男/0女(分班侧 1男/2女/0未知,由分班方换算后传入) */
private Integer gender;
/** 登录账号(可空;空则由用户中心默认逻辑处理) */
private String account;
/** 班内学号(同时决定 sort) */
private String stuClassNo;
/** 初始密码(可空=不带,走用户中心默认密码策略) */
private String pwd;
}
}
@@ -0,0 +1,20 @@
package com.yunhai.bschclassmanage.model.vo.sch;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 年级名称 VO(开放接口 getGradeName 返回):学校年级字典名称 + 对应年级编码与入学年份。
*/
@Data
public class OapiGradeNameVO {
@Schema(description = "年级编码(K12 年级ID)")
private Integer gradeId;
@Schema(description = "年级名称(学校年级字典别称,如 小一/一年级/初一)")
private String gradeName;
@Schema(description = "入学年份")
private Integer year;
}
@@ -0,0 +1,40 @@
package com.yunhai.bschclassmanage.model.vo.sch;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.yunhai.bschclassmanage.model.vo.SelectIntVO;
import com.yunhai.bschclassmanage.model.vo.SelectStrVO;
import lombok.Data;
import java.util.List;
/**
* 教师分组信息(oapi/sch/getTeaCustomGroupList 输出,对应教师分组管理页面的组/组员/组长三栏)
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class OapiGroupVO {
/** 组ID(sch_teacher_custom_group.id) */
private Integer groupId;
/** 组名称 */
private String groupName;
/** 组类型(1年级组 2教研组 3备课组) */
private Integer groupType;
/** 组类型名称 */
private String groupTypeName;
/** 学科集合 [{id:学科编码, name:学科名称}] */
private List<SelectStrVO> subjects;
/** 年级集合 [{id:年级编码, name:年级名称}] */
private List<SelectIntVO> grades;
/** 组员集合 [{id:教师id, name:教师名称}](不含组长) */
private List<SelectIntVO> members;
/** 组长集合 [{id:教师id, name:教师名称}] */
private List<SelectIntVO> leaders;
}
@@ -0,0 +1,44 @@
package com.yunhai.bschclassmanage.model.vo.sync;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import java.util.List;
/**
* 分班结果推送-添加班级出参(POST /oapi/class/AddClass)。
* 返回本地班号 → 平台 classId 映射,供 AddClassStudent 分组使用。
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class SyncClassResultVO {
/** 起始年级编码(base_sch_system_grade.grade_id,本学段最小编码) */
private Integer gradeId;
/** 年级组id(平台组) */
private Integer gradeGroupId;
/** 班级结果(按入参顺序) */
private List<ClassItem> classes;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class ClassItem {
/** 班号(=入参 sort) */
private Integer sort;
/** 平台班级id(班级组id) */
private Integer classId;
/** 班级编码(enrollYear + 两位班号) */
private String code;
/** 班级名称("N班") */
private String name;
/** true=本次新建;false=按 code 查重命中已有班级组(幂等重推,未覆盖既有数据) */
private Boolean created;
}
}
@@ -0,0 +1,38 @@
package com.yunhai.bschclassmanage.model.vo.sync;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import java.util.List;
/**
* 分班结果推送-添加班级学生出参(POST /oapi/class/AddClassStudent)。
* 逐生返回成败,不因个别失败整体报错(聚合模式)。
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class SyncStudentResultVO {
/** 逐生结果(与入参顺序一致) */
private List<StudentItem> results;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class StudentItem {
/** 姓名 */
private String name;
/** 登录账号(=入参 account,可能为空) */
private String account;
/** 是否成功(含幂等命中"已存在") */
private Boolean ok;
/** 平台学生id(用户中心用户id,成功时返回) */
private Integer stuId;
/** 失败原因 / 幂等命中说明(成功时也可能非空,如"已存在") */
private String reason;
}
}
@@ -0,0 +1,26 @@
package com.yunhai.bschclassmanage.service;
import com.yunhai.bschclassmanage.model.tool.SyncClassDTO;
import com.yunhai.bschclassmanage.model.tool.SyncClassStudentDTO;
import com.yunhai.bschclassmanage.model.vo.sync.SyncClassResultVO;
import com.yunhai.bschclassmanage.model.vo.sync.SyncStudentResultVO;
/**
* 分班结果推送开放接口(/oapi/class/)。
* 调用方为分班管理(AcaClassAssign):提交分班结果后把班级与学生落到班级管理。
*/
public interface ISyncClassService {
/**
* 添加班级(同步组):学生组→[学段组]→年级组(name=入学年份)→班级组(code=年份+两位班号) + sch_class。
* 幂等:按组 name/code 查重复用,不覆盖既有班级数据;新建组失败时补偿删除本次新建的组。
*/
SyncClassResultVO addClass(SyncClassDTO dto);
/**
* 添加班级学生:用户中心建号 + sch_student 落库。
* 默认聚合模式(逐生返回成败,个别失败不中断);rollbackOnError=true 走整批回滚。
* 幂等:按 account(全库)/ 班内姓名查重跳过。
*/
SyncStudentResultVO addClassStudent(SyncClassStudentDTO dto);
}
@@ -329,7 +329,7 @@ public class SchDicSubjectService implements ISchDicSubjectService {
public List<SelectStrVO> select(Integer schId, Integer schsystemId) {
List<SchDicSubject> list = getSchDicSubjectCacheList(schId, schsystemId);
// 按 code 去重,保留顺序(若需要保持原顺序,可用 LinkedHashSet 包装)
list = list.stream()
list = list.stream().filter(s -> Boolean.TRUE.equals(s.getEnable()))
.collect(Collectors.collectingAndThen(
Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(SchDicSubject::getCode))),
ArrayList::new
@@ -348,6 +348,16 @@ public class SchDicSubjectService implements ISchDicSubjectService {
List<SchDicSubject> list = getSchDicSubjectCacheList(schId, schsystemId).stream()
.filter(s -> Boolean.TRUE.equals(s.getEnable()))
.collect(Collectors.toList());
return ModelMapperUtil.toMap(list, SchDicSubject::getCode, SchDicSubject::getName);
// 按序号升序排序(null 排最后),与学科管理列表口径一致
list.sort(Comparator.comparing(SchDicSubject::getSort,
Comparator.nullsLast(Comparator.naturalOrder())));
// LinkedHashMap 保持排序后的遍历顺序;重复 code 保留第一个
Map<String, String> map = new LinkedHashMap<>();
for (SchDicSubject subject : list) {
if (subject.getCode() != null) {
map.putIfAbsent(subject.getCode(), subject.getName());
}
}
return map;
}
}
@@ -300,13 +300,15 @@ public class StudentClassHistoryService implements IStudentClassHistoryService {
*/
public Map<String, Object> getHistoryClassTeachByStuId(Integer schId, Integer stuId) {
// 1. 解析学生所属班级 & 姓名
// stu_id 是用户中心用户ID,学生在多所学校各有就学记录(同一 stu_id 多行),需按当前学校过滤后取一条
Integer classId = null;
String studentName = null;
Optional<SchStudent> live = schStudentRepository.findByStuId(stuId);
if (live.isPresent() && live.get().getClassId() != null
&& Objects.equals(live.get().getSchId(), schId)) {
classId = live.get().getClassId();
studentName = live.get().getName();
SchStudent liveMatch = schStudentRepository.findByStuId(stuId).stream()
.filter(s -> s.getClassId() != null && Objects.equals(s.getSchId(), schId))
.findFirst().orElse(null);
if (liveMatch != null) {
classId = liveMatch.getClassId();
studentName = liveMatch.getName();
} else {
List<SchStudentHistory> hist = schStudentHistoryRepository.findBySchIdAndStuId(schId, stuId);
if (hist.isEmpty()) {
@@ -0,0 +1,437 @@
package com.yunhai.bschclassmanage.service.impl.sync;
import com.yunhai.bschclassmanage.Repository.SchClassRepository;
import com.yunhai.bschclassmanage.Repository.SchStudentRepository;
import com.yunhai.bschclassmanage.entity.BaseSchsystemGrade;
import com.yunhai.bschclassmanage.entity.SchClass;
import com.yunhai.bschclassmanage.entity.SchStudent;
import com.yunhai.bschclassmanage.enums.AcademicStage;
import com.yunhai.bschclassmanage.enums.NodeType;
import com.yunhai.bschclassmanage.enums.StuAbnormalStatus;
import com.yunhai.bschclassmanage.enums.TransferStatus;
import com.yunhai.bschclassmanage.enums.UserStatus;
import com.yunhai.bschclassmanage.model.tool.SyncClassDTO;
import com.yunhai.bschclassmanage.model.tool.SyncClassStudentDTO;
import com.yunhai.bschclassmanage.model.vo.SelectIntVO;
import com.yunhai.bschclassmanage.model.vo.sync.SyncClassResultVO;
import com.yunhai.bschclassmanage.model.vo.sync.SyncStudentResultVO;
import com.yunhai.bschclassmanage.service.IBaseSchsystemGradeService;
import com.yunhai.bschclassmanage.service.IBaseSchsystemService;
import com.yunhai.bschclassmanage.service.IComSchTreeService;
import com.yunhai.bschclassmanage.service.ISchBinSchsystemService;
import com.yunhai.bschclassmanage.service.ISendUserBaseService;
import com.yunhai.bschclassmanage.service.ISyncClassService;
import com.yunhai.bschclassmanage.service.impl.classes.SchClassCacheService;
import com.yunhai.bschclassmanage.service.impl.student.SchStudentCacheService;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 分班结果推送开放接口实现。
* <p>组创建模板:SchClassInfoService.addBaseSchClass;差异①:入学年份取入参 enrollYear(不用 NowYearTerm 推导,
* 分班多发生在 6-8 月旧学年末,照抄模板会把年级组建到旧年份);差异②:班号以入参 sort 为准(分班结果已定班号,
* 不按 findMaxCodeNumber 自动续号)。</p>
* <p>回滚策略:本地事务 + 外部系统补偿删除(rollbackCreatedGroups/rollbackSyncedStudents)+ 幂等重推自愈;
* createdGroupIds 只记本次新建的组,查重复用的既有组绝不进回滚清单。</p>
*/
@Slf4j
@Service
public class SyncClassService implements ISyncClassService {
@Autowired
private SchClassRepository schClassRepository;
@Autowired
private SchStudentRepository schStudentRepository;
@Autowired
private IComSchTreeService comSchTreeService;
@Autowired
private ISchBinSchsystemService schBinSchsystemService;
@Autowired
private IBaseSchsystemService baseSchsystemService;
@Autowired
private IBaseSchsystemGradeService baseSchsystemGradeService;
@Autowired
private ISendUserBaseService sendUserBaseService;
@Autowired
private SchClassCacheService classCacheService;
@Autowired
private SchStudentCacheService studentCacheService;
// ==================== 添加班级(同步组) ====================
@Override
@Transactional(rollbackFor = Exception.class)
public SyncClassResultVO addClass(SyncClassDTO dto) {
if (dto == null || dto.getSchId() == null || dto.getSchId() <= 0) {
throw new DException("schId不能为空");
}
String stageName = AcademicStage.nameById(dto.getStage() == null ? -1 : dto.getStage());
if (stageName == null) {
throw new DException("stage参数非法(AcademicStage口径:1小学/2初中/3高中)");
}
if (dto.getEnrollYear() == null || dto.getEnrollYear() <= 0) {
throw new DException("enrollYear不能为空(入学年份需显式传入)");
}
if (dto.getClasses() == null || dto.getClasses().isEmpty()) {
throw new DException("classes不能为空");
}
Set<Integer> sorts = new HashSet<>();
for (SyncClassDTO.ClassItem item : dto.getClasses()) {
if (item.getSort() == null || item.getSort() <= 0 || item.getSort() > 99) {
throw new DException("班级班号非法(须为1-99):" + item.getSort());
}
if (!sorts.add(item.getSort())) {
throw new DException("班级班号重复:" + item.getSort());
}
}
Integer schId = dto.getSchId();
Integer enrollYear = dto.getEnrollYear();
// 1. 学生组(组树在用户中心,不在本地库)
BoaGroup stuGroup = comSchTreeService.getStuTree(schId, false);
if (stuGroup == null) {
throw new DException("学生组不存在!");
}
// 2. 学段定位:逐学制比对学段名(起始年级编码由各校 base_sch_system_grade 数据决定,不硬编码)
List<Integer> schsystemIds = schBinSchsystemService.getSchBinSchsystemIds(schId);
Integer schsystemId = null;
for (Integer id : schsystemIds) {
if (stageName.equals(baseSchsystemService.getAcademicStageName(id))) {
schsystemId = id;
break;
}
}
if (schsystemId == null) {
throw new DException("学校未配置该学段:" + stageName);
}
List<BaseSchsystemGrade> gradeList = baseSchsystemGradeService.getSchGradeCacheList(schId, schsystemId, null);
if (gradeList == null || gradeList.isEmpty()) {
throw new DException("学校学制未配置年级,无法定位起始年级");
}
gradeList.sort(Comparator.comparing(BaseSchsystemGrade::getGradeId));
Integer gradeId = gradeList.get(0).getGradeId();
List<Integer> createdGroupIds = new ArrayList<>();
try {
Integer parentId = stuGroup.getId();
// 3. 多学段学校:查/建学段组
if (schsystemIds.size() > 1) {
List<BoaGroup> stageGroups = comSchTreeService.getAcademicStageGroupList(schId);
Optional<BoaGroup> hit = stageGroups == null ? Optional.empty()
: stageGroups.stream().filter(g -> stageName.equals(g.getName())).findFirst();
if (hit.isPresent()) {
parentId = hit.get().getId();
} else {
BoaGroup sectionGroup = sendUserBaseService.addGroup(stageName, parentId, schsystemId, NodeType.SECTION, schId);
createdGroupIds.add(sectionGroup.getId());
parentId = sectionGroup.getId();
}
}
// 4. 查/建年级组(⚠️ 多学段学校各学段都有同年份同名年级组,必须 .get(schsystemId) 过滤本学段)
Integer gradeGroupId = null;
List<SelectIntVO> gradeGroups = comSchTreeService.getSchGroupTypeList(schId, NodeType.GRADE.getId()).get(schsystemId);
if (gradeGroups != null) {
Optional<SelectIntVO> hit = gradeGroups.stream()
.filter(g -> enrollYear.toString().equals(g.getName())).findFirst();
if (hit.isPresent()) {
gradeGroupId = hit.get().getId();
}
}
if (gradeGroupId == null) {
BoaGroup gradeGroup = sendUserBaseService.addGroup(enrollYear.toString(), parentId, enrollYear, NodeType.GRADE, schId);
createdGroupIds.add(gradeGroup.getId());
gradeGroupId = gradeGroup.getId();
}
// 5. 逐班查/建班级组(code=入学年份+两位班号,入参 sort 为准)+ sch_class 幂等落库
List<SelectIntVO> classGroups = comSchTreeService.getSchGroupTypeList(schId, NodeType.CLASS.getId()).get(schsystemId);
Map<String, Integer> classGroupByName = classGroups == null ? Collections.emptyMap()
: classGroups.stream().collect(Collectors.toMap(SelectIntVO::getName, SelectIntVO::getId, (a, b) -> a));
Date now = new Date();
List<SchClass> saveList = new ArrayList<>();
List<SyncClassResultVO.ClassItem> outClasses = new ArrayList<>();
for (SyncClassDTO.ClassItem item : dto.getClasses()) {
int sort = item.getSort();
String code = enrollYear + String.format("%02d", sort);
Integer classGroupId = classGroupByName.get(code);
boolean created = false;
if (classGroupId == null) {
BoaGroup classGroup = sendUserBaseService.addGroup(code, gradeGroupId, sort, NodeType.CLASS, schId);
createdGroupIds.add(classGroup.getId());
classGroupId = classGroup.getId();
created = true;
}
// sch_class 按 classId(班级组id)幂等:已有行不覆盖(要修改走班级管理编辑功能);缺行补建
SchClass existRow = schClassRepository.findByClassIdAndSchId(classGroupId, schId).orElse(null);
String outName = sort + "班";
if (existRow == null) {
SchClass row = new SchClass();
row.setSchId(schId);
row.setGradeId(gradeId);
row.setSort(sort);
row.setCode(code);
row.setName(outName);
row.setCname(item.getName());
row.setGroupId(gradeGroupId);
row.setClassId(classGroupId);
row.setGraduate(false);
row.setEnrollYear(enrollYear);
if (item.getTeacherId() != null) {
row.setFirstTeacherId(item.getTeacherId());
}
row.setCreateDate(now);
row.setUpdateDate(now);
saveList.add(row);
} else {
outName = existRow.getName() != null ? existRow.getName() : outName;
}
SyncClassResultVO.ClassItem out = new SyncClassResultVO.ClassItem();
out.setSort(sort);
out.setClassId(classGroupId);
out.setCode(code);
out.setName(outName);
out.setCreated(created);
outClasses.add(out);
}
if (!saveList.isEmpty()) {
schClassRepository.saveAll(saveList);
}
classCacheService.clearClassCache(schId);
SyncClassResultVO vo = new SyncClassResultVO();
vo.setGradeId(gradeId);
vo.setGradeGroupId(gradeGroupId);
vo.setClasses(outClasses);
log.info("[addClass] schId={}, stage={}, enrollYear={}, 新建组={}, 班级 {} 个(新建 {})",
schId, stageName, enrollYear, createdGroupIds.size(), outClasses.size(),
outClasses.stream().filter(c -> Boolean.TRUE.equals(c.getCreated())).count());
return vo;
} catch (Exception e) {
// 组的删除是对用户中心的远程调用,不归本地事务管,须显式补偿;清单只含本次新建的组
log.error("[addClass] 添加班级失败,回滚本次新建组 {} 个:{}", createdGroupIds.size(), createdGroupIds, e);
sendUserBaseService.rollbackCreatedGroups(createdGroupIds, schId);
if (e instanceof DException) {
throw (DException) e;
}
throw new DException("添加班级失败:" + e.getMessage());
}
}
// ==================== 添加班级学生(同步学生) ====================
@Override
@Transactional(rollbackFor = Exception.class)
public SyncStudentResultVO addClassStudent(SyncClassStudentDTO dto) {
if (dto == null || dto.getSchId() == null || dto.getSchId() <= 0) {
throw new DException("schId不能为空");
}
if (dto.getStudents() == null || dto.getStudents().isEmpty()) {
throw new DException("students不能为空");
}
Integer schId = dto.getSchId();
boolean allOrNothing = Boolean.TRUE.equals(dto.getRollbackOnError());
// 整批预检:classId 必须属于本校(此时无任何副作用,直接报错)
Set<Integer> classIdSet = new LinkedHashSet<>();
for (SyncClassStudentDTO.StudentItem stu : dto.getStudents()) {
if (stu.getClassId() != null) {
classIdSet.add(stu.getClassId());
}
}
if (classIdSet.isEmpty()) {
throw new DException("students[].classId不能为空");
}
List<SchClass> belongClasses = schClassRepository.findBySchIdAndClassIds(schId, new ArrayList<>(classIdSet));
Map<Integer, SchClass> classMap = belongClasses.stream()
.collect(Collectors.toMap(SchClass::getClassId, c -> c, (a, b) -> a));
for (Integer classId : classIdSet) {
if (!classMap.containsKey(classId)) {
throw new DException("班级不存在或不属于本校:classId=" + classId);
}
}
Date now = new Date();
List<SchStudent> saveList = new ArrayList<>();
// 已建平台账号(按下标与 groupIds 对应,供整批回滚)
List<BoaUser> syncedList = new ArrayList<>();
List<Integer> syncedGroupIds = new ArrayList<>();
// 本批内 (classId, name) / account 查重(库内查重看不到本批未落库的行)
Set<String> batchNameKeys = new HashSet<>();
Set<String> batchAccounts = new HashSet<>();
List<SyncStudentResultVO.StudentItem> results = new ArrayList<>();
for (SyncClassStudentDTO.StudentItem stu : dto.getStudents()) {
SyncStudentResultVO.StudentItem r = new SyncStudentResultVO.StudentItem();
r.setName(stu.getName());
r.setAccount(stu.getAccount());
String fail = null;
if (StringUtils.isBlank(stu.getName())) {
fail = "姓名不能为空";
} else if (stu.getClassId() == null) {
fail = "classId不能为空";
}
if (fail == null && StringUtils.isNotBlank(stu.getAccount())) {
String account = stu.getAccount().trim();
r.setAccount(account);
// 幂等查重①:account 全库查询(非本校范围)
Optional<SchStudent> exist = schStudentRepository.findByAccount(account);
if (exist.isPresent()) {
SchStudent s = exist.get();
if (!schId.equals(s.getSchId())) {
fail = "账号已被其他学校占用";
} else if (Objects.equals(stu.getClassId(), s.getClassId())) {
r.setOk(true);
r.setStuId(s.getStuId());
r.setReason("已存在");
results.add(r);
continue;
} else {
fail = "账号已存在于其他班级";
}
} else if (!batchAccounts.add(account)) {
fail = "本批账号重复";
}
}
if (fail == null) {
// 幂等查重②:班内姓名唯一(含本批)
String nameKey = stu.getClassId() + "_" + stu.getName();
if (schStudentRepository.existsBySchIdAndClassIdAndName(schId, stu.getClassId(), stu.getName())) {
r.setOk(true);
r.setReason("班内同名已存在");
results.add(r);
continue;
}
if (!batchNameKeys.add(nameKey)) {
fail = "本批班级内姓名重复";
}
}
if (fail != null) {
r.setOk(false);
r.setReason(fail);
results.add(r);
if (allOrNothing) {
rollbackAllOrNothing(syncedList, syncedGroupIds, schId, "整批失败(all-or-nothing):" + stu.getName() + " " + fail);
}
continue;
}
// 新增:先收集不入库,尾部统一 saveAll
SchStudent entity = new SchStudent();
entity.setSchId(schId);
entity.setName(stu.getName());
entity.setGender(stu.getGender());
entity.setAccount(r.getAccount());
entity.setStuClassNo(stu.getStuClassNo());
entity.setGroupId(stu.getClassId());
entity.setClassId(stu.getClassId());
entity.setTransferStatus(TransferStatus.IN.getId());
entity.setAbnormalStatus(StuAbnormalStatus.NORMAL.getId());
entity.setStatus(UserStatus.NORMAL.getId());
entity.setSort(sortOf(stu.getStuClassNo()));
entity.setCreateDate(now);
entity.setUpdateDate(now);
try {
BoaUser boaUser = sendUserBaseService.addStudent(entity, stu.getPwd());
entity.setStuId(boaUser.getId());
saveList.add(entity);
syncedList.add(boaUser);
syncedGroupIds.add(stu.getClassId());
r.setOk(true);
r.setStuId(boaUser.getId());
} catch (Exception e) {
log.error("[addClassStudent] 建号失败 schId={}, name={}, account={}", schId, stu.getName(), r.getAccount(), e);
r.setOk(false);
String msg = e.getMessage();
r.setReason(msg == null || msg.isEmpty() ? e.getClass().getSimpleName() : msg);
results.add(r);
if (allOrNothing) {
rollbackAllOrNothing(syncedList, syncedGroupIds, schId, "整批失败(all-or-nothing):" + stu.getName() + " " + r.getReason());
}
continue;
}
results.add(r);
}
try {
if (!saveList.isEmpty()) {
schStudentRepository.saveAll(saveList);
}
} catch (Exception e) {
// 落库失败属批次级失败:all-or-nothing 删已建账号;聚合模式保留账号(重推按 account 幂等补齐,避免删号/重建抖动)
log.error("[addClassStudent] 学生落库失败 schId={}, 已建账号 {} 个", schId, syncedList.size(), e);
if (allOrNothing) {
rollbackAllOrNothing(syncedList, syncedGroupIds, schId, "整批失败(all-or-nothing):学生落库失败 " + e.getMessage());
}
throw new DException("学生落库失败(已建账号保留,可重推补齐):" + e.getMessage());
}
studentCacheService.clearClassStudentCaches(schId, classIdSet);
long okCount = results.stream().filter(x -> Boolean.TRUE.equals(x.getOk())).count();
log.info("[addClassStudent] schId={}, 学生 {} 人:成功 {} 失败 {}(allOrNothing={})",
schId, results.size(), okCount, results.size() - okCount, allOrNothing);
SyncStudentResultVO vo = new SyncStudentResultVO();
vo.setResults(results);
return vo;
}
/** all-or-nothing 失败出口:删本批已建平台账号后整批报错(本地事务由 @Transactional 回滚) */
private void rollbackAllOrNothing(List<BoaUser> syncedList, List<Integer> syncedGroupIds,
Integer schId, String message) {
sendUserBaseService.rollbackSyncedStudents(syncedList, syncedGroupIds, schId);
throw new DException(message);
}
/** 学号数字部分 → 排序(与导入模板口径一致) */
private Integer sortOf(String stuClassNo) {
if (StringUtils.isBlank(stuClassNo)) {
return null;
}
try {
String digits = stuClassNo.replaceAll("\\D+", "");
if (StringUtils.isNotBlank(digits)) {
return Integer.parseInt(digits);
}
} catch (NumberFormatException ignored) {
// ignore
}
return null;
}
}
@@ -0,0 +1,93 @@
package com.yunhai.bschclassmanage.service.impl.teacher;
import com.yunhai.bschclassmanage.Repository.SchTeacherGroupRepository;
import com.yunhai.bschclassmanage.Repository.SchTeacherRepository;
import com.yunhai.bschclassmanage.entity.SchTeacher;
import com.yunhai.bschclassmanage.entity.SchTeacherGroup;
import com.yunhai.bschclassmanage.model.dto.TeacherBaseInfoDTO;
import com.yunhai.bschclassmanage.annotation.SyncOpLog;
import com.yunhai.bschclassmanage.enums.SyncDataType;
import com.yunhai.bschclassmanage.service.ISendUserBaseService;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.platform.utils.exception.DException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
/** 开放接口只修改四项资料,经既有教师同步通道(SendUserBaseService)推送用户中心。 */
@Service
public class SchTeacherBaseInfoService {
@Autowired private SchTeacherRepository teacherRepository;
@Autowired private SchTeacherGroupRepository groupRepository;
@Autowired private ISendUserBaseService sendUserBaseService;
@Autowired private SchTeacherCacheService cacheService;
public TeacherBaseInfoDTO get(Integer schId, Integer teaId) {
requireMembership(schId, teaId);
return toDto(schId, teachers(teaId).get(0));
}
@Transactional(rollbackFor = Exception.class)
@SyncOpLog(SyncDataType.SEND_EDIT_TEACHER)
public TeacherBaseInfoDTO edit(TeacherBaseInfoDTO dto) {
List<SchTeacherGroup> memberships = requireMembership(dto.getSchId(), dto.getTeaId());
List<SchTeacher> teachers = teachers(dto.getTeaId());
String name = dto.getName() == null ? "" : dto.getName().trim();
if (name.isEmpty()) throw new DException("姓名不能为空");
// 同名规则与管理端一致,排除教师自己的所有本地记录。
List<Integer> ids = groupRepository.findTeaIdsBySchId(dto.getSchId());
if (teacherRepository.findByTeaIdIn(ids).stream()
.anyMatch(t -> !dto.getTeaId().equals(t.getTeaId()) && name.equals(t.getName()))) {
throw new DException("该校下已存在同名教师:" + name);
}
for (SchTeacher teacher : teachers) {
teacher.setName(name);
teacher.setGender(dto.getGender());
teacher.setIdCard(trim(dto.getIdcard()));
teacher.setTel(trim(dto.getPhone()));
teacher.setUpdateDate(new Date());
teacher.setUpdateUserId(dto.getTeaId());
}
// 先同步;失败抛异常并回滚本地事务。teaId 可能对应多行,统一修改。
// 复用管理端同款教师同步(用户中心 /oapi/user/edit/{id},身份追加语义、不改密码);
// groupId 仅用于解析学校范围,不会移动教师分组。
BoaUser synced = sendUserBaseService.editTeacher(teachers.get(0), null, memberships.get(0).getGroupId());
if (synced == null || !dto.getTeaId().equals(synced.getId())) {
throw new DException("用户中心未返回有效教师信息,同步失败");
}
teacherRepository.saveAll(teachers);
Set<Integer> schools = groupRepository.findByTeaIdIn(Collections.singletonList(dto.getTeaId())).stream()
.map(SchTeacherGroup::getSchId).collect(Collectors.toSet());
schools.add(dto.getSchId());
cacheService.clearTeacherCaches(schools);
return toDto(dto.getSchId(), teachers.get(0));
}
private List<SchTeacherGroup> requireMembership(Integer schId, Integer teaId) {
if (schId == null || schId <= 0 || teaId == null || teaId <= 0) {
throw new DException("学校和教师平台用户ID不能为空");
}
List<SchTeacherGroup> memberships = groupRepository.findByTeaIdAndSchId(teaId, schId).stream()
.filter(g -> Integer.valueOf(0).equals(g.getTransferStatus())).collect(Collectors.toList());
if (memberships.isEmpty()) throw new DException("教师不属于指定学校或已转出");
return memberships;
}
private List<SchTeacher> teachers(Integer teaId) {
List<SchTeacher> teachers = teacherRepository.findByTeaId(teaId);
if (teachers.isEmpty()) throw new DException("教师不存在");
return teachers;
}
private TeacherBaseInfoDTO toDto(Integer schId, SchTeacher teacher) {
TeacherBaseInfoDTO dto = new TeacherBaseInfoDTO();
dto.setSchId(schId); dto.setTeaId(teacher.getTeaId()); dto.setName(teacher.getName());
dto.setGender(teacher.getGender()); dto.setIdcard(teacher.getIdCard()); dto.setPhone(teacher.getTel());
return dto;
}
private String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); }
}
@@ -191,7 +191,7 @@ public class SchTeacherRelationService {
}
// 异步记录操作日志
schTeacherRepository.findByTeaId(teaId).ifPresent(teacher ->
schTeacherRepository.findByTeaId(teaId).stream().findFirst().ifPresent(teacher ->
logService.setTransferInLogs(user, Collections.singletonList(teacher), targetGroupId));
// 清除所有涉及学校的缓存
+8
View File
@@ -84,6 +84,14 @@ yh:
# 本应用自身身份(客户端签名用)
app-key: 100002
app-secret: 2314a3b158b24a838b2b9b69fd3db118
# 调用方信任名单:分班管理(AcaClassAssign,/oapi/class/AddClass|AddClassStudent)。
# 注意:app-auth 见 X-App-Key 即校验,未登记的 appKey 一律 401(实测 2026-10-21)。
# 生产发布需在 Nacos 同步登记该 appKey/secret,并对 /oapi/class/* 启用 enforce-patterns。
trusted-apps:
"100026": "73c08fceae3b48788358195760bf5977"
server:
# 分班推送写接口强制验签;只读 /oapi/sch/* 维持匿名(既有调用方不受影响)
enforce-patterns: [/oapi/class/*]
open:
# 四域服务目录零配置(userbase/userclass/usermsg/flowcenter 枚举兜底 = base-host + 默认 ctx)
base-host: http://base.yunhaixingtong.com
@@ -0,0 +1,106 @@
package com.yunhai.bschclassmanage;
import com.yunhai.bschclassmanage.service.IStudentClassHistoryService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* 本地复现 api/history/getHistoryClassTeachByStuId?stuId=10068 的内部服务错误。
* 直接调用 service 层(绕过 SSO 过滤器),打印真实异常堆栈。
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
class ReproduceHistoryTeachByStuIdTest {
@Autowired
private IStudentClassHistoryService studentClassHistoryService;
@Test
void reproduce() {
try {
// 模拟真实 HTTP 场景:UserContextInterceptor.resolveUser 恒返回 null,schId 为 null
Object result = studentClassHistoryService.getHistoryClassTeachByStuId(null, 10068);
System.out.println("=== SUCCESS (schId=null) ===");
System.out.println(result);
} catch (Throwable e) {
System.out.println("=== EXCEPTION (schId=null): " + e.getClass().getName() + " ===");
System.out.println("message: " + e.getMessage());
e.printStackTrace();
}
}
@Autowired
private com.yunhai.bschclassmanage.utils.RedisUtil redisUtil;
@Autowired
private com.yunhai.bschclassmanage.Repository.SchStudentRepository schStudentRepository;
/**
* 回归用例:同一 stu_id 在 sch_student 存在多行(学生在多所学校各有一条就学记录)。
* 修复前 findByStuId 返回 Optional,会抛 NonUniqueResultException(线上表现为 code 6 内部服务错误)。
* 修复后应取当前学校那一行正常返回。@Transactional 保证测试数据回滚。
*/
@org.springframework.transaction.annotation.Transactional
@Test
void duplicateStuIdShouldNotCrash() {
// 本地库里 stu_id=10068 已有一行(sch_id=3, class_id=298),再造一行别校的记录
com.yunhai.bschclassmanage.entity.SchStudent other = new com.yunhai.bschclassmanage.entity.SchStudent();
other.setStuId(10068);
other.setSchId(999);
other.setClassId(888);
other.setName("重复记录-别校");
schStudentRepository.save(other);
schStudentRepository.flush();
// 当前学校 schId=3 的记录应被正确选中,不再抛 NonUniqueResultException
Object result = studentClassHistoryService.getHistoryClassTeachByStuId(3, 10068);
System.out.println("=== DUPLICATE stu_id RESULT ===");
System.out.println(result);
if (result == null) {
throw new AssertionError("expected non-null result");
}
// 换成重复行所在学校 schId=999:实时表命中但班级不存在 → 应返回业务异常而不是内部错误
try {
studentClassHistoryService.getHistoryClassTeachByStuId(999, 10068);
System.out.println("=== schId=999 unexpectedly succeeded ===");
} catch (com.yunhai.platform.utils.exception.DException e) {
System.out.println("=== schId=999 DException (expected): " + e.getMessage());
}
}
@Test
void poisonCacheRead() {
for (String key : new String[]{
"bsch.classmanage.system_yearterm_poison",
"bsch.classmanage.system_yearterm_poison2"}) {
try {
Object raw = redisUtil.get(key, Object.class);
System.out.println("=== RAW GET [" + key + "]: " + (raw == null ? "null" : raw.getClass().getName()));
} catch (Throwable e) {
System.out.println("=== RAW GET EXCEPTION [" + key + "]: " + e.getClass().getName() + " ===");
System.out.println("message: " + e.getMessage());
}
try {
Object list = redisUtil.getList(key, com.yunhai.bschclassmanage.entity.BaseYearTerm.class);
System.out.println("=== GET LIST [" + key + "]: " + list);
} catch (Throwable e) {
System.out.println("=== GET LIST EXCEPTION [" + key + "]: " + e.getClass().getName() + " ===");
System.out.println("message: " + e.getMessage());
}
}
}
@Test
void reproduceWithSchId() {
try {
Object result = studentClassHistoryService.getHistoryClassTeachByStuId(3, 10068);
System.out.println("=== SUCCESS (schId=3) ===");
System.out.println(result);
} catch (Throwable e) {
System.out.println("=== EXCEPTION (schId=3): " + e.getClass().getName() + " ===");
System.out.println("message: " + e.getMessage());
e.printStackTrace();
}
}
}
@@ -0,0 +1,114 @@
package com.yunhai.bschclassmanage.controller.oapi.sch;
import com.yunhai.bschclassmanage.model.vo.sch.OapiGradeNameVO;
import com.yunhai.bschclassmanage.service.IBaseSchsystemGradeService;
import com.yunhai.bschclassmanage.service.IBaseSchsystemService;
import com.yunhai.bschclassmanage.service.ISchBinSchsystemService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
/** getGradeName 开放接口单测:学段→学制匹配、学年反查年级字典名、无匹配返回 null */
class OapiGetGradeNameTest {
private OapiSchClassController controller;
private ISchBinSchsystemService schBinSchsystemService;
private IBaseSchsystemService baseSchsystemService;
private IBaseSchsystemGradeService baseSchsystemGradeService;
@BeforeEach
void setUp() {
controller = new OapiSchClassController();
schBinSchsystemService = Mockito.mock(ISchBinSchsystemService.class);
baseSchsystemService = Mockito.mock(IBaseSchsystemService.class);
baseSchsystemGradeService = Mockito.mock(IBaseSchsystemGradeService.class);
ReflectionTestUtils.setField(controller, "schClassRepository",
Mockito.mock(com.yunhai.bschclassmanage.Repository.SchClassRepository.class));
ReflectionTestUtils.setField(controller, "schInfoRepository",
Mockito.mock(com.yunhai.bschclassmanage.Repository.SchInfoRepository.class));
ReflectionTestUtils.setField(controller, "schClassService",
Mockito.mock(com.yunhai.bschclassmanage.service.ISchClassService.class));
ReflectionTestUtils.setField(controller, "schTeacherService",
Mockito.mock(com.yunhai.bschclassmanage.service.ISchTeacherService.class));
ReflectionTestUtils.setField(controller, "schBinSchsystemService", schBinSchsystemService);
ReflectionTestUtils.setField(controller, "baseSchsystemService", baseSchsystemService);
ReflectionTestUtils.setField(controller, "baseSchsystemGradeService", baseSchsystemGradeService);
// 云海小学:双学段(小学+初中),各自年级字典与入学年份映射
when(schBinSchsystemService.getSchBinSchsystemIds(27)).thenReturn(Arrays.asList(5, 9));
when(baseSchsystemService.getAcademicStageName(5)).thenReturn("小学");
when(baseSchsystemService.getAcademicStageName(9)).thenReturn("初中");
Map<Integer, Integer> primaryYears = new LinkedHashMap<>();
primaryYears.put(1, 2026);
primaryYears.put(2, 2025);
primaryYears.put(3, 2024);
when(baseSchsystemGradeService.getNowYearGradeMap(27, 5)).thenReturn(primaryYears);
Map<Integer, String> primaryNames = new HashMap<>();
primaryNames.put(1, "小一");
primaryNames.put(2, "小二");
primaryNames.put(3, "小三");
when(baseSchsystemGradeService.getGradeNameMap(27, 5, null)).thenReturn(primaryNames);
Map<Integer, Integer> juniorYears = new LinkedHashMap<>();
juniorYears.put(7, 2026);
juniorYears.put(8, 2025);
when(baseSchsystemGradeService.getNowYearGradeMap(27, 9)).thenReturn(juniorYears);
Map<Integer, String> juniorNames = new HashMap<>();
juniorNames.put(7, "初一");
juniorNames.put(8, "初二");
when(baseSchsystemGradeService.getGradeNameMap(27, 9, null)).thenReturn(juniorNames);
}
@Test
void resolvesGradeNameByStageAndYear() {
// 小学 2026 级入学 → 小一(学校字典真实名称)
OapiGradeNameVO vo = controller.getGradeName(27, 2026, 1).getBody();
assertEquals(1, vo.getGradeId());
assertEquals("小一", vo.getGradeName());
assertEquals(2026, vo.getYear());
// 初中 2026 → 初一(同校多学段按 stage 区分)
assertEquals("初一", controller.getGradeName(27, 2026, 2).getBody().getGradeName());
// 低年级已升学:2024 入学的小学年级 → 小三
assertEquals("小三", controller.getGradeName(27, 2024, 1).getBody().getGradeName());
}
@Test
void returnsNullWhenNoMatch() {
// 年份无对应年级(超出字典范围)
assertNull(controller.getGradeName(27, 2020, 1).getBody());
// 学段在学校未开设(高中不存在)
assertNull(controller.getGradeName(27, 2026, 3).getBody());
// 参数缺失
assertNull(controller.getGradeName(null, 2026, 1).getBody());
assertNull(controller.getGradeName(27, null, 1).getBody());
}
@Test
void skipsBrokenSchsystemAndContinues() {
// 单个学制学段名缺失(学制未找到等)跳过,不影响其余学制匹配
when(baseSchsystemService.getAcademicStageName(5)).thenThrow(new RuntimeException("学制未找到!"));
assertEquals("初一", controller.getGradeName(27, 2026, 2).getBody().getGradeName());
assertNull(controller.getGradeName(27, 2026, 1).getBody());
}
@Test
void emptyYearMapReturnsNull() {
when(baseSchsystemGradeService.getNowYearGradeMap(eq(27), anyInt())).thenReturn(new HashMap<>());
assertNull(controller.getGradeName(27, 2026, 1).getBody());
}
}
@@ -0,0 +1,77 @@
package com.yunhai.bschclassmanage.service.impl.teacher;
import com.yunhai.bschclassmanage.Repository.*;
import com.yunhai.bschclassmanage.entity.*;
import com.yunhai.bschclassmanage.model.dto.TeacherBaseInfoDTO;
import com.yunhai.bschclassmanage.service.ISendUserBaseService;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
@ExtendWith(MockitoExtension.class)
class SchTeacherBaseInfoServiceTest {
@InjectMocks private SchTeacherBaseInfoService service;
@Mock private SchTeacherRepository teacherRepository;
@Mock private SchTeacherGroupRepository groupRepository;
@Mock private ISendUserBaseService sendUserBaseService;
@Mock private SchTeacherCacheService cacheService;
private SchTeacherGroup group(int school) {
SchTeacherGroup g = new SchTeacherGroup();
g.setSchId(school); g.setTeaId(101); g.setGroupId(31); g.setTransferStatus(0);
return g;
}
private TeacherBaseInfoDTO request() {
TeacherBaseInfoDTO d = new TeacherBaseInfoDTO();
d.setSchId(3); d.setTeaId(101); d.setName("新姓名"); d.setGender(1); d.setPhone("");
return d;
}
private List<SchTeacher> givenTeacher() {
when(groupRepository.findByTeaIdAndSchId(101, 3)).thenReturn(Collections.singletonList(group(3)));
SchTeacher a = new SchTeacher(); a.setId(11); a.setTeaId(101); a.setAccount("teacher"); a.setStatus(1);
SchTeacher b = new SchTeacher(); b.setId(12); b.setTeaId(101); b.setAccount("teacher"); b.setStatus(1);
List<SchTeacher> rows = Arrays.asList(a,b);
when(teacherRepository.findByTeaId(101)).thenReturn(rows);
return rows;
}
@Test void updatesAllTeacherRowsAndInvalidatesAllSchoolsAfterSync() {
List<SchTeacher> rows = givenTeacher();
when(sendUserBaseService.editTeacher(any(SchTeacher.class), isNull(), eq(31)))
.thenReturn(new BoaUser(101, "teacher", "新姓名"));
when(groupRepository.findByTeaIdIn(Collections.singletonList(101))).thenReturn(Arrays.asList(group(3),group(4)));
assertEquals("新姓名", service.edit(request()).getName());
for (SchTeacher t : rows) {
assertEquals("新姓名", t.getName()); assertEquals(1,t.getGender());
assertNull(t.getTel()); assertEquals("teacher",t.getAccount()); assertEquals(1,t.getStatus());
}
InOrder order = inOrder(sendUserBaseService,teacherRepository,cacheService);
order.verify(sendUserBaseService).editTeacher(any(SchTeacher.class), isNull(), eq(31));
order.verify(teacherRepository).saveAll(rows);
order.verify(cacheService).clearTeacherCaches(new HashSet<>(Arrays.asList(3,4)));
}
@Test void rejectsWrongSchoolAndTransferredOutTeacher() {
SchTeacherGroup out = group(3); out.setTransferStatus(1);
when(groupRepository.findByTeaIdAndSchId(101,3)).thenReturn(Collections.singletonList(out));
assertThrows(DException.class, () -> service.edit(request()));
verifyNoInteractions(sendUserBaseService,teacherRepository,cacheService);
}
@Test void syncFailureDoesNotSaveOrClearCache() {
List<SchTeacher> rows = givenTeacher();
when(sendUserBaseService.editTeacher(any(SchTeacher.class), isNull(), eq(31)))
.thenThrow(new DException("平台失败"));
assertThrows(DException.class, () -> service.edit(request()));
verify(teacherRepository,never()).saveAll(any()); verifyNoInteractions(cacheService);
// 同步前已就地改写的实体随事务回滚,不落库
assertEquals("新姓名", rows.get(0).getName());
}
}