diff --git a/.zcode/plans/plan-sess_c50300fe-a0f6-414e-ad2c-09599f93480a.md b/.zcode/plans/plan-sess_c50300fe-a0f6-414e-ad2c-09599f93480a.md new file mode 100644 index 0000000..5f7b07f --- /dev/null +++ b/.zcode/plans/plan-sess_c50300fe-a0f6-414e-ad2c-09599f93480a.md @@ -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 teachers, Integer schId)`**:批量查询组关系并回填到每个教师的 `@Transient groupList` 字段,复用项目既有模式(SchTeacherService.java:96-98 的 `findByTeaIdIn` + `groupingBy`): + +```java +private void fillGroupList(List teachers, Integer schId) { + if (teachers == null || teachers.isEmpty()) return; + List teaIds = teachers.stream() + .map(SchTeacher::getTeaId) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + if (teaIds.isEmpty()) return; + List relations = (schId != null) + ? schTeacherGroupRepository.findByTeaIdInAndSchId(teaIds, schId) + : schTeacherGroupRepository.findByTeaIdIn(teaIds); + Map> 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,且键名为原始大写形式)。 \ No newline at end of file diff --git a/src/main/java/com/yunhai/bschclassmanage/controller/oapi/sync/SyncClassController.java b/src/main/java/com/yunhai/bschclassmanage/controller/oapi/sync/SyncClassController.java new file mode 100644 index 0000000..cd3aa91 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/controller/oapi/sync/SyncClassController.java @@ -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)。 + *

与既有 /oapi/group、/oapi/user 同步接口不同:本控制器用强类型 DTO 入参 + 结果 VO 出参 + * (推送方需要拿到 本地班号→平台classId 映射与逐生成败结果,void 返回无法承载), + * 风格对齐 /oapi/sch 只读接口。

+ */ +@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 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 AddClassStudent(@RequestBody SyncClassStudentDTO dto) { + return ResponseEntity.ok(syncClassService.addClassStudent(dto)); + } +} diff --git a/src/main/java/com/yunhai/bschclassmanage/enums/SyncDataType.java b/src/main/java/com/yunhai/bschclassmanage/enums/SyncDataType.java index be0ca5e..96e2def 100644 --- a/src/main/java/com/yunhai/bschclassmanage/enums/SyncDataType.java +++ b/src/main/java/com/yunhai/bschclassmanage/enums/SyncDataType.java @@ -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","编辑组"), diff --git a/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncClassDTO.java b/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncClassDTO.java new file mode 100644 index 0000000..a213444 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncClassDTO.java @@ -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 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; + } +} diff --git a/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncClassStudentDTO.java b/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncClassStudentDTO.java new file mode 100644 index 0000000..8ab89a0 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/model/tool/SyncClassStudentDTO.java @@ -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 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; + } +} diff --git a/src/main/java/com/yunhai/bschclassmanage/model/vo/sync/SyncClassResultVO.java b/src/main/java/com/yunhai/bschclassmanage/model/vo/sync/SyncClassResultVO.java new file mode 100644 index 0000000..a16c8e7 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/model/vo/sync/SyncClassResultVO.java @@ -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 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; + } +} diff --git a/src/main/java/com/yunhai/bschclassmanage/model/vo/sync/SyncStudentResultVO.java b/src/main/java/com/yunhai/bschclassmanage/model/vo/sync/SyncStudentResultVO.java new file mode 100644 index 0000000..6a2afce --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/model/vo/sync/SyncStudentResultVO.java @@ -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 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; + } +} diff --git a/src/main/java/com/yunhai/bschclassmanage/service/ISyncClassService.java b/src/main/java/com/yunhai/bschclassmanage/service/ISyncClassService.java new file mode 100644 index 0000000..4f2ebe2 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/service/ISyncClassService.java @@ -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); +} diff --git a/src/main/java/com/yunhai/bschclassmanage/service/impl/sync/SyncClassService.java b/src/main/java/com/yunhai/bschclassmanage/service/impl/sync/SyncClassService.java new file mode 100644 index 0000000..7b567a2 --- /dev/null +++ b/src/main/java/com/yunhai/bschclassmanage/service/impl/sync/SyncClassService.java @@ -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; + +/** + * 分班结果推送开放接口实现。 + *

组创建模板:SchClassInfoService.addBaseSchClass;差异①:入学年份取入参 enrollYear(不用 NowYearTerm 推导, + * 分班多发生在 6-8 月旧学年末,照抄模板会把年级组建到旧年份);差异②:班号以入参 sort 为准(分班结果已定班号, + * 不按 findMaxCodeNumber 自动续号)。

+ *

回滚策略:本地事务 + 外部系统补偿删除(rollbackCreatedGroups/rollbackSyncedStudents)+ 幂等重推自愈; + * createdGroupIds 只记本次新建的组,查重复用的既有组绝不进回滚清单。

+ */ +@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 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 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 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 createdGroupIds = new ArrayList<>(); + try { + Integer parentId = stuGroup.getId(); + + // 3. 多学段学校:查/建学段组 + if (schsystemIds.size() > 1) { + List stageGroups = comSchTreeService.getAcademicStageGroupList(schId); + Optional 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 gradeGroups = comSchTreeService.getSchGroupTypeList(schId, NodeType.GRADE.getId()).get(schsystemId); + if (gradeGroups != null) { + Optional 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 classGroups = comSchTreeService.getSchGroupTypeList(schId, NodeType.CLASS.getId()).get(schsystemId); + Map classGroupByName = classGroups == null ? Collections.emptyMap() + : classGroups.stream().collect(Collectors.toMap(SelectIntVO::getName, SelectIntVO::getId, (a, b) -> a)); + + Date now = new Date(); + List saveList = new ArrayList<>(); + List 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 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 belongClasses = schClassRepository.findBySchIdAndClassIds(schId, new ArrayList<>(classIdSet)); + Map 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 saveList = new ArrayList<>(); + // 已建平台账号(按下标与 groupIds 对应,供整批回滚) + List syncedList = new ArrayList<>(); + List syncedGroupIds = new ArrayList<>(); + // 本批内 (classId, name) / account 查重(库内查重看不到本批未落库的行) + Set batchNameKeys = new HashSet<>(); + Set batchAccounts = new HashSet<>(); + List 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 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 syncedList, List 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; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2653891..dbe05af 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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