同步班级学生
build / build-push (push) Successful in 1m4s

This commit is contained in:
wanghu
2026-10-21 08:47:21 +08:00
parent a669bb67ec
commit cee411472a
10 changed files with 745 additions and 0 deletions
@@ -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,且键名为原始大写形式)。
@@ -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,41 @@
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+"班") */
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,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);
}
@@ -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;
}
}
+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