班级绑定
build / build-push (push) Successful in 55s

This commit is contained in:
wanghu
2026-10-20 12:11:32 +08:00
parent 624805ef2c
commit 21d15bd79f
12 changed files with 532 additions and 192 deletions
+3
View File
@@ -47,6 +47,9 @@ yh:
# 受信应用(服务端验签用):appKey -> appSecret # 受信应用(服务端验签用):appKey -> appSecret
trusted-apps: trusted-apps:
100617: 2a3b4c5d6e7f8091a2b3c4d5e6f70819 100617: 2a3b4c5d6e7f8091a2b3c4d5e6f70819
open:
# 班级管理(bschclass)服务基址:绑定班级页班级数据源(/oapi/sch/getSchClassList 等)
bschclass: http://base.yunhaixingtong.com/userclass
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data # 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
FILE_PATH: /data/upload FILE_PATH: /data/upload
+43 -24
View File
@@ -172,31 +172,54 @@ ok(
JSON.stringify(myTreeRows[0]) JSON.stringify(myTreeRows[0])
); );
// 9.7 绑定班级:class 档案 → BindClass/UnbindClass/BatchUnbind → 年级 Tab 过滤 // 9.7 绑定班级:班级数据源=班级管理平台接口(bschclass,本应用不落库、无 Add/Delete)
const clsNameA = `冒烟高一班${stamp}`; // → BindClass/UnbindClass/BatchUnbind → 年级 Tab 过滤
const clsNameB = `冒烟高二班${stamp}`; // 前置:dev 身份 schId=3(云海中学)在平台须有高一/高二班级
const clsAddA = await api("POST", "/api/class/Add", { form: { className: clsNameA, grade: 1, studentCount: 45, headTeacher: "王老师" } }); const clsListAll = await api("GET", "/api/class/List");
ok("class Add(高一)", clsAddA.code === 0 && clsAddA.data > 0, clsAddA.msg); ok("class List code=0(平台班级)", clsListAll.code === 0 && Array.isArray(clsListAll.data), clsListAll.msg);
const clsAddB = await api("POST", "/api/class/Add", { form: { className: clsNameB, grade: 2, studentCount: 44, headTeacher: "陈老师" } }); ok(
ok("class Add(高二)", clsAddB.code === 0 && clsAddB.data > 0, clsAddB.msg); "class List 仅含高中学段(gradeId 10/11/12 之外的学段被过滤)",
ok("class Add 校内重名拒绝", (await api("POST", "/api/class/Add", { form: { className: clsNameA, grade: 1 } })).code !== 0); clsListAll.code === 0 && (clsListAll.data || []).every(c => [1, 2, 3].includes(c.grade)),
ok("class Add 非法年级拒绝", (await api("POST", "/api/class/Add", { form: { className: `冒烟班X${stamp}`, grade: 9 } })).code !== 0); 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 clsListG1 = await api("GET", "/api/class/List", { params: { grade: 1 } });
const clsA = clsListG1.data ? clsListG1.data.find(c => c.serID === clsAddA.data) : null; ok(
ok("class List grade=1 含新班级(未绑定 classroomId=null)", clsListG1.code === 0 && clsA && clsA.classroomId == null && clsA.headTeacher === "王老师", JSON.stringify(clsA)); "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 } }); 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 === clsNameA, bind.msg); 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: clsAddA.data } }); 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 班级已被其他教室占用拒绝", 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: 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 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; const boundRow = roomsG1.data ? roomsG1.data.list.find(r => r.serID === room1) : null;
ok( ok(
"List grade=1 含已绑教室且行含班级信息(名/人数/班主任)", "List grade=1 含已绑教室且行含班级信息(名/人数/班主任同平台源)",
roomsG1.code === 0 && roomsG1.data.total === 6 && boundRow && boundRow.classId === clsAddA.data && roomsG1.code === 0 && roomsG1.data.total === 6 && boundRow && boundRow.classId === clsA.serID &&
boundRow.className === clsNameA && boundRow.studentCount === 45 && boundRow.headTeacher === "王老师", boundRow.className === clsA.className && (boundRow.studentCount ?? null) === (clsA.studentCount ?? null) &&
boundRow.headTeacher === clsA.headTeacher,
JSON.stringify(boundRow) JSON.stringify(boundRow)
); );
const roomsG2 = await api("GET", "/api/classroom/List", { params: { buildingId, grade: 2, page: 1, count: 20 } }); 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 } }); 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("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("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); ok("UnbindClass 未绑定教室静默成功", (await api("POST", "/api/classroom/UnbindClass", { form: { serid: room1 } })).code === 0);
// 批量绑定两间 → 批量解绑 3 间(含 1 间未绑定 → skipped) // 批量绑定两间 → 批量解绑 3 间(含 1 间未绑定 → skipped);班级属平台数据,解绑即可、无需清理
await api("POST", "/api/classroom/BindClass", { json: { serID: room1, classId: clsAddA.data } }); 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: clsAddB.data } }); 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] }); const bUnbind = await api("POST", "/api/classroom/BatchUnbind", { json: [room1, rooms.data.list[1].serID, rooms.data.list[2].serID] });
ok( ok(
"BatchUnbind 3 间(updated=2, skipped=1)", "BatchUnbind 3 间(updated=2, skipped=1)",
@@ -223,8 +244,6 @@ ok(
JSON.stringify(bUnbind.data) JSON.stringify(bUnbind.data)
); );
ok("BatchUnbind 空集合拒绝", (await api("POST", "/api/classroom/BatchUnbind", { json: [] })).code !== 0); 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 模式):绑定班级在第二位 // 菜单兜底(仅本地免 SSO 模式):绑定班级在第二位
if (!COOKIE) { if (!COOKIE) {
@@ -52,8 +52,10 @@ public class ControllerUserContextAspect {
} else if (devContextEnabled) { } else if (devContextEnabled) {
info.userId = 99999; info.userId = 99999;
info.userName = "本地开发"; info.userName = "本地开发";
info.schId = 1; // schId=3(云海中学/李晶校):班级数据已切平台接口,dev 校须在平台有班级数据才能联调绑定班级页;
log.debug("[UserContext] 无 SSO 会话,已使用本地开发身份(schId=1)"); // userId 保持 99999(平台查无此用户 → GetUserMod 走本地兜底菜单,不回真实模块树)
info.schId = 3;
log.debug("[UserContext] 无 SSO 会话,已使用本地开发身份(schId=3)");
} }
info.ip = clientIp(); info.ip = clientIp();
UserContext.set(info); UserContext.set(info);
@@ -1,6 +1,5 @@
package com.yunhai.acaclassroom.controller.api; package com.yunhai.acaclassroom.controller.api;
import com.yunhai.acaclassroom.annotation.OperationLog;
import com.yunhai.acaclassroom.config.Config; import com.yunhai.acaclassroom.config.Config;
import com.yunhai.acaclassroom.service.IClassService; import com.yunhai.acaclassroom.service.IClassService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -10,7 +9,8 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 班级档案接口(class/,绑定班级页数据源)。 * 班级接口(class/,绑定班级页数据源)。
* 班级数据实时取自班级管理(bschclass)开放接口,本应用不落库、不提供增删。
*/ */
@RestController @RestController
@RequestMapping(value = Config.API_PATH + "/class/") @RequestMapping(value = Config.API_PATH + "/class/")
@@ -19,25 +19,9 @@ public class ClassController {
@Autowired @Autowired
private IClassService classService; private IClassService classService;
/** 班级列表(全校,可按年级过滤;行含 classroomId=绑定的教室 id,未绑定为 null)。 */ /** 班级列表(全校高中学段,可按年级过滤;行含 classroomId=绑定的教室 id,未绑定为 null)。 */
@GetMapping("List") @GetMapping("List")
public List<Map<String, Object>> list(@RequestParam(value = "grade", required = false) Integer grade) { public List<Map<String, Object>> list(@RequestParam(value = "grade", required = false) Integer grade) {
return classService.list(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);
}
} }
@@ -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;
}
@@ -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} 响应项。
*
* <p>id 体系:{@link #getId()} 是 sch_class 物理主键;{@link #getClassId()} 是分组体系
* classId(学生树 GetStuTree 的班级节点 id 与之同源),本应用的教室绑定关系
* {@code aca_classroom.class_id} 存它——与班级服务生态(学生树/任教)同一把钥匙。</p>
*
* <p>年级口径:平台 gradeId 为平台年级字典(10=高一 11=高二 12=高三;小学 1~6、
* 初中 7~9、其他学段另有取值),本应用只有高中三个年级 Tab,
* {@link #highGrade()} 把 10/11/12 映射为 1/2/3,其余返回 null(绑定页不展示)。</p>
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class PlatformSchoolClass {
/** 平台年级字典 → 本应用年级(1=高一 2=高二 3=高三)映射 */
private static final Map<Integer, Integer> HIGH_GRADE_MAP = createHighGradeMap();
private static Map<Integer, Integer> createHighGradeMap() {
Map<Integer, Integer> 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;
}
}
@@ -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<SchoolClass, Integer> {
List<SchoolClass> findBySchIdOrderByGradeAscIdAsc(Integer schId);
boolean existsBySchIdAndClassName(Integer schId, String className);
}
@@ -4,21 +4,18 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 班级档案服务(绑定班级页)。 * 班级服务(绑定班级页数据源)。
*
* <p>班级数据实时取自班级管理(bschclass)开放接口,不在本应用落库;
* 教室绑定关系存 {@code aca_classroom.class_id}(平台分组体系 classId)。</p>
*/ */
public interface IClassService { public interface IClassService {
/** /**
* 班级列表(全校,可按年级过滤)。 * 班级列表(全校高中学段,可按年级过滤)。
* *
* @param grade 1=高一 2=高二 3=高三(可空=全部) * @param grade 1=高一 2=高二 3=高三(可空=全部)
* @return 行含 serID/className/grade/studentCount/headTeacher/classroomId(未绑定为 null) * @return 行含 serID(=平台 classId)/className/grade/studentCount(可空)/headTeacher(可空)/classroomId(未绑定为 null)
*/ */
List<Map<String, Object>> list(Integer grade); List<Map<String, Object>> list(Integer grade);
/** 新增班级,返回 id。 */
Integer add(String className, Integer grade, Integer studentCount, String headTeacher);
/** 删除班级(已被教室绑定时拒绝)。 */
void delete(Integer id);
} }
@@ -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)开放接口客户端,绑定班级页班级数据的唯一来源。
*
* <p>接口口径与 AcaClassAssign 一致(配置 {@code yh.open.bschclass}):</p>
* <ul>
* <li>班级列表:GET {@code /oapi/sch/getSchClassList?schId=&includeGraduate=false}(不含毕业班);</li>
* <li>学生树:GET {@code /oapi/SchGroup/GetStuTree?schId=&toUser=true},
* 学校→学段→年级→班级→学生五层,班级节点 id 即班级列表的 classId,
* 其下 type=2 子节点为学生,用于派生各班学生人数。</li>
* </ul>
*
* <p>两个接口均做 5 分钟内存缓存(TTL,按 schId);拉取失败回退上一份缓存,
* 无缓存时降级为空(列表)/空映射(人数),不阻断页面——与同族应用降级口径一致。</p>
*/
@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<Integer, ClassListCacheEntry> classListCache = new ConcurrentHashMap<>();
/** 学生人数缓存:schId -> {time, classId -> 人数} */
private final ConcurrentHashMap<Integer, StuCountCacheEntry> stuCountCache = new ConcurrentHashMap<>();
private static class ClassListCacheEntry {
final long time;
final List<PlatformSchoolClass> list;
ClassListCacheEntry(long time, List<PlatformSchoolClass> list) {
this.time = time;
this.list = list;
}
}
private static class StuCountCacheEntry {
final long time;
final Map<Integer, Integer> counts;
StuCountCacheEntry(long time, Map<Integer, Integer> 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<PlatformSchoolClass> 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<PlatformSchoolClass> 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<Integer, Integer> 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<Integer, Integer> 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<PlatformSchoolClass> 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<List<PlatformSchoolClass>> resp = restTemplate.exchange(
url, HttpMethod.GET, null,
new ParameterizedTypeReference<List<PlatformSchoolClass>>() {
});
List<PlatformSchoolClass> 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<Integer, Integer> 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<List<Map<String, Object>>> resp = restTemplate.exchange(
url, HttpMethod.GET, null,
new ParameterizedTypeReference<List<Map<String, Object>>>() {
});
List<Map<String, Object>> body = resp.getBody();
Map<Integer, Integer> counts = new LinkedHashMap<>();
if (body != null) {
// 固定层级 学校→学段→年级→班级→学生(与 AcaClassAssign buildIndex 同一口径):
// 第 4 层班级节点 id 即班级列表 classId,其子节点 type=2 的为学生
for (Map<String, Object> school : orEmpty(body)) {
for (Map<String, Object> system : childrenOf(school)) {
for (Map<String, Object> grade : childrenOf(system)) {
for (Map<String, Object> 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<Map<String, Object>> childrenOf(Map<String, Object> node) {
if (node == null) {
return Collections.emptyList();
}
Object children = node.get("children");
return children instanceof List ? (List<Map<String, Object>>) children : Collections.emptyList();
}
private List<Map<String, Object>> orEmpty(List<Map<String, Object>> 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;
}
}
}
@@ -1,28 +1,28 @@
package com.yunhai.acaclassroom.service.impl; package com.yunhai.acaclassroom.service.impl;
import com.yunhai.acaclassroom.entity.Classroom; import com.yunhai.acaclassroom.model.PlatformSchoolClass;
import com.yunhai.acaclassroom.entity.SchoolClass;
import com.yunhai.acaclassroom.repository.ClassroomRepository; import com.yunhai.acaclassroom.repository.ClassroomRepository;
import com.yunhai.acaclassroom.repository.SchoolClassRepository;
import com.yunhai.acaclassroom.service.IClassService; import com.yunhai.acaclassroom.service.IClassService;
import com.yunhai.acaclassroom.utils.UserContext; 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.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* 班级档案服务实现。班级数据量小(单校十~百级),整取后内存过滤。 * 班级服务实现(绑定班级页数据源)。
*
* <p>班级档案不在本应用落库:班级数据实时取自班级管理(bschclass)开放接口
* {@link BschClassClient}(5 分钟缓存、失败降级),绑定关系仍存本应用
* {@code aca_classroom.class_id}(存平台分组体系 classId)。此前本地档案表
* {@code aca_class} 及 class/Add·Delete 接口已随数据源切换移除。</p>
*/ */
@Service @Service
public class ClassService implements IClassService { public class ClassService implements IClassService {
@Autowired @Autowired
private SchoolClassRepository classRepository; private BschClassClient bschClassClient;
@Autowired @Autowired
private ClassroomRepository classroomRepository; private ClassroomRepository classroomRepository;
@@ -33,72 +33,35 @@ public class ClassService implements IClassService {
if (schId == null) { if (schId == null) {
return Collections.emptyList(); return Collections.emptyList();
} }
List<SchoolClass> classes = classRepository.findBySchIdOrderByGradeAscIdAsc(schId); // 仅保留高中学段(gradeId 10/11/12 → 1/2/3),其余学段不进绑定页
List<PlatformSchoolClass> 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) { 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(绑定状态下拉过滤/行回显用) // 班级 → 绑定的教室 id(绑定状态下拉过滤/行回显用)
List<Integer> ids = classes.stream().map(SchoolClass::getId).collect(Collectors.toList()); List<Integer> ids = classes.stream().map(PlatformSchoolClass::getClassId).collect(Collectors.toList());
Map<Integer, Integer> boundRooms = ids.isEmpty() Map<Integer, Integer> boundRooms = ids.isEmpty()
? Collections.emptyMap() ? Collections.emptyMap()
: classroomRepository.findBySchIdAndClassIdIn(schId, ids).stream() : 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<Integer, Integer> stuCounts = bschClassClient.studentCounts(schId);
return classes.stream().map(c -> { return classes.stream().map(c -> {
Map<String, Object> vo = new LinkedHashMap<>(); Map<String, Object> vo = new LinkedHashMap<>();
vo.put("serID", c.getId()); vo.put("serID", c.getClassId());
vo.put("className", c.getClassName()); vo.put("className", c.displayName());
vo.put("grade", c.getGrade()); vo.put("grade", c.highGrade());
vo.put("studentCount", c.getStudentCount()); vo.put("studentCount", stuCounts.get(c.getClassId()));
vo.put("headTeacher", c.getHeadTeacher()); vo.put("headTeacher", c.getFirstTeacherName());
vo.put("classroomId", boundRooms.get(c.getId())); vo.put("classroomId", boundRooms.get(c.getClassId()));
return vo; return vo;
}).collect(Collectors.toList()); }).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);
}
} }
@@ -6,6 +6,7 @@ import com.yunhai.acaclassroom.entity.ClassroomType;
import com.yunhai.acaclassroom.model.ClassroomBatchAddRequest; import com.yunhai.acaclassroom.model.ClassroomBatchAddRequest;
import com.yunhai.acaclassroom.model.ClassroomBatchEditRequest; import com.yunhai.acaclassroom.model.ClassroomBatchEditRequest;
import com.yunhai.acaclassroom.model.ClassroomEditRequest; import com.yunhai.acaclassroom.model.ClassroomEditRequest;
import com.yunhai.acaclassroom.model.PlatformSchoolClass;
import com.yunhai.acaclassroom.repository.ClassroomRepository; import com.yunhai.acaclassroom.repository.ClassroomRepository;
import com.yunhai.acaclassroom.repository.ClassroomTypeRepository; import com.yunhai.acaclassroom.repository.ClassroomTypeRepository;
import com.yunhai.acaclassroom.repository.EquipmentRepository;import com.yunhai.acaclassroom.service.IClassroomService; 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; private com.yunhai.acaclassroom.repository.BuildingRepository buildingRepository;
@Autowired @Autowired
private com.yunhai.acaclassroom.repository.SchoolClassRepository classRepository; private BschClassClient bschClassClient;
@Autowired @Autowired
private BuildingService buildingService; private BuildingService buildingService;
@@ -93,10 +94,11 @@ public class ClassroomService implements IClassroomService {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
// 班级映射:绑定状态过滤 / 年级 Tab 过滤 / 行内班级信息展示共用 // 班级映射(平台班级,键=分组体系 classId):绑定状态过滤 / 年级 Tab 过滤 / 行内班级信息展示共用
Map<Integer, com.yunhai.acaclassroom.entity.SchoolClass> classMap = classRepository Map<Integer, PlatformSchoolClass> classMap = bschClassClient.classList(schId).stream()
.findBySchIdOrderByGradeAscIdAsc(schId).stream() .filter(c -> c.getClassId() != null)
.collect(Collectors.toMap(com.yunhai.acaclassroom.entity.SchoolClass::getId, c -> c, (a, b) -> a)); .collect(Collectors.toMap(PlatformSchoolClass::getClassId, c -> c, (a, b) -> a));
Map<Integer, Integer> stuCounts = bschClassClient.studentCounts(schId);
if ("bound".equals(bindStatus)) { if ("bound".equals(bindStatus)) {
rooms = rooms.stream().filter(r -> r.getClassId() != null).collect(Collectors.toList()); rooms = rooms.stream().filter(r -> r.getClassId() != null).collect(Collectors.toList());
} else if ("unbound".equals(bindStatus)) { } else if ("unbound".equals(bindStatus)) {
@@ -108,8 +110,8 @@ public class ClassroomService implements IClassroomService {
if (r.getClassId() == null) { if (r.getClassId() == null) {
return true; return true;
} }
com.yunhai.acaclassroom.entity.SchoolClass c = classMap.get(r.getClassId()); PlatformSchoolClass c = classMap.get(r.getClassId());
return c != null && grade.equals(c.getGrade()); return c != null && grade.equals(c.highGrade());
}).collect(Collectors.toList()); }).collect(Collectors.toList());
} }
@@ -163,11 +165,11 @@ public class ClassroomService implements IClassroomService {
Map<String, Object> vo = toVo(r, typeNames); Map<String, Object> vo = toVo(r, typeNames);
vo.put("campusName", campusNames.getOrDefault(r.getCampusId(), "")); vo.put("campusName", campusNames.getOrDefault(r.getCampusId(), ""));
vo.put("buildingName", buildingNames.getOrDefault(r.getBuildingId(), "")); 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("classId", r.getClassId());
vo.put("className", bound != null ? bound.getClassName() : null); vo.put("className", bound != null ? bound.displayName() : null);
vo.put("studentCount", bound != null ? bound.getStudentCount() : null); vo.put("studentCount", bound != null ? stuCounts.get(r.getClassId()) : null);
vo.put("headTeacher", bound != null ? bound.getHeadTeacher() : null); vo.put("headTeacher", bound != null ? bound.getFirstTeacherName() : null);
Map<Integer, Integer> eqQty = roomEquipmentQty.getOrDefault(r.getId(), Collections.emptyMap()); Map<Integer, Integer> eqQty = roomEquipmentQty.getOrDefault(r.getId(), Collections.emptyMap());
vo.put("equipmentIds", new ArrayList<>(eqQty.keySet())); vo.put("equipmentIds", new ArrayList<>(eqQty.keySet()));
vo.put("equipmentQtys", eqQty); vo.put("equipmentQtys", eqQty);
@@ -473,8 +475,9 @@ public class ClassroomService implements IClassroomService {
throw new BizException(ErrorCode.PARAM_ERROR, "教室ID与班级ID不得为空"); throw new BizException(ErrorCode.PARAM_ERROR, "教室ID与班级ID不得为空");
} }
Classroom r = loadOwned(serID); 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, "班级不存在或无权操作"); throw new BizException(ErrorCode.PARAM_ERROR, "班级不存在或无权操作");
} }
// 一个班级同时只能绑一间教室 // 一个班级同时只能绑一间教室
@@ -488,7 +491,7 @@ public class ClassroomService implements IClassroomService {
classroomRepository.save(r); classroomRepository.save(r);
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("classId", classId); result.put("classId", classId);
result.put("className", c.getClassName()); result.put("className", c.displayName());
return result; return result;
} }
@@ -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;