diff --git a/docker/application-prod.yml b/docker/application-prod.yml index db3838f..d0a0740 100644 --- a/docker/application-prod.yml +++ b/docker/application-prod.yml @@ -47,6 +47,9 @@ yh: # 受信应用(服务端验签用):appKey -> appSecret trusted-apps: 100617: 2a3b4c5d6e7f8091a2b3c4d5e6f70819 + open: + # 班级管理(bschclass)服务基址:绑定班级页班级数据源(/oapi/sch/getSchClassList 等) + bschclass: http://base.yunhaixingtong.com/userclass # 上传文件落容器卷,compose 里挂到宿主机 ./upload-data FILE_PATH: /data/upload diff --git a/smoke/smoke.mjs b/smoke/smoke.mjs index 89b8694..2032c3c 100644 --- a/smoke/smoke.mjs +++ b/smoke/smoke.mjs @@ -172,31 +172,54 @@ ok( JSON.stringify(myTreeRows[0]) ); -// 9.7 绑定班级:class 档案 → BindClass/UnbindClass/BatchUnbind → 年级 Tab 过滤 -const clsNameA = `冒烟高一班${stamp}`; -const clsNameB = `冒烟高二班${stamp}`; -const clsAddA = await api("POST", "/api/class/Add", { form: { className: clsNameA, grade: 1, studentCount: 45, headTeacher: "王老师" } }); -ok("class Add(高一)", clsAddA.code === 0 && clsAddA.data > 0, clsAddA.msg); -const clsAddB = await api("POST", "/api/class/Add", { form: { className: clsNameB, grade: 2, studentCount: 44, headTeacher: "陈老师" } }); -ok("class Add(高二)", clsAddB.code === 0 && clsAddB.data > 0, clsAddB.msg); -ok("class Add 校内重名拒绝", (await api("POST", "/api/class/Add", { form: { className: clsNameA, grade: 1 } })).code !== 0); -ok("class Add 非法年级拒绝", (await api("POST", "/api/class/Add", { form: { className: `冒烟班X${stamp}`, grade: 9 } })).code !== 0); +// 9.7 绑定班级:班级数据源=班级管理平台接口(bschclass,本应用不落库、无 Add/Delete) +// → BindClass/UnbindClass/BatchUnbind → 年级 Tab 过滤 +// 前置:dev 身份 schId=3(云海中学)在平台须有高一/高二班级 +const clsListAll = await api("GET", "/api/class/List"); +ok("class List code=0(平台班级)", clsListAll.code === 0 && Array.isArray(clsListAll.data), clsListAll.msg); +ok( + "class List 仅含高中学段(gradeId 10/11/12 之外的学段被过滤)", + clsListAll.code === 0 && (clsListAll.data || []).every(c => [1, 2, 3].includes(c.grade)), + JSON.stringify(clsListAll.data && clsListAll.data.find(c => ![1, 2, 3].includes(c.grade))) +); +const hs = (clsListAll.data || []).filter(c => c.grade === 1 || c.grade === 2); +const clsA = hs.find(c => c.grade === 1 && c.classroomId == null); +const clsB = hs.find(c => c.grade === 2 && c.classroomId == null); +ok( + "平台班级数据可用(高一/高二各有未绑定班级)", + !!clsA && !!clsB, + JSON.stringify(hs && hs.map(c => `${c.serID}:${c.className}:room${c.classroomId}`)) +); +ok( + "class List 行含 serID/className/studentCount(可空)/headTeacher", + clsA && clsA.serID > 0 && !!clsA.className && (clsA.studentCount == null || Number.isInteger(clsA.studentCount)), + JSON.stringify(clsA) +); const clsListG1 = await api("GET", "/api/class/List", { params: { grade: 1 } }); -const clsA = clsListG1.data ? clsListG1.data.find(c => c.serID === clsAddA.data) : null; -ok("class List grade=1 含新班级(未绑定 classroomId=null)", clsListG1.code === 0 && clsA && clsA.classroomId == null && clsA.headTeacher === "王老师", JSON.stringify(clsA)); +ok( + "class List grade=1 只含高一且新班级未绑定(classroomId=null)", + clsListG1.code === 0 && (clsListG1.data || []).length > 0 && (clsListG1.data || []).every(c => c.grade === 1) + && (clsListG1.data || []).find(c => c.serID === clsA.serID)?.classroomId == null, + JSON.stringify(clsListG1.data && clsListG1.data.length) +); -const bind = await api("POST", "/api/classroom/BindClass", { json: { serID: room1, classId: clsAddA.data } }); -ok("classroom BindClass 返回班级名", bind.code === 0 && bind.data && bind.data.className === clsNameA, bind.msg); -const bindDup = await api("POST", "/api/classroom/BindClass", { json: { serID: rooms.data.list[1].serID, classId: clsAddA.data } }); +const bind = await api("POST", "/api/classroom/BindClass", { json: { serID: room1, classId: clsA.serID } }); +ok("classroom BindClass 返回班级名(平台展示名)", bind.code === 0 && bind.data && bind.data.className === clsA.className, bind.msg); +const bindDup = await api("POST", "/api/classroom/BindClass", { json: { serID: rooms.data.list[1].serID, classId: clsA.serID } }); ok("BindClass 班级已被其他教室占用拒绝", bindDup.code !== 0); ok("BindClass 缺参拒绝", (await api("POST", "/api/classroom/BindClass", { json: { serID: room1 } })).code !== 0); +ok( + "BindClass 班级不在平台列表拒绝", + (await api("POST", "/api/classroom/BindClass", { json: { serID: rooms.data.list[2].serID, classId: 999999999 } })).code !== 0 +); const roomsG1 = await api("GET", "/api/classroom/List", { params: { buildingId, grade: 1, page: 1, count: 20 } }); const boundRow = roomsG1.data ? roomsG1.data.list.find(r => r.serID === room1) : null; ok( - "List grade=1 含已绑教室且行含班级信息(名/人数/班主任)", - roomsG1.code === 0 && roomsG1.data.total === 6 && boundRow && boundRow.classId === clsAddA.data && - boundRow.className === clsNameA && boundRow.studentCount === 45 && boundRow.headTeacher === "王老师", + "List grade=1 含已绑教室且行含班级信息(名/人数/班主任同平台源)", + roomsG1.code === 0 && roomsG1.data.total === 6 && boundRow && boundRow.classId === clsA.serID && + boundRow.className === clsA.className && (boundRow.studentCount ?? null) === (clsA.studentCount ?? null) && + boundRow.headTeacher === clsA.headTeacher, JSON.stringify(boundRow) ); const roomsG2 = await api("GET", "/api/classroom/List", { params: { buildingId, grade: 2, page: 1, count: 20 } }); @@ -208,14 +231,12 @@ ok( const roomsBoundOnly = await api("GET", "/api/classroom/List", { params: { buildingId, grade: 1, bindStatus: "bound", page: 1, count: 20 } }); ok("List bindStatus=bound 只含已绑 1 间", roomsBoundOnly.code === 0 && roomsBoundOnly.data.total === 1); -ok("class Delete 绑定中拒绝", (await api("POST", "/api/class/Delete", { form: { serid: clsAddA.data } })).code !== 0); - ok("classroom UnbindClass", (await api("POST", "/api/classroom/UnbindClass", { form: { serid: room1 } })).code === 0); ok("UnbindClass 未绑定教室静默成功", (await api("POST", "/api/classroom/UnbindClass", { form: { serid: room1 } })).code === 0); -// 批量绑定两间 → 批量解绑 3 间(含 1 间未绑定 → skipped) -await api("POST", "/api/classroom/BindClass", { json: { serID: room1, classId: clsAddA.data } }); -await api("POST", "/api/classroom/BindClass", { json: { serID: rooms.data.list[1].serID, classId: clsAddB.data } }); +// 批量绑定两间 → 批量解绑 3 间(含 1 间未绑定 → skipped);班级属平台数据,解绑即可、无需清理 +await api("POST", "/api/classroom/BindClass", { json: { serID: room1, classId: clsA.serID } }); +await api("POST", "/api/classroom/BindClass", { json: { serID: rooms.data.list[1].serID, classId: clsB.serID } }); const bUnbind = await api("POST", "/api/classroom/BatchUnbind", { json: [room1, rooms.data.list[1].serID, rooms.data.list[2].serID] }); ok( "BatchUnbind 3 间(updated=2, skipped=1)", @@ -223,8 +244,6 @@ ok( JSON.stringify(bUnbind.data) ); ok("BatchUnbind 空集合拒绝", (await api("POST", "/api/classroom/BatchUnbind", { json: [] })).code !== 0); -ok("class Delete 解绑后可删 A", (await api("POST", "/api/class/Delete", { form: { serid: clsAddA.data } })).code === 0); -ok("class Delete 解绑后可删 B", (await api("POST", "/api/class/Delete", { form: { serid: clsAddB.data } })).code === 0); // 菜单兜底(仅本地免 SSO 模式):绑定班级在第二位 if (!COOKIE) { diff --git a/src/main/java/com/yunhai/acaclassroom/aspect/ControllerUserContextAspect.java b/src/main/java/com/yunhai/acaclassroom/aspect/ControllerUserContextAspect.java index 77e4a81..aeacf95 100644 --- a/src/main/java/com/yunhai/acaclassroom/aspect/ControllerUserContextAspect.java +++ b/src/main/java/com/yunhai/acaclassroom/aspect/ControllerUserContextAspect.java @@ -52,8 +52,10 @@ public class ControllerUserContextAspect { } else if (devContextEnabled) { info.userId = 99999; info.userName = "本地开发"; - info.schId = 1; - log.debug("[UserContext] 无 SSO 会话,已使用本地开发身份(schId=1)"); + // schId=3(云海中学/李晶校):班级数据已切平台接口,dev 校须在平台有班级数据才能联调绑定班级页; + // userId 保持 99999(平台查无此用户 → GetUserMod 走本地兜底菜单,不回真实模块树) + info.schId = 3; + log.debug("[UserContext] 无 SSO 会话,已使用本地开发身份(schId=3)"); } info.ip = clientIp(); UserContext.set(info); diff --git a/src/main/java/com/yunhai/acaclassroom/controller/api/ClassController.java b/src/main/java/com/yunhai/acaclassroom/controller/api/ClassController.java index 13fca2e..6b0758e 100644 --- a/src/main/java/com/yunhai/acaclassroom/controller/api/ClassController.java +++ b/src/main/java/com/yunhai/acaclassroom/controller/api/ClassController.java @@ -1,6 +1,5 @@ package com.yunhai.acaclassroom.controller.api; -import com.yunhai.acaclassroom.annotation.OperationLog; import com.yunhai.acaclassroom.config.Config; import com.yunhai.acaclassroom.service.IClassService; import org.springframework.beans.factory.annotation.Autowired; @@ -10,7 +9,8 @@ import java.util.List; import java.util.Map; /** - * 班级档案接口(class/,绑定班级页数据源)。 + * 班级接口(class/,绑定班级页数据源)。 + * 班级数据实时取自班级管理(bschclass)开放接口,本应用不落库、不提供增删。 */ @RestController @RequestMapping(value = Config.API_PATH + "/class/") @@ -19,25 +19,9 @@ public class ClassController { @Autowired private IClassService classService; - /** 班级列表(全校,可按年级过滤;行含 classroomId=绑定的教室 id,未绑定为 null)。 */ + /** 班级列表(全校高中学段,可按年级过滤;行含 classroomId=绑定的教室 id,未绑定为 null)。 */ @GetMapping("List") public List> list(@RequestParam(value = "grade", required = false) Integer grade) { return classService.list(grade); } - - @PostMapping("Add") - @OperationLog(module = "教室管理-绑定班级", type = "CREATE") - public Integer add(@RequestParam("className") String className, - @RequestParam("grade") Integer grade, - @RequestParam(value = "studentCount", required = false) Integer studentCount, - @RequestParam(value = "headTeacher", required = false) String headTeacher) { - return classService.add(className, grade, studentCount, headTeacher); - } - - /** 删除班级(已被教室绑定时拒绝)。 */ - @PostMapping("Delete") - @OperationLog(module = "教室管理-绑定班级", type = "DELETE") - public void delete(@RequestParam("serid") Integer serid) { - classService.delete(serid); - } } diff --git a/src/main/java/com/yunhai/acaclassroom/entity/SchoolClass.java b/src/main/java/com/yunhai/acaclassroom/entity/SchoolClass.java deleted file mode 100644 index 82311c3..0000000 --- a/src/main/java/com/yunhai/acaclassroom/entity/SchoolClass.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.yunhai.acaclassroom.entity; - -import lombok.Getter; -import lombok.Setter; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Table; - -/** - * 班级档案(绑定班级页数据源)。班名校内唯一;教室经 aca_classroom.class_id 引用。 - */ -@Entity -@Getter -@Setter -@Table(name = "aca_class") -public class SchoolClass extends BaseEntity { - - /** 年级:1=高一 2=高二 3=高三 */ - public static final int GRADE_1 = 1; - public static final int GRADE_2 = 2; - public static final int GRADE_3 = 3; - - /** 班级名称(如 高一1班),校内唯一 */ - @Column(name = "class_name", nullable = false, length = 50) - private String className; - - /** 年级:1=高一 2=高二 3=高三 */ - @Column(name = "grade", nullable = false) - private Integer grade; - - /** 学生人数 */ - @Column(name = "student_count", nullable = false) - private Integer studentCount; - - /** 班主任姓名 */ - @Column(name = "head_teacher", length = 50) - private String headTeacher; - - /** 所属学校 id(多租户) */ - @Column(name = "sch_id", nullable = false) - private Integer schId; -} diff --git a/src/main/java/com/yunhai/acaclassroom/model/PlatformSchoolClass.java b/src/main/java/com/yunhai/acaclassroom/model/PlatformSchoolClass.java new file mode 100644 index 0000000..c68e29f --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/model/PlatformSchoolClass.java @@ -0,0 +1,197 @@ +package com.yunhai.acaclassroom.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * 班级管理(bschclass)开放接口 {@code /oapi/sch/getSchClassList} 响应项。 + * + *

id 体系:{@link #getId()} 是 sch_class 物理主键;{@link #getClassId()} 是分组体系 + * classId(学生树 GetStuTree 的班级节点 id 与之同源),本应用的教室绑定关系 + * {@code aca_classroom.class_id} 存它——与班级服务生态(学生树/任教)同一把钥匙。

+ * + *

年级口径:平台 gradeId 为平台年级字典(10=高一 11=高二 12=高三;小学 1~6、 + * 初中 7~9、其他学段另有取值),本应用只有高中三个年级 Tab, + * {@link #highGrade()} 把 10/11/12 映射为 1/2/3,其余返回 null(绑定页不展示)。

+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class PlatformSchoolClass { + + /** 平台年级字典 → 本应用年级(1=高一 2=高二 3=高三)映射 */ + private static final Map HIGH_GRADE_MAP = createHighGradeMap(); + + private static Map createHighGradeMap() { + Map m = new HashMap<>(); + m.put(10, 1); + m.put(11, 2); + m.put(12, 3); + return Collections.unmodifiableMap(m); + } + + /** sch_class 物理主键 */ + private Integer id; + + /** 分组体系班级 id(学生树班级节点同源),绑定关系存此值 */ + private Integer classId; + + /** 班级编码(如 202601) */ + private String code; + + /** 班级名(如 1班,不含年级前缀) */ + private String name; + + /** 平台年级字典 id(10=高一 11=高二 12=高三,小学/初中另有序列) */ + private Integer gradeId; + + /** 年级名(如 高一) */ + private String gradeName; + + private Integer schId; + + private String schName; + + /** 班主任(第一任课教师) */ + private Integer firstTeacherId; + + private String firstTeacherName; + + private Integer campusId; + + private Integer enrollYear; + + private Boolean graduate; + + /** 校内展示序 */ + private Integer sort; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getClassId() { + return classId; + } + + public void setClassId(Integer classId) { + this.classId = classId; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getGradeId() { + return gradeId; + } + + public void setGradeId(Integer gradeId) { + this.gradeId = gradeId; + } + + public String getGradeName() { + return gradeName; + } + + public void setGradeName(String gradeName) { + this.gradeName = gradeName; + } + + public Integer getSchId() { + return schId; + } + + public void setSchId(Integer schId) { + this.schId = schId; + } + + public String getSchName() { + return schName; + } + + public void setSchName(String schName) { + this.schName = schName; + } + + public Integer getFirstTeacherId() { + return firstTeacherId; + } + + public void setFirstTeacherId(Integer firstTeacherId) { + this.firstTeacherId = firstTeacherId; + } + + public String getFirstTeacherName() { + return firstTeacherName; + } + + public void setFirstTeacherName(String firstTeacherName) { + this.firstTeacherName = firstTeacherName; + } + + public Integer getCampusId() { + return campusId; + } + + public void setCampusId(Integer campusId) { + this.campusId = campusId; + } + + public Integer getEnrollYear() { + return enrollYear; + } + + public void setEnrollYear(Integer enrollYear) { + this.enrollYear = enrollYear; + } + + public Boolean getGraduate() { + return graduate; + } + + public void setGraduate(Boolean graduate) { + this.graduate = graduate; + } + + public Integer getSort() { + return sort; + } + + public void setSort(Integer sort) { + this.sort = sort; + } + + /** 映射到本应用的年级(1/2/3);非高中学段返回 null */ + public Integer highGrade() { + return gradeId == null ? null : HIGH_GRADE_MAP.get(gradeId); + } + + /** 展示名:年级名 + 班级名(如 高一1班);年级名缺失时退化为班级名 */ + public String displayName() { + String n = name == null ? "" : name.trim(); + String g = gradeName == null ? "" : gradeName.trim(); + if (g.isEmpty()) { + return n; + } + // 年级名已是完整班级名前缀(如平台侧直接叫"高一1班")时不重复拼接 + return n.startsWith(g) ? n : g + n; + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/repository/SchoolClassRepository.java b/src/main/java/com/yunhai/acaclassroom/repository/SchoolClassRepository.java deleted file mode 100644 index 49199ad..0000000 --- a/src/main/java/com/yunhai/acaclassroom/repository/SchoolClassRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.yunhai.acaclassroom.repository; - -import com.yunhai.acaclassroom.entity.SchoolClass; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -/** - * 班级仓储。单校班级量级为十~百级,整取后服务层过滤。 - */ -public interface SchoolClassRepository extends JpaRepository { - - List findBySchIdOrderByGradeAscIdAsc(Integer schId); - - boolean existsBySchIdAndClassName(Integer schId, String className); -} diff --git a/src/main/java/com/yunhai/acaclassroom/service/IClassService.java b/src/main/java/com/yunhai/acaclassroom/service/IClassService.java index d099f4e..b776078 100644 --- a/src/main/java/com/yunhai/acaclassroom/service/IClassService.java +++ b/src/main/java/com/yunhai/acaclassroom/service/IClassService.java @@ -4,21 +4,18 @@ import java.util.List; import java.util.Map; /** - * 班级档案服务(绑定班级页)。 + * 班级服务(绑定班级页数据源)。 + * + *

班级数据实时取自班级管理(bschclass)开放接口,不在本应用落库; + * 教室绑定关系存 {@code aca_classroom.class_id}(平台分组体系 classId)。

*/ public interface IClassService { /** - * 班级列表(全校,可按年级过滤)。 + * 班级列表(全校高中学段,可按年级过滤)。 * * @param grade 1=高一 2=高二 3=高三(可空=全部) - * @return 行含 serID/className/grade/studentCount/headTeacher/classroomId(未绑定为 null) + * @return 行含 serID(=平台 classId)/className/grade/studentCount(可空)/headTeacher(可空)/classroomId(未绑定为 null) */ List> list(Integer grade); - - /** 新增班级,返回 id。 */ - Integer add(String className, Integer grade, Integer studentCount, String headTeacher); - - /** 删除班级(已被教室绑定时拒绝)。 */ - void delete(Integer id); } diff --git a/src/main/java/com/yunhai/acaclassroom/service/impl/BschClassClient.java b/src/main/java/com/yunhai/acaclassroom/service/impl/BschClassClient.java new file mode 100644 index 0000000..015648c --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/impl/BschClassClient.java @@ -0,0 +1,225 @@ +package com.yunhai.acaclassroom.service.impl; + +import com.yunhai.acaclassroom.model.PlatformSchoolClass; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 班级管理(bschclass)开放接口客户端,绑定班级页班级数据的唯一来源。 + * + *

接口口径与 AcaClassAssign 一致(配置 {@code yh.open.bschclass}):

+ *
    + *
  • 班级列表:GET {@code /oapi/sch/getSchClassList?schId=&includeGraduate=false}(不含毕业班);
  • + *
  • 学生树:GET {@code /oapi/SchGroup/GetStuTree?schId=&toUser=true}, + * 学校→学段→年级→班级→学生五层,班级节点 id 即班级列表的 classId, + * 其下 type=2 子节点为学生,用于派生各班学生人数。
  • + *
+ * + *

两个接口均做 5 分钟内存缓存(TTL,按 schId);拉取失败回退上一份缓存, + * 无缓存时降级为空(列表)/空映射(人数),不阻断页面——与同族应用降级口径一致。

+ */ +@Component +public class BschClassClient { + + private static final Logger log = LoggerFactory.getLogger(BschClassClient.class); + + /** 缓存有效期 ms */ + private static final long CACHE_TTL_MS = 5 * 60 * 1000L; + + /** 学生树中学生节点 type */ + private static final Integer STU_NODE_TYPE = 2; + + /** 班级管理服务基址 */ + @Value("${yh.open.bschclass:}") + private String bschClassHost; + + private final RestTemplate restTemplate; + + /** 班级列表缓存:schId -> {time, list} */ + private final ConcurrentHashMap classListCache = new ConcurrentHashMap<>(); + + /** 学生人数缓存:schId -> {time, classId -> 人数} */ + private final ConcurrentHashMap stuCountCache = new ConcurrentHashMap<>(); + + private static class ClassListCacheEntry { + final long time; + final List list; + + ClassListCacheEntry(long time, List list) { + this.time = time; + this.list = list; + } + } + + private static class StuCountCacheEntry { + final long time; + final Map counts; + + StuCountCacheEntry(long time, Map counts) { + this.time = time; + this.counts = counts; + } + } + + public BschClassClient() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(10000); + this.restTemplate = new RestTemplate(factory); + } + + /** 学校班级列表(不含毕业班,5 分钟缓存;不可达回退旧缓存,无缓存降级空列表)。 */ + public List classList(int schId) { + long now = System.currentTimeMillis(); + ClassListCacheEntry cached = classListCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.list; + } + synchronized (this) { + cached = classListCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.list; + } + List list = fetchClassList(schId); + if (list == null) { + return cached != null ? cached.list : Collections.emptyList(); + } + classListCache.put(schId, new ClassListCacheEntry(now, list)); + return list; + } + } + + /** 班级 id → 学生人数(学生树派生,5 分钟缓存;树不可达回退旧缓存,无缓存降级空映射)。 */ + public Map studentCounts(int schId) { + long now = System.currentTimeMillis(); + StuCountCacheEntry cached = stuCountCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.counts; + } + synchronized (this) { + cached = stuCountCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.counts; + } + Map counts = fetchStudentCounts(schId); + if (counts == null) { + return cached != null ? cached.counts : Collections.emptyMap(); + } + stuCountCache.put(schId, new StuCountCacheEntry(now, counts)); + return counts; + } + } + + /** 按班级 id 找单个班级(走列表缓存);不存在返回 null。 */ + public PlatformSchoolClass find(int schId, Integer classId) { + if (classId == null) { + return null; + } + return classList(schId).stream() + .filter(c -> classId.equals(c.getClassId())) + .findFirst() + .orElse(null); + } + + private List fetchClassList(int schId) { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[fetchClassList] 班级管理接口未配置(yh.open.bschclass)"); + return Collections.emptyList(); + } + String url = bschClassHost + "/oapi/sch/getSchClassList?schId=" + schId + "&includeGraduate=false"; + try { + ResponseEntity> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>() { + }); + List body = resp.getBody(); + log.info("[fetchClassList] schId={}, 班级 {} 个", schId, body == null ? 0 : body.size()); + return body != null ? body : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[fetchClassList] 请求班级列表失败, schId={}, url={}", schId, url, e); + return null; + } + } + + /** 拉学生树并按班级节点计数;不可达返回 null(区分"树为空")。 */ + private Map fetchStudentCounts(int schId) { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[fetchStudentCounts] 班级管理接口未配置(yh.open.bschclass)"); + return Collections.emptyMap(); + } + String url = bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId + "&toUser=true"; + try { + ResponseEntity>> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>>() { + }); + List> body = resp.getBody(); + Map counts = new LinkedHashMap<>(); + if (body != null) { + // 固定层级 学校→学段→年级→班级→学生(与 AcaClassAssign buildIndex 同一口径): + // 第 4 层班级节点 id 即班级列表 classId,其子节点 type=2 的为学生 + for (Map school : orEmpty(body)) { + for (Map system : childrenOf(school)) { + for (Map grade : childrenOf(system)) { + for (Map clazz : childrenOf(grade)) { + Integer classId = asInt(clazz.get("id")); + if (classId != null) { + counts.put(classId, (int) childrenOf(clazz).stream() + .filter(stu -> stu != null && STU_NODE_TYPE.equals(asInt(stu.get("type")))) + .count()); + } + } + } + } + } + } + log.info("[fetchStudentCounts] schId={}, 班级人数 {} 项", schId, counts.size()); + return counts; + } catch (RestClientException e) { + log.error("[fetchStudentCounts] 请求学生树失败, schId={}, url={}", schId, url, e); + return null; + } + } + + @SuppressWarnings("unchecked") + private List> childrenOf(Map node) { + if (node == null) { + return Collections.emptyList(); + } + Object children = node.get("children"); + return children instanceof List ? (List>) children : Collections.emptyList(); + } + + private List> orEmpty(List> list) { + return list != null ? list : Collections.emptyList(); + } + + private Integer asInt(Object v) { + if (v == null) { + return null; + } + if (v instanceof Number) { + return ((Number) v).intValue(); + } + try { + return Integer.valueOf(String.valueOf(v)); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/impl/ClassService.java b/src/main/java/com/yunhai/acaclassroom/service/impl/ClassService.java index 4e2f58f..3e90dda 100644 --- a/src/main/java/com/yunhai/acaclassroom/service/impl/ClassService.java +++ b/src/main/java/com/yunhai/acaclassroom/service/impl/ClassService.java @@ -1,28 +1,28 @@ package com.yunhai.acaclassroom.service.impl; -import com.yunhai.acaclassroom.entity.Classroom; -import com.yunhai.acaclassroom.entity.SchoolClass; +import com.yunhai.acaclassroom.model.PlatformSchoolClass; import com.yunhai.acaclassroom.repository.ClassroomRepository; -import com.yunhai.acaclassroom.repository.SchoolClassRepository; import com.yunhai.acaclassroom.service.IClassService; import com.yunhai.acaclassroom.utils.UserContext; -import com.yunhai.platform.apistd.BizException; -import com.yunhai.platform.utils.exception.ErrorCode; 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; /** - * 班级档案服务实现。班级数据量小(单校十~百级),整取后内存过滤。 + * 班级服务实现(绑定班级页数据源)。 + * + *

班级档案不在本应用落库:班级数据实时取自班级管理(bschclass)开放接口 + * {@link BschClassClient}(5 分钟缓存、失败降级),绑定关系仍存本应用 + * {@code aca_classroom.class_id}(存平台分组体系 classId)。此前本地档案表 + * {@code aca_class} 及 class/Add·Delete 接口已随数据源切换移除。

*/ @Service public class ClassService implements IClassService { @Autowired - private SchoolClassRepository classRepository; + private BschClassClient bschClassClient; @Autowired private ClassroomRepository classroomRepository; @@ -33,72 +33,35 @@ public class ClassService implements IClassService { if (schId == null) { return Collections.emptyList(); } - List classes = classRepository.findBySchIdOrderByGradeAscIdAsc(schId); + // 仅保留高中学段(gradeId 10/11/12 → 1/2/3),其余学段不进绑定页 + List classes = bschClassClient.classList(schId).stream() + .filter(c -> c.highGrade() != null && c.getClassId() != null) + .sorted(Comparator + .comparingInt((PlatformSchoolClass c) -> c.highGrade()) + .thenComparing(c -> c.getSort() == null ? Integer.MAX_VALUE : c.getSort()) + .thenComparing(c -> c.getId() == null ? Integer.MAX_VALUE : c.getId())) + .collect(Collectors.toList()); if (grade != null) { - classes = classes.stream().filter(c -> grade.equals(c.getGrade())).collect(Collectors.toList()); + classes = classes.stream().filter(c -> grade.equals(c.highGrade())).collect(Collectors.toList()); } // 班级 → 绑定的教室 id(绑定状态下拉过滤/行回显用) - List ids = classes.stream().map(SchoolClass::getId).collect(Collectors.toList()); + List ids = classes.stream().map(PlatformSchoolClass::getClassId).collect(Collectors.toList()); Map boundRooms = ids.isEmpty() ? Collections.emptyMap() : classroomRepository.findBySchIdAndClassIdIn(schId, ids).stream() - .collect(Collectors.toMap(Classroom::getClassId, Classroom::getId, (a, b) -> a)); + .collect(Collectors.toMap(com.yunhai.acaclassroom.entity.Classroom::getClassId, + com.yunhai.acaclassroom.entity.Classroom::getId, (a, b) -> a)); + // 学生人数来自学生树(树不可达时为空映射 → 前端隐藏该段) + Map stuCounts = bschClassClient.studentCounts(schId); return classes.stream().map(c -> { Map vo = new LinkedHashMap<>(); - vo.put("serID", c.getId()); - vo.put("className", c.getClassName()); - vo.put("grade", c.getGrade()); - vo.put("studentCount", c.getStudentCount()); - vo.put("headTeacher", c.getHeadTeacher()); - vo.put("classroomId", boundRooms.get(c.getId())); + vo.put("serID", c.getClassId()); + vo.put("className", c.displayName()); + vo.put("grade", c.highGrade()); + vo.put("studentCount", stuCounts.get(c.getClassId())); + vo.put("headTeacher", c.getFirstTeacherName()); + vo.put("classroomId", boundRooms.get(c.getClassId())); return vo; }).collect(Collectors.toList()); } - - @Override - @Transactional - public Integer add(String className, Integer grade, Integer studentCount, String headTeacher) { - Integer schId = UserContext.getSchId(); - if (schId == null) { - throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "未能获取当前学校信息,请重新登录"); - } - String name = className != null ? className.trim() : ""; - if (name.isEmpty() || name.length() > 50) { - throw new BizException(ErrorCode.PARAM_ERROR, "请输入 50 字以内的班级名称"); - } - if (grade == null || grade < SchoolClass.GRADE_1 || grade > SchoolClass.GRADE_3) { - throw new BizException(ErrorCode.PARAM_ERROR, "年级只支持 1=高一 2=高二 3=高三"); - } - if (classRepository.existsBySchIdAndClassName(schId, name)) { - throw new BizException(ErrorCode.PARAM_ERROR, "班级名称已存在"); - } - int count = studentCount == null ? 0 : studentCount; - if (count < 0 || count > 10000) { - throw new BizException(ErrorCode.PARAM_ERROR, "学生人数不正确"); - } - SchoolClass c = new SchoolClass(); - c.setClassName(name); - c.setGrade(grade); - c.setStudentCount(count); - c.setHeadTeacher(headTeacher != null && !headTeacher.trim().isEmpty() ? headTeacher.trim() : null); - c.setSchId(schId); - return classRepository.save(c).getId(); - } - - @Override - @Transactional - public void delete(Integer id) { - Integer schId = UserContext.getSchId(); - if (schId == null) { - throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "未能获取当前学校信息,请重新登录"); - } - SchoolClass c = classRepository.findById(id).orElse(null); - if (c == null || !schId.equals(c.getSchId())) { - throw new BizException(ErrorCode.PARAM_ERROR, "班级不存在或无权操作"); - } - if (classroomRepository.existsBySchIdAndClassId(schId, id)) { - throw new BizException(ErrorCode.PARAM_ERROR, "该班级已绑定教室,请先解绑再删除"); - } - classRepository.delete(c); - } } diff --git a/src/main/java/com/yunhai/acaclassroom/service/impl/ClassroomService.java b/src/main/java/com/yunhai/acaclassroom/service/impl/ClassroomService.java index ecf1619..b8d7033 100644 --- a/src/main/java/com/yunhai/acaclassroom/service/impl/ClassroomService.java +++ b/src/main/java/com/yunhai/acaclassroom/service/impl/ClassroomService.java @@ -6,6 +6,7 @@ import com.yunhai.acaclassroom.entity.ClassroomType; import com.yunhai.acaclassroom.model.ClassroomBatchAddRequest; import com.yunhai.acaclassroom.model.ClassroomBatchEditRequest; import com.yunhai.acaclassroom.model.ClassroomEditRequest; +import com.yunhai.acaclassroom.model.PlatformSchoolClass; import com.yunhai.acaclassroom.repository.ClassroomRepository; import com.yunhai.acaclassroom.repository.ClassroomTypeRepository; import com.yunhai.acaclassroom.repository.EquipmentRepository;import com.yunhai.acaclassroom.service.IClassroomService; @@ -49,7 +50,7 @@ public class ClassroomService implements IClassroomService { private com.yunhai.acaclassroom.repository.BuildingRepository buildingRepository; @Autowired - private com.yunhai.acaclassroom.repository.SchoolClassRepository classRepository; + private BschClassClient bschClassClient; @Autowired private BuildingService buildingService; @@ -93,10 +94,11 @@ public class ClassroomService implements IClassroomService { .collect(Collectors.toList()); } - // 班级映射:绑定状态过滤 / 年级 Tab 过滤 / 行内班级信息展示共用 - Map classMap = classRepository - .findBySchIdOrderByGradeAscIdAsc(schId).stream() - .collect(Collectors.toMap(com.yunhai.acaclassroom.entity.SchoolClass::getId, c -> c, (a, b) -> a)); + // 班级映射(平台班级,键=分组体系 classId):绑定状态过滤 / 年级 Tab 过滤 / 行内班级信息展示共用 + Map classMap = bschClassClient.classList(schId).stream() + .filter(c -> c.getClassId() != null) + .collect(Collectors.toMap(PlatformSchoolClass::getClassId, c -> c, (a, b) -> a)); + Map stuCounts = bschClassClient.studentCounts(schId); if ("bound".equals(bindStatus)) { rooms = rooms.stream().filter(r -> r.getClassId() != null).collect(Collectors.toList()); } else if ("unbound".equals(bindStatus)) { @@ -108,8 +110,8 @@ public class ClassroomService implements IClassroomService { if (r.getClassId() == null) { return true; } - com.yunhai.acaclassroom.entity.SchoolClass c = classMap.get(r.getClassId()); - return c != null && grade.equals(c.getGrade()); + PlatformSchoolClass c = classMap.get(r.getClassId()); + return c != null && grade.equals(c.highGrade()); }).collect(Collectors.toList()); } @@ -163,11 +165,11 @@ public class ClassroomService implements IClassroomService { Map vo = toVo(r, typeNames); vo.put("campusName", campusNames.getOrDefault(r.getCampusId(), "")); vo.put("buildingName", buildingNames.getOrDefault(r.getBuildingId(), "")); - com.yunhai.acaclassroom.entity.SchoolClass bound = r.getClassId() != null ? classMap.get(r.getClassId()) : null; + PlatformSchoolClass bound = r.getClassId() != null ? classMap.get(r.getClassId()) : null; vo.put("classId", r.getClassId()); - vo.put("className", bound != null ? bound.getClassName() : null); - vo.put("studentCount", bound != null ? bound.getStudentCount() : null); - vo.put("headTeacher", bound != null ? bound.getHeadTeacher() : null); + vo.put("className", bound != null ? bound.displayName() : null); + vo.put("studentCount", bound != null ? stuCounts.get(r.getClassId()) : null); + vo.put("headTeacher", bound != null ? bound.getFirstTeacherName() : null); Map eqQty = roomEquipmentQty.getOrDefault(r.getId(), Collections.emptyMap()); vo.put("equipmentIds", new ArrayList<>(eqQty.keySet())); vo.put("equipmentQtys", eqQty); @@ -473,8 +475,9 @@ public class ClassroomService implements IClassroomService { throw new BizException(ErrorCode.PARAM_ERROR, "教室ID与班级ID不得为空"); } Classroom r = loadOwned(serID); - com.yunhai.acaclassroom.entity.SchoolClass c = classRepository.findById(classId).orElse(null); - if (c == null || !r.getSchId().equals(c.getSchId())) { + // 班级以班级管理平台数据为准:须存在于本校班级列表(班级不在本应用落库) + PlatformSchoolClass c = bschClassClient.find(r.getSchId(), classId); + if (c == null) { throw new BizException(ErrorCode.PARAM_ERROR, "班级不存在或无权操作"); } // 一个班级同时只能绑一间教室 @@ -488,7 +491,7 @@ public class ClassroomService implements IClassroomService { classroomRepository.save(r); Map result = new LinkedHashMap<>(); result.put("classId", classId); - result.put("className", c.getClassName()); + result.put("className", c.displayName()); return result; } diff --git a/src/main/resources/sql/acaclassroom_migration_drop_capacity.sql b/src/main/resources/sql/acaclassroom_migration_drop_capacity.sql new file mode 100644 index 0000000..d9e23c8 --- /dev/null +++ b/src/main/resources/sql/acaclassroom_migration_drop_capacity.sql @@ -0,0 +1,6 @@ +-- 迁移:移除教室容纳人数(capacity)。 +-- 实体 Classroom 已删除该字段;JPA ddl-auto=update 只加列不删列,需手工执行本脚本。 +-- 本地:mysql -uroot -p123123 < acaclassroom_migration_drop_capacity.sql +USE aca_classroom; + +ALTER TABLE aca_classroom DROP COLUMN capacity;