commit
build / build-push (push) Failing after 2m7s

This commit is contained in:
wanghu
2026-10-20 09:13:14 +08:00
parent e3c0226e47
commit 0e75efa023
66 changed files with 4556 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
target/
.idea/
*.iml
*.log
.DS_Store
+52
View File
@@ -0,0 +1,52 @@
# Notice 生产配置(线上以 nacos 的 notice.yaml 为准,本文件是 nacos 不可达时的兜底)
# 真正上线请在 Nacos 新建 dataId=notice.yaml、group=NOTICE 的配置覆盖以下项
# (尤其 datasource 的库名/账号、yh.sso.client-host-url)。
server:
port: 8070
servlet:
# 对外经 caddy 暴露为 http://base.yunhaixingtong.com/acaclassroom
context-path: /acaclassroom
spring:
mvc:
date-format: yyyy-MM-dd HH:mm:ss
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss
datasource:
# 与 UserBase 共用同一台 mysql8;库名为 aca_classroom(需先在 mysql8 上建库)
url: jdbc:mysql://mysql8:3306/aca_classroom?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 'CVf^x$sW*b6CGHxh'
driver-class-name: com.mysql.cj.jdbc.Driver
min-idle: 10
max-active: 30
jpa:
database: MySQL
show-sql: false
hibernate:
ddl-auto: update
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
properties:
hibernate.order_by.default_null_ordering: last
open-in-view: true
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh:
sso:
server-url-prefix: http://sso.yunhaixingtong.com
client-host-url: http://base.yunhaixingtong.com/acaclassroom
allow-headers: all
allow-origins: all
url-pattern: /api/*
app-auth:
enabled: true
# 本应用自身身份(客户端签名用)
app-key: 100617
app-secret: 2a3b4c5d6e7f8091a2b3c4d5e6f70819
# 受信应用(服务端验签用):appKey -> appSecret
trusted-apps:
100617: 2a3b4c5d6e7f8091a2b3c4d5e6f70819
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
FILE_PATH: /data/upload
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Notice 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml)
# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到
# 镜像内烤好的 application-prod.yml 兜底默认。
set -e
echo "[run-app] starting Notice (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..."
exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/acaclassroom.jar \
--spring.profiles.active=prod
+208
View File
@@ -0,0 +1,208 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yunhai</groupId>
<artifactId>acaclassroom</artifactId>
<version>0.0.1</version>
<name>AcaClassroom</name>
<description>教室管理子应用</description>
<properties>
<java.version>1.8</java.version>
<!-- 源码统一 UTF-8:命令行 mvn 编译在中文 Windows 上缺省走 GBK,中文/特殊字符字面量会乱码 -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties>
<dependencyManagement>
<!-- 基础平台版本管理(含 yh-* 架包与 spring-boot-dependencies 对齐) -->
<dependencies>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-platform-dependencies</artifactId>
<version>1.0.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring Cloud Alibaba(Nacos 配置中心,对齐 Boot 2.7) -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2021.0.5.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- 仅引入 Spring Security 的加密模块(BCryptPasswordEncoder),不引入整套安全框架 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- Boot 2.4+ 默认不再处理 bootstrap.yml,需显式引入以继续从 Nacos 拉取配置 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
<version>3.1.8</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JSON 序列化(fastjson,主类开启安全模式) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.80</version>
</dependency>
<!-- Swagger / OpenAPI 文档(springdoc,兼容 Boot 2.7) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.7.0</version>
</dependency>
<!-- 基础平台全家桶(聚合 starter):认证(CAS/会话/SSO)+ 应用间签名鉴权 + 用户中心静态门面
+ 统一响应 + actuator 健康探活,一行带齐(模块构成见 BasePlatform/后端项目知识库.md)。
约定:标准业务服务一律引聚合 starter;平台能力提供方(如 UserBase)按需单引。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-spring-boot-starter</artifactId>
</dependency>
<!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。
版本 3.5.1 已在本地 .m2 仓库。 -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.5.1</version>
</dependency>
<!-- Excel 导入导出(EasyExcel 3.x,注解驱动、流式写入内存友好) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.2</version>
</dependency>
<!-- 不引 spring-boot-starter-data-redis / cache:代码零使用,data-redis 会自动装配
RedisHealthIndicator,连不上 redis 会把 /actuator/health 整体拉成 DOWN(503)。 -->
</dependencies>
<build>
<finalName>acaclassroom</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- 关闭增量编译,避免 Windows 下 maven-status 状态文件被锁导致
"Error while storing the mojo status / 拒绝访问" -->
<useIncrementalCompilation>false</useIncrementalCompilation>
</configuration>
</plugin>
<!-- 打可执行 fat jar(Docker 镜像直接 java -jar 启动) -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.yunhai.acaclassroom.AcaClassroomApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>nexus-public</id>
<name>bsch/nexus public</name>
<url>http://nexus.yunhaixingtong.com/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<!-- 快照 updatePolicy=never:平台 yh-* 是 SNAPSHOT,其 YschBaseOpenApi 曾在
openapi.client↔openapi.base 间漂移;钉 never 让本地沿用已缓存构建,避免无 -U 时被静默刷新而断链。
需要跟随平台更新时,手动 `mvn -U`(会覆盖本策略)或清 ~/.m2 后重建。 -->
<snapshots><enabled>true</enabled><updatePolicy>never</updatePolicy></snapshots>
</repository>
</repositories>
<!--
ci 构建档案:将构建产物重定向到 IDE(IntelliJ/Eclipse)索引目录之外,
避免 IDE 后台进程持续锁定 target/maven-status/.../createdFiles.lst 导致
"Error while storing the mojo status / 拒绝访问"。
输出目录需从未被 IDE 打开过。常规本地构建无需激活此 profile。
-->
<profiles>
<profile>
<id>ci</id>
<build>
<directory>C:/aca-classroom-build/acaclassroom</directory>
</build>
</profile>
</profiles>
</project>
+280
View File
@@ -0,0 +1,280 @@
/**
* 教室管理后端冒烟脚本(Node 18+,无第三方依赖)。
*
* 用法:
* node smoke/smoke.mjs # 无认证模式(本地 YH_SSO_URLPATTERN=/none/* + ACACLASSROOM_DEVCONTEXT_ENABLED=true)
* TEACHER_COOKIE="CASTGC=..." node smoke/smoke.mjs # 真实 SSO 会话(浏览器复制 Cookie)
*
* 覆盖:健康检查 → 基础/校区/类型/楼栋/教室 全链路(自建数据 → 断言 → 逆序清理)。
*/
const BASE = process.env.SMOKE_BASE || "http://localhost:8106";
const COOKIE = process.env.TEACHER_COOKIE || "";
const headers = { ...(COOKIE ? { Cookie: COOKIE } : {}) };
let passed = 0, failed = 0;
const ok = (name, cond, detail = "") => {
if (cond) { passed++; console.log(` ✓ ${name}`); }
else { failed++; console.error(` ✗ ${name} ${detail}`); }
};
async function api(method, path, { form, json, params } = {}) {
const url = new URL(BASE + path);
if (params) Object.entries(params).forEach(([k, v]) => v != null && url.searchParams.set(k, v));
const opt = { method, headers: { ...headers } };
if (json != null) {
opt.headers["Content-Type"] = "application/json;charset=UTF-8";
opt.body = JSON.stringify(json);
} else if (form != null) {
opt.headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8";
opt.body = new URLSearchParams(form).toString();
}
const res = await fetch(url, opt);
const text = await res.text();
try { return JSON.parse(text); } catch { return { code: -1, msg: text.substring(0, 120) }; }
}
console.log(`[smoke] target=${BASE} auth=${COOKIE ? "cookie" : "none(dev-context)"}`);
// 1. 健康
const health = await fetch(`${BASE}/actuator/health`).then(r => r.json()).catch(() => null);
ok("actuator/health UP", health && health.status === "UP");
// 2. 基础
const info = await api("GET", "/api/base/GetUserInfo");
ok("GetUserInfo code=0", info.code === 0, JSON.stringify(info).substring(0, 80));
const mods = await api("GET", "/api/base/GetUserMod");
ok("GetUserMod code=0", mods.code === 0);
// 3. 校区
const stamp = Date.now().toString(36);
const campusName = `冒烟校区${stamp}`;
const cAdd = await api("POST", "/api/campus/Add", { form: { campusName, sortOrder: 99 } });
ok("campus Add", cAdd.code === 0 && cAdd.data > 0, cAdd.msg);
const campusId = cAdd.data;
const cDup = await api("POST", "/api/campus/Add", { form: { campusName } });
ok("campus Add 重名拒绝", cDup.code !== 0);
const cList = await api("GET", "/api/campus/List");
ok("campus List 含新校区", cList.code === 0 && cList.data.some(c => c.serID === campusId));
// 4. 教室类型
const typeName = `冒烟类型${stamp}`;
const tAdd = await api("POST", "/api/classtype/Add", { form: { typeName } });
ok("classtype Add", tAdd.code === 0, tAdd.msg);
const typeId = tAdd.data;
// 5. 楼栋
const bAdd = await api("POST", "/api/building/Add", { form: { campusId, buildingName: `冒烟楼栋${stamp}`, buildingCode: `S${stamp.slice(-3)}`, floorCount: 2 } });
ok("building Add", bAdd.code === 0, bAdd.msg);
const buildingId = bAdd.data;
// 6. 教室批量添加(带设备)+ 列表
const eqSeed = await api("POST", "/api/equipment/Add", { form: { equipmentName: `冒烟设备${stamp}` } });
ok("equipment Add(独立台账)", eqSeed.code === 0, eqSeed.msg);
const eqSeedId = eqSeed.data;
const eqSeedDup = await api("POST", "/api/equipment/Add", { form: { equipmentName: `冒烟设备${stamp}` } });
ok("equipment Add 校内重名拒绝", eqSeedDup.code !== 0);
const batch = await api("POST", "/api/classroom/BatchAdd", { json: { buildingId, floorFrom: 1, floorTo: 2, countPerFloor: 3, typeId, equipments: [{ equipmentId: eqSeedId, quantity: 2 }] } });
ok("classroom BatchAdd 6 间(带设备,每间数量 2)", batch.code === 0 && batch.data.created === 6, batch.msg);
const batchBad = await api("POST", "/api/classroom/BatchAdd", { json: { buildingId, floorFrom: 1, floorTo: 9, countPerFloor: 1, typeId } });
ok("BatchAdd 楼层超限拒绝", batchBad.code !== 0);
const rooms = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20 } });
ok("classroom List 6 间", rooms.code === 0 && rooms.data.total === 6, `total=${rooms.data && rooms.data.total}`);
const room1 = rooms.data.list[0].serID;
ok("教室行含设备关联(数量 2,名称×2)", rooms.data.list.every(r => Array.isArray(r.equipmentIds) && r.equipmentIds.includes(eqSeedId) && r.equipmentQtys && r.equipmentQtys[eqSeedId] === 2 && /冒烟设备.*×2$/.test(r.equipmentNames)), JSON.stringify(rooms.data.list[0] && rooms.data.list[0].equipmentNames));
const floors = await api("GET", "/api/classroom/Floors", { params: { buildingId } });
ok("classroom Floors [1,2]", floors.code === 0 && JSON.stringify(floors.data) === "[1,2]");
const bStats = await api("GET", "/api/building/Stats", { params: { campusId } });
ok("building Stats total=6", bStats.code === 0 && bStats.data.some(b => b.serID === buildingId && b.total === 6));
// 7. 教室编辑(含设备全量替换)+ 编号守卫
const edit = await api("POST", "/api/classroom/Edit", { json: { serID: room1, roomName: `冒烟教室${stamp}`, status: "closed", capacity: 50, seatCount: 45, equipmentIds: [] } });
ok("classroom Edit(清空设备)", edit.code === 0, edit.msg);
const roomsAfterEdit = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20 } });
const room1After = roomsAfterEdit.data.list.find(r => r.serID === room1);
ok("Edit 后教室设备已清空", room1After && room1After.equipmentIds.length === 0);
const editQty = await api("POST", "/api/classroom/Edit", { json: { serID: room1, equipments: [{ equipmentId: eqSeedId, quantity: 3 }] } });
ok("classroom Edit equipments 按数量替换", editQty.code === 0, editQty.msg);
const roomsAfterQty = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20 } });
const room1Qty = roomsAfterQty.data.list.find(r => r.serID === room1);
ok("Edit 后设备数量为 3(名称×3)", room1Qty && room1Qty.equipmentQtys && room1Qty.equipmentQtys[eqSeedId] === 3 && /冒烟设备.*×3$/.test(room1Qty.equipmentNames), JSON.stringify(room1Qty && room1Qty.equipmentQtys));
const editClear = await api("POST", "/api/classroom/Edit", { json: { serID: room1, equipmentIds: [] } });
ok("classroom Edit 旧字段 equipmentIds 清空设备(兼容)", editClear.code === 0, editClear.msg);
const room2code = roomsAfterEdit.data.list[1].roomCode;
const editDup = await api("POST", "/api/classroom/Edit", { json: { serID: roomsAfterEdit.data.list[1].serID, roomCode: roomsAfterEdit.data.list[2].roomCode } });
ok("Edit 楼栋内编号重复拒绝", editDup.code !== 0);
// 8. 设备编辑 + 引用删除守卫(设备被教室引用时不可删)
const eqList = await api("GET", "/api/equipment/List");
ok("equipment List 含引用数与统计数量(5 间 × 每间 2 = 10)", eqList.code === 0 && eqList.data.list.some(e => e.serID === eqSeedId && e.classroomCount === 5 && e.quantity === 10), JSON.stringify(eqList.data && eqList.data.list.find(e => e.serID === eqSeedId)));
const eqEdit = await api("POST", "/api/equipment/Edit", { form: { serID: eqSeedId, equipmentName: `冒烟设备${stamp}` } });
ok("equipment Edit", eqEdit.code === 0, eqEdit.msg);
const eqDelGuarded = await api("POST", "/api/equipment/Delete", { form: { serid: eqSeedId } });
ok("equipment Delete 被引用守卫拒绝", eqDelGuarded.code !== 0);
// 9. 新增接口:列表排序 / 楼栋楼层分布 / 教室批量编辑
const sortedDesc = await api("GET", "/api/classroom/List", {
params: { buildingId, page: 1, count: 20, sortField: "capacity", order: "desc" },
});
ok(
"List 排序 capacity desc",
sortedDesc.code === 0 &&
sortedDesc.data.list[0].capacity >= sortedDesc.data.list[sortedDesc.data.list.length - 1].capacity,
JSON.stringify(sortedDesc.data && sortedDesc.data.list.map(r => r.capacity))
);
const sortedFloorDesc = await api("GET", "/api/classroom/List", {
params: { buildingId, page: 1, count: 20, sortField: "floor", order: "desc" },
});
ok("List 排序 floor desc 首行为 2 层", sortedFloorDesc.code === 0 && sortedFloorDesc.data.list[0].floor === 2);
const defaultOrder = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20 } });
ok(
"List 不传排序参数保持默认序(楼层升序 + 编号升序)",
defaultOrder.code === 0 &&
defaultOrder.data.list[0].floor === 1 &&
defaultOrder.data.list[0].roomCode <= defaultOrder.data.list[1].roomCode
);
const bStatsFloors = await api("GET", "/api/building/Stats", { params: { campusId } });
const mineBuilding = bStatsFloors.data && bStatsFloors.data.find(b => b.serID === buildingId);
ok(
"building Stats 新增 floors 楼层分布(2 层,每层 3 间)",
mineBuilding &&
Array.isArray(mineBuilding.floors) &&
mineBuilding.floors.length === 2 &&
mineBuilding.floors[0].floor === 1 &&
mineBuilding.floors[0].total === 3 &&
mineBuilding.floors[1].floor === 2,
JSON.stringify(mineBuilding && mineBuilding.floors)
);
// 9.5 空间作用域查询(教室管理三栏布局):按校区 List + Tree 扁平数据
const byCampus = await api("GET", "/api/classroom/List", { params: { campusId, page: 1, count: 20 } });
ok(
"List 按校区作用域命中 6 间",
byCampus.code === 0 && byCampus.data.total === 6,
`total=${byCampus.data && byCampus.data.total}`
);
ok(
"List 行含校区/楼栋名称",
byCampus.data.list.every(r => r.campusName === campusName && r.buildingName === `冒烟楼栋${stamp}`),
JSON.stringify(byCampus.data.list[0])
);
const byCampusFloor = await api("GET", "/api/classroom/List", { params: { campusId, floor: 1, page: 1, count: 20 } });
ok("List 校区+楼层作用域命中 3 间", byCampusFloor.code === 0 && byCampusFloor.data.total === 3);
ok("List 校区/楼栋均缺拒绝", (await api("GET", "/api/classroom/List", { params: { page: 1, count: 20 } })).code !== 0);
const treeRows = await api("GET", "/api/classroom/Tree");
const myTreeRows = treeRows.data ? treeRows.data.filter(r => r.buildingId === buildingId) : [];
ok(
"Tree 扁平数据含本楼栋 6 间(轻量字段)",
treeRows.code === 0 && myTreeRows.length === 6 && myTreeRows.every(r => r.campusId === campusId && r.roomCode && r.status),
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);
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));
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 } });
ok("BindClass 班级已被其他教室占用拒绝", bindDup.code !== 0);
ok("BindClass 缺参拒绝", (await api("POST", "/api/classroom/BindClass", { json: { serID: room1 } })).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 === "王老师",
JSON.stringify(boundRow)
);
const roomsG2 = await api("GET", "/api/classroom/List", { params: { buildingId, grade: 2, page: 1, count: 20 } });
ok(
"List grade=2 排除高一已绑定教室(6-1=5,其余未绑定)",
roomsG2.code === 0 && roomsG2.data.total === 5 && roomsG2.data.list.every(r => r.serID !== room1 && r.classId == null),
`total=${roomsG2.data && roomsG2.data.total}`
);
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 } });
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)",
bUnbind.code === 0 && bUnbind.data.updated === 2 && bUnbind.data.skipped === 1,
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) {
const modsTree = await api("GET", "/api/base/GetUserMod");
const names = (modsTree.data || []).map(m => m.name);
ok(
"GetUserMod 菜单序 教室管理→绑定班级→基础设置",
modsTree.code === 0 && JSON.stringify(names) === JSON.stringify(["教室管理", "绑定班级", "基础设置"]),
JSON.stringify(names)
);
}
const allIds = roomsAfterEdit.data.list.map(r => r.serID);
const bEditStatus = await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: allIds, status: "closed" } });
ok("classroom BatchEdit 批量关闭 6 间", bEditStatus.code === 0 && bEditStatus.data.updated === 6, bEditStatus.msg);
const afterBatch = await api("GET", "/api/classroom/List", { params: { buildingId, page: 1, count: 20, status: "closed" } });
ok("BatchEdit 后按 closed 过滤命中 6 间", afterBatch.code === 0 && afterBatch.data.total === 6);
const bEditType = await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: allIds.slice(0, 2), typeId } });
ok("classroom BatchEdit 批量改类型 2 间", bEditType.code === 0 && bEditType.data.updated === 2, bEditType.msg);
ok("BatchEdit 空集合拒绝", (await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: [] } })).code !== 0);
ok("BatchEdit 未指定修改项拒绝", (await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: [room1] } })).code !== 0);
ok(
"BatchEdit 非法状态拒绝",
(await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: [room1], status: "xxx" } })).code !== 0
);
const bEditSkip = await api("POST", "/api/classroom/BatchEdit", { json: { serIDs: [room1, 99999999], status: "open" } });
ok(
"BatchEdit 不存在 id 计入 skipped(不整批失败)",
bEditSkip.code === 0 && bEditSkip.data.updated === 1 && bEditSkip.data.skipped === 1,
JSON.stringify(bEditSkip.data)
);
// 10. 其余删除守卫(楼栋有教室 → 类型被引用 → 校区有楼栋)
const delBuilding = await api("POST", "/api/building/Delete", { form: { serid: buildingId } });
ok("building Delete 守卫拒绝", delBuilding.code !== 0);
const delType = await api("POST", "/api/classtype/Delete", { form: { serid: typeId } });
ok("classtype Delete 守卫拒绝", delType.code !== 0);
const delCampus = await api("POST", "/api/campus/Delete", { form: { serid: campusId } });
ok("campus Delete 守卫拒绝", delCampus.code !== 0);
// 11. 逆序清理:教室(关联随删)→ 楼栋 → 类型 → 设备 → 校区
const delRooms = await api("POST", "/api/classroom/Delete", { json: allIds });
ok("classroom Delete 全部(关联自动清理)", delRooms.code === 0, delRooms.msg);
ok("equipment Delete(解除引用后可删)", (await api("POST", "/api/equipment/Delete", { form: { serid: eqSeedId } })).code === 0);
ok("building Delete", (await api("POST", "/api/building/Delete", { form: { serid: buildingId } })).code === 0);
ok("classtype Delete", (await api("POST", "/api/classtype/Delete", { form: { serid: typeId } })).code === 0);
ok("campus Delete", (await api("POST", "/api/campus/Delete", { form: { serid: campusId } })).code === 0);
console.log(`\n[smoke] 通过 ${passed} / ${passed + failed}`);
process.exit(failed ? 1 : 0);
@@ -0,0 +1,18 @@
package com.yunhai.acaclassroom;
import com.alibaba.fastjson.parser.ParserConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication(scanBasePackages = "com.yunhai.acaclassroom")
@EnableTransactionManagement
public class AcaClassroomApplication {
public static void main(String[] args) {
// 开启 fastjson 安全模式
ParserConfig.getGlobalInstance().setSafeMode(true);
SpringApplication.run(AcaClassroomApplication.class, args);
}
}
@@ -0,0 +1,22 @@
package com.yunhai.acaclassroom.annotation;
import java.lang.annotation.*;
/**
* 标注在控制器方法上,声明一次需要落审计日志的操作(决策 D2「保留日志表」→ 统一操作日志)。
* <p>由 {@link com.yunhai.acaclassroom.aspect.SysOperationLogAspect} 环绕拦截,写入 {@code sys_operation_log}。</p>
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OperationLog {
/** 业务模块,如 "教室管理-校区" */
String module() default "";
/** 操作类型:CREATE / UPDATE / DELETE / QUERY(缺省按方法名启发式推断) */
String type() default "";
/** 日志级别:1 重要(删除) / 2 次要(新增/编辑) / 3 一般(查询)。缺省按 type 推断 */
int level() default 0;
}
@@ -0,0 +1,104 @@
package com.yunhai.acaclassroom.aspect;
import com.yunhai.acaclassroom.utils.UserContext;
import com.yunhai.platform.auth.BoaUserContext;
import com.yunhai.platform.utils.model.BoaUser;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
/**
* 把基础平台的当前登录用户({@link BoaUser})复制到自有 {@link UserContext}(ThreadLocal),请求结束清理。
* <p>参照 UserclassService 的 ControllerUserContextAspect:优先从控制器入参里的 {@code BoaUser} 取,
* 否则回退到框架 {@code BoaUserContext#get()}。本模块为纯教师端,无视角切换。</p>
*/
@Aspect
@Component
@Order(1)
public class ControllerUserContextAspect {
private static final Logger log = LoggerFactory.getLogger(ControllerUserContextAspect.class);
/** 本地联调开关(默认关闭):无 SSO 会话时用固定开发身份填充 UserContext,便于无登录端到端联调。生产严禁开启。 */
@Value("${acaclassroom.dev-context.enabled:false}")
private boolean devContextEnabled;
@Around("execution(* com.yunhai.acaclassroom.controller..*.*(..))")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
try {
BoaUser boaUser = resolveBoaUser(pjp.getArgs());
UserContext.UserInfo info = new UserContext.UserInfo();
if (boaUser != null) {
info.userId = boaUser.getId();
info.userName = boaUser.getName();
info.type = boaUser.getType();
if (boaUser.getCurrentRole() != null) {
info.schId = boaUser.getCurrentRole().getSchId();
} else if (boaUser.getSchool() != null) {
info.schId = boaUser.getSchool().getId();
}
if (boaUser.getRoleIds() != null && !boaUser.getRoleIds().isEmpty()) {
info.roleId = boaUser.getRoleIds().get(0);
}
} else if (devContextEnabled) {
info.userId = 99999;
info.userName = "本地开发";
info.schId = 1;
log.debug("[UserContext] 无 SSO 会话,已使用本地开发身份(schId=1)");
}
info.ip = clientIp();
UserContext.set(info);
return pjp.proceed();
} finally {
UserContext.clear();
}
}
/** 控制器入参中若声明了 BoaUser(框架有参数解析器注入),优先用它;否则取 ThreadLocal 门面。 */
private BoaUser resolveBoaUser(Object[] args) {
if (args != null) {
for (Object a : args) {
if (a instanceof BoaUser) {
return (BoaUser) a;
}
}
}
try {
return BoaUserContext.get();
} catch (Exception e) {
log.debug("[UserContext] 读取 BoaUserContext 失败: {}", e.getMessage());
return null;
}
}
private HttpServletRequest request() {
Object attrs = RequestContextHolder.getRequestAttributes();
return attrs instanceof ServletRequestAttributes ? ((ServletRequestAttributes) attrs).getRequest() : null;
}
private String clientIp() {
HttpServletRequest req = request();
if (req == null) {
return null;
}
String ip = req.getHeader("X-Forwarded-For");
if (ip != null && !ip.isEmpty()) {
int comma = ip.indexOf(',');
return comma > 0 ? ip.substring(0, comma).trim() : ip.trim();
}
ip = req.getHeader("X-Real-IP");
if (ip != null && !ip.isEmpty()) {
return ip.trim();
}
return req.getRemoteAddr();
}
}
@@ -0,0 +1,210 @@
package com.yunhai.acaclassroom.aspect;
import com.alibaba.fastjson.JSON;
import com.yunhai.acaclassroom.annotation.OperationLog;
import com.yunhai.acaclassroom.entity.SysOperationLog;
import com.yunhai.acaclassroom.service.ISysOperationLogService;
import com.yunhai.acaclassroom.utils.GzipUtil;
import com.yunhai.acaclassroom.utils.UserContext;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 统一操作日志切面(参照 UserclassService SysOperationLogAspect):
* 点切**全部控制器方法**,无需逐方法标注注解;
* <ul>
* <li>操作类型:方法名启发式(delete/del/remove→DELETE,add/create/insert/save/import/upload→CREATE,
* GET/查询→QUERY,其余→UPDATE);方法上的 {@link OperationLog} 可显式覆盖 type/level;</li>
* <li>入参:过滤 servlet/文件对象,JSON 序列化后正则脱敏(password/pwd/secret/token 等);</li>
* <li>响应:脱敏 + 截断后 GZIP 压缩存 {@code response_data};</li>
* <li>落库:走 {@code auditLogExecutor} 异步保存,失败仅告警,不影响主业务。</li>
* </ul>
* <p>@Order 大于 {@link ControllerUserContextAspect},保证写日志时 {@link UserContext} 已填充。</p>
*/
@Aspect
@Component
@Order(10)
public class SysOperationLogAspect {
private static final Logger log = LoggerFactory.getLogger(SysOperationLogAspect.class);
/** JSON 键值脱敏(参照 UserclassService SensitiveDataUtil) */
private static final Pattern SENSITIVE_KEY = Pattern.compile(
"(\"(?:password|pwd|pass|secret|token|appsecret|accesskey|idcard|credential)\"\\s*:\\s*\")(.*?)(\")",
Pattern.CASE_INSENSITIVE);
private static final int PARAMS_MAX = 2000;
private static final int RESPONSE_MAX = 10240;
@Autowired
private ISysOperationLogService sysOperationLogService;
@Around("execution(* com.yunhai.acaclassroom.controller..*.*(..))")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
int status = 0;
String errorMsg = null;
Object result = null;
try {
result = pjp.proceed();
return result;
} catch (Throwable t) {
status = 1;
errorMsg = truncate(t.getMessage(), 2000);
throw t;
} finally {
try {
save(pjp, result, status, errorMsg, System.currentTimeMillis() - start);
} catch (Exception e) {
log.warn("[SysOperationLog] 写入操作日志失败: {}", e.getMessage());
}
}
}
private void save(ProceedingJoinPoint pjp, Object result, int status, String errorMsg, long cost) {
SysOperationLog entity = new SysOperationLog();
MethodSignature sig = (MethodSignature) pjp.getSignature();
String methodName = sig.getName();
OperationLog ann = sig.getMethod().getAnnotation(OperationLog.class);
entity.setClassMethod(sig.getDeclaringType().getSimpleName() + "#" + methodName);
HttpServletRequest req = request();
String httpMethod = req != null ? req.getMethod() : "";
entity.setRequestUri(req != null ? req.getRequestURI() : null);
entity.setRequestMethod(httpMethod);
entity.setUserAgent(req != null ? truncate(req.getHeader("User-Agent"), 512) : null);
String opType = ann != null && !ann.type().isEmpty() ? ann.type() : resolveType(methodName, httpMethod);
entity.setOperationType(opType);
entity.setLogLevel(ann != null && ann.level() > 0 ? ann.level() : inferLevel(opType));
entity.setUserId(UserContext.getUserId());
entity.setUserName(UserContext.getUserName());
entity.setSchId(UserContext.getSchId());
entity.setRequestIp(UserContext.getIp());
entity.setRequestParams(maskArgs(pjp.getArgs()));
entity.setStatusCode(status);
entity.setCostTime(cost);
entity.setErrorMsg(errorMsg);
if (status == 0 && result != null && !"QUERY".equals(opType)) {
entity.setResponseData(GzipUtil.compress(truncate(maskJson(safeJson(result)), RESPONSE_MAX)));
}
sysOperationLogService.saveAsync(entity);
}
/** 方法名启发式判定操作类型(参照 UserclassService OperationLogResolver)。 */
private String resolveType(String methodName, String httpMethod) {
String n = methodName.toLowerCase(Locale.ROOT);
if (n.contains("delete") || n.contains("del") || n.contains("remove")) {
return "DELETE";
}
if (n.startsWith("add") || n.startsWith("create") || n.startsWith("insert") || n.startsWith("save")
|| n.contains("import") || n.contains("upload") || n.startsWith("batch")) {
return "CREATE";
}
if (n.startsWith("get") || n.startsWith("list") || n.startsWith("query") || n.startsWith("find")
|| n.startsWith("stat") || "GET".equalsIgnoreCase(httpMethod)) {
return "QUERY";
}
return "UPDATE";
}
private int inferLevel(String opType) {
if ("DELETE".equals(opType)) {
return 1;
}
if ("CREATE".equals(opType) || "UPDATE".equals(opType)) {
return 2;
}
return 3;
}
/** 拼接可读入参,过滤 servlet/文件类对象并做简单脱敏。 */
private String maskArgs(Object[] args) {
if (args == null || args.length == 0) {
return "";
}
StringBuilder sb = new StringBuilder();
for (Object a : args) {
if (a == null || a instanceof HttpServletRequest || a instanceof HttpServletResponse
|| a instanceof MultipartFile || a instanceof org.springframework.data.domain.Pageable) {
continue;
}
String s;
try {
s = safeJson(a);
} catch (Exception e) {
s = "[unprintable]";
}
if (sb.length() > 0) {
sb.append("&");
}
sb.append(s);
}
return truncate(sb.toString(), PARAMS_MAX);
}
private String safeJson(Object o) {
if (o == null) {
return "null";
}
if (o.getClass().isArray()) {
return Arrays.deepToString((Object[]) o);
}
if (o instanceof String || o instanceof Number || o instanceof Boolean) {
return String.valueOf(o);
}
try {
return maskJson(JSON.toJSONString(o));
} catch (Exception e) {
return String.valueOf(o);
}
}
/** JSON 敏感键值替换为 ***(参照 UserclassService SensitiveDataUtil,绝不出错)。 */
private String maskJson(String json) {
if (json == null) {
return null;
}
try {
Matcher m = SENSITIVE_KEY.matcher(json);
StringBuffer sb = new StringBuffer();
while (m.find()) {
m.appendReplacement(sb, Matcher.quoteReplacement(m.group(1) + "***" + m.group(3)));
}
m.appendTail(sb);
return sb.toString();
} catch (Exception e) {
return json;
}
}
private HttpServletRequest request() {
Object attrs = RequestContextHolder.getRequestAttributes();
return attrs instanceof ServletRequestAttributes ? ((ServletRequestAttributes) attrs).getRequest() : null;
}
private String truncate(String s, int max) {
if (s == null) {
return null;
}
return s.length() <= max ? s : s.substring(0, max);
}
}
@@ -0,0 +1,47 @@
package com.yunhai.acaclassroom.common.listener;
import com.yunhai.acaclassroom.entity.BaseEntity;
import com.yunhai.acaclassroom.utils.UserContext;
import org.springframework.stereotype.Component;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import java.time.LocalDateTime;
/**
* JPA 审计监听器:实体持久化/更新前自动填充创建人/更新人与时间戳。
* <p>决策 D4:{@code create_by / update_by} 存**当前登录用户 ID**(数字 id),不再存登录账号。
* id 取自 {@link UserContext#getUserId()}(由 ControllerUserContextAspect 在请求线程填充);
* 无登录上下文(启动种子/系统任务)时为 null,保持原值。</p>
*/
@Component
public class AuditEntityListener {
@PrePersist
public void prePersist(BaseEntity entity) {
LocalDateTime now = LocalDateTime.now();
entity.setCreateTime(now);
entity.setUpdateTime(now);
Integer uid = currentUserId();
if (uid != null) {
if (entity.getCreateBy() == null) {
entity.setCreateBy(uid);
}
entity.setUpdateBy(uid);
}
}
@PreUpdate
public void preUpdate(BaseEntity entity) {
entity.setUpdateTime(LocalDateTime.now());
Integer uid = currentUserId();
if (uid != null) {
entity.setUpdateBy(uid);
}
}
/** 当前登录用户 ID(数字);无上下文返回 null。 */
private Integer currentUserId() {
return UserContext.getUserId();
}
}
@@ -0,0 +1,10 @@
package com.yunhai.acaclassroom.common.validation;
/**
* JSR-303 分组校验标记接口(对齐 UserBase 规范)。
* Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。
*/
public interface ValidationGroups {
interface Create {}
interface Update {}
}
@@ -0,0 +1,30 @@
package com.yunhai.acaclassroom.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 操作日志异步落库线程池(参照 UserclassService auditLogExecutor)。
* <p>队列打满时丢弃任务(DiscardPolicy):审计日志让位于主业务,绝不阻塞请求。</p>
*/
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("auditLogExecutor")
public Executor auditLogExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(2000);
executor.setThreadNamePrefix("audit-log-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
executor.initialize();
return executor;
}
}
@@ -0,0 +1,11 @@
package com.yunhai.acaclassroom.config;
/**
* 全局路径常量。与 UserBase 一致:API 统一挂在 /{platform}/ 之下(platform = api / manage / mobiapi)。
*/
public enum Config {
Inst;
public static final String API_PATH = "/{platform}"; // manage / api / mobiapi,前面有/后面无/,拼接子路径时用"/"分隔
public static final String OPEN_API_PATH = "/oapi";
}
@@ -0,0 +1,35 @@
package com.yunhai.acaclassroom.config;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import java.time.format.DateTimeFormatter;
/**
* Jackson 时间序列化全局约定(对齐旧 .NET 版 "yyyy-MM-dd HH:mm:ss"):
* 接口出入参中的 LocalDateTime/LocalDate 统一使用该格式,避免 ISO 的 "T" 分隔符
* (如 2026-09-01T16:38:40)直接暴露给前端表格。
*/
@Configuration
public class JacksonConfig {
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> builder
.serializers(
new LocalDateTimeSerializer(DATE_TIME),
new LocalDateSerializer(DATE))
.deserializers(
new LocalDateTimeDeserializer(DATE_TIME),
new LocalDateDeserializer(DATE));
}
}
@@ -0,0 +1,22 @@
package com.yunhai.acaclassroom.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 接口文档(springdoc-openapi)。
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI acaClassroomOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("教室管理接口文档")
.description("教室管理子应用:校区、楼栋、教室类型、教室管理(springdoc-openapi)")
.version("1.0"));
}
}
@@ -0,0 +1,58 @@
package com.yunhai.acaclassroom.controller.api;
import com.yunhai.acaclassroom.config.Config;
import com.yunhai.acaclassroom.service.IUserCenterService;
import com.yunhai.acaclassroom.utils.UserContext;
import com.yunhai.platform.utils.model.BoaUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 平台代理域接口(base/)。
* <p>路径前缀:{@code Config.API_PATH + "/base/"}。
* 承载平台代理接口:GetUserInfo / GetUserMod,业务逻辑已下沉 {@link IUserCenterService}。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/base/")
public class BaseApiController {
@Autowired
private IUserCenterService userCenterService;
/**
* 获取当前登录用户信息(顶栏姓名/头像数据源)。
* <p>CAS 会话身份优先;无会话且 dev-context 开启时按 {@code acaclassroom.dev-context.login-name}
* 查平台用户中心返回真实用户(免 SSO 联调也能拿到 lijing 等账号的姓名与头像)。
* 两者皆无时返回 null,由前端降级显示默认头像与"管理员"。</p>
*/
@GetMapping("GetUserInfo")
public BoaUser getUserInfo(BoaUser authUserWithDept) {
return userCenterService.getCurrentUser(authUserWithDept);
}
/**
* 获取当前用户已授权的模块列表(左侧菜单树)。
* 模块树组装已下沉 IUserCenterService;modId 与前端路由 meta.modId 对齐(见计划文档 D6)。
* 无 CAS 会话(本地联调)时回退 UserContext(acaclassroom.dev-context 填充的开发身份)。
*/
@GetMapping("GetUserMod")
public List<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
Integer userId;
Integer schId;
if (authUserWithDept != null) {
userId = authUserWithDept.getId();
schId = authUserWithDept.getCurrentRole() != null
? authUserWithDept.getCurrentRole().getSchId() : null;
} else {
userId = UserContext.getUserId();
schId = UserContext.getSchId();
}
return userCenterService.getUserModuleTree(userId, schId);
}
}
@@ -0,0 +1,60 @@
package com.yunhai.acaclassroom.controller.api;
import com.yunhai.acaclassroom.annotation.OperationLog;
import com.yunhai.acaclassroom.config.Config;
import com.yunhai.acaclassroom.service.IBuildingService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 楼栋管理接口(building/)。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/building/")
public class BuildingController {
@Autowired
private IBuildingService buildingService;
/** 楼栋列表(campusId 可空=全部)。 */
@GetMapping("List")
public List<Map<String, Object>> list(@RequestParam(value = "campusId", required = false) Integer campusId) {
return buildingService.list(campusId);
}
/** 楼栋统计(教室管理页"显示图")。 */
@GetMapping("Stats")
public List<Map<String, Object>> stats(@RequestParam(value = "campusId", required = false) Integer campusId) {
return buildingService.stats(campusId);
}
@PostMapping("Add")
@OperationLog(module = "教室管理-楼栋", type = "CREATE")
public Integer add(@RequestParam("campusId") Integer campusId,
@RequestParam("buildingName") String buildingName,
@RequestParam("buildingCode") String buildingCode,
@RequestParam("floorCount") Integer floorCount,
@RequestParam(value = "sortOrder", required = false) Integer sortOrder) {
return buildingService.add(campusId, buildingName, buildingCode, floorCount, sortOrder);
}
@PostMapping("Edit")
@OperationLog(module = "教室管理-楼栋", type = "UPDATE")
public void edit(@RequestParam("serID") Integer serID,
@RequestParam("campusId") Integer campusId,
@RequestParam("buildingName") String buildingName,
@RequestParam("buildingCode") String buildingCode,
@RequestParam("floorCount") Integer floorCount,
@RequestParam(value = "sortOrder", required = false) Integer sortOrder) {
buildingService.edit(serID, campusId, buildingName, buildingCode, floorCount, sortOrder);
}
@PostMapping("Delete")
@OperationLog(module = "教室管理-楼栋", type = "DELETE")
public void delete(@RequestParam("serid") Integer serid) {
buildingService.delete(serid);
}
}
@@ -0,0 +1,80 @@
package com.yunhai.acaclassroom.controller.api;
import com.yunhai.acaclassroom.annotation.OperationLog;
import com.yunhai.acaclassroom.config.Config;
import com.yunhai.acaclassroom.service.ICampusService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 校区管理接口(campus/)。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/campus/")
public class CampusController {
@Autowired
private ICampusService campusService;
/** 校区列表(含楼栋数/教室数)。 */
@GetMapping("List")
public List<Map<String, Object>> list() {
return campusService.list();
}
/** 校区统计(总览页)。 */
@GetMapping("Stats")
public List<Map<String, Object>> stats() {
return campusService.stats();
}
@PostMapping("Add")
@OperationLog(module = "教室管理-校区", type = "CREATE")
public Integer add(@RequestParam("campusName") String campusName,
@RequestParam(value = "sortOrder", required = false) Integer sortOrder) {
return campusService.add(campusName, sortOrder);
}
@PostMapping("Edit")
@OperationLog(module = "教室管理-校区", type = "UPDATE")
public void edit(@RequestParam("serID") Integer serID,
@RequestParam("campusName") String campusName,
@RequestParam(value = "sortOrder", required = false) Integer sortOrder) {
campusService.edit(serID, campusName, sortOrder);
}
@PostMapping("Delete")
@OperationLog(module = "教室管理-校区", type = "DELETE")
public void delete(@RequestParam("serid") Integer serid) {
campusService.delete(serid);
}
/** 按 ids 顺序重排(sortOrder = 序号)。 */
@PostMapping("Sort")
@OperationLog(module = "教室管理-校区", type = "UPDATE")
public void sort(@RequestParam("ids") String ids) {
campusService.sort(parseIds(ids));
}
static List<Integer> parseIds(String ids) {
if (ids == null || ids.trim().isEmpty()) {
return java.util.Collections.emptyList();
}
try {
String[] parts = ids.split(",");
List<Integer> result = new java.util.ArrayList<>();
for (String p : parts) {
if (!p.trim().isEmpty()) {
result.add(Integer.valueOf(p.trim()));
}
}
return result;
} catch (NumberFormatException e) {
throw new com.yunhai.platform.apistd.BizException(
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "ids 格式不正确");
}
}
}
@@ -0,0 +1,43 @@
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;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 班级档案接口(class/,绑定班级页数据源)。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/class/")
public class ClassController {
@Autowired
private IClassService classService;
/** 班级列表(全校,可按年级过滤;行含 classroomId=绑定的教室 id,未绑定为 null)。 */
@GetMapping("List")
public List<Map<String, Object>> 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);
}
}
@@ -0,0 +1,115 @@
package com.yunhai.acaclassroom.controller.api;
import com.yunhai.acaclassroom.annotation.OperationLog;
import com.yunhai.acaclassroom.config.Config;
import com.yunhai.acaclassroom.model.ClassroomBatchAddRequest;
import com.yunhai.acaclassroom.model.ClassroomBindRequest;
import com.yunhai.acaclassroom.model.ClassroomBatchEditRequest;
import com.yunhai.acaclassroom.model.ClassroomEditRequest;
import com.yunhai.acaclassroom.service.IClassroomService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 教室管理接口(classroom/)。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/classroom/")
public class ClassroomController {
@Autowired
private IClassroomService classroomService;
/**
* 教室列表(空间作用域:楼栋或校区二选一,楼栋优先;
* 支持楼层/类型/状态/关键字/年级/绑定状态过滤 + 排序 + 分页)。
*/
@GetMapping("List")
public Map<String, Object> list(@RequestParam(value = "buildingId", required = false) Integer buildingId,
@RequestParam(value = "campusId", required = false) Integer campusId,
@RequestParam(value = "floor", required = false) Integer floor,
@RequestParam(value = "typeId", required = false) Integer typeId,
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "grade", required = false) Integer grade,
@RequestParam(value = "bindStatus", required = false) String bindStatus,
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "count", defaultValue = "20") Integer count,
@RequestParam(value = "sortField", required = false) String sortField,
@RequestParam(value = "order", required = false) String order) {
return classroomService.list(buildingId, campusId, floor, typeId, status, keyword, grade, bindStatus,
page, count, sortField, order);
}
/** 楼栋下已有教室的楼层列表(筛选项)。 */
@GetMapping("Floors")
public List<Integer> floors(@RequestParam("buildingId") Integer buildingId) {
return classroomService.floors(buildingId);
}
/** 全校教室扁平列表(教室管理页「空间筛选树」数据源,轻量字段)。 */
@GetMapping("Tree")
public List<Map<String, Object>> tree() {
return classroomService.tree();
}
/**
* 批量添加教室:输入楼层区间(1~楼栋楼层数量)、每层教室数量、教室类型;
* 编号默认 {前缀}{楼层2位}{序号2位},重复的跳过。
*/
@PostMapping("BatchAdd")
@OperationLog(module = "教室管理-教室", type = "CREATE")
public Map<String, Object> batchAdd(@RequestBody ClassroomBatchAddRequest req) {
return classroomService.batchAdd(req);
}
/** 编辑教室(类型/名称/编号/状态/容纳人数/座位数)。 */
@PostMapping("Edit")
@OperationLog(module = "教室管理-教室", type = "UPDATE")
public void edit(@RequestBody ClassroomEditRequest req) {
classroomService.edit(req);
}
/**
* 批量编辑教室:仅支持 教室状态 / 教室类型 两项(可只改其一),
* 供列表页多选后的批量操作条使用;返回 {updated, skipped, total}。
*/
@PostMapping("BatchEdit")
@OperationLog(module = "教室管理-教室", type = "UPDATE")
public Map<String, Object> batchEdit(@RequestBody ClassroomBatchEditRequest req) {
return classroomService.batchEdit(req);
}
/** 批量删除教室(JSON id 数组)。 */
@PostMapping("Delete")
@OperationLog(module = "教室管理-教室", type = "DELETE")
public void delete(@RequestBody List<Integer> ids) {
classroomService.delete(ids);
}
/**
* 教室绑定班级(JSON {serID, classId};一个班级同时只能绑一间教室)。
*/
@PostMapping("BindClass")
@OperationLog(module = "教室管理-绑定班级", type = "UPDATE")
public Map<String, Object> bindClass(@RequestBody ClassroomBindRequest req) {
return classroomService.bindClass(req.getSerID(), req.getClassId());
}
/** 教室解绑班级(表单 serid;未绑定时静默成功)。 */
@PostMapping("UnbindClass")
@OperationLog(module = "教室管理-绑定班级", type = "UPDATE")
public void unbindClass(@RequestParam("serid") Integer serid) {
classroomService.unbindClass(serid);
}
/** 批量解绑(JSON id 数组;未绑定/无效教室计入 skipped)。 */
@PostMapping("BatchUnbind")
@OperationLog(module = "教室管理-绑定班级", type = "UPDATE")
public Map<String, Object> batchUnbind(@RequestBody List<Integer> serIDs) {
return classroomService.batchUnbind(serIDs);
}
}
@@ -0,0 +1,56 @@
package com.yunhai.acaclassroom.controller.api;
import com.yunhai.acaclassroom.annotation.OperationLog;
import com.yunhai.acaclassroom.config.Config;
import com.yunhai.acaclassroom.service.IClassroomTypeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 教室类型管理接口(classtype/)。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/classtype/")
public class ClassroomTypeController {
@Autowired
private IClassroomTypeService classroomTypeService;
/** 类型列表(含引用的教室数)。 */
@GetMapping("List")
public List<Map<String, Object>> list() {
return classroomTypeService.list();
}
@PostMapping("Add")
@OperationLog(module = "教室管理-教室类型", type = "CREATE")
public Integer add(@RequestParam("typeName") String typeName,
@RequestParam(value = "sortOrder", required = false) Integer sortOrder) {
return classroomTypeService.add(typeName, sortOrder);
}
@PostMapping("Edit")
@OperationLog(module = "教室管理-教室类型", type = "UPDATE")
public void edit(@RequestParam("serID") Integer serID,
@RequestParam("typeName") String typeName,
@RequestParam(value = "enableFlag", defaultValue = "1") Integer enableFlag,
@RequestParam(value = "sortOrder", required = false) Integer sortOrder) {
classroomTypeService.edit(serID, typeName, enableFlag, sortOrder);
}
@PostMapping("Delete")
@OperationLog(module = "教室管理-教室类型", type = "DELETE")
public void delete(@RequestParam("serid") Integer serid) {
classroomTypeService.delete(serid);
}
/** 按 ids 顺序重排。 */
@PostMapping("Sort")
@OperationLog(module = "教室管理-教室类型", type = "UPDATE")
public void sort(@RequestParam("ids") String ids) {
classroomTypeService.sort(CampusController.parseIds(ids));
}
}
@@ -0,0 +1,48 @@
package com.yunhai.acaclassroom.controller.api;
import com.yunhai.acaclassroom.annotation.OperationLog;
import com.yunhai.acaclassroom.config.Config;
import com.yunhai.acaclassroom.service.IEquipmentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 教室设备管理接口(equipment/,基础设置 · 设备管理)。
* 独立设备台账:设备不挂教室,教室(批量添加/编辑)通过 classroom/ 接口勾选设备。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/equipment/")
public class EquipmentController {
@Autowired
private IEquipmentService equipmentService;
/** 设备列表(全校全量,支持关键字过滤,行含统计使用数量 quantity 与引用教室数 classroomCount)。 */
@GetMapping("List")
public Map<String, Object> list(@RequestParam(value = "keyword", required = false) String keyword) {
return equipmentService.list(keyword);
}
@PostMapping("Add")
@OperationLog(module = "教室管理-设备", type = "CREATE")
public Integer add(@RequestParam("equipmentName") String equipmentName,
@RequestParam(value = "remark", required = false) String remark) {
return equipmentService.add(equipmentName, remark);
}
@PostMapping("Edit")
@OperationLog(module = "教室管理-设备", type = "UPDATE")
public void edit(@RequestParam("serID") Integer serID,
@RequestParam("equipmentName") String equipmentName,
@RequestParam(value = "remark", required = false) String remark) {
equipmentService.edit(serID, equipmentName, remark);
}
@PostMapping("Delete")
@OperationLog(module = "教室管理-设备", type = "DELETE")
public void delete(@RequestParam("serid") Integer serid) {
equipmentService.delete(serid);
}
}
@@ -0,0 +1,49 @@
package com.yunhai.acaclassroom.entity;
import com.yunhai.acaclassroom.common.listener.AuditEntityListener;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link AuditEntityListener} 自动填充)。
*/
@MappedSuperclass
@Getter
@Setter
@EntityListeners(AuditEntityListener.class)
public abstract class BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
/**
* 创建人用户ID(数字;决策 D4,不存登录账号)
*/
@Column(name = "create_by", updatable = false)
private Integer createBy;
/**
* 创建时间
*/
@Column(name = "create_time", updatable = false)
private LocalDateTime createTime;
/**
* 更新人用户ID(数字;决策 D4,不存登录账号)
*/
@Column(name = "update_by")
private Integer updateBy;
/**
* 更新时间
*/
@Column(name = "update_time")
private LocalDateTime updateTime;
}
@@ -0,0 +1,42 @@
package com.yunhai.acaclassroom.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 楼栋(基础设置):所属校区 + 名称/编号/楼层数量/排序号。楼栋编号校内唯一。
*/
@Entity
@Getter
@Setter
@Table(name = "aca_building")
public class Building extends BaseEntity {
/** 所属校区 id(aca_campus.id) */
@Column(name = "campus_id", nullable = false)
private Integer campusId;
/** 楼栋名称 */
@Column(name = "building_name", nullable = false, length = 100)
private String buildingName;
/** 楼栋编号(校内唯一,批量生成教室编号的前缀) */
@Column(name = "building_code", nullable = false, length = 32)
private String buildingCode;
/** 楼层数量(教室楼层不得超过该值) */
@Column(name = "floor_count", nullable = false)
private Integer floorCount;
/** 排序号(小在前) */
@Column(name = "sort_order")
private Integer sortOrder;
/** 所属学校 id(多租户) */
@Column(name = "sch_id", nullable = false)
private Integer schId;
}
@@ -0,0 +1,30 @@
package com.yunhai.acaclassroom.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 校区(基础设置)。多租户按 sch_id 隔离,校区名校内唯一。
*/
@Entity
@Getter
@Setter
@Table(name = "aca_campus")
public class Campus extends BaseEntity {
/** 校区名称(校内唯一) */
@Column(name = "campus_name", nullable = false, length = 100)
private String campusName;
/** 排序号(小在前) */
@Column(name = "sort_order")
private Integer sortOrder;
/** 所属学校 id(多租户) */
@Column(name = "sch_id", nullable = false)
private Integer schId;
}
@@ -0,0 +1,62 @@
package com.yunhai.acaclassroom.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 教室。campus_id 随楼栋冗余存储(校区调整楼栋时同步),楼栋内教室编号唯一。
*/
@Entity
@Getter
@Setter
@Table(name = "aca_classroom")
public class Classroom extends BaseEntity {
/** 所属校区 id(冗余自楼栋,列表/统计按校区过滤免联表) */
@Column(name = "campus_id", nullable = false)
private Integer campusId;
/** 所属楼栋 id(aca_building.id) */
@Column(name = "building_id", nullable = false)
private Integer buildingId;
/** 所在楼层(1..楼栋楼层数量) */
@Column(name = "floor", nullable = false)
private Integer floor;
/** 教室名称 */
@Column(name = "room_name", nullable = false, length = 100)
private String roomName;
/** 教室编号(楼栋内唯一,默认 楼栋编号+楼层2位+序号2位,如 A0103) */
@Column(name = "room_code", nullable = false, length = 32)
private String roomCode;
/** 教室类型 id(aca_classroom_type.id) */
@Column(name = "type_id")
private Integer typeId;
/** 教室状态:open 开放 / closed 关闭 */
@Column(name = "status", nullable = false, length = 8)
private String status;
/** 教室容纳人数 */
@Column(name = "capacity")
private Integer capacity;
/** 座位数 */
@Column(name = "seat_count")
private Integer seatCount;
/** 绑定班级 id(aca_class.id,可空=未绑定;同一班级同时只能绑一间教室) */
@Column(name = "class_id")
private Integer classId;
/** 所属学校 id(多租户) */
@Column(name = "sch_id", nullable = false)
private Integer schId;
}
@@ -0,0 +1,39 @@
package com.yunhai.acaclassroom.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 教室-设备关联(教室编辑/批量添加时勾选的设备集合)。
*/
@Entity
@Getter
@Setter
@Table(name = "aca_classroom_equipment",
uniqueConstraints = @UniqueConstraint(columnNames = {"classroom_id", "equipment_id"}))
public class ClassroomEquipment extends BaseEntity {
/** 教室 id(aca_classroom.id) */
@Column(name = "classroom_id", nullable = false)
private Integer classroomId;
/** 设备 id(aca_equipment.id) */
@Column(name = "equipment_id", nullable = false)
private Integer equipmentId;
/**
* 每间教室的设备数量(批量添加/编辑时按设备指定,缺省 1)。
* 列为后加:ddl-auto 建出的存量行可能为 NULL,读取时按 1 处理。
*/
@Column(name = "quantity")
private Integer quantity;
/** 所属学校 id(多租户) */
@Column(name = "sch_id", nullable = false)
private Integer schId;
}
@@ -0,0 +1,34 @@
package com.yunhai.acaclassroom.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 教室类型(基础设置)。类型名校内唯一;停用后不可再用于新增教室,已有教室不受影响。
*/
@Entity
@Getter
@Setter
@Table(name = "aca_classroom_type")
public class ClassroomType extends BaseEntity {
/** 类型名称(校内唯一,如 普通教室/多媒体教室/实验室) */
@Column(name = "type_name", nullable = false, length = 50)
private String typeName;
/** 启用标志:1 启用 / 0 停用 */
@Column(name = "enable_flag", nullable = false)
private Integer enableFlag;
/** 排序号(小在前) */
@Column(name = "sort_order")
private Integer sortOrder;
/** 所属学校 id(多租户) */
@Column(name = "sch_id", nullable = false)
private Integer schId;
}
@@ -0,0 +1,33 @@
package com.yunhai.acaclassroom.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 教室设备(基础设置 · 设备管理)。独立设备台账,不挂教室;
* 教室通过 {@link ClassroomEquipment} 关联引用设备(教室批量添加/编辑时勾选)。
* 设备不存数量,使用量按教室关联的 quantity 汇总统计。
* 设备名称校内唯一;被教室引用的设备不可删除(删除守卫)。
*/
@Entity
@Getter
@Setter
@Table(name = "aca_equipment")
public class Equipment extends BaseEntity {
/** 设备名称(校内唯一,如 投影仪/空调/多媒体讲台) */
@Column(name = "equipment_name", nullable = false, length = 100)
private String equipmentName;
/** 备注 */
@Column(name = "remark", length = 200)
private String remark;
/** 所属学校 id(多租户) */
@Column(name = "sch_id", nullable = false)
private Integer schId;
}
@@ -0,0 +1,43 @@
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,88 @@
package com.yunhai.acaclassroom.entity;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import javax.persistence.*;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 统一操作日志(审计轨)。参照 UserclassService {@code sys_operation_log}。
* <p>由 {@link com.yunhai.acaclassroom.aspect.SysOperationLogAspect} 对全部控制器方法环绕落库
* (写操作可加 {@code @OperationLog} 显式指定模块/类型)。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sys_operation_log")
public class SysOperationLog implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 1 重要(删除) / 2 次要(新增/编辑) / 3 一般(查询) */
@Column(name = "log_level", nullable = false)
private Integer logLevel;
/** DELETE / CREATE / UPDATE / QUERY */
@Column(name = "operation_type", nullable = false, length = 16)
private String operationType;
/** 操作人平台用户 id(来自 UserContext,非账号) */
@Column(name = "user_id")
private Integer userId;
/** 操作人姓名 */
@Column(name = "user_name", length = 64)
private String userName;
/** 学校 id(多租户隔离) */
@Column(name = "sch_id")
private Integer schId;
@Column(name = "request_ip", length = 64)
private String requestIp;
@Column(name = "request_uri", length = 512)
private String requestUri;
@Column(name = "request_method", length = 16)
private String requestMethod;
/** 目标类#方法 */
@Column(name = "class_method", length = 512)
private String classMethod;
/** 入参(脱敏后),截断 */
@Column(name = "request_params", columnDefinition = "TEXT")
private String requestParams;
/** 响应(脱敏 + 截断后 GZIP 压缩),MEDIUMBLOB */
@Lob
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
private byte[] responseData;
/** 业务状态:0 成功 / 非 0 失败 */
@Column(name = "status_code", nullable = false)
private Integer statusCode;
/** 耗时(毫秒) */
@Column(name = "cost_time", nullable = false)
private Long costTime;
@Column(name = "user_agent", length = 512)
private String userAgent;
/** 异常信息,截断 2000 */
@Column(name = "error_msg", columnDefinition = "TEXT")
private String errorMsg;
@CreationTimestamp
@Column(name = "create_time", nullable = false, updatable = false)
private LocalDateTime createTime;
}
@@ -0,0 +1,96 @@
package com.yunhai.acaclassroom.model;
/**
* 教室批量添加请求(JSON)。
* <p>按楼层区间逐层生成教室:楼层从 floorFrom 到 floorTo(含),每层 countPerFloor 间;
* 编号默认 {楼栋编号}{楼层2位}{序号2位},与已有编号冲突的跳过。</p>
*/
public class ClassroomBatchAddRequest {
private Integer buildingId;
/** 起始楼层(≥1) */
private Integer floorFrom;
/** 截止楼层(≤楼栋楼层数量,≥floorFrom) */
private Integer floorTo;
/** 每层教室数量(≥1) */
private Integer countPerFloor;
/** 教室类型 id */
private Integer typeId;
/** 编号/名称前缀(缺省用楼栋编号) */
private String namePrefix;
/** 关联设备 id 集合(应用到本次新生成的教室,数量按 1;可空)。优先级低于 equipments */
private java.util.List<Integer> equipmentIds;
/** 关联设备(应用到本次新生成的教室,含每间数量;可空) */
private java.util.List<ClassroomEquipmentItem> equipments;
public java.util.List<ClassroomEquipmentItem> getEquipments() {
return equipments;
}
public void setEquipments(java.util.List<ClassroomEquipmentItem> equipments) {
this.equipments = equipments;
}
public java.util.List<Integer> getEquipmentIds() {
return equipmentIds;
}
public void setEquipmentIds(java.util.List<Integer> equipmentIds) {
this.equipmentIds = equipmentIds;
}
public Integer getBuildingId() {
return buildingId;
}
public void setBuildingId(Integer buildingId) {
this.buildingId = buildingId;
}
public Integer getFloorFrom() {
return floorFrom;
}
public void setFloorFrom(Integer floorFrom) {
this.floorFrom = floorFrom;
}
public Integer getFloorTo() {
return floorTo;
}
public void setFloorTo(Integer floorTo) {
this.floorTo = floorTo;
}
public Integer getCountPerFloor() {
return countPerFloor;
}
public void setCountPerFloor(Integer countPerFloor) {
this.countPerFloor = countPerFloor;
}
public Integer getTypeId() {
return typeId;
}
public void setTypeId(Integer typeId) {
this.typeId = typeId;
}
public String getNamePrefix() {
return namePrefix;
}
public void setNamePrefix(String namePrefix) {
this.namePrefix = namePrefix;
}
}
@@ -0,0 +1,46 @@
package com.yunhai.acaclassroom.model;
import java.util.List;
/**
* 教室批量编辑请求(JSON)。
*
* 用于列表页多选后的批量操作:只允许修改「教室状态」与「教室类型」两项;
* 两者均为可空——为 null 表示该项不改动,至少需要指定其中一项。
* 名称/编号/容纳人数/座位数等逐间差异字段不在批量范围(避免误覆盖)。
*/
public class ClassroomBatchEditRequest {
/** 目标教室 id 集合 */
private List<Integer> serIDs;
/** 目标状态:open 开放 / closed 关闭;null = 不改动 */
private String status;
/** 目标教室类型 id;null = 不改动 */
private Integer typeId;
public List<Integer> getSerIDs() {
return serIDs;
}
public void setSerIDs(List<Integer> serIDs) {
this.serIDs = serIDs;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Integer getTypeId() {
return typeId;
}
public void setTypeId(Integer typeId) {
this.typeId = typeId;
}
}
@@ -0,0 +1,18 @@
package com.yunhai.acaclassroom.model;
import lombok.Getter;
import lombok.Setter;
/**
* 教室绑定班级请求(绑定班级页行内下拉)。
*/
@Getter
@Setter
public class ClassroomBindRequest {
/** 教室 id */
private Integer serID;
/** 班级 id(aca_class.id) */
private Integer classId;
}
@@ -0,0 +1,100 @@
package com.yunhai.acaclassroom.model;
/**
* 教室编辑请求(JSON)。楼层/楼栋/校区为档案归属,不在编辑范围。
*/
public class ClassroomEditRequest {
private Integer serID;
private String roomName;
private String roomCode;
private Integer typeId;
/** open 开放 / closed 关闭 */
private String status;
private Integer capacity;
private Integer seatCount;
/** 关联设备 id 集合(null=不改动;数组(含空)=全量替换该教室的设备,数量按 1)。优先级低于 equipments */
private java.util.List<Integer> equipmentIds;
/** 关联设备(null=不改动;数组(含空)=全量替换该教室的设备,含每间数量) */
private java.util.List<ClassroomEquipmentItem> equipments;
public java.util.List<ClassroomEquipmentItem> getEquipments() {
return equipments;
}
public void setEquipments(java.util.List<ClassroomEquipmentItem> equipments) {
this.equipments = equipments;
}
public java.util.List<Integer> getEquipmentIds() {
return equipmentIds;
}
public void setEquipmentIds(java.util.List<Integer> equipmentIds) {
this.equipmentIds = equipmentIds;
}
public Integer getSerID() {
return serID;
}
public void setSerID(Integer serID) {
this.serID = serID;
}
public String getRoomName() {
return roomName;
}
public void setRoomName(String roomName) {
this.roomName = roomName;
}
public String getRoomCode() {
return roomCode;
}
public void setRoomCode(String roomCode) {
this.roomCode = roomCode;
}
public Integer getTypeId() {
return typeId;
}
public void setTypeId(Integer typeId) {
this.typeId = typeId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public Integer getCapacity() {
return capacity;
}
public void setCapacity(Integer capacity) {
this.capacity = capacity;
}
public Integer getSeatCount() {
return seatCount;
}
public void setSeatCount(Integer seatCount) {
this.seatCount = seatCount;
}
}
@@ -0,0 +1,30 @@
package com.yunhai.acaclassroom.model;
/**
* 设备关联项(教室批量添加/编辑请求用):设备 id + 每间教室数量。
* <p>数量缺省或非法(&lt;1)时按 1 处理,上限 999。</p>
*/
public class ClassroomEquipmentItem {
/** 设备 id(aca_equipment.id) */
private Integer equipmentId;
/** 每间教室的设备数量(缺省 1) */
private Integer quantity;
public Integer getEquipmentId() {
return equipmentId;
}
public void setEquipmentId(Integer equipmentId) {
this.equipmentId = equipmentId;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
}
@@ -0,0 +1,24 @@
package com.yunhai.acaclassroom.repository;
import com.yunhai.acaclassroom.entity.Building;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 楼栋仓储。
*/
public interface BuildingRepository extends JpaRepository<Building, Integer> {
List<Building> findBySchIdOrderBySortOrderAscIdAsc(Integer schId);
List<Building> findBySchIdAndCampusIdOrderBySortOrderAscIdAsc(Integer schId, Integer campusId);
boolean existsBySchIdAndBuildingCode(Integer schId, String buildingCode);
boolean existsBySchIdAndBuildingCodeAndIdNot(Integer schId, String buildingCode, Integer id);
boolean existsBySchIdAndCampusId(Integer schId, Integer campusId);
long countBySchIdAndCampusId(Integer schId, Integer campusId);
}
@@ -0,0 +1,20 @@
package com.yunhai.acaclassroom.repository;
import com.yunhai.acaclassroom.entity.Campus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 校区仓储。
*/
public interface CampusRepository extends JpaRepository<Campus, Integer> {
List<Campus> findBySchIdOrderBySortOrderAscIdAsc(Integer schId);
boolean existsBySchIdAndCampusName(Integer schId, String campusName);
boolean existsBySchIdAndCampusNameAndIdNot(Integer schId, String campusName, Integer id);
long countBySchIdAndCampusName(Integer schId, String campusName);
}
@@ -0,0 +1,24 @@
package com.yunhai.acaclassroom.repository;
import com.yunhai.acaclassroom.entity.ClassroomEquipment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 教室-设备关联仓储。
*/
public interface ClassroomEquipmentRepository extends JpaRepository<ClassroomEquipment, Integer> {
List<ClassroomEquipment> findBySchId(Integer schId);
List<ClassroomEquipment> findBySchIdAndClassroomId(Integer schId, Integer classroomId);
List<ClassroomEquipment> findBySchIdAndClassroomIdIn(Integer schId, List<Integer> classroomIds);
boolean existsBySchIdAndEquipmentId(Integer schId, Integer equipmentId);
void deleteBySchIdAndClassroomId(Integer schId, Integer classroomId);
void deleteBySchIdAndClassroomIdIn(Integer schId, List<Integer> classroomIds);
}
@@ -0,0 +1,35 @@
package com.yunhai.acaclassroom.repository;
import com.yunhai.acaclassroom.entity.Classroom;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 教室仓储。列表/统计数据量小(单校教室百级),按楼栋整取后在服务层过滤分页。
*/
public interface ClassroomRepository extends JpaRepository<Classroom, Integer> {
List<Classroom> findBySchIdAndBuildingIdOrderByFloorAscRoomCodeAsc(Integer schId, Integer buildingId);
List<Classroom> findBySchIdOrderByFloorAscRoomCodeAsc(Integer schId);
List<Classroom> findBySchId(Integer schId);
List<Classroom> findBySchIdAndCampusId(Integer schId, Integer campusId);
boolean existsBySchIdAndBuildingId(Integer schId, Integer buildingId);
boolean existsBySchIdAndBuildingIdAndRoomCode(Integer schId, Integer buildingId, String roomCode);
boolean existsBySchIdAndBuildingIdAndRoomCodeAndIdNot(Integer schId, Integer buildingId, String roomCode, Integer id);
long countBySchIdAndTypeId(Integer schId, Integer typeId);
/** 绑定班级占用查询:某班级是否已被本校教室绑定(一个班级同时只绑一间) */
boolean existsBySchIdAndClassId(Integer schId, Integer classId);
List<Classroom> findBySchIdAndClassIdIn(Integer schId, List<Integer> classIds);
void deleteBySchIdAndIdIn(Integer schId, List<Integer> ids);
}
@@ -0,0 +1,18 @@
package com.yunhai.acaclassroom.repository;
import com.yunhai.acaclassroom.entity.ClassroomType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 教室类型仓储。
*/
public interface ClassroomTypeRepository extends JpaRepository<ClassroomType, Integer> {
List<ClassroomType> findBySchIdOrderBySortOrderAscIdAsc(Integer schId);
boolean existsBySchIdAndTypeName(Integer schId, String typeName);
boolean existsBySchIdAndTypeNameAndIdNot(Integer schId, String typeName, Integer id);
}
@@ -0,0 +1,20 @@
package com.yunhai.acaclassroom.repository;
import com.yunhai.acaclassroom.entity.Equipment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 教室设备仓储(独立设备台账)。
*/
public interface EquipmentRepository extends JpaRepository<Equipment, Integer> {
List<Equipment> findBySchId(Integer schId);
List<Equipment> findBySchIdOrderByIdDesc(Integer schId);
boolean existsBySchIdAndEquipmentName(Integer schId, String equipmentName);
boolean existsBySchIdAndEquipmentNameAndIdNot(Integer schId, String equipmentName, Integer id);
}
@@ -0,0 +1,16 @@
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);
}
@@ -0,0 +1,10 @@
package com.yunhai.acaclassroom.repository;
import com.yunhai.acaclassroom.entity.SysOperationLog;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* 操作日志仓储。
*/
public interface SysOperationLogRepository extends JpaRepository<SysOperationLog, Long> {
}
@@ -0,0 +1,29 @@
package com.yunhai.acaclassroom.service;
import java.util.List;
import java.util.Map;
/**
* 楼栋管理服务。
*/
public interface IBuildingService {
/** 楼栋列表(campusId 为空返回全部,含所属校区名与教室数)。 */
List<Map<String, Object>> list(Integer campusId);
/**
* 楼栋统计(教室管理页"显示图":每楼栋教室 开放/关闭/总数)。
* 每项额外含 floors 楼层分布([{floor,total,open,closed}],楼层升序,仅实际有教室的楼层),
* 供前端楼栋卡片绘制楼层条;为新增字段,旧调用方忽略即可。
*/
List<Map<String, Object>> stats(Integer campusId);
/** 新增楼栋,返回新 id。 */
Integer add(Integer campusId, String buildingName, String buildingCode, Integer floorCount, Integer sortOrder);
/** 编辑楼栋(楼层调小时不校验已有教室楼层,由前端提示)。 */
void edit(Integer serID, Integer campusId, String buildingName, String buildingCode, Integer floorCount, Integer sortOrder);
/** 删除楼栋(存在教室时拒绝)。 */
void delete(Integer serid);
}
@@ -0,0 +1,28 @@
package com.yunhai.acaclassroom.service;
import java.util.List;
import java.util.Map;
/**
* 校区管理服务。
*/
public interface ICampusService {
/** 校区列表(含楼栋数/教室数,管理页用)。 */
List<Map<String, Object>> list();
/** 校区统计(总览页:每校区楼栋数与教室 开放/关闭/总数)。 */
List<Map<String, Object>> stats();
/** 新增校区,返回新 id。 */
Integer add(String campusName, Integer sortOrder);
/** 编辑校区。 */
void edit(Integer serID, String campusName, Integer sortOrder);
/** 删除校区(存在楼栋时拒绝)。 */
void delete(Integer serid);
/** 按 ids 顺序重排(sortOrder = 序号)。 */
void sort(List<Integer> ids);
}
@@ -0,0 +1,24 @@
package com.yunhai.acaclassroom.service;
import java.util.List;
import java.util.Map;
/**
* 班级档案服务(绑定班级页)。
*/
public interface IClassService {
/**
* 班级列表(全校,可按年级过滤)。
*
* @param grade 1=高一 2=高二 3=高三(可空=全部)
* @return 行含 serID/className/grade/studentCount/headTeacher/classroomId(未绑定为 null)
*/
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,62 @@
package com.yunhai.acaclassroom.service;
import com.yunhai.acaclassroom.model.ClassroomBatchAddRequest;
import com.yunhai.acaclassroom.model.ClassroomBatchEditRequest;
import com.yunhai.acaclassroom.model.ClassroomEditRequest;
import java.util.List;
import java.util.Map;
/**
* 教室管理服务。
*/
public interface IClassroomService {
/**
* 教室列表(空间作用域:按楼栋或按校区,可按楼层/类型/状态/关键字过滤,内存分页)。
*
* @param buildingId 楼栋 id(与 campusId 至少传其一,楼栋优先)
* @param campusId 校区 id(buildingId 为空时按校区查全部楼栋的教室)
* @param floor 楼层(可空)
* @param typeId 教室类型(可空)
* @param status open/closed(可空)
* @param keyword 教室名称/编号模糊(可空)
* @param grade 年级过滤(绑定班级页 Tab:1=高一 2=高二 3=高三;可空不过滤。
* 过滤口径=未绑定教室 + 绑定班级为该年级的教室)
* @param bindStatus 绑定状态过滤 bound/unbound(可空)
* @param page 页码(1 起)
* @param count 每页条数
* @param sortField 排序字段 floor/roomCode/roomName/status/capacity/seatCount(可空=默认序:楼层升序+编号升序)
* @param order asc/desc(可空=asc)
*/
Map<String, Object> list(Integer buildingId, Integer campusId, Integer floor, Integer typeId, String status,
String keyword, Integer grade, String bindStatus, Integer page, Integer count,
String sortField, String order);
/** 楼栋下已有教室的楼层列表(升序,筛选项用)。 */
List<Integer> floors(Integer buildingId);
/** 全校教室扁平列表(空间筛选树数据源,仅轻量字段)。 */
List<Map<String, Object>> tree();
/** 批量添加教室,返回 {created, skipped, total}。 */
Map<String, Object> batchAdd(ClassroomBatchAddRequest req);
/** 编辑教室(类型/名称/编号/状态/容纳人数/座位数)。 */
void edit(ClassroomEditRequest req);
/** 批量编辑教室(仅状态 / 教室类型,可只改其一),返回 {updated, skipped, total}。 */
Map<String, Object> batchEdit(ClassroomBatchEditRequest req);
/** 批量删除教室。 */
void delete(List<Integer> ids);
/** 教室绑定班级(班级同校且未被其他教室占用),返回 {classId, className}。 */
Map<String, Object> bindClass(Integer serID, Integer classId);
/** 教室解绑班级(未绑定时静默成功)。 */
void unbindClass(Integer serID);
/** 批量解绑(未绑定/不存在的教室计入 skipped),返回 {updated, skipped, total}。 */
Map<String, Object> batchUnbind(List<Integer> serIDs);
}
@@ -0,0 +1,25 @@
package com.yunhai.acaclassroom.service;
import java.util.List;
import java.util.Map;
/**
* 教室类型管理服务。
*/
public interface IClassroomTypeService {
/** 类型列表(含引用的教室数)。 */
List<Map<String, Object>> list();
/** 新增类型,返回新 id。 */
Integer add(String typeName, Integer sortOrder);
/** 编辑类型(名称/启停/排序)。 */
void edit(Integer serID, String typeName, Integer enableFlag, Integer sortOrder);
/** 删除类型(已被教室引用时拒绝)。 */
void delete(Integer serid);
/** 按 ids 顺序重排。 */
void sort(List<Integer> ids);
}
@@ -0,0 +1,25 @@
package com.yunhai.acaclassroom.service;
import java.util.Map;
/**
* 教室设备管理服务(基础设置 · 设备管理)。独立设备台账,不挂教室;
* 教室(批量添加/编辑)通过教室-设备关联引用设备。
*/
public interface IEquipmentService {
/**
* 设备列表(全校全量,可按关键字过滤)。
* 行含 quantity(按教室关联用量汇总的使用数量)与 classroomCount(引用该设备的教室数)。
*/
Map<String, Object> list(String keyword);
/** 新增设备(名称校内唯一),返回新 id。 */
Integer add(String equipmentName, String remark);
/** 编辑设备(名称/备注)。 */
void edit(Integer serID, String equipmentName, String remark);
/** 删除设备(被教室引用时拒绝)。 */
void delete(Integer serid);
}
@@ -0,0 +1,15 @@
package com.yunhai.acaclassroom.service;
import com.yunhai.acaclassroom.entity.SysOperationLog;
/**
* 操作日志服务(审计轨)。落库走专用线程池异步执行,失败仅告警。
*/
public interface ISysOperationLogService {
/**
* 异步保存一条操作日志(auditLogExecutor 线程池)。
* 绝不抛出影响主业务的异常。
*/
void saveAsync(SysOperationLog entity);
}
@@ -0,0 +1,36 @@
package com.yunhai.acaclassroom.service;
import com.yunhai.platform.utils.model.BoaUser;
import java.util.List;
import java.util.Map;
/**
* 用户中心门面服务(对齐 UserBase {@code UserCenterService} 模式)。
* 封装 {@code YschBaseOpenApi} 静态门面调用,Controller 只保留"取参 → 调 Service → 返回"的薄壳。
*/
public interface IUserCenterService {
/**
* 获取当前用户可访问的模块树(左侧菜单)。
* <p>从基础平台拉取当前用户模块列表(含学校上下文),组装为前端 layout 期望的树结构
* ({@code modId/page/children}),对应前端 GetUserMod 接口。</p>
*
* @param userId 用户ID
* @param schId 学校ID(可为 null)
* @return 模块树(顶层 List,含 children 嵌套)
*/
List<Map<String, Object>> getUserModuleTree(Integer userId, Integer schId);
/**
* 解析"当前用户":CAS 会话身份优先原样返回;无会话且 dev-context 开启时,
* 按 {@code acaclassroom.dev-context.login-name} 查平台用户中心返回真实用户
* (含姓名/头像,供顶栏展示与 UserContext 审计身份使用)。
* <p>查无此人/平台不可达/未配置 login-name 时返回 null,由调用方按各自语义兜底
* (GetUserInfo 返回空,切面回退占位身份 99999)。</p>
*
* @param casUser CAS 会话注入的用户(可为 null)
* @return 当前用户;无法确定时为 null
*/
BoaUser getCurrentUser(BoaUser casUser);
}
@@ -0,0 +1,227 @@
package com.yunhai.acaclassroom.service.impl;
import com.yunhai.acaclassroom.entity.Building;
import com.yunhai.acaclassroom.entity.Campus;
import com.yunhai.acaclassroom.entity.Classroom;
import com.yunhai.acaclassroom.repository.BuildingRepository;
import com.yunhai.acaclassroom.repository.CampusRepository;
import com.yunhai.acaclassroom.repository.ClassroomRepository;
import com.yunhai.acaclassroom.service.IBuildingService;
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;
/**
* 楼栋管理服务实现。
*/
@Service
public class BuildingService implements IBuildingService {
@Autowired
private BuildingRepository buildingRepository;
@Autowired
private CampusRepository campusRepository;
@Autowired
private ClassroomRepository classroomRepository;
@Override
public List<Map<String, Object>> list(Integer campusId) {
Integer schId = UserContext.getSchId();
if (schId == null) {
return Collections.emptyList();
}
List<Building> buildings = campusId != null
? buildingRepository.findBySchIdAndCampusIdOrderBySortOrderAscIdAsc(schId, campusId)
: buildingRepository.findBySchIdOrderBySortOrderAscIdAsc(schId);
Map<Integer, String> campusNames = campusRepository.findBySchIdOrderBySortOrderAscIdAsc(schId).stream()
.collect(Collectors.toMap(Campus::getId, Campus::getCampusName, (a, b) -> a, LinkedHashMap::new));
Map<Integer, Long> roomCount = classroomRepository.findBySchId(schId).stream()
.collect(Collectors.groupingBy(Classroom::getBuildingId, Collectors.counting()));
List<Map<String, Object>> result = new ArrayList<>();
for (Building b : buildings) {
Map<String, Object> vo = toVo(b);
vo.put("campusName", campusNames.getOrDefault(b.getCampusId(), ""));
vo.put("classroomCount", roomCount.getOrDefault(b.getId(), 0L));
result.add(vo);
}
return result;
}
@Override
public List<Map<String, Object>> stats(Integer campusId) {
Integer schId = UserContext.getSchId();
if (schId == null) {
return Collections.emptyList();
}
List<Building> buildings = campusId != null
? buildingRepository.findBySchIdAndCampusIdOrderBySortOrderAscIdAsc(schId, campusId)
: buildingRepository.findBySchIdOrderBySortOrderAscIdAsc(schId);
// 一次取全校教室,按楼栋聚合(避免逐楼栋查库)
Map<Integer, List<Classroom>> byBuilding = classroomRepository.findBySchId(schId).stream()
.collect(Collectors.groupingBy(Classroom::getBuildingId));
List<Map<String, Object>> result = new ArrayList<>();
for (Building b : buildings) {
List<Classroom> rooms = byBuilding.getOrDefault(b.getId(), Collections.emptyList());
long open = rooms.stream().filter(r -> "open".equals(r.getStatus())).count();
Map<String, Object> vo = new LinkedHashMap<>();
vo.put("serID", b.getId());
vo.put("campusId", b.getCampusId());
vo.put("buildingName", b.getBuildingName());
vo.put("buildingCode", b.getBuildingCode());
vo.put("floorCount", b.getFloorCount());
vo.put("sortOrder", b.getSortOrder());
vo.put("total", rooms.size());
vo.put("open", open);
vo.put("closed", rooms.size() - open);
// 楼层分布(新增字段,供教室管理页楼栋卡片绘制「楼层条」;仅含实际有教室的楼层,按楼层升序)
vo.put("floors", floorBreakdown(rooms));
result.add(vo);
}
return result;
}
/** 按楼层聚合教室数(total/open/closed),楼层升序。 */
private List<Map<String, Object>> floorBreakdown(List<Classroom> rooms) {
Map<Integer, List<Classroom>> byFloor = rooms.stream()
.filter(r -> r.getFloor() != null)
.collect(Collectors.groupingBy(Classroom::getFloor, TreeMap::new, Collectors.toList()));
List<Map<String, Object>> floors = new ArrayList<>();
for (Map.Entry<Integer, List<Classroom>> e : byFloor.entrySet()) {
List<Classroom> list = e.getValue();
long fOpen = list.stream().filter(r -> "open".equals(r.getStatus())).count();
Map<String, Object> f = new LinkedHashMap<>();
f.put("floor", e.getKey());
f.put("total", list.size());
f.put("open", fOpen);
f.put("closed", list.size() - fOpen);
floors.add(f);
}
return floors;
}
@Override
@Transactional
public Integer add(Integer campusId, String buildingName, String buildingCode, Integer floorCount, Integer sortOrder) {
String name = requireText(buildingName, "楼栋名称不得为空");
String code = requireText(buildingCode, "楼栋编号不得为空");
Integer schId = requireSchId();
Campus campus = loadOwnedCampus(campusId);
if (buildingRepository.existsBySchIdAndBuildingCode(schId, code)) {
throw new BizException(ErrorCode.PARAM_ERROR, "楼栋编号已存在");
}
Building b = new Building();
b.setCampusId(campus.getId());
b.setBuildingName(name);
b.setBuildingCode(code);
b.setFloorCount(requireFloorCount(floorCount));
b.setSortOrder(sortOrder != null ? sortOrder : 0);
b.setSchId(schId);
return buildingRepository.save(b).getId();
}
@Override
@Transactional
public void edit(Integer serID, Integer campusId, String buildingName, String buildingCode, Integer floorCount, Integer sortOrder) {
String name = requireText(buildingName, "楼栋名称不得为空");
String code = requireText(buildingCode, "楼栋编号不得为空");
Building b = loadOwned(serID);
if (buildingRepository.existsBySchIdAndBuildingCodeAndIdNot(b.getSchId(), code, serID)) {
throw new BizException(ErrorCode.PARAM_ERROR, "楼栋编号已存在");
}
Integer oldCampusId = b.getCampusId();
if (campusId != null) {
Campus campus = loadOwnedCampus(campusId);
b.setCampusId(campus.getId());
}
b.setBuildingName(name);
b.setBuildingCode(code);
b.setFloorCount(requireFloorCount(floorCount));
b.setSortOrder(sortOrder != null ? sortOrder : 0);
buildingRepository.save(b);
// 楼栋归属变化时同步教室冗余校区
if (campusId != null && !campusId.equals(oldCampusId)) {
for (Classroom r : classroomRepository.findBySchIdAndBuildingIdOrderByFloorAscRoomCodeAsc(b.getSchId(), serID)) {
r.setCampusId(campusId);
classroomRepository.save(r);
}
}
}
@Override
@Transactional
public void delete(Integer serid) {
Building b = loadOwned(serid);
if (classroomRepository.existsBySchIdAndBuildingId(b.getSchId(), b.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "该楼栋下存在教室,请先删除教室");
}
buildingRepository.delete(b);
}
private Map<String, Object> toVo(Building b) {
Map<String, Object> vo = new LinkedHashMap<>();
vo.put("serID", b.getId());
vo.put("campusId", b.getCampusId());
vo.put("buildingName", b.getBuildingName());
vo.put("buildingCode", b.getBuildingCode());
vo.put("floorCount", b.getFloorCount());
vo.put("sortOrder", b.getSortOrder());
return vo;
}
/** 加载本校楼栋(跨校/不存在报错)。 */
Building loadOwned(Integer id) {
if (id == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "楼栋ID不得为空");
}
Building b = buildingRepository.findById(id).orElse(null);
Integer schId = UserContext.getSchId();
if (b == null || schId == null || !schId.equals(b.getSchId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "楼栋不存在或无权操作");
}
return b;
}
private Campus loadOwnedCampus(Integer campusId) {
if (campusId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "所属校区不得为空");
}
Campus c = campusRepository.findById(campusId).orElse(null);
Integer schId = UserContext.getSchId();
if (c == null || schId == null || !schId.equals(c.getSchId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "所属校区不存在");
}
return c;
}
private Integer requireFloorCount(Integer floorCount) {
if (floorCount == null || floorCount < 1 || floorCount > 99) {
throw new BizException(ErrorCode.PARAM_ERROR, "楼层数量必须在 1~99 之间");
}
return floorCount;
}
private Integer requireSchId() {
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "未能获取当前学校信息,请重新登录");
}
return schId;
}
private String requireText(String s, String message) {
if (s == null || s.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, message);
}
return s.trim();
}
}
@@ -0,0 +1,172 @@
package com.yunhai.acaclassroom.service.impl;
import com.yunhai.acaclassroom.entity.Campus;
import com.yunhai.acaclassroom.entity.Classroom;
import com.yunhai.acaclassroom.repository.BuildingRepository;
import com.yunhai.acaclassroom.repository.CampusRepository;
import com.yunhai.acaclassroom.repository.ClassroomRepository;
import com.yunhai.acaclassroom.service.ICampusService;
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;
/**
* 校区管理服务实现。
*/
@Service
public class CampusService implements ICampusService {
@Autowired
private CampusRepository campusRepository;
@Autowired
private BuildingRepository buildingRepository;
@Autowired
private ClassroomRepository classroomRepository;
@Override
public List<Map<String, Object>> list() {
Integer schId = UserContext.getSchId();
if (schId == null) {
return Collections.emptyList();
}
List<Campus> campuses = campusRepository.findBySchIdOrderBySortOrderAscIdAsc(schId);
// 楼栋数按校区聚合;教室数用冗余 campus_id 直查
Map<Integer, Long> buildingCount = new HashMap<>();
for (Campus c : campuses) {
buildingCount.put(c.getId(), buildingRepository.countBySchIdAndCampusId(schId, c.getId()));
}
Map<Integer, Long> classroomCount = classroomRepository.findBySchId(schId).stream()
.collect(Collectors.groupingBy(Classroom::getCampusId, Collectors.counting()));
List<Map<String, Object>> result = new ArrayList<>();
for (Campus c : campuses) {
Map<String, Object> vo = toVo(c);
vo.put("buildingCount", buildingCount.getOrDefault(c.getId(), 0L));
vo.put("classroomCount", classroomCount.getOrDefault(c.getId(), 0L));
result.add(vo);
}
return result;
}
@Override
public List<Map<String, Object>> stats() {
Integer schId = UserContext.getSchId();
if (schId == null) {
return Collections.emptyList();
}
List<Map<String, Object>> result = new ArrayList<>();
for (Campus c : campusRepository.findBySchIdOrderBySortOrderAscIdAsc(schId)) {
Map<String, Object> vo = new LinkedHashMap<>();
vo.put("serID", c.getId());
vo.put("campusName", c.getCampusName());
vo.put("buildingCount", buildingRepository.countBySchIdAndCampusId(schId, c.getId()));
long total = 0;
long open = 0;
for (Classroom r : classroomRepository.findBySchIdAndCampusId(schId, c.getId())) {
total++;
if ("open".equals(r.getStatus())) {
open++;
}
}
vo.put("total", total);
vo.put("open", open);
vo.put("closed", total - open);
result.add(vo);
}
return result;
}
@Override
@Transactional
public Integer add(String campusName, Integer sortOrder) {
String name = requireText(campusName, "校区名称不得为空");
Integer schId = requireSchId();
if (campusRepository.existsBySchIdAndCampusName(schId, name)) {
throw new BizException(ErrorCode.PARAM_ERROR, "校区名称已存在");
}
Campus c = new Campus();
c.setCampusName(name);
c.setSortOrder(sortOrder != null ? sortOrder : 0);
c.setSchId(schId);
return campusRepository.save(c).getId();
}
@Override
@Transactional
public void edit(Integer serID, String campusName, Integer sortOrder) {
String name = requireText(campusName, "校区名称不得为空");
Campus c = loadOwned(serID);
if (campusRepository.existsBySchIdAndCampusNameAndIdNot(c.getSchId(), name, serID)) {
throw new BizException(ErrorCode.PARAM_ERROR, "校区名称已存在");
}
c.setCampusName(name);
c.setSortOrder(sortOrder != null ? sortOrder : 0);
campusRepository.save(c);
}
@Override
@Transactional
public void delete(Integer serid) {
Campus c = loadOwned(serid);
if (buildingRepository.existsBySchIdAndCampusId(c.getSchId(), c.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "该校区下存在楼栋,请先删除楼栋");
}
campusRepository.delete(c);
}
@Override
@Transactional
public void sort(List<Integer> ids) {
if (ids == null || ids.isEmpty()) {
return;
}
for (int i = 0; i < ids.size(); i++) {
Campus c = loadOwned(ids.get(i));
c.setSortOrder(i + 1);
campusRepository.save(c);
}
}
private Map<String, Object> toVo(Campus c) {
Map<String, Object> vo = new LinkedHashMap<>();
vo.put("serID", c.getId());
vo.put("campusName", c.getCampusName());
vo.put("sortOrder", c.getSortOrder());
return vo;
}
/** 加载本校校区(跨校/不存在报错)。 */
private Campus loadOwned(Integer id) {
if (id == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "校区ID不得为空");
}
Campus c = campusRepository.findById(id).orElse(null);
Integer schId = UserContext.getSchId();
if (c == null || schId == null || !schId.equals(c.getSchId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "校区不存在或无权操作");
}
return c;
}
private Integer requireSchId() {
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "未能获取当前学校信息,请重新登录");
}
return schId;
}
private String requireText(String s, String message) {
if (s == null || s.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, message);
}
return s.trim();
}
}
@@ -0,0 +1,104 @@
package com.yunhai.acaclassroom.service.impl;
import com.yunhai.acaclassroom.entity.Classroom;
import com.yunhai.acaclassroom.entity.SchoolClass;
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;
/**
* 班级档案服务实现。班级数据量小(单校十~百级),整取后内存过滤。
*/
@Service
public class ClassService implements IClassService {
@Autowired
private SchoolClassRepository classRepository;
@Autowired
private ClassroomRepository classroomRepository;
@Override
public List<Map<String, Object>> list(Integer grade) {
Integer schId = UserContext.getSchId();
if (schId == null) {
return Collections.emptyList();
}
List<SchoolClass> classes = classRepository.findBySchIdOrderByGradeAscIdAsc(schId);
if (grade != null) {
classes = classes.stream().filter(c -> grade.equals(c.getGrade())).collect(Collectors.toList());
}
// 班级 → 绑定的教室 id(绑定状态下拉过滤/行回显用)
List<Integer> ids = classes.stream().map(SchoolClass::getId).collect(Collectors.toList());
Map<Integer, Integer> boundRooms = ids.isEmpty()
? Collections.emptyMap()
: classroomRepository.findBySchIdAndClassIdIn(schId, ids).stream()
.collect(Collectors.toMap(Classroom::getClassId, Classroom::getId, (a, b) -> a));
return classes.stream().map(c -> {
Map<String, Object> 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()));
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);
}
}
@@ -0,0 +1,610 @@
package com.yunhai.acaclassroom.service.impl;
import com.yunhai.acaclassroom.entity.Building;
import com.yunhai.acaclassroom.entity.Classroom;
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.repository.ClassroomRepository;
import com.yunhai.acaclassroom.repository.ClassroomTypeRepository;
import com.yunhai.acaclassroom.repository.EquipmentRepository;import com.yunhai.acaclassroom.service.IClassroomService;
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;
/**
* 教室管理服务实现。
*/
@Service
public class ClassroomService implements IClassroomService {
/** 状态常量:开放 */
public static final String STATUS_OPEN = "open";
/** 状态常量:关闭 */
public static final String STATUS_CLOSED = "closed";
@Autowired
private ClassroomRepository classroomRepository;
@Autowired
private ClassroomTypeRepository typeRepository;
@Autowired
private EquipmentRepository equipmentRepository;
@Autowired
private com.yunhai.acaclassroom.repository.ClassroomEquipmentRepository classroomEquipmentRepository;
@Autowired
private com.yunhai.acaclassroom.repository.CampusRepository campusRepository;
@Autowired
private com.yunhai.acaclassroom.repository.BuildingRepository buildingRepository;
@Autowired
private com.yunhai.acaclassroom.repository.SchoolClassRepository classRepository;
@Autowired
private BuildingService buildingService;
@Override
public Map<String, Object> list(Integer buildingId, Integer campusId, Integer floor, Integer typeId, String status,
String keyword, Integer grade, String bindStatus,
Integer page, Integer count, String sortField, String order) {
if (buildingId == null && campusId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "请至少指定校区或楼栋");
}
Integer schId = UserContext.getSchId();
if (schId == null) {
Map<String, Object> empty = new LinkedHashMap<>();
empty.put("list", Collections.emptyList());
empty.put("total", 0);
empty.put("pageIndex", 1);
empty.put("pageSize", 0);
return empty;
}
// 作用域:楼栋优先;无楼栋时按校区取全部楼栋的教室
List<Classroom> rooms = buildingId != null
? classroomRepository.findBySchIdAndBuildingIdOrderByFloorAscRoomCodeAsc(schId, buildingId)
: classroomRepository.findBySchIdAndCampusId(schId, campusId);
// 过滤(数据量小,内存过滤)
if (floor != null) {
rooms = rooms.stream().filter(r -> floor.equals(r.getFloor())).collect(Collectors.toList());
}
if (typeId != null) {
rooms = rooms.stream().filter(r -> typeId.equals(r.getTypeId())).collect(Collectors.toList());
}
if (status != null && !status.isEmpty()) {
rooms = rooms.stream().filter(r -> status.equals(r.getStatus())).collect(Collectors.toList());
}
if (keyword != null && !keyword.trim().isEmpty()) {
String kw = keyword.trim().toLowerCase(Locale.ROOT);
rooms = rooms.stream().filter(r ->
(r.getRoomName() != null && r.getRoomName().toLowerCase(Locale.ROOT).contains(kw))
|| (r.getRoomCode() != null && r.getRoomCode().toLowerCase(Locale.ROOT).contains(kw)))
.collect(Collectors.toList());
}
// 班级映射:绑定状态过滤 / 年级 Tab 过滤 / 行内班级信息展示共用
Map<Integer, com.yunhai.acaclassroom.entity.SchoolClass> classMap = classRepository
.findBySchIdOrderByGradeAscIdAsc(schId).stream()
.collect(Collectors.toMap(com.yunhai.acaclassroom.entity.SchoolClass::getId, c -> c, (a, b) -> a));
if ("bound".equals(bindStatus)) {
rooms = rooms.stream().filter(r -> r.getClassId() != null).collect(Collectors.toList());
} else if ("unbound".equals(bindStatus)) {
rooms = rooms.stream().filter(r -> r.getClassId() == null).collect(Collectors.toList());
}
if (grade != null) {
// Tab 口径:未绑定的教室(当前年级可绑)+ 已绑定该年级班级的教室
rooms = rooms.stream().filter(r -> {
if (r.getClassId() == null) {
return true;
}
com.yunhai.acaclassroom.entity.SchoolClass c = classMap.get(r.getClassId());
return c != null && grade.equals(c.getGrade());
}).collect(Collectors.toList());
}
// 校区作用域取数无序,先补齐默认序(楼层升序 + 编号升序),与楼栋作用域的仓储序一致
if (buildingId == null) {
rooms.sort(Comparator.comparing(Classroom::getFloor,
Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(Classroom::getRoomCode, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
}
// 排序(sortField 为空时保持仓储层默认序:楼层升序 + 编号升序,与改造前行为一致)
rooms = applySort(rooms, sortField, order);
// 类型名映射
Map<Integer, String> typeNames = typeRepository.findBySchIdOrderBySortOrderAscIdAsc(schId).stream()
.collect(Collectors.toMap(ClassroomType::getId, ClassroomType::getTypeName, (a, b) -> a));
// 校区/楼栋名映射(列表跨楼栋时行内直接展示,空间筛选树与详情面板用)
Map<Integer, String> campusNames = campusRepository.findBySchIdOrderBySortOrderAscIdAsc(schId).stream()
.collect(Collectors.toMap(com.yunhai.acaclassroom.entity.Campus::getId,
com.yunhai.acaclassroom.entity.Campus::getCampusName, (a, b) -> a));
Map<Integer, String> buildingNames = buildingRepository.findBySchIdOrderBySortOrderAscIdAsc(schId).stream()
.collect(Collectors.toMap(Building::getId, Building::getBuildingName, (a, b) -> a));
// 分页(1 起)
int pageIndex = page != null && page >= 1 ? page : 1;
int pageSize = count != null && count >= 1 ? Math.min(count, 200) : 20;
int total = rooms.size();
int from = Math.min((pageIndex - 1) * pageSize, total);
int to = Math.min(from + pageSize, total);
List<Classroom> pageRooms = rooms.subList(from, to);
// 绑定班级信息(行内展示:班级名/学生人数/班主任)
// 本页教室的设备关联 → 设备名映射、数量映射(quantity 列为后加,存量 NULL 按 1)
List<Integer> pageRoomIds = pageRooms.stream().map(Classroom::getId).collect(Collectors.toList());
Map<Integer, Map<Integer, Integer>> roomEquipmentQty = new HashMap<>();
Map<Integer, String> equipmentNames = new HashMap<>();
if (!pageRoomIds.isEmpty()) {
equipmentRepository.findBySchId(schId).forEach(eq ->
equipmentNames.put(eq.getId(), eq.getEquipmentName()));
for (com.yunhai.acaclassroom.entity.ClassroomEquipment ce :
classroomEquipmentRepository.findBySchIdAndClassroomIdIn(schId, pageRoomIds)) {
int qty = ce.getQuantity() == null || ce.getQuantity() < 1 ? 1 : ce.getQuantity();
roomEquipmentQty.computeIfAbsent(ce.getClassroomId(), k -> new LinkedHashMap<>())
.put(ce.getEquipmentId(), qty);
}
}
List<Map<String, Object>> list = new ArrayList<>();
for (Classroom r : pageRooms) {
Map<String, Object> 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;
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);
Map<Integer, Integer> eqQty = roomEquipmentQty.getOrDefault(r.getId(), Collections.emptyMap());
vo.put("equipmentIds", new ArrayList<>(eqQty.keySet()));
vo.put("equipmentQtys", eqQty);
vo.put("equipmentNames", eqQty.entrySet().stream()
.map(e -> {
String name = equipmentNames.getOrDefault(e.getKey(), "");
return name.isEmpty() ? "" : (e.getValue() > 1 ? name + "×" + e.getValue() : name);
})
.filter(s -> !s.isEmpty())
.collect(Collectors.joining("、")));
list.add(vo);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", list);
result.put("total", total);
result.put("pageIndex", pageIndex);
result.put("pageSize", pageSize);
return result;
}
@Override
public List<Integer> floors(Integer buildingId) {
Integer schId = UserContext.getSchId();
if (schId == null || buildingId == null) {
return Collections.emptyList();
}
return classroomRepository.findBySchIdAndBuildingIdOrderByFloorAscRoomCodeAsc(schId, buildingId).stream()
.map(Classroom::getFloor)
.distinct()
.sorted()
.collect(Collectors.toList());
}
@Override
public List<Map<String, Object>> tree() {
Integer schId = UserContext.getSchId();
if (schId == null) {
return Collections.emptyList();
}
// 单校教室百级,一次整取;前端按 校区→楼栋→楼层 自行组树
return classroomRepository.findBySchIdOrderByFloorAscRoomCodeAsc(schId).stream()
.map(r -> {
Map<String, Object> vo = new LinkedHashMap<>();
vo.put("serID", r.getId());
vo.put("campusId", r.getCampusId());
vo.put("buildingId", r.getBuildingId());
vo.put("floor", r.getFloor());
vo.put("roomName", r.getRoomName());
vo.put("roomCode", r.getRoomCode());
vo.put("status", r.getStatus());
return vo;
})
.collect(Collectors.toList());
}
@Override
@Transactional
public Map<String, Object> batchAdd(ClassroomBatchAddRequest req) {
if (req == null || req.getBuildingId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "楼栋ID不得为空");
}
Integer floorFrom = req.getFloorFrom();
Integer floorTo = req.getFloorTo();
Integer countPerFloor = req.getCountPerFloor();
if (floorFrom == null || floorTo == null || floorFrom < 1 || floorTo < floorFrom) {
throw new BizException(ErrorCode.PARAM_ERROR, "楼层区间不正确(需 1 ≤ 起始 ≤ 截止)");
}
if (countPerFloor == null || countPerFloor < 1 || countPerFloor > 99) {
throw new BizException(ErrorCode.PARAM_ERROR, "每层教室数量必须在 1~99 之间");
}
Building building = buildingService.loadOwned(req.getBuildingId());
if (floorTo > building.getFloorCount()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"截止楼层不得超过该楼栋楼层数量(" + building.getFloorCount() + " 层)");
}
String prefix = req.getNamePrefix() != null && !req.getNamePrefix().trim().isEmpty()
? req.getNamePrefix().trim() : building.getBuildingCode();
// 校验设备 id 均属本校(批量添加:设备按数量应用到本次新建的教室)
Map<Integer, Integer> equipmentMap = sanitizeEquipmentMap(req.getEquipments(), req.getEquipmentIds(), building.getSchId());
int created = 0;
int skipped = 0;
List<Classroom> createdRooms = new ArrayList<>();
for (int floor = floorFrom; floor <= floorTo; floor++) {
for (int seq = 1; seq <= countPerFloor; seq++) {
String code = String.format("%s%02d%02d", prefix, floor, seq);
if (classroomRepository.existsBySchIdAndBuildingIdAndRoomCode(
building.getSchId(), building.getId(), code)) {
skipped++;
continue;
}
Classroom r = new Classroom();
r.setCampusId(building.getCampusId());
r.setBuildingId(building.getId());
r.setFloor(floor);
r.setRoomName(code);
r.setRoomCode(code);
r.setTypeId(req.getTypeId());
r.setStatus(STATUS_OPEN);
r.setCapacity(0);
r.setSeatCount(0);
r.setSchId(building.getSchId());
createdRooms.add(classroomRepository.save(r));
created++;
}
}
// 新建教室写入设备关联(含每间数量)
if (!equipmentMap.isEmpty()) {
for (Classroom r : createdRooms) {
equipmentMap.forEach((eqId, qty) -> {
com.yunhai.acaclassroom.entity.ClassroomEquipment ce =
new com.yunhai.acaclassroom.entity.ClassroomEquipment();
ce.setClassroomId(r.getId());
ce.setEquipmentId(eqId);
ce.setQuantity(qty);
ce.setSchId(building.getSchId());
classroomEquipmentRepository.save(ce);
});
}
}
if (created == 0) {
throw new BizException(ErrorCode.PARAM_ERROR,
"未生成任何教室:编号均与楼栋内已有教室重复(前缀 " + prefix + ")");
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("created", created);
result.put("skipped", skipped);
result.put("total", created + skipped);
return result;
}
@Override
@Transactional
public void edit(ClassroomEditRequest req) {
if (req == null || req.getSerID() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "教室ID不得为空");
}
String name = req.getRoomName() != null && !req.getRoomName().trim().isEmpty()
? req.getRoomName().trim() : null;
String code = req.getRoomCode() != null && !req.getRoomCode().trim().isEmpty()
? req.getRoomCode().trim() : null;
Classroom r = loadOwned(req.getSerID());
if (code != null && classroomRepository.existsBySchIdAndBuildingIdAndRoomCodeAndIdNot(
r.getSchId(), r.getBuildingId(), code, r.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "该楼栋内教室编号已存在");
}
if (req.getStatus() != null && !STATUS_OPEN.equals(req.getStatus()) && !STATUS_CLOSED.equals(req.getStatus())) {
throw new BizException(ErrorCode.PARAM_ERROR, "教室状态只支持 open/closed");
}
if (req.getTypeId() != null) {
ClassroomType type = typeRepository.findById(req.getTypeId()).orElse(null);
if (type == null || !r.getSchId().equals(type.getSchId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "教室类型不存在");
}
r.setTypeId(type.getId());
}
if (name != null) {
r.setRoomName(name);
}
if (code != null) {
r.setRoomCode(code);
}
if (req.getStatus() != null) {
r.setStatus(req.getStatus());
}
if (req.getCapacity() != null) {
if (req.getCapacity() < 0 || req.getCapacity() > 100000) {
throw new BizException(ErrorCode.PARAM_ERROR, "容纳人数不正确");
}
r.setCapacity(req.getCapacity());
}
if (req.getSeatCount() != null) {
if (req.getSeatCount() < 0 || req.getSeatCount() > 100000) {
throw new BizException(ErrorCode.PARAM_ERROR, "座位数不正确");
}
r.setSeatCount(req.getSeatCount());
}
classroomRepository.save(r);
// 设备关联:equipments/equipmentIds 均为 null=不改动;任一非 null(含空)=全量替换
if (req.getEquipments() != null || req.getEquipmentIds() != null) {
Map<Integer, Integer> equipmentMap =
sanitizeEquipmentMap(req.getEquipments(), req.getEquipmentIds(), r.getSchId());
classroomEquipmentRepository.deleteBySchIdAndClassroomId(r.getSchId(), r.getId());
equipmentMap.forEach((eqId, qty) -> {
com.yunhai.acaclassroom.entity.ClassroomEquipment ce =
new com.yunhai.acaclassroom.entity.ClassroomEquipment();
ce.setClassroomId(r.getId());
ce.setEquipmentId(eqId);
ce.setQuantity(qty);
ce.setSchId(r.getSchId());
classroomEquipmentRepository.save(ce);
});
}
}
/**
* 解析设备关联为 有序 map:设备 id → 每间数量。
* equipments 优先(数量缺省/非法按 1,上限 999;重复 id 取最大数量),否则回退 equipmentIds(数量按 1);
* 仅保留本校存在的设备。
*/
private Map<Integer, Integer> sanitizeEquipmentMap(
List<com.yunhai.acaclassroom.model.ClassroomEquipmentItem> equipments,
List<Integer> legacyEquipmentIds, Integer schId) {
Map<Integer, Integer> result = new LinkedHashMap<>();
if (equipments != null) {
for (com.yunhai.acaclassroom.model.ClassroomEquipmentItem item : equipments) {
if (item == null || item.getEquipmentId() == null) {
continue;
}
int qty = item.getQuantity() == null || item.getQuantity() < 1
? 1 : Math.min(item.getQuantity(), 999);
result.merge(item.getEquipmentId(), qty, Math::max);
}
} else if (legacyEquipmentIds != null) {
legacyEquipmentIds.stream().filter(Objects::nonNull).forEach(id -> result.put(id, 1));
}
if (result.isEmpty()) {
return result;
}
Set<Integer> valid = equipmentRepository.findBySchId(schId).stream()
.map(com.yunhai.acaclassroom.entity.Equipment::getId)
.collect(Collectors.toSet());
result.keySet().retainAll(valid);
return result;
}
@Override
@Transactional
public void delete(List<Integer> ids) {
if (ids == null || ids.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "请选择要删除的教室");
}
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "未能获取当前学校信息,请重新登录");
}
// 设备关联随教室删除一并清理(设备台账本身保留)
classroomEquipmentRepository.deleteBySchIdAndClassroomIdIn(schId, ids);
classroomRepository.deleteBySchIdAndIdIn(schId, ids);
}
@Override
@Transactional
public Map<String, Object> batchEdit(ClassroomBatchEditRequest req) {
if (req == null || req.getSerIDs() == null || req.getSerIDs().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "请选择要修改的教室");
}
boolean changeStatus = req.getStatus() != null && !req.getStatus().trim().isEmpty();
boolean changeType = req.getTypeId() != null;
if (!changeStatus && !changeType) {
throw new BizException(ErrorCode.PARAM_ERROR, "请至少指定要修改的项(教室状态或教室类型)");
}
String status = changeStatus ? req.getStatus().trim() : null;
if (changeStatus && !STATUS_OPEN.equals(status) && !STATUS_CLOSED.equals(status)) {
throw new BizException(ErrorCode.PARAM_ERROR, "教室状态只支持 open/closed");
}
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "未能获取当前学校信息,请重新登录");
}
if (changeType) {
ClassroomType type = typeRepository.findById(req.getTypeId()).orElse(null);
if (type == null || !schId.equals(type.getSchId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "教室类型不存在");
}
}
// 去重后逐条处理:不存在 / 跨校的 id 计入 skipped,不整批失败(与批量添加的跳过语义一致)
Set<Integer> ids = new LinkedHashSet<>(req.getSerIDs());
int updated = 0;
int skipped = 0;
for (Integer id : ids) {
if (id == null) {
skipped++;
continue;
}
Classroom r = classroomRepository.findById(id).orElse(null);
if (r == null || !schId.equals(r.getSchId())) {
skipped++;
continue;
}
if (changeStatus) {
r.setStatus(status);
}
if (changeType) {
r.setTypeId(req.getTypeId());
}
classroomRepository.save(r);
updated++;
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("updated", updated);
result.put("skipped", skipped);
result.put("total", ids.size());
return result;
}
@Override
@Transactional
public Map<String, Object> bindClass(Integer serID, Integer classId) {
if (serID == null || classId == null) {
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())) {
throw new BizException(ErrorCode.PARAM_ERROR, "班级不存在或无权操作");
}
// 一个班级同时只能绑一间教室
classroomRepository.findBySchIdAndClassIdIn(r.getSchId(), Collections.singletonList(classId)).stream()
.filter(other -> !other.getId().equals(r.getId()))
.findFirst()
.ifPresent(other -> {
throw new BizException(ErrorCode.PARAM_ERROR, "该班级已绑定教室「" + other.getRoomName() + "」,请先解绑");
});
r.setClassId(classId);
classroomRepository.save(r);
Map<String, Object> result = new LinkedHashMap<>();
result.put("classId", classId);
result.put("className", c.getClassName());
return result;
}
@Override
@Transactional
public void unbindClass(Integer serID) {
if (serID == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "教室ID不得为空");
}
Classroom r = loadOwned(serID);
if (r.getClassId() == null) {
return;
}
r.setClassId(null);
classroomRepository.save(r);
}
@Override
@Transactional
public Map<String, Object> batchUnbind(List<Integer> serIDs) {
if (serIDs == null || serIDs.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "请选择要解绑的教室");
}
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "未能获取当前学校信息,请重新登录");
}
int updated = 0;
int skipped = 0;
for (Integer id : new LinkedHashSet<>(serIDs)) {
if (id == null) {
skipped++;
continue;
}
Classroom r = classroomRepository.findById(id).orElse(null);
if (r == null || !schId.equals(r.getSchId())) {
skipped++;
continue;
}
if (r.getClassId() != null) {
r.setClassId(null);
classroomRepository.save(r);
updated++;
} else {
skipped++;
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("updated", updated);
result.put("skipped", skipped);
result.put("total", serIDs.size());
return result;
}
/**
* 教室列表排序。
* sortField 为空或未知时**原样返回**(保持仓储层默认序:楼层升序 + 编号升序),
* 因此不传排序参数的既有调用方行为完全不变。
* 支持字段:floor / roomCode / roomName / status / capacity / seatCount;order:asc(默认)/ desc。
*/
private List<Classroom> applySort(List<Classroom> rooms, String sortField, String order) {
if (rooms == null || rooms.isEmpty() || sortField == null || sortField.trim().isEmpty()) {
return rooms;
}
Comparator<String> strCmp = Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER);
Comparator<Integer> intCmp = Comparator.nullsLast(Comparator.<Integer>naturalOrder());
Comparator<Classroom> cmp;
switch (sortField.trim()) {
case "roomCode":
cmp = Comparator.comparing((Classroom r) -> r.getRoomCode(), strCmp);
break;
case "roomName":
cmp = Comparator.comparing((Classroom r) -> r.getRoomName(), strCmp);
break;
case "status":
cmp = Comparator.comparing((Classroom r) -> r.getStatus(), strCmp);
break;
case "capacity":
cmp = Comparator.comparing((Classroom r) -> r.getCapacity(), intCmp);
break;
case "seatCount":
cmp = Comparator.comparing((Classroom r) -> r.getSeatCount(), intCmp);
break;
case "floor":
cmp = Comparator.comparing((Classroom r) -> r.getFloor(), intCmp)
.thenComparing((Classroom r) -> r.getRoomCode(), strCmp);
break;
default:
return rooms;
}
List<Classroom> sorted = new ArrayList<>(rooms);
sorted.sort("desc".equalsIgnoreCase(order == null ? "" : order.trim()) ? cmp.reversed() : cmp);
return sorted;
}
private Map<String, Object> toVo(Classroom r, Map<Integer, String> typeNames) {
Map<String, Object> vo = new LinkedHashMap<>();
vo.put("serID", r.getId());
vo.put("buildingId", r.getBuildingId());
vo.put("campusId", r.getCampusId());
vo.put("floor", r.getFloor());
vo.put("roomName", r.getRoomName());
vo.put("roomCode", r.getRoomCode());
vo.put("typeId", r.getTypeId());
vo.put("typeName", r.getTypeId() != null ? typeNames.getOrDefault(r.getTypeId(), "") : "");
vo.put("status", r.getStatus());
vo.put("capacity", r.getCapacity());
vo.put("seatCount", r.getSeatCount());
return vo;
}
private Classroom loadOwned(Integer id) {
Classroom r = classroomRepository.findById(id).orElse(null);
Integer schId = UserContext.getSchId();
if (r == null || schId == null || !schId.equals(r.getSchId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "教室不存在或无权操作");
}
return r;
}
}
@@ -0,0 +1,131 @@
package com.yunhai.acaclassroom.service.impl;
import com.yunhai.acaclassroom.entity.Classroom;
import com.yunhai.acaclassroom.entity.ClassroomType;
import com.yunhai.acaclassroom.repository.ClassroomRepository;
import com.yunhai.acaclassroom.repository.ClassroomTypeRepository;
import com.yunhai.acaclassroom.service.IClassroomTypeService;
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;
/**
* 教室类型管理服务实现。
*/
@Service
public class ClassroomTypeService implements IClassroomTypeService {
@Autowired
private ClassroomTypeRepository typeRepository;
@Autowired
private ClassroomRepository classroomRepository;
@Override
public List<Map<String, Object>> list() {
Integer schId = UserContext.getSchId();
if (schId == null) {
return Collections.emptyList();
}
Map<Integer, Long> usedCount = classroomRepository.findBySchId(schId).stream()
.filter(r -> r.getTypeId() != null)
.collect(Collectors.groupingBy(Classroom::getTypeId, Collectors.counting()));
List<Map<String, Object>> result = new ArrayList<>();
for (ClassroomType t : typeRepository.findBySchIdOrderBySortOrderAscIdAsc(schId)) {
Map<String, Object> vo = new LinkedHashMap<>();
vo.put("serID", t.getId());
vo.put("typeName", t.getTypeName());
vo.put("enableFlag", t.getEnableFlag());
vo.put("sortOrder", t.getSortOrder());
vo.put("classroomCount", usedCount.getOrDefault(t.getId(), 0L));
result.add(vo);
}
return result;
}
@Override
@Transactional
public Integer add(String typeName, Integer sortOrder) {
String name = requireText(typeName, "类型名称不得为空");
Integer schId = requireSchId();
if (typeRepository.existsBySchIdAndTypeName(schId, name)) {
throw new BizException(ErrorCode.PARAM_ERROR, "类型名称已存在");
}
ClassroomType t = new ClassroomType();
t.setTypeName(name);
t.setEnableFlag(1);
t.setSortOrder(sortOrder != null ? sortOrder : 0);
t.setSchId(schId);
return typeRepository.save(t).getId();
}
@Override
@Transactional
public void edit(Integer serID, String typeName, Integer enableFlag, Integer sortOrder) {
String name = requireText(typeName, "类型名称不得为空");
ClassroomType t = loadOwned(serID);
if (typeRepository.existsBySchIdAndTypeNameAndIdNot(t.getSchId(), name, serID)) {
throw new BizException(ErrorCode.PARAM_ERROR, "类型名称已存在");
}
t.setTypeName(name);
t.setEnableFlag(enableFlag != null && enableFlag == 0 ? 0 : 1);
t.setSortOrder(sortOrder != null ? sortOrder : 0);
typeRepository.save(t);
}
@Override
@Transactional
public void delete(Integer serid) {
ClassroomType t = loadOwned(serid);
if (classroomRepository.countBySchIdAndTypeId(t.getSchId(), t.getId()) > 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "该类型已被教室使用,无法删除");
}
typeRepository.delete(t);
}
@Override
@Transactional
public void sort(List<Integer> ids) {
if (ids == null || ids.isEmpty()) {
return;
}
for (int i = 0; i < ids.size(); i++) {
ClassroomType t = loadOwned(ids.get(i));
t.setSortOrder(i + 1);
typeRepository.save(t);
}
}
private ClassroomType loadOwned(Integer id) {
if (id == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "类型ID不得为空");
}
ClassroomType t = typeRepository.findById(id).orElse(null);
Integer schId = UserContext.getSchId();
if (t == null || schId == null || !schId.equals(t.getSchId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "类型不存在或无权操作");
}
return t;
}
private Integer requireSchId() {
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "未能获取当前学校信息,请重新登录");
}
return schId;
}
private String requireText(String s, String message) {
if (s == null || s.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, message);
}
return s.trim();
}
}
@@ -0,0 +1,151 @@
package com.yunhai.acaclassroom.service.impl;
import com.yunhai.acaclassroom.entity.Equipment;
import com.yunhai.acaclassroom.repository.ClassroomEquipmentRepository;
import com.yunhai.acaclassroom.repository.EquipmentRepository;
import com.yunhai.acaclassroom.service.IEquipmentService;
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;
/**
* 教室设备管理服务实现(独立设备台账)。
*/
@Service
public class EquipmentService implements IEquipmentService {
@Autowired
private EquipmentRepository equipmentRepository;
@Autowired
private ClassroomEquipmentRepository classroomEquipmentRepository;
@Override
public Map<String, Object> list(String keyword) {
Integer schId = UserContext.getSchId();
if (schId == null) {
return emptyPage();
}
List<Equipment> rows = new ArrayList<>(equipmentRepository.findBySchIdOrderByIdDesc(schId));
if (keyword != null && !keyword.trim().isEmpty()) {
String kw = keyword.trim().toLowerCase(Locale.ROOT);
rows = rows.stream().filter(e ->
e.getEquipmentName() != null && e.getEquipmentName().toLowerCase(Locale.ROOT).contains(kw))
.collect(Collectors.toList());
}
// 引用统计:教室数与使用数量(quantity 列为后加,存量 NULL 按 1)均按设备聚合
Map<Integer, Long> refCount = new HashMap<>();
Map<Integer, Integer> usedQty = new HashMap<>();
for (com.yunhai.acaclassroom.entity.ClassroomEquipment ce : classroomEquipmentRepository.findBySchId(schId)) {
refCount.merge(ce.getEquipmentId(), 1L, Long::sum);
usedQty.merge(ce.getEquipmentId(), ce.getQuantity() == null ? 1 : ce.getQuantity(), Integer::sum);
}
List<Map<String, Object>> list = new ArrayList<>();
for (Equipment e : rows) {
Map<String, Object> vo = toVo(e);
vo.put("quantity", usedQty.getOrDefault(e.getId(), 0));
vo.put("classroomCount", refCount.getOrDefault(e.getId(), 0L));
list.add(vo);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", list);
result.put("total", list.size());
return result;
}
@Override
@Transactional
public Integer add(String equipmentName, String remark) {
String name = requireText(equipmentName, "设备名称不得为空");
Integer schId = requireSchId();
if (equipmentRepository.existsBySchIdAndEquipmentName(schId, name)) {
throw new BizException(ErrorCode.PARAM_ERROR, "设备名称已存在");
}
Equipment e = new Equipment();
e.setEquipmentName(name);
e.setRemark(trimRemark(remark));
e.setSchId(schId);
return equipmentRepository.save(e).getId();
}
@Override
@Transactional
public void edit(Integer serID, String equipmentName, String remark) {
String name = requireText(equipmentName, "设备名称不得为空");
Equipment e = loadOwned(serID);
if (equipmentRepository.existsBySchIdAndEquipmentNameAndIdNot(e.getSchId(), name, e.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "设备名称已存在");
}
e.setEquipmentName(name);
e.setRemark(trimRemark(remark));
equipmentRepository.save(e);
}
@Override
@Transactional
public void delete(Integer serid) {
Equipment e = loadOwned(serid);
if (classroomEquipmentRepository.existsBySchIdAndEquipmentId(e.getSchId(), e.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "该设备已被教室使用,无法删除");
}
equipmentRepository.delete(e);
}
private Map<String, Object> toVo(Equipment e) {
Map<String, Object> vo = new LinkedHashMap<>();
vo.put("serID", e.getId());
vo.put("equipmentName", e.getEquipmentName());
vo.put("remark", e.getRemark());
return vo;
}
private Equipment loadOwned(Integer id) {
if (id == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "设备ID不得为空");
}
Equipment e = equipmentRepository.findById(id).orElse(null);
Integer schId = UserContext.getSchId();
if (e == null || schId == null || !schId.equals(e.getSchId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "设备不存在或无权操作");
}
return e;
}
private String trimRemark(String remark) {
if (remark == null) {
return null;
}
String t = remark.trim();
return t.isEmpty() ? null : (t.length() > 200 ? t.substring(0, 200) : t);
}
private Map<String, Object> emptyPage() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", Collections.emptyList());
result.put("total", 0);
return result;
}
private Integer requireSchId() {
Integer schId = UserContext.getSchId();
if (schId == null) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "未能获取当前学校信息,请重新登录");
}
return schId;
}
private String requireText(String s, String message) {
if (s == null || s.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, message);
}
return s.trim();
}
}
@@ -0,0 +1,32 @@
package com.yunhai.acaclassroom.service.impl;
import com.yunhai.acaclassroom.entity.SysOperationLog;
import com.yunhai.acaclassroom.repository.SysOperationLogRepository;
import com.yunhai.acaclassroom.service.ISysOperationLogService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
/**
* 操作日志服务实现:异步落库(参照 UserclassService SysOperationLogServiceImpl.saveAsync)。
*/
@Service
public class SysOperationLogService implements ISysOperationLogService {
private static final Logger log = LoggerFactory.getLogger(SysOperationLogService.class);
@Autowired
private SysOperationLogRepository sysOperationLogRepository;
@Async("auditLogExecutor")
@Override
public void saveAsync(SysOperationLog entity) {
try {
sysOperationLogRepository.save(entity);
} catch (Exception e) {
log.warn("[SysOperationLog] 异步保存失败: {}", e.getMessage());
}
}
}
@@ -0,0 +1,206 @@
package com.yunhai.acaclassroom.service.impl;
import com.yunhai.acaclassroom.service.IUserCenterService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* 用户中心门面服务实现:GetUserMod 模块树组装。
* <p>跨仓调用统一收敛到 {@link YschBaseOpenApi} 门面,Controller 不直接静态调用。</p>
*/
@Service
public class UserCenterService implements IUserCenterService {
private static final Logger log = LoggerFactory.getLogger(UserCenterService.class);
/**
* 本地联调开关(与 ControllerUserContextAspect 同键):免 SSO 联调时平台用户中心
* 里不存在开发身份(99999),getUserModule 必然返回空,前端会切成单应用模式
* 隐藏左侧菜单。开关开启且平台返回空时,回退本地默认模块树(modId 与前端
* routes.ts 的 meta.modId 契约一致)。生产严禁开启,不受影响。
*/
@Value("${acaclassroom.dev-context.enabled:false}")
private boolean devContextEnabled;
/**
* dev-context 联调账号(平台登录名,如 lijing):开关开启且无 CAS 会话时,
* 按此账号查平台用户中心取真实身份(姓名/头像/学校),顶栏与审计身份均为真实值。
* 环境变量:{@code ACACLASSROOM_DEVCONTEXT_LOGINNAME}。不配则 GetUserInfo 返回空(前端显示"管理员")。
*/
@Value("${acaclassroom.dev-context.login-name:}")
private String devLoginName;
/** dev 身份缓存(进程内):正面 5 分钟,查无此人/异常负缓存 60 秒,避免每请求打一次平台接口 */
private static final long DEV_USER_TTL_MS = 5 * 60 * 1000L;
private static final long DEV_USER_NEG_TTL_MS = 60 * 1000L;
private volatile BoaUser devUserCache;
/** 0 = 从未加载(不能用 Long.MIN_VALUE:now - MIN_VALUE 溢出为负会被误判成缓存命中) */
private volatile long devUserCacheAt = 0L;
@Override
public List<Map<String, Object>> getUserModuleTree(Integer userId, Integer schId) {
if (userId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空");
}
List<BoaModule> modules = YschBaseOpenApi.getUserModule(userId, schId);
if (modules == null || modules.isEmpty()) {
if (devContextEnabled) {
log.info("[GetUserMod] 平台未返回模块(本地联调身份 userId={}),回退本地默认模块树", userId);
return buildModuleTree(defaultModules());
}
return Collections.emptyList();
}
// 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望
return buildModuleTree(modules);
}
@Override
public BoaUser getCurrentUser(BoaUser casUser) {
if (casUser != null) {
return casUser;
}
if (!devContextEnabled) {
return null;
}
return resolveDevUser();
}
/** 启动即打印 dev 联调身份解析入参,便于排查环境变量是否绑定成功(仅 dev-context 开启时输出)。 */
@javax.annotation.PostConstruct
void logDevContext() {
if (devContextEnabled) {
log.info("[DevContext] 免SSO联调开关已开启,联调账号 acaclassroom.dev-context.login-name='{}'",
devLoginName == null ? "" : devLoginName.trim());
}
}
/**
* 按 {@code dev-context.login-name} 查平台用户中心取真实 dev 身份(带缓存)。
* 未配置/查无此人/平台异常均返回 null(负缓存避免联调期反复打失败请求)。
*/
private BoaUser resolveDevUser() {
String account = devLoginName == null ? "" : devLoginName.trim();
if (account.isEmpty()) {
return null;
}
long now = System.currentTimeMillis();
BoaUser cached = devUserCache;
long loadedAt = devUserCacheAt;
long ttl = cached != null ? DEV_USER_TTL_MS : DEV_USER_NEG_TTL_MS;
if (loadedAt != 0L && now - loadedAt < ttl) {
return cached;
}
synchronized (this) {
if (devUserCacheAt != 0L
&& System.currentTimeMillis() - devUserCacheAt < (devUserCache != null ? DEV_USER_TTL_MS : DEV_USER_NEG_TTL_MS)) {
return devUserCache;
}
BoaUser user = null;
try {
user = YschBaseOpenApi.getUserByLoginName(account);
if (user == null) {
log.warn("[DevContext] 平台用户中心未查到账号 {},GetUserInfo 返回空(60s 内不再重试)", account);
} else {
log.info("[DevContext] dev 身份解析为平台账号 {}:id={} name={}", account, user.getId(), user.getName());
}
} catch (Exception e) {
log.warn("[DevContext] 平台用户中心查询账号 {} 失败:{}(60s 内不再重试)", account, e.getMessage());
}
devUserCache = user;
devUserCacheAt = System.currentTimeMillis();
return user;
}
}
/** 本地联调默认模块树:教室管理 → 绑定班级 → 基础设置(顺序即左侧菜单顺序)。 */
private List<BoaModule> defaultModules() {
BoaModule classroom = new BoaModule();
classroom.setId(41);
classroom.setParentId(0);
classroom.setName("教室管理");
classroom.setPath("/classroom");
classroom.setLevel(1);
classroom.setSortNo(1);
classroom.setIcon("");
BoaModule classBind = new BoaModule();
classBind.setId(43);
classBind.setParentId(0);
classBind.setName("绑定班级");
classBind.setPath("/classbind");
classBind.setLevel(1);
classBind.setSortNo(2);
classBind.setIcon("");
BoaModule setting = new BoaModule();
setting.setId(42);
setting.setParentId(0);
setting.setName("基础设置");
setting.setPath("/setting");
setting.setLevel(1);
setting.setSortNo(3);
setting.setIcon("");
List<BoaModule> list = new ArrayList<>();
list.add(classroom);
list.add(classBind);
list.add(setting);
return list;
}
/**
* 将扁平模块列表组装为树。
* 顶层取 parentId=0 或父节点不在列表中的节点,保持 SDK(用户中心)返回顺序。
*/
private List<Map<String, Object>> buildModuleTree(List<BoaModule> modules) {
Set<Integer> moduleIds = new HashSet<>();
for (BoaModule m : modules) {
moduleIds.add(m.getId());
}
// 每个模块节点(含 children 占位)
Map<Integer, Map<String, Object>> nodeMap = new LinkedHashMap<>();
for (BoaModule m : modules) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", m.getId());
node.put("modId", m.getId());
node.put("name", m.getName());
node.put("parentId", m.getParentId() != null ? m.getParentId() : 0);
node.put("page", m.getPath() != null ? m.getPath() : "");
node.put("level", m.getLevel() != null ? m.getLevel() : 0);
node.put("icon", m.getIcon() != null ? m.getIcon() : "");
node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0);
node.put("usable", true);
node.put("children", new ArrayList<>());
nodeMap.put(m.getId(), node);
}
// 挂父子关系
List<Map<String, Object>> roots = new ArrayList<>();
for (BoaModule m : modules) {
Map<String, Object> node = nodeMap.get(m.getId());
int parentId = m.getParentId() != null ? m.getParentId() : 0;
if (parentId == 0 || !moduleIds.contains(parentId)) {
roots.add(node);
} else {
@SuppressWarnings("unchecked")
List<Map<String, Object>> children = (List<Map<String, Object>>) nodeMap.get(parentId).get("children");
children.add(node);
}
}
if (roots.isEmpty()) {
log.warn("[GetUserMod] 模块列表无根节点, count={}", modules.size());
}
return roots;
}
}
@@ -0,0 +1,50 @@
package com.yunhai.acaclassroom.utils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
/**
* GZIP 压缩工具(参照 UserclassService GzipUtil),用于操作日志响应体压缩存储。
*/
public final class GzipUtil {
private GzipUtil() {
}
/** 字符串 → GZIP 字节。失败返回 null(调用方按可空处理,不影响主业务)。 */
public static byte[] compress(String data) {
if (data == null || data.isEmpty()) {
return null;
}
try (ByteArrayOutputStream bos = new ByteArrayOutputStream(data.length() / 2)) {
try (GZIPOutputStream gzip = new GZIPOutputStream(bos)) {
gzip.write(data.getBytes(StandardCharsets.UTF_8));
}
return bos.toByteArray();
} catch (Exception e) {
return null;
}
}
/** GZIP 字节 → 字符串。失败返回 null。 */
public static String decompress(byte[] data) {
if (data == null || data.length == 0) {
return null;
}
try (ByteArrayInputStream bis = new ByteArrayInputStream(data);
GZIPInputStream gzip = new GZIPInputStream(bis);
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] buf = new byte[4096];
int n;
while ((n = gzip.read(buf)) != -1) {
bos.write(buf, 0, n);
}
return new String(bos.toByteArray(), StandardCharsets.UTF_8);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,88 @@
package com.yunhai.acaclassroom.utils;
/**
* 当前请求的用户上下文(ThreadLocal),参照 UserclassService 的自有 UserContext。
* <p>由 {@link com.yunhai.acaclassroom.aspect.ControllerUserContextAspect} 在控制器执行前,
* 从基础平台 {@code BoaUserContext.get()}(或控制器 {@code BoaUser} 入参)复制填充,请求结束 {@link #clear()}。</p>
*
* <p>业务约定:实体只存数字 id,不存登录账号。
* <ul>
* <li>{@link #getUserId()} —— 平台用户数字 id(教师用户),业务外键/审计列存此值;</li>
* <li>{@link #getSchId()} —— 当前身份所属学校,多租户隔离。</li>
* </ul>
*/
public final class UserContext {
private static final ThreadLocal<UserInfo> HOLDER = new ThreadLocal<>();
private UserContext() {
}
public static void set(UserInfo info) {
HOLDER.set(info);
}
public static UserInfo get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
// ============ 便捷读取(无上下文时返回 null,调用方按需判空/兜底) ============
/** 平台用户数字 id(教师用户统一用它做业务外键,不存账号) */
public static Integer getUserId() {
UserInfo i = HOLDER.get();
return i == null ? null : i.userId;
}
/** 显示姓名(非登录账号) */
public static String getUserName() {
UserInfo i = HOLDER.get();
return i == null ? null : i.userName;
}
public static Integer getSchId() {
UserInfo i = HOLDER.get();
return i == null ? null : i.schId;
}
public static Integer getRoleId() {
UserInfo i = HOLDER.get();
return i == null ? null : i.roleId;
}
public static Integer getType() {
UserInfo i = HOLDER.get();
return i == null ? null : i.type;
}
public static String getIp() {
UserInfo i = HOLDER.get();
return i == null ? null : i.ip;
}
/** 不可变快照。 */
public static final class UserInfo {
public Integer userId;
public String userName;
public Integer schId;
public Integer roleId;
public Integer type;
public String ip;
public UserInfo() {
}
public UserInfo(Integer userId, String userName, Integer schId, Integer roleId, Integer type, String ip) {
this.userId = userId;
this.userName = userName;
this.schId = schId;
this.roleId = roleId;
this.type = type;
this.ip = ip;
}
}
}
+63
View File
@@ -0,0 +1,63 @@
server:
port: 8106
spring:
mvc:
date-format: yyyy-MM-dd HH:mm:ss
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss
datasource:
url: jdbc:mysql://localhost:3306/aca_classroom?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai
username: root
password: 123123
driver-class-name: com.mysql.cj.jdbc.Driver
min-idle: 10
max-active: 30
#jpa
jpa:
database: MySQL
show-sql: true
hibernate:
# 开发期使用 update 自动建表;生产环境设置成 none
ddl-auto: update
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
properties:
hibernate.order_by.default_null_ordering: last
open-in-view: true
#文件上传
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
#本地关闭 rabbit
rabbitmq:
listener:
direct:
auto-startup: false
simple:
auto-startup: false
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh:
sso:
server-url-prefix: http://sso.yunhaixingtong.com
client-host-url: http://localhost:8106
allow-headers: all
allow-origins: all
url-pattern: /api/*
mobi-api-pattern: /mobiapi/*
app-auth:
enabled: true
# 本应用自身身份(客户端签名用)
app-key: 100024
app-secret: 4492df661f3740d38e68ee954cebdfba
open:
enabled: true
base-host: http://base.yunhaixingtong.com
user-center-url: http://base.yunhaixingtong.com/userbase
# 班级管理(bschclass)服务基址
bschclass: http://base.yunhaixingtong.com/userclass
+33
View File
@@ -0,0 +1,33 @@
spring:
application:
name: acaclassroom
profiles:
active: ${SPRING_PROFILES_ACTIVE:local}
cloud:
nacos:
config:
enabled: ${NACOS_CONFIG_ENABLED:false}
server-addr: ${NACOS_ADDR:nacos.yunhaixingtong.com:8848}
namespace: ${SPRING_CLOUD_NACOS_CONFIG_NAMESPACE:6196f0de-a28d-4894-8f10-e2982b57c6bc}
# 应用自身配置所在分组
group: OA_APP
# 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension},即 notice.yaml
file-extension: yaml
encode: UTF-8
# nacos 不可达时不强制退出,回退到本地 application.yml 兜底
fail-fast: false
# 只读账号
username: ${SPRING_CLOUD_NACOS_USERNAME:yh-reader}
password: ${SPRING_CLOUD_NACOS_PASSWORD:123456}
# 共享公共配置:public 空间下的 common.properties
shared-configs:
# ① 1 号(最低优先级):public 命名空间(namespace="" 固定写空字符串)的 application-default.yaml
- data-id: application-default.yaml
group: DEFAULT_GROUP
namespace: "" # ⭐ 固定 = "",强制指向 Nacos "public" 命名空间,不读上面的全局 prod namespace
refresh: false
# ② 2 号(中等优先级):prod 命名空间(不写 namespace = 继承顶部全局 prod namespace id)的 application-infra.yaml
- data-id: application-infra.yaml
group: DEFAULT_GROUP
refresh: false
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 引入 Spring Boot 默认彩色日志变量 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<!-- 控制台:所有环境都有,且带颜色2 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>${CONSOLE_LOG_CHARSET}</charset>
</encoder>
</appender>
<!-- 文件日志:只有非 local 环境才启用 -->
<springProfile name="!local">
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/app/logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/app/logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
</encoder>
</appender>
</springProfile>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<springProfile name="!local">
<appender-ref ref="FILE" />
</springProfile>
</root>
</configuration>
@@ -0,0 +1,10 @@
-- 教室管理(acaclassroom)初始化脚本:仅建库;表结构由 JPA ddl-auto=update 自动创建。
-- 本地:mysql -uroot -p123123 < acaclassroom_init.sql
CREATE DATABASE IF NOT EXISTS aca_classroom DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE aca_classroom;
-- 种子数据(可选):ddl-auto 建表后执行,或由页面手工维护。
-- INSERT INTO aca_classroom_type (type_name, enable_flag, sort_order, sch_id, create_time, update_time) VALUES
-- ('普通教室', 1, 1, <schId>, NOW(), NOW()),
-- ('多媒体教室', 1, 2, <schId>, NOW(), NOW()),
-- ('实验室', 1, 3, <schId>, NOW(), NOW());