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):
+ * 点切**全部控制器方法**,无需逐方法标注注解;
+ *
+ * - 操作类型:方法名启发式(delete/del/remove→DELETE,add/create/insert/save/import/upload→CREATE,
+ * GET/查询→QUERY,其余→UPDATE);方法上的 {@link OperationLog} 可显式覆盖 type/level;
+ * - 入参:过滤 servlet/文件对象,JSON 序列化后正则脱敏(password/pwd/secret/token 等);
+ * - 响应:脱敏 + 截断后 GZIP 压缩存 {@code response_data};
+ * - 落库:走 {@code auditLogExecutor} 异步保存,失败仅告警,不影响主业务。
+ *
+ * @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