@@ -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,且键名为原始大写形式)。
|
||||
+52
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user