diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a46b5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +.idea/ +*.iml +*.log +.DS_Store diff --git a/docker/application-prod.yml b/docker/application-prod.yml new file mode 100644 index 0000000..db3838f --- /dev/null +++ b/docker/application-prod.yml @@ -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 diff --git a/docker/run-app.sh b/docker/run-app.sh new file mode 100644 index 0000000..5f57236 --- /dev/null +++ b/docker/run-app.sh @@ -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 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..1574125 --- /dev/null +++ b/pom.xml @@ -0,0 +1,208 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + acaclassroom + 0.0.1 + AcaClassroom + 教室管理子应用 + + 1.8 + + UTF-8 + UTF-8 + + + + + + + com.yunhai.platform + yh-platform-dependencies + 1.0.0-SNAPSHOT + pom + import + + + + com.alibaba.cloud + spring-cloud-alibaba-dependencies + 2021.0.5.0 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-aop + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.security + spring-security-crypto + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + org.springframework.cloud + spring-cloud-starter-bootstrap + 3.1.8 + + + + com.mysql + mysql-connector-j + runtime + + + + org.projectlombok + lombok + true + + + + + com.alibaba + fastjson + 1.2.80 + + + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + + + + + com.yunhai.platform + yh-spring-boot-starter + + + + + org.jasig.cas.client + cas-client-core + 3.5.1 + + + + + com.alibaba + easyexcel + 3.3.2 + + + + + + + + acaclassroom + + + org.apache.maven.plugins + maven-compiler-plugin + + + false + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.acaclassroom.AcaClassroomApplication + + + + + repackage + + + + + + + + + + + nexus-public + bsch/nexus public + http://nexus.yunhaixingtong.com/repository/maven-public/ + true + + truenever + + + + + + ci + + C:/aca-classroom-build/acaclassroom + + + + + diff --git a/smoke/smoke.mjs b/smoke/smoke.mjs new file mode 100644 index 0000000..2f46f81 --- /dev/null +++ b/smoke/smoke.mjs @@ -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); diff --git a/src/main/java/com/yunhai/acaclassroom/AcaClassroomApplication.java b/src/main/java/com/yunhai/acaclassroom/AcaClassroomApplication.java new file mode 100644 index 0000000..eb310c8 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/AcaClassroomApplication.java @@ -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); + } + +} diff --git a/src/main/java/com/yunhai/acaclassroom/annotation/OperationLog.java b/src/main/java/com/yunhai/acaclassroom/annotation/OperationLog.java new file mode 100644 index 0000000..2d41001 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/annotation/OperationLog.java @@ -0,0 +1,22 @@ +package com.yunhai.acaclassroom.annotation; + +import java.lang.annotation.*; + +/** + * 标注在控制器方法上,声明一次需要落审计日志的操作(决策 D2「保留日志表」→ 统一操作日志)。 + *

由 {@link com.yunhai.acaclassroom.aspect.SysOperationLogAspect} 环绕拦截,写入 {@code sys_operation_log}。

+ */ +@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; +} diff --git a/src/main/java/com/yunhai/acaclassroom/aspect/ControllerUserContextAspect.java b/src/main/java/com/yunhai/acaclassroom/aspect/ControllerUserContextAspect.java new file mode 100644 index 0000000..77e4a81 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/aspect/ControllerUserContextAspect.java @@ -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),请求结束清理。 + *

参照 UserclassService 的 ControllerUserContextAspect:优先从控制器入参里的 {@code BoaUser} 取, + * 否则回退到框架 {@code BoaUserContext#get()}。本模块为纯教师端,无视角切换。

+ */ +@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(); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/aspect/SysOperationLogAspect.java b/src/main/java/com/yunhai/acaclassroom/aspect/SysOperationLogAspect.java new file mode 100644 index 0000000..62b6cf7 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/aspect/SysOperationLogAspect.java @@ -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): + * 点切**全部控制器方法**,无需逐方法标注注解; + * + *

@Order 大于 {@link ControllerUserContextAspect},保证写日志时 {@link UserContext} 已填充。

+ */ +@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); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/common/listener/AuditEntityListener.java b/src/main/java/com/yunhai/acaclassroom/common/listener/AuditEntityListener.java new file mode 100644 index 0000000..554fbf1 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/common/listener/AuditEntityListener.java @@ -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 审计监听器:实体持久化/更新前自动填充创建人/更新人与时间戳。 + *

决策 D4:{@code create_by / update_by} 存**当前登录用户 ID**(数字 id),不再存登录账号。 + * id 取自 {@link UserContext#getUserId()}(由 ControllerUserContextAspect 在请求线程填充); + * 无登录上下文(启动种子/系统任务)时为 null,保持原值。

+ */ +@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(); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/common/validation/ValidationGroups.java b/src/main/java/com/yunhai/acaclassroom/common/validation/ValidationGroups.java new file mode 100644 index 0000000..9012257 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/common/validation/ValidationGroups.java @@ -0,0 +1,10 @@ +package com.yunhai.acaclassroom.common.validation; + +/** + * JSR-303 分组校验标记接口(对齐 UserBase 规范)。 + * Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。 + */ +public interface ValidationGroups { + interface Create {} + interface Update {} +} diff --git a/src/main/java/com/yunhai/acaclassroom/config/AsyncConfig.java b/src/main/java/com/yunhai/acaclassroom/config/AsyncConfig.java new file mode 100644 index 0000000..ed8a179 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/config/AsyncConfig.java @@ -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)。 + *

队列打满时丢弃任务(DiscardPolicy):审计日志让位于主业务,绝不阻塞请求。

+ */ +@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; + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/config/Config.java b/src/main/java/com/yunhai/acaclassroom/config/Config.java new file mode 100644 index 0000000..05b4721 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/config/Config.java @@ -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"; +} diff --git a/src/main/java/com/yunhai/acaclassroom/config/JacksonConfig.java b/src/main/java/com/yunhai/acaclassroom/config/JacksonConfig.java new file mode 100644 index 0000000..5ebbe74 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/config/JacksonConfig.java @@ -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)); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/config/SwaggerConfig.java b/src/main/java/com/yunhai/acaclassroom/config/SwaggerConfig.java new file mode 100644 index 0000000..e765f5f --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/config/SwaggerConfig.java @@ -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")); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/controller/api/BaseApiController.java b/src/main/java/com/yunhai/acaclassroom/controller/api/BaseApiController.java new file mode 100644 index 0000000..7b3d108 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/controller/api/BaseApiController.java @@ -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/)。 + *

路径前缀:{@code Config.API_PATH + "/base/"}。 + * 承载平台代理接口:GetUserInfo / GetUserMod,业务逻辑已下沉 {@link IUserCenterService}。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/base/") +public class BaseApiController { + + @Autowired + private IUserCenterService userCenterService; + + /** + * 获取当前登录用户信息(顶栏姓名/头像数据源)。 + *

CAS 会话身份优先;无会话且 dev-context 开启时按 {@code acaclassroom.dev-context.login-name} + * 查平台用户中心返回真实用户(免 SSO 联调也能拿到 lijing 等账号的姓名与头像)。 + * 两者皆无时返回 null,由前端降级显示默认头像与"管理员"。

+ */ + @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> 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); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/controller/api/BuildingController.java b/src/main/java/com/yunhai/acaclassroom/controller/api/BuildingController.java new file mode 100644 index 0000000..136e649 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/controller/api/BuildingController.java @@ -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> list(@RequestParam(value = "campusId", required = false) Integer campusId) { + return buildingService.list(campusId); + } + + /** 楼栋统计(教室管理页"显示图")。 */ + @GetMapping("Stats") + public List> 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); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/controller/api/CampusController.java b/src/main/java/com/yunhai/acaclassroom/controller/api/CampusController.java new file mode 100644 index 0000000..228ab5e --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/controller/api/CampusController.java @@ -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> list() { + return campusService.list(); + } + + /** 校区统计(总览页)。 */ + @GetMapping("Stats") + public List> 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 parseIds(String ids) { + if (ids == null || ids.trim().isEmpty()) { + return java.util.Collections.emptyList(); + } + try { + String[] parts = ids.split(","); + List 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 格式不正确"); + } + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/controller/api/ClassController.java b/src/main/java/com/yunhai/acaclassroom/controller/api/ClassController.java new file mode 100644 index 0000000..13fca2e --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/controller/api/ClassController.java @@ -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> list(@RequestParam(value = "grade", required = false) Integer grade) { + return classService.list(grade); + } + + @PostMapping("Add") + @OperationLog(module = "教室管理-绑定班级", type = "CREATE") + public Integer add(@RequestParam("className") String className, + @RequestParam("grade") Integer grade, + @RequestParam(value = "studentCount", required = false) Integer studentCount, + @RequestParam(value = "headTeacher", required = false) String headTeacher) { + return classService.add(className, grade, studentCount, headTeacher); + } + + /** 删除班级(已被教室绑定时拒绝)。 */ + @PostMapping("Delete") + @OperationLog(module = "教室管理-绑定班级", type = "DELETE") + public void delete(@RequestParam("serid") Integer serid) { + classService.delete(serid); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/controller/api/ClassroomController.java b/src/main/java/com/yunhai/acaclassroom/controller/api/ClassroomController.java new file mode 100644 index 0000000..381086d --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/controller/api/ClassroomController.java @@ -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 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 floors(@RequestParam("buildingId") Integer buildingId) { + return classroomService.floors(buildingId); + } + + /** 全校教室扁平列表(教室管理页「空间筛选树」数据源,轻量字段)。 */ + @GetMapping("Tree") + public List> tree() { + return classroomService.tree(); + } + + /** + * 批量添加教室:输入楼层区间(1~楼栋楼层数量)、每层教室数量、教室类型; + * 编号默认 {前缀}{楼层2位}{序号2位},重复的跳过。 + */ + @PostMapping("BatchAdd") + @OperationLog(module = "教室管理-教室", type = "CREATE") + public Map 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 batchEdit(@RequestBody ClassroomBatchEditRequest req) { + return classroomService.batchEdit(req); + } + + /** 批量删除教室(JSON id 数组)。 */ + @PostMapping("Delete") + @OperationLog(module = "教室管理-教室", type = "DELETE") + public void delete(@RequestBody List ids) { + classroomService.delete(ids); + } + + /** + * 教室绑定班级(JSON {serID, classId};一个班级同时只能绑一间教室)。 + */ + @PostMapping("BindClass") + @OperationLog(module = "教室管理-绑定班级", type = "UPDATE") + public Map 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 batchUnbind(@RequestBody List serIDs) { + return classroomService.batchUnbind(serIDs); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/controller/api/ClassroomTypeController.java b/src/main/java/com/yunhai/acaclassroom/controller/api/ClassroomTypeController.java new file mode 100644 index 0000000..c391faa --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/controller/api/ClassroomTypeController.java @@ -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> 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)); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/controller/api/EquipmentController.java b/src/main/java/com/yunhai/acaclassroom/controller/api/EquipmentController.java new file mode 100644 index 0000000..4a0b69d --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/controller/api/EquipmentController.java @@ -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 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); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/entity/BaseEntity.java b/src/main/java/com/yunhai/acaclassroom/entity/BaseEntity.java new file mode 100644 index 0000000..4e9189d --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/entity/BaseEntity.java @@ -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; +} diff --git a/src/main/java/com/yunhai/acaclassroom/entity/Building.java b/src/main/java/com/yunhai/acaclassroom/entity/Building.java new file mode 100644 index 0000000..834e3cc --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/entity/Building.java @@ -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; +} diff --git a/src/main/java/com/yunhai/acaclassroom/entity/Campus.java b/src/main/java/com/yunhai/acaclassroom/entity/Campus.java new file mode 100644 index 0000000..a9f8e74 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/entity/Campus.java @@ -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; +} diff --git a/src/main/java/com/yunhai/acaclassroom/entity/Classroom.java b/src/main/java/com/yunhai/acaclassroom/entity/Classroom.java new file mode 100644 index 0000000..37f19bd --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/entity/Classroom.java @@ -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; +} diff --git a/src/main/java/com/yunhai/acaclassroom/entity/ClassroomEquipment.java b/src/main/java/com/yunhai/acaclassroom/entity/ClassroomEquipment.java new file mode 100644 index 0000000..29c91f8 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/entity/ClassroomEquipment.java @@ -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; +} diff --git a/src/main/java/com/yunhai/acaclassroom/entity/ClassroomType.java b/src/main/java/com/yunhai/acaclassroom/entity/ClassroomType.java new file mode 100644 index 0000000..9bea058 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/entity/ClassroomType.java @@ -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; +} diff --git a/src/main/java/com/yunhai/acaclassroom/entity/Equipment.java b/src/main/java/com/yunhai/acaclassroom/entity/Equipment.java new file mode 100644 index 0000000..5e44d7d --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/entity/Equipment.java @@ -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; +} diff --git a/src/main/java/com/yunhai/acaclassroom/entity/SchoolClass.java b/src/main/java/com/yunhai/acaclassroom/entity/SchoolClass.java new file mode 100644 index 0000000..82311c3 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/entity/SchoolClass.java @@ -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; +} diff --git a/src/main/java/com/yunhai/acaclassroom/entity/SysOperationLog.java b/src/main/java/com/yunhai/acaclassroom/entity/SysOperationLog.java new file mode 100644 index 0000000..e2fec8b --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/entity/SysOperationLog.java @@ -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}。 + *

由 {@link com.yunhai.acaclassroom.aspect.SysOperationLogAspect} 对全部控制器方法环绕落库 + * (写操作可加 {@code @OperationLog} 显式指定模块/类型)。

+ */ +@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; +} diff --git a/src/main/java/com/yunhai/acaclassroom/model/ClassroomBatchAddRequest.java b/src/main/java/com/yunhai/acaclassroom/model/ClassroomBatchAddRequest.java new file mode 100644 index 0000000..1b310a2 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/model/ClassroomBatchAddRequest.java @@ -0,0 +1,96 @@ +package com.yunhai.acaclassroom.model; + +/** + * 教室批量添加请求(JSON)。 + *

按楼层区间逐层生成教室:楼层从 floorFrom 到 floorTo(含),每层 countPerFloor 间; + * 编号默认 {楼栋编号}{楼层2位}{序号2位},与已有编号冲突的跳过。

+ */ +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 equipmentIds; + + /** 关联设备(应用到本次新生成的教室,含每间数量;可空) */ + private java.util.List equipments; + + public java.util.List getEquipments() { + return equipments; + } + + public void setEquipments(java.util.List equipments) { + this.equipments = equipments; + } + + public java.util.List getEquipmentIds() { + return equipmentIds; + } + + public void setEquipmentIds(java.util.List 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; + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/model/ClassroomBatchEditRequest.java b/src/main/java/com/yunhai/acaclassroom/model/ClassroomBatchEditRequest.java new file mode 100644 index 0000000..683a348 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/model/ClassroomBatchEditRequest.java @@ -0,0 +1,46 @@ +package com.yunhai.acaclassroom.model; + +import java.util.List; + +/** + * 教室批量编辑请求(JSON)。 + * + * 用于列表页多选后的批量操作:只允许修改「教室状态」与「教室类型」两项; + * 两者均为可空——为 null 表示该项不改动,至少需要指定其中一项。 + * 名称/编号/容纳人数/座位数等逐间差异字段不在批量范围(避免误覆盖)。 + */ +public class ClassroomBatchEditRequest { + + /** 目标教室 id 集合 */ + private List serIDs; + + /** 目标状态:open 开放 / closed 关闭;null = 不改动 */ + private String status; + + /** 目标教室类型 id;null = 不改动 */ + private Integer typeId; + + public List getSerIDs() { + return serIDs; + } + + public void setSerIDs(List 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; + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/model/ClassroomBindRequest.java b/src/main/java/com/yunhai/acaclassroom/model/ClassroomBindRequest.java new file mode 100644 index 0000000..05ef4f2 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/model/ClassroomBindRequest.java @@ -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; +} diff --git a/src/main/java/com/yunhai/acaclassroom/model/ClassroomEditRequest.java b/src/main/java/com/yunhai/acaclassroom/model/ClassroomEditRequest.java new file mode 100644 index 0000000..4c16a98 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/model/ClassroomEditRequest.java @@ -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 equipmentIds; + + /** 关联设备(null=不改动;数组(含空)=全量替换该教室的设备,含每间数量) */ + private java.util.List equipments; + + public java.util.List getEquipments() { + return equipments; + } + + public void setEquipments(java.util.List equipments) { + this.equipments = equipments; + } + + public java.util.List getEquipmentIds() { + return equipmentIds; + } + + public void setEquipmentIds(java.util.List 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; + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/model/ClassroomEquipmentItem.java b/src/main/java/com/yunhai/acaclassroom/model/ClassroomEquipmentItem.java new file mode 100644 index 0000000..c0f9a48 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/model/ClassroomEquipmentItem.java @@ -0,0 +1,30 @@ +package com.yunhai.acaclassroom.model; + +/** + * 设备关联项(教室批量添加/编辑请求用):设备 id + 每间教室数量。 + *

数量缺省或非法(<1)时按 1 处理,上限 999。

+ */ +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; + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/repository/BuildingRepository.java b/src/main/java/com/yunhai/acaclassroom/repository/BuildingRepository.java new file mode 100644 index 0000000..0f125ea --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/repository/BuildingRepository.java @@ -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 { + + List findBySchIdOrderBySortOrderAscIdAsc(Integer schId); + + List 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); +} diff --git a/src/main/java/com/yunhai/acaclassroom/repository/CampusRepository.java b/src/main/java/com/yunhai/acaclassroom/repository/CampusRepository.java new file mode 100644 index 0000000..6954e54 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/repository/CampusRepository.java @@ -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 { + + List findBySchIdOrderBySortOrderAscIdAsc(Integer schId); + + boolean existsBySchIdAndCampusName(Integer schId, String campusName); + + boolean existsBySchIdAndCampusNameAndIdNot(Integer schId, String campusName, Integer id); + + long countBySchIdAndCampusName(Integer schId, String campusName); +} diff --git a/src/main/java/com/yunhai/acaclassroom/repository/ClassroomEquipmentRepository.java b/src/main/java/com/yunhai/acaclassroom/repository/ClassroomEquipmentRepository.java new file mode 100644 index 0000000..3a2cf2f --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/repository/ClassroomEquipmentRepository.java @@ -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 { + + List findBySchId(Integer schId); + + List findBySchIdAndClassroomId(Integer schId, Integer classroomId); + + List findBySchIdAndClassroomIdIn(Integer schId, List classroomIds); + + boolean existsBySchIdAndEquipmentId(Integer schId, Integer equipmentId); + + void deleteBySchIdAndClassroomId(Integer schId, Integer classroomId); + + void deleteBySchIdAndClassroomIdIn(Integer schId, List classroomIds); +} diff --git a/src/main/java/com/yunhai/acaclassroom/repository/ClassroomRepository.java b/src/main/java/com/yunhai/acaclassroom/repository/ClassroomRepository.java new file mode 100644 index 0000000..7cb6b70 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/repository/ClassroomRepository.java @@ -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 { + + List findBySchIdAndBuildingIdOrderByFloorAscRoomCodeAsc(Integer schId, Integer buildingId); + + List findBySchIdOrderByFloorAscRoomCodeAsc(Integer schId); + + List findBySchId(Integer schId); + + List 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 findBySchIdAndClassIdIn(Integer schId, List classIds); + + void deleteBySchIdAndIdIn(Integer schId, List ids); +} diff --git a/src/main/java/com/yunhai/acaclassroom/repository/ClassroomTypeRepository.java b/src/main/java/com/yunhai/acaclassroom/repository/ClassroomTypeRepository.java new file mode 100644 index 0000000..5e668d3 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/repository/ClassroomTypeRepository.java @@ -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 { + + List findBySchIdOrderBySortOrderAscIdAsc(Integer schId); + + boolean existsBySchIdAndTypeName(Integer schId, String typeName); + + boolean existsBySchIdAndTypeNameAndIdNot(Integer schId, String typeName, Integer id); +} diff --git a/src/main/java/com/yunhai/acaclassroom/repository/EquipmentRepository.java b/src/main/java/com/yunhai/acaclassroom/repository/EquipmentRepository.java new file mode 100644 index 0000000..7f46259 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/repository/EquipmentRepository.java @@ -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 { + + List findBySchId(Integer schId); + + List findBySchIdOrderByIdDesc(Integer schId); + + boolean existsBySchIdAndEquipmentName(Integer schId, String equipmentName); + + boolean existsBySchIdAndEquipmentNameAndIdNot(Integer schId, String equipmentName, Integer id); +} diff --git a/src/main/java/com/yunhai/acaclassroom/repository/SchoolClassRepository.java b/src/main/java/com/yunhai/acaclassroom/repository/SchoolClassRepository.java new file mode 100644 index 0000000..49199ad --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/repository/SchoolClassRepository.java @@ -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 { + + List findBySchIdOrderByGradeAscIdAsc(Integer schId); + + boolean existsBySchIdAndClassName(Integer schId, String className); +} diff --git a/src/main/java/com/yunhai/acaclassroom/repository/SysOperationLogRepository.java b/src/main/java/com/yunhai/acaclassroom/repository/SysOperationLogRepository.java new file mode 100644 index 0000000..33e780f --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/repository/SysOperationLogRepository.java @@ -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 { +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/IBuildingService.java b/src/main/java/com/yunhai/acaclassroom/service/IBuildingService.java new file mode 100644 index 0000000..5562817 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/IBuildingService.java @@ -0,0 +1,29 @@ +package com.yunhai.acaclassroom.service; + +import java.util.List; +import java.util.Map; + +/** + * 楼栋管理服务。 + */ +public interface IBuildingService { + + /** 楼栋列表(campusId 为空返回全部,含所属校区名与教室数)。 */ + List> list(Integer campusId); + + /** + * 楼栋统计(教室管理页"显示图":每楼栋教室 开放/关闭/总数)。 + * 每项额外含 floors 楼层分布([{floor,total,open,closed}],楼层升序,仅实际有教室的楼层), + * 供前端楼栋卡片绘制楼层条;为新增字段,旧调用方忽略即可。 + */ + List> 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); +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/ICampusService.java b/src/main/java/com/yunhai/acaclassroom/service/ICampusService.java new file mode 100644 index 0000000..88fb284 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/ICampusService.java @@ -0,0 +1,28 @@ +package com.yunhai.acaclassroom.service; + +import java.util.List; +import java.util.Map; + +/** + * 校区管理服务。 + */ +public interface ICampusService { + + /** 校区列表(含楼栋数/教室数,管理页用)。 */ + List> list(); + + /** 校区统计(总览页:每校区楼栋数与教室 开放/关闭/总数)。 */ + List> 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 ids); +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/IClassService.java b/src/main/java/com/yunhai/acaclassroom/service/IClassService.java new file mode 100644 index 0000000..d099f4e --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/IClassService.java @@ -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> list(Integer grade); + + /** 新增班级,返回 id。 */ + Integer add(String className, Integer grade, Integer studentCount, String headTeacher); + + /** 删除班级(已被教室绑定时拒绝)。 */ + void delete(Integer id); +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/IClassroomService.java b/src/main/java/com/yunhai/acaclassroom/service/IClassroomService.java new file mode 100644 index 0000000..43626b9 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/IClassroomService.java @@ -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 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 floors(Integer buildingId); + + /** 全校教室扁平列表(空间筛选树数据源,仅轻量字段)。 */ + List> tree(); + + /** 批量添加教室,返回 {created, skipped, total}。 */ + Map batchAdd(ClassroomBatchAddRequest req); + + /** 编辑教室(类型/名称/编号/状态/容纳人数/座位数)。 */ + void edit(ClassroomEditRequest req); + + /** 批量编辑教室(仅状态 / 教室类型,可只改其一),返回 {updated, skipped, total}。 */ + Map batchEdit(ClassroomBatchEditRequest req); + + /** 批量删除教室。 */ + void delete(List ids); + + /** 教室绑定班级(班级同校且未被其他教室占用),返回 {classId, className}。 */ + Map bindClass(Integer serID, Integer classId); + + /** 教室解绑班级(未绑定时静默成功)。 */ + void unbindClass(Integer serID); + + /** 批量解绑(未绑定/不存在的教室计入 skipped),返回 {updated, skipped, total}。 */ + Map batchUnbind(List serIDs); +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/IClassroomTypeService.java b/src/main/java/com/yunhai/acaclassroom/service/IClassroomTypeService.java new file mode 100644 index 0000000..68d8721 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/IClassroomTypeService.java @@ -0,0 +1,25 @@ +package com.yunhai.acaclassroom.service; + +import java.util.List; +import java.util.Map; + +/** + * 教室类型管理服务。 + */ +public interface IClassroomTypeService { + + /** 类型列表(含引用的教室数)。 */ + List> 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 ids); +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/IEquipmentService.java b/src/main/java/com/yunhai/acaclassroom/service/IEquipmentService.java new file mode 100644 index 0000000..6d16935 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/IEquipmentService.java @@ -0,0 +1,25 @@ +package com.yunhai.acaclassroom.service; + +import java.util.Map; + +/** + * 教室设备管理服务(基础设置 · 设备管理)。独立设备台账,不挂教室; + * 教室(批量添加/编辑)通过教室-设备关联引用设备。 + */ +public interface IEquipmentService { + + /** + * 设备列表(全校全量,可按关键字过滤)。 + * 行含 quantity(按教室关联用量汇总的使用数量)与 classroomCount(引用该设备的教室数)。 + */ + Map list(String keyword); + + /** 新增设备(名称校内唯一),返回新 id。 */ + Integer add(String equipmentName, String remark); + + /** 编辑设备(名称/备注)。 */ + void edit(Integer serID, String equipmentName, String remark); + + /** 删除设备(被教室引用时拒绝)。 */ + void delete(Integer serid); +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/ISysOperationLogService.java b/src/main/java/com/yunhai/acaclassroom/service/ISysOperationLogService.java new file mode 100644 index 0000000..012026f --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/ISysOperationLogService.java @@ -0,0 +1,15 @@ +package com.yunhai.acaclassroom.service; + +import com.yunhai.acaclassroom.entity.SysOperationLog; + +/** + * 操作日志服务(审计轨)。落库走专用线程池异步执行,失败仅告警。 + */ +public interface ISysOperationLogService { + + /** + * 异步保存一条操作日志(auditLogExecutor 线程池)。 + * 绝不抛出影响主业务的异常。 + */ + void saveAsync(SysOperationLog entity); +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/IUserCenterService.java b/src/main/java/com/yunhai/acaclassroom/service/IUserCenterService.java new file mode 100644 index 0000000..f8ef1a5 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/IUserCenterService.java @@ -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 { + + /** + * 获取当前用户可访问的模块树(左侧菜单)。 + *

从基础平台拉取当前用户模块列表(含学校上下文),组装为前端 layout 期望的树结构 + * ({@code modId/page/children}),对应前端 GetUserMod 接口。

+ * + * @param userId 用户ID + * @param schId 学校ID(可为 null) + * @return 模块树(顶层 List,含 children 嵌套) + */ + List> getUserModuleTree(Integer userId, Integer schId); + + /** + * 解析"当前用户":CAS 会话身份优先原样返回;无会话且 dev-context 开启时, + * 按 {@code acaclassroom.dev-context.login-name} 查平台用户中心返回真实用户 + * (含姓名/头像,供顶栏展示与 UserContext 审计身份使用)。 + *

查无此人/平台不可达/未配置 login-name 时返回 null,由调用方按各自语义兜底 + * (GetUserInfo 返回空,切面回退占位身份 99999)。

+ * + * @param casUser CAS 会话注入的用户(可为 null) + * @return 当前用户;无法确定时为 null + */ + BoaUser getCurrentUser(BoaUser casUser); +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/impl/BuildingService.java b/src/main/java/com/yunhai/acaclassroom/service/impl/BuildingService.java new file mode 100644 index 0000000..861bcc6 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/impl/BuildingService.java @@ -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> list(Integer campusId) { + Integer schId = UserContext.getSchId(); + if (schId == null) { + return Collections.emptyList(); + } + List buildings = campusId != null + ? buildingRepository.findBySchIdAndCampusIdOrderBySortOrderAscIdAsc(schId, campusId) + : buildingRepository.findBySchIdOrderBySortOrderAscIdAsc(schId); + Map campusNames = campusRepository.findBySchIdOrderBySortOrderAscIdAsc(schId).stream() + .collect(Collectors.toMap(Campus::getId, Campus::getCampusName, (a, b) -> a, LinkedHashMap::new)); + Map roomCount = classroomRepository.findBySchId(schId).stream() + .collect(Collectors.groupingBy(Classroom::getBuildingId, Collectors.counting())); + + List> result = new ArrayList<>(); + for (Building b : buildings) { + Map 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> stats(Integer campusId) { + Integer schId = UserContext.getSchId(); + if (schId == null) { + return Collections.emptyList(); + } + List buildings = campusId != null + ? buildingRepository.findBySchIdAndCampusIdOrderBySortOrderAscIdAsc(schId, campusId) + : buildingRepository.findBySchIdOrderBySortOrderAscIdAsc(schId); + // 一次取全校教室,按楼栋聚合(避免逐楼栋查库) + Map> byBuilding = classroomRepository.findBySchId(schId).stream() + .collect(Collectors.groupingBy(Classroom::getBuildingId)); + + List> result = new ArrayList<>(); + for (Building b : buildings) { + List rooms = byBuilding.getOrDefault(b.getId(), Collections.emptyList()); + long open = rooms.stream().filter(r -> "open".equals(r.getStatus())).count(); + Map 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> floorBreakdown(List rooms) { + Map> byFloor = rooms.stream() + .filter(r -> r.getFloor() != null) + .collect(Collectors.groupingBy(Classroom::getFloor, TreeMap::new, Collectors.toList())); + List> floors = new ArrayList<>(); + for (Map.Entry> e : byFloor.entrySet()) { + List list = e.getValue(); + long fOpen = list.stream().filter(r -> "open".equals(r.getStatus())).count(); + Map 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 toVo(Building b) { + Map 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(); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/impl/CampusService.java b/src/main/java/com/yunhai/acaclassroom/service/impl/CampusService.java new file mode 100644 index 0000000..42a269e --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/impl/CampusService.java @@ -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> list() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + return Collections.emptyList(); + } + List campuses = campusRepository.findBySchIdOrderBySortOrderAscIdAsc(schId); + // 楼栋数按校区聚合;教室数用冗余 campus_id 直查 + Map buildingCount = new HashMap<>(); + for (Campus c : campuses) { + buildingCount.put(c.getId(), buildingRepository.countBySchIdAndCampusId(schId, c.getId())); + } + Map classroomCount = classroomRepository.findBySchId(schId).stream() + .collect(Collectors.groupingBy(Classroom::getCampusId, Collectors.counting())); + List> result = new ArrayList<>(); + for (Campus c : campuses) { + Map 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> stats() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + return Collections.emptyList(); + } + List> result = new ArrayList<>(); + for (Campus c : campusRepository.findBySchIdOrderBySortOrderAscIdAsc(schId)) { + Map 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 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 toVo(Campus c) { + Map 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(); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/impl/ClassService.java b/src/main/java/com/yunhai/acaclassroom/service/impl/ClassService.java new file mode 100644 index 0000000..4e2f58f --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/impl/ClassService.java @@ -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> list(Integer grade) { + Integer schId = UserContext.getSchId(); + if (schId == null) { + return Collections.emptyList(); + } + List classes = classRepository.findBySchIdOrderByGradeAscIdAsc(schId); + if (grade != null) { + classes = classes.stream().filter(c -> grade.equals(c.getGrade())).collect(Collectors.toList()); + } + // 班级 → 绑定的教室 id(绑定状态下拉过滤/行回显用) + List ids = classes.stream().map(SchoolClass::getId).collect(Collectors.toList()); + Map 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 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); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/impl/ClassroomService.java b/src/main/java/com/yunhai/acaclassroom/service/impl/ClassroomService.java new file mode 100644 index 0000000..0c526ee --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/impl/ClassroomService.java @@ -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 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 empty = new LinkedHashMap<>(); + empty.put("list", Collections.emptyList()); + empty.put("total", 0); + empty.put("pageIndex", 1); + empty.put("pageSize", 0); + return empty; + } + // 作用域:楼栋优先;无楼栋时按校区取全部楼栋的教室 + List 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 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 typeNames = typeRepository.findBySchIdOrderBySortOrderAscIdAsc(schId).stream() + .collect(Collectors.toMap(ClassroomType::getId, ClassroomType::getTypeName, (a, b) -> a)); + // 校区/楼栋名映射(列表跨楼栋时行内直接展示,空间筛选树与详情面板用) + Map campusNames = campusRepository.findBySchIdOrderBySortOrderAscIdAsc(schId).stream() + .collect(Collectors.toMap(com.yunhai.acaclassroom.entity.Campus::getId, + com.yunhai.acaclassroom.entity.Campus::getCampusName, (a, b) -> a)); + Map 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 pageRooms = rooms.subList(from, to); + + // 绑定班级信息(行内展示:班级名/学生人数/班主任) + // 本页教室的设备关联 → 设备名映射、数量映射(quantity 列为后加,存量 NULL 按 1) + List pageRoomIds = pageRooms.stream().map(Classroom::getId).collect(Collectors.toList()); + Map> roomEquipmentQty = new HashMap<>(); + Map 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> list = new ArrayList<>(); + for (Classroom r : pageRooms) { + Map vo = toVo(r, typeNames); + vo.put("campusName", campusNames.getOrDefault(r.getCampusId(), "")); + vo.put("buildingName", buildingNames.getOrDefault(r.getBuildingId(), "")); + com.yunhai.acaclassroom.entity.SchoolClass bound = r.getClassId() != null ? classMap.get(r.getClassId()) : null; + 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 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 result = new LinkedHashMap<>(); + result.put("list", list); + result.put("total", total); + result.put("pageIndex", pageIndex); + result.put("pageSize", pageSize); + return result; + } + + @Override + public List 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> tree() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + return Collections.emptyList(); + } + // 单校教室百级,一次整取;前端按 校区→楼栋→楼层 自行组树 + return classroomRepository.findBySchIdOrderByFloorAscRoomCodeAsc(schId).stream() + .map(r -> { + Map 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 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 equipmentMap = sanitizeEquipmentMap(req.getEquipments(), req.getEquipmentIds(), building.getSchId()); + + int created = 0; + int skipped = 0; + List 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 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 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 sanitizeEquipmentMap( + List equipments, + List legacyEquipmentIds, Integer schId) { + Map 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 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 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 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 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 result = new LinkedHashMap<>(); + result.put("updated", updated); + result.put("skipped", skipped); + result.put("total", ids.size()); + return result; + } + + @Override + @Transactional + public Map 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 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 batchUnbind(List 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 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 applySort(List rooms, String sortField, String order) { + if (rooms == null || rooms.isEmpty() || sortField == null || sortField.trim().isEmpty()) { + return rooms; + } + Comparator strCmp = Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER); + Comparator intCmp = Comparator.nullsLast(Comparator.naturalOrder()); + Comparator 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 sorted = new ArrayList<>(rooms); + sorted.sort("desc".equalsIgnoreCase(order == null ? "" : order.trim()) ? cmp.reversed() : cmp); + return sorted; + } + + private Map toVo(Classroom r, Map typeNames) { + Map 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; + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/impl/ClassroomTypeService.java b/src/main/java/com/yunhai/acaclassroom/service/impl/ClassroomTypeService.java new file mode 100644 index 0000000..9e5dc5b --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/impl/ClassroomTypeService.java @@ -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> list() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + return Collections.emptyList(); + } + Map usedCount = classroomRepository.findBySchId(schId).stream() + .filter(r -> r.getTypeId() != null) + .collect(Collectors.groupingBy(Classroom::getTypeId, Collectors.counting())); + List> result = new ArrayList<>(); + for (ClassroomType t : typeRepository.findBySchIdOrderBySortOrderAscIdAsc(schId)) { + Map 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 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(); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/impl/EquipmentService.java b/src/main/java/com/yunhai/acaclassroom/service/impl/EquipmentService.java new file mode 100644 index 0000000..0b5d34d --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/impl/EquipmentService.java @@ -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 list(String keyword) { + Integer schId = UserContext.getSchId(); + if (schId == null) { + return emptyPage(); + } + List 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 refCount = new HashMap<>(); + Map 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> list = new ArrayList<>(); + for (Equipment e : rows) { + Map vo = toVo(e); + vo.put("quantity", usedQty.getOrDefault(e.getId(), 0)); + vo.put("classroomCount", refCount.getOrDefault(e.getId(), 0L)); + list.add(vo); + } + Map 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 toVo(Equipment e) { + Map 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 emptyPage() { + Map 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(); + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/impl/SysOperationLogService.java b/src/main/java/com/yunhai/acaclassroom/service/impl/SysOperationLogService.java new file mode 100644 index 0000000..dd3ed33 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/impl/SysOperationLogService.java @@ -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()); + } + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/service/impl/UserCenterService.java b/src/main/java/com/yunhai/acaclassroom/service/impl/UserCenterService.java new file mode 100644 index 0000000..7459242 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/service/impl/UserCenterService.java @@ -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 模块树组装。 + *

跨仓调用统一收敛到 {@link YschBaseOpenApi} 门面,Controller 不直接静态调用。

+ */ +@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> getUserModuleTree(Integer userId, Integer schId) { + if (userId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空"); + } + List 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 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 list = new ArrayList<>(); + list.add(classroom); + list.add(classBind); + list.add(setting); + return list; + } + + /** + * 将扁平模块列表组装为树。 + * 顶层取 parentId=0 或父节点不在列表中的节点,保持 SDK(用户中心)返回顺序。 + */ + private List> buildModuleTree(List modules) { + Set moduleIds = new HashSet<>(); + for (BoaModule m : modules) { + moduleIds.add(m.getId()); + } + + // 每个模块节点(含 children 占位) + Map> nodeMap = new LinkedHashMap<>(); + for (BoaModule m : modules) { + Map 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> roots = new ArrayList<>(); + for (BoaModule m : modules) { + Map 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> children = (List>) nodeMap.get(parentId).get("children"); + children.add(node); + } + } + + if (roots.isEmpty()) { + log.warn("[GetUserMod] 模块列表无根节点, count={}", modules.size()); + } + return roots; + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/utils/GzipUtil.java b/src/main/java/com/yunhai/acaclassroom/utils/GzipUtil.java new file mode 100644 index 0000000..1a6a647 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/utils/GzipUtil.java @@ -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; + } + } +} diff --git a/src/main/java/com/yunhai/acaclassroom/utils/UserContext.java b/src/main/java/com/yunhai/acaclassroom/utils/UserContext.java new file mode 100644 index 0000000..445ac8c --- /dev/null +++ b/src/main/java/com/yunhai/acaclassroom/utils/UserContext.java @@ -0,0 +1,88 @@ +package com.yunhai.acaclassroom.utils; + +/** + * 当前请求的用户上下文(ThreadLocal),参照 UserclassService 的自有 UserContext。 + *

由 {@link com.yunhai.acaclassroom.aspect.ControllerUserContextAspect} 在控制器执行前, + * 从基础平台 {@code BoaUserContext.get()}(或控制器 {@code BoaUser} 入参)复制填充,请求结束 {@link #clear()}。

+ * + *

业务约定:实体只存数字 id,不存登录账号。 + *

    + *
  • {@link #getUserId()} —— 平台用户数字 id(教师用户),业务外键/审计列存此值;
  • + *
  • {@link #getSchId()} —— 当前身份所属学校,多租户隔离。
  • + *
+ */ +public final class UserContext { + + private static final ThreadLocal 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; + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..34d80be --- /dev/null +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..2345ef1 --- /dev/null +++ b/src/main/resources/bootstrap.yml @@ -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 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..20612de --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,35 @@ + + + + + + + + + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET} + + + + + + + /app/logs/app.log + + /app/logs/app.%d{yyyy-MM-dd}.log + 30 + 5GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/sql/acaclassroom_init.sql b/src/main/resources/sql/acaclassroom_init.sql new file mode 100644 index 0000000..71e4d69 --- /dev/null +++ b/src/main/resources/sql/acaclassroom_init.sql @@ -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, , NOW(), NOW()), +-- ('多媒体教室', 1, 2, , NOW(), NOW()), +-- ('实验室', 1, 3, , NOW(), NOW());