From 28cf7826c32fd907a2681cdc17e1c8dd05118599 Mon Sep 17 00:00:00 2001 From: wanghu Date: Sun, 20 Sep 2026 08:52:02 +0800 Subject: [PATCH] commit --- .../dormnotice-frontend-removed/dormapi.ts | 2519 +++++++++++++++++ .../dormnotice-frontend-removed/env.mock.bak | 9 + .backup/dormnotice-frontend-removed/index.ts | 6 + .backup/dormnotice-frontend-removed/menu.ts | 17 + .gitignore | 5 + docker/Dockerfile | 18 + docker/application-prod.yml | 52 + docker/run-app.sh | 9 + pom.xml | 205 ++ smoke/closed-loop.mjs | 124 + smoke/smoke.mjs | 161 ++ .../dormnotice/DormNoticeApplication.java | 18 + .../dormnotice/annotation/OperationLog.java | 22 + .../aspect/ControllerUserContextAspect.java | 119 + .../dormnotice/aspect/OperationLogAspect.java | 154 + .../common/listener/NoticeAuditListener.java | 47 + .../common/validation/ValidationGroups.java | 10 + .../com/yunhai/dormnotice/config/Config.java | 11 + .../dormnotice/config/DictSeedRunner.java | 70 + .../dormnotice/config/JacksonConfig.java | 35 + .../dormnotice/config/ReadBackfillRunner.java | 67 + .../dormnotice/config/SwaggerConfig.java | 22 + .../yunhai/dormnotice/constant/DormEnums.java | 142 + .../controller/api/BaseApiController.java | 143 + .../controller/api/ContactsController.java | 99 + .../controller/api/DictController.java | 66 + .../controller/api/DormController.java | 120 + .../controller/api/FileController.java | 88 + .../controller/api/NoticeController.java | 178 ++ .../yunhai/dormnotice/entity/BaseEntity.java | 49 + .../dormnotice/entity/ContactsGroup.java | 28 + .../dormnotice/entity/ContactsGroupUser.java | 35 + .../dormnotice/entity/DormBuilding.java | 54 + .../dormnotice/entity/DormNoticeRead.java | 37 + .../yunhai/dormnotice/entity/DormRoom.java | 47 + .../yunhai/dormnotice/entity/DormStudent.java | 66 + .../com/yunhai/dormnotice/entity/Notice.java | 112 + .../com/yunhai/dormnotice/entity/Receipt.java | 84 + .../com/yunhai/dormnotice/entity/SysDict.java | 40 + .../dormnotice/entity/SysOperationLog.java | 83 + .../com/yunhai/dormnotice/entity/UpFile.java | 47 + .../dormnotice/model/BasePageQuery.java | 14 + .../dormnotice/model/BasePageResult.java | 33 + .../yunhai/dormnotice/model/SchTeaList.java | 106 + .../dormnotice/model/bsch/SchClassDto.java | 26 + .../dormnotice/model/bsch/SchInfoDto.java | 25 + .../dormnotice/model/bsch/SchStudentDto.java | 35 + .../model/notice/InitializeRedVo.java | 20 + .../model/notice/UpFileResultVo.java | 30 + .../dormnotice/model/notice/UpFileVo.java | 31 + .../dormnotice/model/notice/UserModuleVo.java | 122 + .../repository/ContactsGroupRepository.java | 27 + .../ContactsGroupUserRepository.java | 42 + .../repository/DormBuildingRepository.java | 12 + .../repository/DormNoticeReadRepository.java | 18 + .../repository/DormRoomRepository.java | 19 + .../repository/DormStudentRepository.java | 22 + .../repository/NoticeRepository.java | 13 + .../repository/ReceiptRepository.java | 25 + .../repository/SysDictRepository.java | 15 + .../repository/SysOperationLogRepository.java | 10 + .../repository/UpFileRepository.java | 26 + .../dormnotice/service/IClassService.java | 31 + .../dormnotice/service/IContactsService.java | 79 + .../dormnotice/service/IDictService.java | 35 + .../dormnotice/service/IDormService.java | 46 + .../dormnotice/service/IFileService.java | 99 + .../dormnotice/service/INoticeService.java | 83 + .../service/IUserCenterService.java | 51 + .../dormnotice/service/impl/ClassService.java | 123 + .../service/impl/ContactsService.java | 403 +++ .../dormnotice/service/impl/DictService.java | 175 ++ .../dormnotice/service/impl/DormService.java | 532 ++++ .../dormnotice/service/impl/FileService.java | 335 +++ .../service/impl/NoticeService.java | 1285 +++++++++ .../service/impl/UserCenterService.java | 268 ++ .../yunhai/dormnotice/utils/PageUtils.java | 37 + .../yunhai/dormnotice/utils/UserContext.java | 105 + src/main/resources/application.yml | 72 + src/main/resources/bootstrap.yml | 33 + src/main/resources/logback-spring.xml | 35 + 81 files changed, 9716 insertions(+) create mode 100644 .backup/dormnotice-frontend-removed/dormapi.ts create mode 100644 .backup/dormnotice-frontend-removed/env.mock.bak create mode 100644 .backup/dormnotice-frontend-removed/index.ts create mode 100644 .backup/dormnotice-frontend-removed/menu.ts create mode 100644 .gitignore create mode 100644 docker/Dockerfile create mode 100644 docker/application-prod.yml create mode 100644 docker/run-app.sh create mode 100644 pom.xml create mode 100644 smoke/closed-loop.mjs create mode 100644 smoke/smoke.mjs create mode 100644 src/main/java/com/yunhai/dormnotice/DormNoticeApplication.java create mode 100644 src/main/java/com/yunhai/dormnotice/annotation/OperationLog.java create mode 100644 src/main/java/com/yunhai/dormnotice/aspect/ControllerUserContextAspect.java create mode 100644 src/main/java/com/yunhai/dormnotice/aspect/OperationLogAspect.java create mode 100644 src/main/java/com/yunhai/dormnotice/common/listener/NoticeAuditListener.java create mode 100644 src/main/java/com/yunhai/dormnotice/common/validation/ValidationGroups.java create mode 100644 src/main/java/com/yunhai/dormnotice/config/Config.java create mode 100644 src/main/java/com/yunhai/dormnotice/config/DictSeedRunner.java create mode 100644 src/main/java/com/yunhai/dormnotice/config/JacksonConfig.java create mode 100644 src/main/java/com/yunhai/dormnotice/config/ReadBackfillRunner.java create mode 100644 src/main/java/com/yunhai/dormnotice/config/SwaggerConfig.java create mode 100644 src/main/java/com/yunhai/dormnotice/constant/DormEnums.java create mode 100644 src/main/java/com/yunhai/dormnotice/controller/api/BaseApiController.java create mode 100644 src/main/java/com/yunhai/dormnotice/controller/api/ContactsController.java create mode 100644 src/main/java/com/yunhai/dormnotice/controller/api/DictController.java create mode 100644 src/main/java/com/yunhai/dormnotice/controller/api/DormController.java create mode 100644 src/main/java/com/yunhai/dormnotice/controller/api/FileController.java create mode 100644 src/main/java/com/yunhai/dormnotice/controller/api/NoticeController.java create mode 100644 src/main/java/com/yunhai/dormnotice/entity/BaseEntity.java create mode 100644 src/main/java/com/yunhai/dormnotice/entity/ContactsGroup.java create mode 100644 src/main/java/com/yunhai/dormnotice/entity/ContactsGroupUser.java create mode 100644 src/main/java/com/yunhai/dormnotice/entity/DormBuilding.java create mode 100644 src/main/java/com/yunhai/dormnotice/entity/DormNoticeRead.java create mode 100644 src/main/java/com/yunhai/dormnotice/entity/DormRoom.java create mode 100644 src/main/java/com/yunhai/dormnotice/entity/DormStudent.java create mode 100644 src/main/java/com/yunhai/dormnotice/entity/Notice.java create mode 100644 src/main/java/com/yunhai/dormnotice/entity/Receipt.java create mode 100644 src/main/java/com/yunhai/dormnotice/entity/SysDict.java create mode 100644 src/main/java/com/yunhai/dormnotice/entity/SysOperationLog.java create mode 100644 src/main/java/com/yunhai/dormnotice/entity/UpFile.java create mode 100644 src/main/java/com/yunhai/dormnotice/model/BasePageQuery.java create mode 100644 src/main/java/com/yunhai/dormnotice/model/BasePageResult.java create mode 100644 src/main/java/com/yunhai/dormnotice/model/SchTeaList.java create mode 100644 src/main/java/com/yunhai/dormnotice/model/bsch/SchClassDto.java create mode 100644 src/main/java/com/yunhai/dormnotice/model/bsch/SchInfoDto.java create mode 100644 src/main/java/com/yunhai/dormnotice/model/bsch/SchStudentDto.java create mode 100644 src/main/java/com/yunhai/dormnotice/model/notice/InitializeRedVo.java create mode 100644 src/main/java/com/yunhai/dormnotice/model/notice/UpFileResultVo.java create mode 100644 src/main/java/com/yunhai/dormnotice/model/notice/UpFileVo.java create mode 100644 src/main/java/com/yunhai/dormnotice/model/notice/UserModuleVo.java create mode 100644 src/main/java/com/yunhai/dormnotice/repository/ContactsGroupRepository.java create mode 100644 src/main/java/com/yunhai/dormnotice/repository/ContactsGroupUserRepository.java create mode 100644 src/main/java/com/yunhai/dormnotice/repository/DormBuildingRepository.java create mode 100644 src/main/java/com/yunhai/dormnotice/repository/DormNoticeReadRepository.java create mode 100644 src/main/java/com/yunhai/dormnotice/repository/DormRoomRepository.java create mode 100644 src/main/java/com/yunhai/dormnotice/repository/DormStudentRepository.java create mode 100644 src/main/java/com/yunhai/dormnotice/repository/NoticeRepository.java create mode 100644 src/main/java/com/yunhai/dormnotice/repository/ReceiptRepository.java create mode 100644 src/main/java/com/yunhai/dormnotice/repository/SysDictRepository.java create mode 100644 src/main/java/com/yunhai/dormnotice/repository/SysOperationLogRepository.java create mode 100644 src/main/java/com/yunhai/dormnotice/repository/UpFileRepository.java create mode 100644 src/main/java/com/yunhai/dormnotice/service/IClassService.java create mode 100644 src/main/java/com/yunhai/dormnotice/service/IContactsService.java create mode 100644 src/main/java/com/yunhai/dormnotice/service/IDictService.java create mode 100644 src/main/java/com/yunhai/dormnotice/service/IDormService.java create mode 100644 src/main/java/com/yunhai/dormnotice/service/IFileService.java create mode 100644 src/main/java/com/yunhai/dormnotice/service/INoticeService.java create mode 100644 src/main/java/com/yunhai/dormnotice/service/IUserCenterService.java create mode 100644 src/main/java/com/yunhai/dormnotice/service/impl/ClassService.java create mode 100644 src/main/java/com/yunhai/dormnotice/service/impl/ContactsService.java create mode 100644 src/main/java/com/yunhai/dormnotice/service/impl/DictService.java create mode 100644 src/main/java/com/yunhai/dormnotice/service/impl/DormService.java create mode 100644 src/main/java/com/yunhai/dormnotice/service/impl/FileService.java create mode 100644 src/main/java/com/yunhai/dormnotice/service/impl/NoticeService.java create mode 100644 src/main/java/com/yunhai/dormnotice/service/impl/UserCenterService.java create mode 100644 src/main/java/com/yunhai/dormnotice/utils/PageUtils.java create mode 100644 src/main/java/com/yunhai/dormnotice/utils/UserContext.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/bootstrap.yml create mode 100644 src/main/resources/logback-spring.xml diff --git a/.backup/dormnotice-frontend-removed/dormapi.ts b/.backup/dormnotice-frontend-removed/dormapi.ts new file mode 100644 index 0000000..3102bba --- /dev/null +++ b/.backup/dormnotice-frontend-removed/dormapi.ts @@ -0,0 +1,2519 @@ +/** + * 武校家校信息发布 · 宿舍通知模块 本地 Mock 数据 + * (vite-plugin-mock,仅在 `npm run dev:mock`(--mode mock)下生效) + * + * 契约对齐 src/api/module/dorm.ts: + * - /api/notice/{List,SendList,Detail,GetDetail,GetNextPrev,ReadDetail,Create,Edit,Delete, + * InitializeRed,UserPull,SetTopmost,Expedite,ReceiveRooms,Statis, + * Receipts,ReceiptDetail,Review,Remind,ExportReceipts, + * MyNotices,MyNotice,SubmitReceipt,CancelReceipt} + * - /api/dorm/{BuildingList,RoomList,AddBuilding,EditBuilding,DelBuilding, + * AddRoom,EditRoom,DelRoom,RoomStudents, + * PendingStudents,AssignStudent,RemoveStudent,TransferStudent,SetMonitor} + * - /api/dict/{List,Sync,ManageList,Add,Update,Delete,Sort} + * - /api/base/{GetUserInfo,GetUserMod,GetLayout,SetRole,GetSchoolAll,GetDormTree,FileUpload} + * - /api/contacts/* /api/file/{UploadImage,Preview,Down} + * 统一响应 {code,msg,data},由 alova-core 解包为 data(code==0)。 + * + * 武校寄宿制场景关键点: + * 1. 收件人是「宿舍房间」而不是「班级」:同一班级的学生住不同楼栋房间, + * 按班级下发会造成大量无关触达。receiveList = "ALL" 或 房间 id 逗号串; + * 应收人数 / 已读人数按房间**入住床位数**(住宿生数)累加 —— 家长即挂在住宿生档案上的联系人。 + * 2. 回执方式 receiptMode: + * - none 仅通知(只统计已读,不产生回执记录,家长端「阅读即知悉」) + * - confirm 需确认(家长端**打开通知即自动确认**,无需点击) + * - fill 需填报(家长须按通知声明的 formFields 填报,如留宿起止、探视时间) + * 3. 通知流程 noticeStatus:draft 草稿 → published 已发布 → closed 已截止 → finished 已结束。 + * 4. 紧急程度 level:normal 普通 / important 重要 / urgent 紧急(家长端置顶并标红)。 + * 5. 当前登录演示身份 = 德育处 王建山(wjs);家长端演示视角 = 1号宿舍楼 305 室的住宿生。 + * 6. 房间人员(住宿生)只能通过「安排入住」从待安排池(/api/dorm/PendingStudents)调入, + * 或经「退宿 / 床位缩减」退回池;usedBeds 恒等于花名册长度(单一数据源), + * 不再由 usedBeds 反向凭空生成学生。宿舍长 isMonitor 显式落在花名册上,每间房至多 1 人。 + */ + +/* ============================ 基础数据 ============================ */ + +/** 发布部门字典(校办 / 各处室 / 生活管理科) */ +let deptDict = [ + { SerID: 101, CodeID: "D001", Name: "校办", Enable: 1 }, + { SerID: 102, CodeID: "D002", Name: "德育处", Enable: 1 }, + { SerID: 103, CodeID: "D003", Name: "生活管理科", Enable: 1 }, + { SerID: 104, CodeID: "D004", Name: "教务处", Enable: 1 }, + { SerID: 105, CodeID: "D005", Name: "后勤保障部", Enable: 1 }, + { SerID: 106, CodeID: "D006", Name: "校医务室", Enable: 1 }, + { SerID: 107, CodeID: "D007", Name: "武术训练部", Enable: 1 }, + { SerID: 108, CodeID: "D008", Name: "安全管理办公室", Enable: 1 }, +]; +let deptSeq = 109; + +/** 通知类型字典(前端下拉与统计口径共用;发布时从「宿舍设置-通知类型」扩展) */ +let noticeTypeDict = [ + { SerID: 201, CodeID: "T001", Name: "作息调整", Enable: 1 }, + { SerID: 202, CodeID: "T002", Name: "宿舍调整", Enable: 1 }, + { SerID: 203, CodeID: "T003", Name: "卫生检查", Enable: 1 }, + { SerID: 204, CodeID: "T004", Name: "违规通报", Enable: 1 }, + { SerID: 205, CodeID: "T005", Name: "停水停电", Enable: 1 }, + { SerID: 206, CodeID: "T006", Name: "安全排查", Enable: 1 }, + { SerID: 207, CodeID: "T007", Name: "留宿申请", Enable: 1 }, + { SerID: 208, CodeID: "T008", Name: "家长探视", Enable: 1 }, + { SerID: 209, CodeID: "T009", Name: "夜间查寝", Enable: 1 }, + { SerID: 210, CodeID: "T010", Name: "物品清退", Enable: 1 }, + { SerID: 211, CodeID: "T011", Name: "疫情防控", Enable: 1 }, + { SerID: 212, CodeID: "T012", Name: "假期安排", Enable: 1 }, + { SerID: 213, CodeID: "T013", Name: "其他", Enable: 1 }, +]; +let typeSeq = 214; + +let nextFileId = 9001; + +/** 当前登录用户(发布端·德育处) */ +const ME = { account: "wjs", name: "王建山" }; + +/* ============================ 宿舍楼栋 / 房间 / 床位 ============================ */ + +interface BuildingSeed { + buildingId: number; + buildingName: string; + short: string; + gender: string; + floors: number; + roomsPerFloor: number; + beds: number; + supervisor: string; + phone: string; +} + +const buildingSeeds: BuildingSeed[] = [ + { buildingId: 1, buildingName: "1号宿舍楼(男生)", short: "1号楼", gender: "男生", floors: 6, roomsPerFloor: 8, beds: 8, supervisor: "秦大力", phone: "13903710011" }, + { buildingId: 2, buildingName: "2号宿舍楼(男生)", short: "2号楼", gender: "男生", floors: 5, roomsPerFloor: 8, beds: 8, supervisor: "贺明远", phone: "13903710022" }, + { buildingId: 3, buildingName: "3号宿舍楼(女生)", short: "3号楼", gender: "女生", floors: 4, roomsPerFloor: 6, beds: 6, supervisor: "钱秀兰", phone: "13903710033" }, + { buildingId: 4, buildingName: "4号宿舍楼(武术特长班)", short: "4号楼", gender: "男生", floors: 3, roomsPerFloor: 6, beds: 4, supervisor: "岑治平", phone: "13903710044" }, +]; + +/** 确定性伪随机(同种子稳定,刷新不跳变) */ +function seededRand(seed: number) { + const x = Math.sin(seed) * 10000; + return x - Math.floor(x); +} + +const buildings = buildingSeeds.map(b => ({ ...b })); + +/** roomId = buildingId * 10000 + floor * 100 + index,如 10305 = 1号楼 3层05房 */ +let rooms: any[] = []; +function buildRooms() { + rooms = []; + buildings.forEach(b => { + for (let f = 1; f <= b.floors; f++) { + for (let i = 1; i <= b.roomsPerFloor; i++) { + const roomId = b.buildingId * 10000 + f * 100 + i; + // 入住率 88%~100%(同种子稳定) + const used = Math.max(1, Math.round(b.beds * (0.88 + seededRand(roomId) * 0.12))); + rooms.push({ + roomId, + roomNo: `${f}${String(i).padStart(2, "0")}`, + buildingId: b.buildingId, + buildingName: b.buildingName, + buildingShort: b.short, + floor: f, + bedCount: b.beds, + usedBeds: Math.min(used, b.beds), + supervisorName: `${b.supervisor}(${b.short}${f}层宿管)`, + supervisorPhone: b.phone, + }); + } + } + }); +} +buildRooms(); + +const roomMap: Record = {}; +rooms.forEach(r => (roomMap[String(r.roomId)] = r)); +const ALL_ROOMS = Object.keys(roomMap); +/** 全部宿舍床位数(接收对象为「全部宿舍」时的应读/应收人数上限) */ +const ALL_BEDS = ALL_ROOMS.reduce((s, id) => s + roomMap[id].bedCount, 0); +/** 全部已入住床位数 */ +const ALL_USED_BEDS = ALL_ROOMS.reduce((s, id) => s + roomMap[id].usedBeds, 0); + +/** 楼栋 → 房间树(ztree 结构,供选房间弹窗使用;叶子带 bedCount / usedBeds) */ +function buildDormTree() { + return buildings.map(b => { + const byFloor: Record = {}; + rooms + .filter(r => r.buildingId === b.buildingId) + .forEach(r => { + (byFloor[r.floor] = byFloor[r.floor] || []).push({ + id: r.roomId, + name: `${b.short} ${r.roomNo}(${r.usedBeds}/${r.bedCount} 床)`, + isParent: false, + bedCount: r.bedCount, + usedBeds: r.usedBeds, + supervisorName: r.supervisorName, + }); + }); + return { + id: b.buildingId, + name: b.buildingName, + isParent: true, + children: Object.keys(byFloor) + .map(Number) + .sort((x, y) => x - y) + .map(f => ({ + id: b.buildingId * 10000 + f * 100, + name: `${b.short} ${f} 层`, + isParent: true, + children: byFloor[f], + })), + }; + }); +} + +/* ============================ 住宿生花名册 ============================ */ + +const SURNAMES = ["王", "李", "张", "刘", "陈", "杨", "赵", "黄", "周", "吴", "徐", "孙", "马", "朱", "胡", "郭", "何", "林", "高", "罗", "郑", "梁"]; +/** 名字池按性别分开:安排入住要按性别校验楼栋,出现「周雨萱(男)」这类错配会让演示很假 */ +const GIVEN_M = ["伟", "浩", "鑫", "磊", "洋", "帅", "杰", "涛", "明", "超", "子轩", "浩然", "一鸣", "思远", "俊熙", "振宇", "子豪", "天佑", "嘉豪", "立诚"]; +const GIVEN_F = ["梓涵", "欣怡", "雨桐", "梦琪", "雨萱", "诗涵", "语彤", "佳怡", "思彤", "若曦", "欣然", "筱雨"]; +/** 按性别取名(seed 稳定决定取池中哪一个) */ +function givenName(seed: number, gender: string) { + const pool = gender === "女" ? GIVEN_F : GIVEN_M; + return pool[Math.abs(seed) % pool.length]; +} +/** 武校班级(住宿生按文化班 + 武术班混合编排) */ +const CLASS_NAMES = ["三年级(1)班", "三年级(2)班", "四年级(1)班", "初一(1)班", "初一(2)班", "套路表演班", "散打竞技班", "拳击班"]; + +/** roomId → 住宿生列表(床位数即学生数,1 床 1 生) + * isMonitor 显式落在数据上(默认首位为宿舍长,可由 /api/dorm/SetMonitor 改派), + * 不再靠「数组第一位」隐式约定——否则家长端与回执单里无法标注宿舍长。 */ +const roster: Record = {}; +rooms.forEach((r, ri) => { + const arr: any[] = []; + for (let i = 0; i < r.usedBeds; i++) { + const sid = r.roomId * 100 + i + 1; + const gender = r.buildingName.includes("女生") ? "女" : "男"; + arr.push({ + studentId: sid, + studentName: SURNAMES[(ri * 7 + i) % SURNAMES.length] + givenName(ri * 13 + i * 5, gender), + gender, + bedNo: `${i + 1}号床`, + className: CLASS_NAMES[(ri + i) % CLASS_NAMES.length], + guardianName: SURNAMES[(ri * 7 + i) % SURNAMES.length] + (gender === "女" ? "女士" : "先生"), + guardianPhone: "13" + String(100000000 + Math.floor(seededRand(sid * 7) * 899999999)).slice(0, 9), + relation: seededRand(sid * 11) > 0.55 ? "母亲" : "父亲", + /** 宿舍长(每间房至多 1 人) */ + isMonitor: i === 0, + }); + } + roster[String(r.roomId)] = arr; +}); + +/* ============================ 待安排床位学生池 ============================ */ +/** + * 「人员进房间」的唯一入口数据源:学籍在册但尚未分配床位、以及退宿退回的学生。 + * 房间里的住宿生不再凭空产生 —— 只能通过 /api/dorm/AssignStudent 从本池调入, + * 或经 /api/dorm/RemoveStudent、床位缩减退回本池。 + * studentId 段位 900001+,与房内学生(roomId*100+i,最小 1010101)不冲突。 + */ +const PENDING_SOURCES = ["新生未分配床位", "退宿待重新安排", "楼栋调整待安置", "转学插班待安排"]; +let pendingPool: any[] = []; +(function seedPendingPool() { + for (let i = 0; i < 46; i++) { + const sid = 900001 + i * 37; + const gender = seededRand(sid * 3) > 0.7 ? "女" : "男"; + const surname = SURNAMES[(i * 5 + 3) % SURNAMES.length]; + pendingPool.push({ + studentId: sid, + studentName: surname + givenName(i * 11 + 7, gender), + gender, + className: CLASS_NAMES[(i * 3) % CLASS_NAMES.length], + guardianName: surname + (gender === "女" ? "女士" : "先生"), + guardianPhone: "13" + String(100000000 + Math.floor(seededRand(sid * 13) * 899999999)).slice(0, 9), + sourceDesc: PENDING_SOURCES[i % PENDING_SOURCES.length], + }); + } +})(); + +/** 按性别校验楼栋是否可入住(混合楼栋不限制) */ +function genderConflict(buildingName: string, gender: string): string | null { + if (buildingName.includes("女生")) return gender === "女" ? null : "女生宿舍楼不能安排男生"; + if (buildingName.includes("男生")) return gender === "男" ? null : "男生宿舍楼不能安排女生"; + return null; +} + +/** 退宿 / 床位缩减:把学生退回待安排池 */ +function backToPool(stu: any, sourceDesc: string) { + stu.isMonitor = false; + pendingPool.unshift({ + studentId: stu.studentId, + studentName: stu.studentName, + gender: stu.gender, + className: stu.className, + guardianName: stu.guardianName, + guardianPhone: stu.guardianPhone, + sourceDesc, + }); +} + +/** 床位号重排为 1..n 连续,避免退宿后出现空洞床位 */ +function renumberBeds(list: any[]) { + list.forEach((s, idx) => (s.bedNo = `${idx + 1}号床`)); + if (list.length && !list.some(s => s.isMonitor)) list[0].isMonitor = true; +} + +/* ============================ 枚举文案 ============================ */ + +const NOTICE_STATUS_DESC: Record = { draft: "草稿", published: "已发布", closed: "已截止", finished: "已结束" }; +const RECEIPT_MODE_DESC: Record = { none: "仅通知", confirm: "需确认", fill: "需填报" }; +const LEVEL_DESC: Record = { normal: "普通", important: "重要", urgent: "紧急" }; +const RECEIPT_STATUS_DESC: Record = { none: "未回执", confirmed: "已确认", filled: "已填报", canceled: "已取消" }; + +/* ============================ 需填报类的表单字段声明 ============================ */ + +/** + * 公共区字段:**所有单据完全一致**的部分(分区口径见 types/dorm.ts)。 + * 家长端每次看到的都是这一套;管理端「回执结论」可跨通知合并统计。 + * 学生 / 家长姓名 / 联系电话由表单固定项与回执顶层字段(guardianName / guardianPhone)承载, + * 不在此重复声明。 + */ +const COMMON_FIELDS = [ + { + key: "conclusion", + label: "回执结论", + type: "select", + required: true, + group: "common", + options: [ + { label: "同意 / 确认办理", value: "同意" }, + { label: "不同意 / 不参与", value: "不同意" }, + { label: "需要电话沟通", value: "需沟通" }, + ], + }, +]; + +/** + * 单据模板:**系统预置,发布人只能选类型、不能增删字段**。 + * 之所以收紧成固定模板:回执没有审批下游,字段结构化的意义只在汇总; + * 允许自由拼字段会让每条通知的表头各不相同,导出与统计无法合并口径。 + */ +const FORM_TEMPLATES: Record = { + stay: { + type: "stay", + name: "留宿申请单", + desc: "周末 / 假期学生留宿申请,需注明留宿起止与事由", + fields: [ + { key: "stayStart", label: "留宿开始日期", type: "date", required: true, group: "specific", placeholder: "选择留宿开始日期" }, + { key: "stayEnd", label: "留宿结束日期", type: "date", required: true, group: "specific", placeholder: "选择留宿结束日期" }, + { key: "stayCount", label: "留宿人数", type: "number", required: true, group: "specific", unit: "人" }, + { + key: "stayReason", + label: "留宿事由", + type: "select", + required: true, + group: "specific", + options: [ + { label: "家远不便往返", value: "家远不便往返" }, + { label: "赛前集训", value: "赛前集训" }, + { label: "伤病康复观察", value: "伤病康复观察" }, + { label: "学业补差", value: "学业补差" }, + { label: "其他(请在补充说明中写明)", value: "其他" }, + ], + }, + ], + }, + visit: { + type: "visit", + name: "探视预约单", + desc: "家长到校探视预约,需注明到访日期、时段与人数", + fields: [ + { key: "visitDate", label: "到访日期", type: "date", required: true, group: "specific", placeholder: "选择到访日期" }, + { + key: "visitSlot", + label: "到访时段", + type: "select", + required: true, + group: "specific", + options: [ + { label: "上午 09:00-11:30", value: "上午 09:00-11:30" }, + { label: "下午 14:00-17:00", value: "下午 14:00-17:00" }, + { label: "晚间 18:30-20:00", value: "晚间 18:30-20:00" }, + ], + }, + { key: "visitorCount", label: "到访人数", type: "number", required: true, group: "specific", unit: "人" }, + ], + }, + adjust: { + type: "adjust", + name: "调宿申请单", + desc: "床位调整 / 宿舍调换确认,需注明可配合搬迁的日期", + fields: [ + { key: "moveDate", label: "可配合搬迁日期", type: "date", required: true, group: "specific", placeholder: "选择可配合搬迁的日期" }, + { + key: "adjustReason", + label: "情况说明", + type: "textarea", + required: false, + group: "specific", + placeholder: "如孩子有腰伤需住下铺、需与同训练组同住、恐高等,请在此说明", + }, + ], + }, + general: { + type: "general", + name: "通用回执单", + desc: "只需家长给出回执结论并说明情况,无额外登记项", + fields: [], + }, +}; + +/** 单据模板列表(发布页选型 + 家长端区块展示共用) */ +const FORM_TEMPLATE_LIST = ["stay", "visit", "adjust", "general"].map(k => FORM_TEMPLATES[k]); +const FORM_TYPE_DESC: Record = { stay: "留宿申请单", visit: "探视预约单", adjust: "调宿申请单", general: "通用回执单" }; + +/** + * 按单据类型拼出整张回执单:**公共区 + 专属区**。 + * 通知上的 formFields 一律由后端据此生成,不接受前端上传的字段定义。 + */ +function buildFormFields(formType: string): any[] { + const tpl = FORM_TEMPLATES[formType] || FORM_TEMPLATES.general; + return [...COMMON_FIELDS, ...tpl.fields].map(f => ({ + ...f, + options: f.options ? f.options.map((o: any) => ({ ...o })) : undefined, + })); +} + +/* ============================ 通知数据 ============================ */ + +let notices = [ + { + id: 1, + title: "【紧急】1号宿舍楼夜间作息时间调整(熄灯提前至 21:40)", + noticeType: "作息调整", + level: "urgent", + deptId: "D003", + deptName: "生活管理科", + publisherAccount: "wjs", + publisherName: "王建山", + content: + "

各位住宿生家长:

进入秋冬季节后天气转冷,为保证学生睡眠时长与次日训练状态,经校务会研究决定:自 10 月 8 日起,全校宿舍晚间熄灯时间由 22:20 提前至 21:40,早训起床时间不变(05:50)。

请家长配合督促孩子提前完成洗漱与作业,晚 21:20 后不再接打非紧急电话。宿舍内禁止使用大功率电器与明火取暖,违者按校规处理。

", + effectiveTime: "2026-10-08 21:40:00", + deadlineTime: null, + receiptMode: "none", + needSupervisor: 1, + receiveList: "ALL", + roomRead: [], + files: "9001", + topmost: 1, + isPulled: 0, + smsFlag: 1, + timingSendTime: null, + noticeStatus: "published", + createTime: "2026-09-15 19:20:00", + formType: null, + formFields: null, + }, + { + id: 2, + title: "【紧急】3号宿舍楼东侧主水管抢修临时停水通知", + noticeType: "停水停电", + level: "urgent", + deptId: "D005", + deptName: "后勤保障部", + publisherAccount: "czq", + publisherName: "陈志强", + content: + "

各位家长:

3号宿舍楼东侧主水管出现渗漏,后勤保障部定于 9 月 19 日(周六)08:00 — 17:00 停水抢修,涉及 3号楼全部楼层。

学校已在各层配备应急储水桶与瓶装饮用水,洗漱用水由宿管统一安排至公共洗漱间。请家长提前告知孩子,无需送水到校。恢复供水后前 10 分钟请勿直接饮用。

", + effectiveTime: "2026-09-19 08:00:00", + deadlineTime: null, + receiptMode: "none", + needSupervisor: 1, + receiveList: "30000", + roomRead: ["30101", "30102", "30201"], + files: null, + topmost: 1, + isPulled: 0, + smsFlag: 1, + timingSendTime: null, + noticeStatus: "published", + createTime: "2026-09-16 08:40:00", + formType: null, + formFields: null, + }, + { + id: 3, + title: "2026 年国庆假期学生留宿申请(需家长填报)", + noticeType: "留宿申请", + level: "important", + deptId: "D002", + deptName: "德育处", + publisherAccount: "wjs", + publisherName: "王建山", + content: + "

各位住宿生家长:

国庆假期(10 月 1 日 — 10 月 7 日)学校实行封楼管理,仅保留 1 号楼一层值班区域供留宿学生集中住宿,宿管与值班教练 24 小时在岗。

如孩子确需留宿,请家长于 9 月 28 日 18:00 前在家长端填写《留宿申请单》,写明留宿起止日期、留宿人数与事由。未提交申请的学生须按规定离校,学校不承担擅自留校期间的看护责任。

留宿期间每天安排 2 次宿舍点名(09:00 / 19:00),请家长保持电话畅通。

", + effectiveTime: "2026-10-01 00:00:00", + deadlineTime: "2026-09-28 18:00:00", + receiptMode: "fill", + needSupervisor: 1, + receiveList: "ALL", + roomRead: ["10305", "10101", "20103"], + files: "9002", + topmost: 1, + isPulled: 0, + smsFlag: 1, + timingSendTime: null, + noticeStatus: "published", + createTime: "2026-09-14 15:30:00", + formType: "stay", + formFields: buildFormFields("stay"), + }, + { + id: 4, + title: "初一新生宿舍床位重排方案(需家长确认床位调整)", + noticeType: "宿舍调整", + level: "normal", + deptId: "D003", + deptName: "生活管理科", + publisherAccount: "lhm", + publisherName: "李慧敏", + content: + "

各位家长:

因初一新生入学后武术班重新编排,原按文化班分配的床位已不适应训练分组,生活管理科对 1 号楼 3 层、2 号楼 2 层的 12 个房间进行床位重排,同训练小组学生安排至同一房间,便于早晚训集合与教练查寝。

请家长确认是否接受床位调整,并选择可配合搬迁的日期(学校统一安排 9 月 26 日、27 日两批次搬迁,宿管协助搬运)。确有困难(如伤病、恐高上铺等)请在备注中说明,学校另行安排。

", + effectiveTime: "2026-09-26 09:00:00", + deadlineTime: "2026-09-25 12:00:00", + receiptMode: "fill", + needSupervisor: 1, + receiveList: "10301,10302,10303,10304,10305,10306,20201,20202,20203,20204,20205,20206", + roomRead: ["10301", "10303", "20201"], + files: "9003", + topmost: 0, + isPulled: 0, + smsFlag: 1, + timingSendTime: null, + noticeStatus: "published", + createTime: "2026-09-12 10:15:00", + formType: "adjust", + formFields: buildFormFields("adjust"), + }, + { + id: 5, + title: "家长到校探视预约通知(需填报到访时间)", + noticeType: "家长探视", + level: "normal", + deptId: "D002", + deptName: "德育处", + publisherAccount: "wjs", + publisherName: "王建山", + content: + "

各位家长:

为兼顾学生训练秩序与家长探视需求,本学期探视实行预约制:每周六、周日 09:00 — 20:00 开放探视,请家长至少提前 1 天在家长端填写《探视预约单》,写明到访日期、时段、人数与与学生关系。

探视区域为宿舍楼一楼会客区与校园广场,请勿进入二楼以上住宿区;携带食品请交宿管登记,禁止携带烟酒、管制刀具及大功率电器。

当日到访请携带身份证,在校门与宿舍楼门禁处配合核验。

", + effectiveTime: "2026-09-20 09:00:00", + deadlineTime: "2026-12-31 18:00:00", + receiptMode: "fill", + needSupervisor: 1, + receiveList: "ALL", + roomRead: ["10302", "10305"], + files: "9004", + topmost: 0, + isPulled: 0, + smsFlag: 0, + timingSendTime: null, + noticeStatus: "published", + createTime: "2026-09-10 14:00:00", + formType: "visit", + formFields: buildFormFields("visit"), + }, + { + id: 6, + title: "冬季宿舍用电与消防安全专项排查(需家长确认知悉)", + noticeType: "安全排查", + level: "important", + deptId: "D008", + deptName: "安全管理办公室", + publisherAccount: "zp", + publisherName: "赵鹏", + content: + "

各位家长:

入冬后宿舍用电负荷上升,学校将于 10 月 15 日 — 10 月 20 日开展宿舍用电与消防专项排查,重点检查:私拉电线、电热毯/电煮锅等违规电器、灭火器与烟感有效性、疏散通道畅通情况。

请家长配合教育孩子:宿舍内严禁使用大功率电器与明火,严禁在床上充电。排查中发现的违规物品由学校暂扣并通知家长领回。

请家长阅读后确认知悉(家长端打开本通知即视为已确认,无需点击)。

", + effectiveTime: "2026-10-15 08:00:00", + deadlineTime: "2026-10-14 20:00:00", + receiptMode: "confirm", + needSupervisor: 1, + receiveList: "ALL", + roomRead: ["10102", "10401", "30102", "40201"], + files: null, + topmost: 0, + isPulled: 0, + smsFlag: 0, + timingSendTime: null, + noticeStatus: "published", + createTime: "2026-09-13 16:20:00", + formType: null, + formFields: null, + }, + { + id: 7, + title: "秋季学期第一轮宿舍卫生大检查结果通报", + noticeType: "卫生检查", + level: "normal", + deptId: "D003", + deptName: "生活管理科", + publisherAccount: "lhm", + publisherName: "李慧敏", + content: + "

各位家长:

9 月 5 日 — 9 月 7 日,生活管理科联合德育处对全校 130 间宿舍开展卫生大检查,评分维度包括地面清洁、床铺整理、物品摆放、通风与异味。

本次检查优秀宿舍 41 间、合格 78 间、不合格 11 间(不合格宿舍已由宿管现场指导整改并复查通过)。1 号楼 305、3 号楼 202、4 号楼 103 等 12 间宿舍获得「文明宿舍」流动红旗。

感谢各位家长对宿舍卫生工作的支持,请继续督促孩子保持个人物品整洁。

", + effectiveTime: "2026-09-08 00:00:00", + deadlineTime: null, + receiptMode: "none", + needSupervisor: 0, + receiveList: "ALL", + roomRead: ALL_ROOMS.slice(), + files: "9005", + topmost: 0, + isPulled: 0, + smsFlag: 0, + timingSendTime: null, + noticeStatus: "closed", + createTime: "2026-09-08 09:00:00", + formType: null, + formFields: null, + }, + { + id: 8, + title: "军训期间夜间查寝值守安排(4号宿舍楼)", + noticeType: "夜间查寝", + level: "important", + deptId: "D002", + deptName: "德育处", + publisherAccount: "wjs", + publisherName: "王建山", + content: + "

各位家长:

新生军训期间(9 月 18 日 — 9 月 30 日),4 号宿舍楼实行夜间加密查寝:由宿管 + 值班教练每 2 小时巡查一次(22:00 / 00:00 / 02:00 / 04:00),重点核查在寝人数、身体状况与用电安全。

巡查结果每日 07:30 前同步至班主任。学生如有身体不适(中暑、肌肉拉伤等)可在巡查时报告,医务室安排次日就诊。

请家长知悉,如有特殊情况需临时离寝,须由家长电话向宿管报备。

", + effectiveTime: "2026-09-18 22:00:00", + deadlineTime: null, + receiptMode: "none", + needSupervisor: 1, + receiveList: "40000", + roomRead: ["40101", "40201"], + files: null, + topmost: 0, + isPulled: 0, + smsFlag: 0, + timingSendTime: null, + noticeStatus: "published", + createTime: "2026-09-16 11:10:00", + formType: null, + formFields: null, + }, + { + id: 9, + title: "关于个别宿舍违规使用电煮锅的处理通报", + noticeType: "违规通报", + level: "normal", + deptId: "D008", + deptName: "安全管理办公室", + publisherAccount: "zp", + publisherName: "赵鹏", + content: + "

各位家长:

9 月 9 日夜间查寝中,2 号楼 401、402 房间发现使用电煮锅与热得快各 1 件,存在严重消防隐患。学校已暂扣相关物品,并对当事学生进行安全警示教育,记入学期宿舍管理考核。

请各位家长引以为戒,配合学校做好孩子的用电安全教育。本学期宿舍考核累计 3 次违规的,将取消其住宿资格,另行安排走读。

", + effectiveTime: "2026-09-10 00:00:00", + deadlineTime: null, + receiptMode: "none", + needSupervisor: 0, + receiveList: "20401,20402,20403,20404,20405,20406,20407,20408", + roomRead: ["20401", "20402", "20403", "20404", "20405", "20406", "20407", "20408"], + files: null, + topmost: 0, + isPulled: 0, + smsFlag: 0, + timingSendTime: null, + noticeStatus: "finished", + createTime: "2026-09-10 10:30:00", + formType: null, + formFields: null, + }, + { + id: 10, + title: "学期末宿舍物品清退与寒假寄存安排(需家长确认)", + noticeType: "物品清退", + level: "important", + deptId: "D003", + deptName: "生活管理科", + publisherAccount: "lhm", + publisherName: "李慧敏", + content: + "

各位家长:

学期末宿舍将进行统一清整,请家长于 1 月 15 日前确认以下事项:

1. 需带回的行李由学生自行打包,宿管协助捆扎;
2. 被褥、脸盆等大件可寄存学校仓库(免费,凭寄存单领取),寄存时间至下学期开学后 15 日内;
3. 贵重物品(手机、平板、现金)一律不得寄存,请家长务必督促孩子随身带回。

逾期未清退的遗留物品,学校统一登记保管 30 日后按无主物品处理。请家长确认知悉(打开即视为已确认)。

", + effectiveTime: "2027-01-10 08:00:00", + deadlineTime: "2027-01-15 18:00:00", + receiptMode: "confirm", + needSupervisor: 1, + receiveList: "ALL", + roomRead: [], + files: "9006", + topmost: 0, + isPulled: 0, + smsFlag: 0, + timingSendTime: null, + noticeStatus: "published", + createTime: "2026-09-16 17:45:00", + formType: null, + formFields: null, + }, + { + id: 11, + title: "流感高发期宿舍通风与消杀安排", + noticeType: "疫情防控", + level: "important", + deptId: "D006", + deptName: "校医务室", + publisherAccount: "lhy", + publisherName: "刘海燕", + content: + "

各位家长:

近期昼夜温差大,流感样病例增多。学校安排 10 月 9 日起每日对宿舍公共区域消杀 1 次,房间内每日通风不少于 3 次、每次 30 分钟(07:00 / 12:30 / 17:30 由宿管统一开窗)。

请家长为学生准备一件保暖外套与备用口罩;如孩子出现发热、咳嗽、咽痛,请及时告知班主任或宿管,医务室将安排隔离观察室并第一时间联系家长。

因病缺勤的学生返校须持医院复课证明。

", + effectiveTime: "2026-10-09 00:00:00", + deadlineTime: null, + receiptMode: "none", + needSupervisor: 1, + receiveList: "ALL", + roomRead: ["10305", "10306"], + files: null, + topmost: 0, + isPulled: 0, + smsFlag: 1, + timingSendTime: null, + noticeStatus: "published", + createTime: "2026-09-16 09:25:00", + formType: null, + formFields: null, + }, + { + id: 12, + title: "暑假宿舍楼维修改造告知(不影响本学期住宿)", + noticeType: "假期安排", + level: "normal", + deptId: "D005", + deptName: "后勤保障部", + publisherAccount: "czq", + publisherName: "陈志强", + content: + "

各位家长:

今年暑假(7 月 15 日 — 8 月 20 日)学校对 1 号、2 号宿舍楼进行整体维修改造,内容含:卫生间翻新、墙面涂刷、上下水管道更换、加装公共饮水机与晾衣区。

改造已于 8 月 25 日通过验收,本学期住宿条件相应改善。改造期间给各位家长带来的不便,敬请谅解。

", + effectiveTime: "2026-08-25 00:00:00", + deadlineTime: null, + receiptMode: "none", + needSupervisor: 0, + receiveList: "ALL", + roomRead: ALL_ROOMS.slice(), + files: "9007", + topmost: 0, + isPulled: 0, + smsFlag: 0, + timingSendTime: null, + noticeStatus: "finished", + createTime: "2026-08-26 10:00:00", + formType: null, + formFields: null, + }, + { + id: 13, + title: "春季学期「文明宿舍」评比活动方案(草稿)", + noticeType: "卫生检查", + level: "normal", + deptId: "D003", + deptName: "生活管理科", + publisherAccount: "wjs", + publisherName: "王建山", + content: + "

各位家长:

为营造整洁文明的宿舍环境,拟于春季学期开展「文明宿舍」评比,评分维度含卫生(40 分)、内务规范(30 分)、文化氛围(20 分)、安全守纪(10 分),每月评选一次,累计 3 次获评的宿舍推荐参加市级评比。

(方案与奖品预算尚在审核,暂未发布。)

", + effectiveTime: "2027-03-01 00:00:00", + deadlineTime: null, + receiptMode: "none", + needSupervisor: 0, + receiveList: "ALL", + roomRead: [], + files: null, + topmost: 0, + isPulled: 0, + smsFlag: 0, + timingSendTime: null, + noticeStatus: "draft", + createTime: "2026-09-16 16:30:00", + formType: null, + formFields: null, + }, + { + id: 14, + title: "关于调整女生宿舍归寝时间的补充通知", + noticeType: "作息调整", + level: "normal", + deptId: "D003", + deptName: "生活管理科", + publisherAccount: "lhm", + publisherName: "李慧敏", + content: + "

各位家长:

经听取家长与学生意见,女生宿舍(3 号楼)晚自习后归寝时间由 21:00 延长至 21:15,熄灯时间与男生宿舍一致(21:40)。该项调整自 9 月 20 日起执行。

请家长督促孩子按时归寝,21:15 后楼门关闭,特殊情况须由家长电话向宿管说明。

", + effectiveTime: "2026-09-20 00:00:00", + deadlineTime: null, + receiptMode: "none", + needSupervisor: 1, + receiveList: "30000", + roomRead: ["30101"], + files: null, + topmost: 0, + isPulled: 0, + smsFlag: 0, + timingSendTime: null, + noticeStatus: "published", + createTime: "2026-09-17 08:00:00", + formType: null, + formFields: null, + }, +]; +let noticeSeq = 15; + +const NOTICE_TYPE_NAMES = noticeTypeDict.map(t => t.Name); + +/* ============================ 回执 / 填报记录 ============================ */ + +/** 回执记录(住宿生 × 通知)。仅通知类不产生记录;草稿不产生记录。 */ +const receipts: any[] = []; +let receiptSeq = 1; + +function noticeOf(id: number) { + return notices.find(n => n.id === id); +} +function receiptsOf(noticeId: number) { + return receipts.filter(s => s.noticeId === noticeId); +} + +/** 回执/填报进度派生统计 */ +function progressOf(noticeId: number) { + const list = receiptsOf(noticeId); + const should = list.length; + const confirmed = list.filter(s => s.status === "confirmed").length; + const filled = list.filter(s => s.status === "filled").length; + const canceled = list.filter(s => s.status === "canceled").length; + const acted = confirmed + filled; + const n = noticeOf(noticeId); + return { + should, + confirmed, + filled, + canceled, + unsigned: Math.max(should - acted - canceled, 0), + receiptRate: should ? Math.round((acted / should) * 100) : 0, + rooms: n ? targetRoomIds(n).length : 0, + beds: n ? allowCountOf(n) : 0, + }; +} + +/** 生成某通知的回执记录(按通知状态给不同回执率) */ +function seedReceiptsFor(n: any) { + if (n.noticeStatus === "draft" || n.receiptMode === "none") return; + if (receiptsOf(n.id).length) return; + const rate = n.noticeStatus === "published" ? 0.68 : n.noticeStatus === "closed" ? 0.87 : 0.94; + targetRoomIds(n).forEach((rid: string) => { + const room = roomMap[rid]; + if (!room) return; + (roster[rid] || []).forEach((stu: any, si: number) => { + const r = seededRand(n.id * 1000 + stu.studentId); + const r2 = seededRand(stu.studentId * 3 + n.id); + let status = "none"; + if (r < rate) { + // 已回执:需确认类只可能是「已确认」;需填报类一律「已填报」 + status = n.receiptMode === "confirm" ? "confirmed" : "filled"; + } else if (r > 0.965) { + status = "canceled"; + } + const acted = status === "confirmed" || status === "filled"; + receipts.push({ + id: receiptSeq++, + noticeId: n.id, + studentId: stu.studentId, + studentName: stu.studentName, + roomId: room.roomId, + roomNo: `${room.buildingShort} ${room.roomNo}`, + buildingId: room.buildingId, + buildingName: room.buildingName, + floor: room.floor, + bedNo: stu.bedNo, + className: stu.className, + guardianName: acted ? stu.guardianName : "", + guardianPhone: acted ? stu.guardianPhone : "", + relation: acted ? stu.relation : "", + status, + read: acted ? true : r2 < 0.8, + readAt: null, + submittedAt: acted ? `${String(n.createTime).slice(0, 8)}${String(8 + (si % 10)).padStart(2, "0")} ${9 + (si % 9)}:${String((si * 7) % 60).padStart(2, "0")}:00` : null, + fillData: status === "filled" ? buildFillData(n, stu, r2) : null, + remark: acted && r2 > 0.87 ? pickRemark(n, r2) : "", + }); + }); + }); +} + +/** + * 按统一回执单的字段清单造一份合理的填报内容。 + * 公共区 conclusion 每条必填,按比例撒出「同意 / 不同意 / 需沟通」三种结论, + * 让回执页的结论列有真实分布可看。 + */ +function buildFillData(n: any, stu: any, r: number) { + const out: Record = {}; + const fields: any[] = n.formFields || []; + fields.forEach(f => { + if (f.key === "conclusion") out[f.key] = r > 0.18 ? "同意" : r > 0.06 ? "需沟通" : "不同意"; + else if (f.key === "stayStart") out[f.key] = "2026-10-01"; + else if (f.key === "stayEnd") out[f.key] = r > 0.6 ? "2026-10-07" : "2026-10-03"; + else if (f.key === "stayCount") out[f.key] = "1"; + else if (f.key === "stayReason") out[f.key] = (f.options?.[Math.floor(r * (f.options.length - 1))] || {}).value || "家远不便往返"; + else if (f.key === "visitDate") out[f.key] = "2026-09-26"; + else if (f.key === "visitSlot") out[f.key] = "上午 09:00-11:30"; + else if (f.key === "visitorCount") out[f.key] = String(1 + Math.floor(r * 2)); + else if (f.key === "moveDate") out[f.key] = "2026-09-26"; + else if (f.key === "adjustReason") out[f.key] = r > 0.75 ? "孩子有轻度腰伤,希望安排下铺" : ""; + else out[f.key] = ""; + }); + return out; +} + +function pickRemark(n: any, r: number) { + if (n.noticeType === "留宿申请") return "留宿期间需每日服用中药,请宿管代为保管并提醒"; + if (n.noticeType === "家长探视") return "需接孩子外出就餐,18:00 前送回宿舍"; + if (n.noticeType === "宿舍调整") return "孩子有轻度腰伤,希望安排下铺"; + if (n.noticeType === "安全排查") return "已阅读并会配合学校排查"; + return r > 0.95 ? "已知悉,感谢老师" : ""; +} + +notices.forEach(n => seedReceiptsFor(n)); + +/** 家长端演示住宿生:1 号楼 305 室第一位学生 */ +const ME_ROOM = "10305"; +const DEMO_STUDENT = (() => { + const stu = roster[ME_ROOM][0]; + const room = roomMap[ME_ROOM]; + return { ...stu, roomId: room.roomId, roomNo: room.roomNo, buildingName: room.buildingName, buildingShort: room.buildingShort, floor: room.floor }; +})(); + +/** + * 演示便利:固定让演示学生的「待回执」里有三种回执形态各一条—— + * 一条「需填报」(留宿申请 / 探视预约)、一条「需确认」(打开即自动确认)。 + * 否则随机种子可能导致回执流程演示不到。 + */ +[3, 5, 6].forEach(id => { + receipts + .filter(s => s.noticeId === id && s.studentId === DEMO_STUDENT.studentId) + .forEach(s => { + s.status = "none"; + s.fillData = null; + s.submittedAt = null; + s.guardianName = ""; + s.guardianPhone = ""; + s.relation = ""; + s.remark = ""; + s.read = false; + s.readAt = null; + }); +}); +// 通知 4(宿舍调整·需填报)落在演示学生所在房间,确保「已填报」形态也能看到 +const demoAdjust = receipts.find(s => s.noticeId === 4 && s.studentId === DEMO_STUDENT.studentId); +if (demoAdjust) { + demoAdjust.status = "filled"; + demoAdjust.fillData = { conclusion: "同意", moveDate: "2026-09-26" }; + demoAdjust.guardianName = DEMO_STUDENT.guardianName; + demoAdjust.guardianPhone = DEMO_STUDENT.guardianPhone; + demoAdjust.relation = DEMO_STUDENT.relation; + demoAdjust.submittedAt = "2026-09-13 20:12:00"; + demoAdjust.read = true; +} + +/* ============================ 附件 / 联系组 / 组织树 ============================ */ + +const filesStore: Record = { + 9001: { id: 9001, filename: "宿舍作息时间表(秋冬).pdf", fileTrueName: "宿舍作息时间表(秋冬).pdf", size: 312320 }, + 9002: { id: 9002, filename: "国庆留宿申请表(模板).docx", fileTrueName: "国庆留宿申请表(模板).docx", size: 28672 }, + 9003: { id: 9003, filename: "初一新生床位重排方案.xlsx", fileTrueName: "初一新生床位重排方案.xlsx", size: 41984 }, + 9004: { id: 9004, filename: "校园探视管理规定.pdf", fileTrueName: "校园探视管理规定.pdf", size: 542720 }, + 9005: { id: 9005, filename: "宿舍卫生检查评分细则.pdf", fileTrueName: "宿舍卫生检查评分细则.pdf", size: 233472 }, + 9006: { id: 9006, filename: "学期末物品寄存单.docx", fileTrueName: "学期末物品寄存单.docx", size: 22528 }, + 9007: { id: 9007, filename: "宿舍楼维修改造验收报告.pdf", fileTrueName: "宿舍楼维修改造验收报告.pdf", size: 1048576 }, +}; + +const groups = [ + { serid: 1, GroupName: "全体宿管员", members: [12, 13, 14, 15] }, + { serid: 2, GroupName: "生活老师(1-2号楼)", members: [12, 13, 16] }, + { serid: 3, GroupName: "夜间值班教练组", members: [1, 17, 18] }, +]; +let groupSeq = 4; + +const idNames: Record = { + 1: "王建山", 2: "李慧敏", 3: "张振武", 4: "刘海燕", 5: "陈志强", + 6: "赵鹏", 7: "孙丽", 8: "周文博", 9: "吴桂芳", 10: "郑小龙", 11: "马俊", + 12: "秦大力", 13: "贺明远", 14: "钱秀兰", 15: "岑治平", 16: "许春燕", 17: "邢铁山", 18: "屈子昂", +}; + +/** 教师组织树(宿舍设置-自定义组选人在用;发布对象为宿舍房间树) */ +const schoolTree = [ + { id: 301, name: "校办", isParent: true, children: [{ id: 7, name: "孙丽", isParent: false }, { id: 9, name: "吴桂芳", isParent: false }] }, + { id: 302, name: "德育处", isParent: true, children: [{ id: 1, name: "王建山", isParent: false }, { id: 8, name: "周文博", isParent: false }] }, + { id: 303, name: "生活管理科", isParent: true, children: [{ id: 2, name: "李慧敏", isParent: false }, { id: 12, name: "秦大力", isParent: false }, { id: 13, name: "贺明远", isParent: false }] }, + { id: 304, name: "武术训练部", isParent: true, children: [{ id: 3, name: "张振武", isParent: false }, { id: 17, name: "邢铁山", isParent: false }, { id: 18, name: "屈子昂", isParent: false }] }, + { id: 305, name: "后勤保障部", isParent: true, children: [{ id: 5, name: "陈志强", isParent: false }] }, + { id: 306, name: "校医务室", isParent: true, children: [{ id: 4, name: "刘海燕", isParent: false }] }, + { id: 307, name: "安全管理办公室", isParent: true, children: [{ id: 6, name: "赵鹏", isParent: false }] }, +]; + +/* ============================ 工具 ============================ */ + +function ok(data: any) { + return { code: 0, msg: "操作成功", data }; +} +function fail(msg: string, code = 4) { + return { code, msg, data: null }; +} + +function paginate(content: any[], pageIndex: number, pageSize: number) { + const start = (pageIndex - 1) * pageSize; + const totalPages = pageSize ? Math.ceil(content.length / pageSize) : 0; + return { content: content.slice(start, start + pageSize), total: content.length, pageIndex, pageSize, totalPages, hasNext: pageIndex < totalPages }; +} + +function parseBody(body: any): any { + if (body == null) return {}; + if (typeof body === "object") return body; + const str = String(body).trim(); + if (!str) return {}; + if (str.startsWith("{") || str.startsWith("[")) { + try { + return JSON.parse(str); + } catch { + /* fallthrough */ + } + } + const out: Record = {}; + new URLSearchParams(str).forEach((v, k) => (out[k] = v)); + return out; +} + +/** 合并取参:query(postForm 已把参数拼入 URL)+ JSON body(Create/Edit/Delete/SubmitReceipt/Review) */ +function bodyOf(rawBody: any, query: any): any { + const parsed = parseBody(rawBody); + if (Array.isArray(parsed)) return parsed; + return { ...(query || {}), ...(parsed || {}) }; +} + +/** + * 接收房间 id 数组。 + * 支持三种写法(与服务端约定一致): + * - "ALL" 全部宿舍 + * - "30000" 楼栋前缀(xx000)→ 该楼栋全部房间 + * - "10301,10305" 具体房间 + */ +function targetRoomIds(n: any): string[] { + const raw = String(n.receiveList ?? "").trim(); + if (/^all$/i.test(raw)) return ALL_ROOMS; + const out = new Set(); + raw.split(",") + .map(s => s.trim()) + .filter(Boolean) + .forEach(id => { + if (/^\d+0000$/.test(id) || /^\d0000$/.test(id)) { + // 楼栋前缀:如 30000 → buildingId=3 + const bid = Math.floor(Number(id) / 10000); + ALL_ROOMS.filter(rid => Math.floor(Number(rid) / 10000) === bid).forEach(r => out.add(r)); + } else if (roomMap[id]) { + out.add(id); + } else if (/^\d{5}$/.test(id) && id.endsWith("00")) { + // 楼层前缀:如 10300 → 1号楼3层 + ALL_ROOMS.filter(rid => String(rid).slice(0, 3) === id.slice(0, 3)).forEach(r => out.add(r)); + } + }); + return Array.from(out); +} +function isAllTarget(n: any): boolean { + const raw = String(n.receiveList ?? "").trim(); + return /^all$/i.test(raw) || targetRoomIds(n).length === ALL_ROOMS.length; +} +/** 应收/应读人数:覆盖房间的**入住床位数**(住宿生数)累加 */ +function allowCountOf(n: any): number { + if (/^all$/i.test(String(n.receiveList ?? "").trim())) return ALL_USED_BEDS; + return targetRoomIds(n).reduce((s, id) => s + (roomMap[id]?.usedBeds || 0), 0); +} +function readCountOf(n: any): number { + const targets = targetRoomIds(n); + return (n.roomRead || []).filter((id: string) => targets.includes(id)).reduce((s: number, id: string) => s + (roomMap[id]?.usedBeds || 0), 0); +} + +/** 接收对象可读文本:全部宿舍 / 「楼栋 房间」顿号串(超 12 间折叠) */ +function receiveText(n: any): string { + if (isAllTarget(n)) return `全部宿舍(${ALL_ROOMS.length} 间 / ${ALL_USED_BEDS} 个床位)`; + const ids = targetRoomIds(n); + // 整楼覆盖时折叠为楼栋名 + for (const b of buildings) { + const inB = ids.filter(id => roomMap[id]?.buildingId === b.buildingId); + if (inB.length === ALL_ROOMS.filter(id => roomMap[id].buildingId === b.buildingId).length) { + return `${b.buildingName}(${inB.length} 间)`; + } + } + const names = ids.map(id => `${roomMap[id]?.buildingShort || ""} ${roomMap[id]?.roomNo || id}`); + if (names.length > 12) return `${names.slice(0, 12).join("、")} 等 ${names.length} 间`; + return names.join("、"); +} + +/** 当前视角房间(家长端为演示学生所在房间;发布端不参与已读判定) */ +let viewerRole: "teacher" | "parent" = "teacher"; +function viewerRoom(): string | null { + return viewerRole === "parent" ? ME_ROOM : null; +} +function isReadByMe(n: any): boolean { + const c = viewerRoom(); + return c ? (n.roomRead || []).includes(c) : true; +} +/** 是否已发布(家长端可见) */ +function isPublished(n: any): boolean { + return !n.isPulled && n.noticeStatus !== "draft"; +} +/** 定时未到的通知,家长端不展示 */ +function isScheduledFuture(n: any): boolean { + return !!(n.timingSendTime && n.timingSendTime > "2026-09-26"); +} + +function toVo(n: any) { + const progress = n.noticeStatus === "draft" ? null : progressOf(n.id); + return { + id: n.id, + title: n.title, + content: n.content, + noticeType: n.noticeType, + level: n.level, + levelDesc: LEVEL_DESC[n.level] || "普通", + effectiveTime: n.effectiveTime, + deadlineTime: n.deadlineTime, + receiptMode: n.receiptMode, + receiptModeDesc: RECEIPT_MODE_DESC[n.receiptMode] || "仅通知", + publisherAccount: n.publisherAccount, + publisherName: n.publisherName, + deptId: n.deptId, + deptName: n.deptName, + needSupervisor: n.needSupervisor, + status: isReadByMe(n) ? 2 : 1, + statusDesc: isReadByMe(n) ? "已读" : "未读", + noticeStatus: n.noticeStatus, + noticeStatusDesc: NOTICE_STATUS_DESC[n.noticeStatus] || "已发布", + topmost: n.topmost, + readCount: readCountOf(n), + allowCount: allowCountOf(n), + read: isReadByMe(n), + isPulled: n.isPulled, + timingSendTime: n.timingSendTime, + smsFlag: n.smsFlag, + receiveType: "room", + receiveList: n.receiveList, + receiveText: receiveText(n), + roomCount: targetRoomIds(n).length, + bedCount: allowCountOf(n), + progress, + createBy: n.publisherAccount, + createTime: n.createTime, + updateBy: n.publisherAccount, + updateTime: n.createTime, + UpFile: n.files, + }; +} + +function fileItems(n: any) { + return String(n.files || "") + .split(",") + .map(Number) + .filter(Boolean) + .map(id => { + const f = filesStore[id]; + return { id, name: f?.fileTrueName || "附件", size: f?.size || 0, fileid: id, filename: f?.filename || "" }; + }); +} + +function toDetail(n: any) { + const progress = n.noticeStatus === "draft" ? null : progressOf(n.id); + return { + serid: n.id, + title: n.title, + Contents: n.content, + DeptID: n.deptId, + DeptName: n.deptName, + TeaName: n.publisherName, + EditDate: n.createTime, + numcount: allowCountOf(n), + readnumcount: readCountOf(n), + ReceiveList: n.receiveList, + DsfsTime: n.timingSendTime, + isDsfs: !!n.timingSendTime, + noticeType: n.noticeType, + level: n.level, + levelDesc: LEVEL_DESC[n.level] || "普通", + effectiveTime: n.effectiveTime, + deadlineTime: n.deadlineTime, + receiptMode: n.receiptMode, + receiptModeDesc: RECEIPT_MODE_DESC[n.receiptMode] || "仅通知", + needSupervisor: n.needSupervisor, + noticeStatus: n.noticeStatus, + noticeStatusDesc: NOTICE_STATUS_DESC[n.noticeStatus] || "已发布", + progress, + ChaoSend: fileItems(n), + tlist: /^all$/i.test(String(n.receiveList).trim()) ? "all" : targetRoomIds(n), + receiveType: "room", + receiveText: receiveText(n), + formType: n.formType || null, + formTypeDesc: n.formType ? FORM_TYPE_DESC[n.formType] || "通用回执单" : "", + formFields: n.formFields || null, + }; +} + +function sortByTimeDesc(list: any[]) { + // 紧急 > 置顶 > 时间倒序 + const levelWeight = (l: string) => (l === "urgent" ? 2 : l === "important" ? 1 : 0); + return [...list].sort((a, b) => { + if (levelWeight(a.level) !== levelWeight(b.level)) return levelWeight(b.level) - levelWeight(a.level); + if (a.topmost !== b.topmost) return b.topmost - a.topmost; + return b.createTime > a.createTime ? 1 : -1; + }); +} + +function applyFilters(list: any[], query: any) { + let out = [...list]; + const kw = query.keyword || query.Name; + if (kw) { + out = out.filter( + n => n.title.includes(kw) || n.noticeType.includes(kw) || n.publisherName.includes(kw) || n.deptName.includes(kw) || receiveText(n).includes(kw) + ); + } + const dept = query.deptId || query.Depttype; + if (dept) out = out.filter(n => n.deptId === dept); + if (query.noticeType) out = out.filter(n => n.noticeType === query.noticeType); + if (query.level && query.level !== "all") out = out.filter(n => n.level === query.level); + if (query.buildingId) { + const bid = Number(query.buildingId); + out = out.filter(n => targetRoomIds(n).some(id => (roomMap[id]?.buildingId || 0) === bid)); + } + if (query.noticeStatus && query.noticeStatus !== "all") out = out.filter(n => n.noticeStatus === query.noticeStatus); + const st = query.startTime || query.starttime; + const et = query.endTime || query.endtime; + if (st) out = out.filter(n => String(n.effectiveTime || n.createTime).slice(0, 10) >= String(st).slice(0, 10)); + if (et) out = out.filter(n => String(n.effectiveTime || n.createTime).slice(0, 10) <= String(et).slice(0, 10)); + const rt = Number(query.readType || 0); + if (rt === 1) out = out.filter(n => !isReadByMe(n)); + if (rt === 2) out = out.filter(n => isReadByMe(n)); + return out; +} + +function nowStr() { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; +} + +/** 家长端通知的「我的回执」态 */ +function toParentVo(n: any) { + const my = receipts.find(s => s.noticeId === n.id && s.studentId === DEMO_STUDENT.studentId) || null; + const deadlinePassed = !!n.deadlineTime && n.deadlineTime < nowStr(); + const canReceipt = n.noticeStatus === "published" && !deadlinePassed; + return { + id: n.id, + title: n.title, + noticeType: n.noticeType, + level: n.level, + levelDesc: LEVEL_DESC[n.level] || "普通", + effectiveTime: n.effectiveTime, + deadlineTime: n.deadlineTime, + receiptMode: n.receiptMode, + receiptModeDesc: RECEIPT_MODE_DESC[n.receiptMode] || "仅通知", + content: n.content, + publisherName: n.publisherName, + deptName: n.deptName, + roomNo: DEMO_STUDENT.roomNo, + buildingName: DEMO_STUDENT.buildingName, + bedNo: DEMO_STUDENT.bedNo, + createTime: n.createTime, + noticeStatus: n.noticeStatus, + noticeStatusDesc: NOTICE_STATUS_DESC[n.noticeStatus] || "已发布", + viewOnly: n.receiptMode === "none", + canReceipt: !!canReceipt, + my: my + ? { + receiptId: my.id, + status: my.status, + statusDesc: RECEIPT_STATUS_DESC[my.status] || my.status, + fillData: my.fillData, + guardianName: my.guardianName, + guardianPhone: my.guardianPhone, + relation: my.relation, + remark: my.remark, + submittedAt: my.submittedAt, + } + : null, + }; +} + +/** 单条回执在名单里的展示形态 */ +function toReceiptBrief(s: any) { + return { + receiptId: s.id, + studentId: s.studentId, + studentName: s.studentName, + roomNo: s.roomNo, + buildingName: s.buildingName, + bedNo: s.bedNo, + guardianName: s.guardianName, + guardianPhone: s.guardianPhone, + relation: s.relation, + status: s.status, + statusDesc: RECEIPT_STATUS_DESC[s.status] || s.status, + hasReceipt: s.status === "confirmed" || s.status === "filled", + submittedAt: s.submittedAt, + fillData: s.fillData, + remark: s.remark, + }; +} + +/* ============================ Mock 定义 ============================ */ + +export default [ + /* ---------- 布局 / 用户 ---------- */ + { + url: "/api/base/GetUserInfo", + method: "get", + response: () => + viewerRole === "teacher" + ? ok({ id: 186532, account: ME.account, name: ME.name, role: "teacher", profilePicture: "", school: { id: 3260, name: "少林文武学校" } }) + : ok({ + id: DEMO_STUDENT.studentId, + account: "stu" + DEMO_STUDENT.studentId, + name: DEMO_STUDENT.studentName, + role: "parent", + studentId: DEMO_STUDENT.studentId, + studentName: DEMO_STUDENT.studentName, + roomId: DEMO_STUDENT.roomId, + roomNo: DEMO_STUDENT.roomNo, + buildingName: DEMO_STUDENT.buildingName, + bedNo: DEMO_STUDENT.bedNo, + profilePicture: "", + school: { id: 3260, name: "少林文武学校" }, + }), + }, + { + url: "/api/base/GetUserMod", + method: "get", + response: () => + ok( + viewerRole === "parent" + ? [ + { id: 31, modId: 31, name: "宿舍通知", parentId: 0, page: "/par/notices", orderNumber: 1, usable: true, children: [] }, + { id: 32, modId: 32, name: "我的回执", parentId: 0, page: "/par/mine", orderNumber: 2, usable: true, children: [] }, + { id: 2, modId: 2, name: "发布端(教师)", parentId: 0, page: "/list", orderNumber: 9, usable: true, children: [] }, + ] + : [ + { id: 2, modId: 2, name: "我发布的", parentId: 0, page: "/list", orderNumber: 1, usable: true, children: [] }, + { id: 3, modId: 3, name: "全部通知", parentId: 0, page: "/noticelist", orderNumber: 2, usable: true, children: [] }, + { id: 25, modId: 25, name: "回执情况", parentId: 0, page: "/receipts", orderNumber: 3, usable: true, children: [] }, + { id: 4, modId: 4, name: "通知统计", parentId: 0, page: "/stats", orderNumber: 4, usable: true, children: [] }, + { id: 21, modId: 21, name: "宿舍设置", parentId: 0, page: "/setting", orderNumber: 5, usable: true, children: [] }, + { id: 31, modId: 31, name: "家长端预览", parentId: 0, page: "/par/notices", orderNumber: 8, usable: true, children: [] }, + ] + ), + }, + { + url: "/api/base/GetLayout", + method: "get", + response: () => + ok({ + ssoUrl: "", + username: viewerRole === "teacher" ? ME.name : DEMO_STUDENT.studentName, + pathList: [], + topName: "宿舍通知", + pageUrl: "", + logoUrl: "", + initUrl: "", + id: 0, + avatar: "", + }), + }, + { + // 端分离入口:按 URL 前缀切换当前视角(teacher 发布端 / parent 家长端) + url: "/api/base/SetRole", + method: "post", + response: ({ query, body }: any) => { + const as = String((bodyOf(body, query) || {}).as || query.as || "teacher"); + viewerRole = as === "parent" ? "parent" : "teacher"; + return ok({ role: viewerRole }); + }, + }, + { url: "/api/base/GetSchoolAll", method: "get", response: () => ok(schoolTree) }, + { + // 楼栋 → 楼层 → 房间树(通知发布对象选择:到房间一级) + url: "/api/base/GetDormTree", + method: "get", + response: () => ok(buildDormTree()), + }, + + /* ---------- 宿舍楼栋 / 房间 ---------- */ + { + url: "/api/dorm/BuildingList", + method: "get", + response: () => + ok( + buildings.map(b => { + const inB = rooms.filter(r => r.buildingId === b.buildingId); + return { + buildingId: b.buildingId, + buildingName: b.buildingName, + gender: b.gender, + floorCount: b.floors, + supervisorName: b.supervisor, + supervisorPhone: b.phone, + roomCount: inB.length, + bedCount: inB.reduce((s, r) => s + r.bedCount, 0), + usedBeds: inB.reduce((s, r) => s + r.usedBeds, 0), + }; + }) + ), + }, + { + url: "/api/dorm/RoomList", + method: "get", + response: ({ query }: any) => { + let list = rooms.slice(); + if (query.buildingId) list = list.filter(r => r.buildingId === Number(query.buildingId)); + if (query.keyword) { + const kw = String(query.keyword); + list = list.filter(r => r.roomNo.includes(kw) || r.supervisorName.includes(kw)); + } + return ok( + list.map(r => ({ + roomId: r.roomId, + roomNo: r.roomNo, + buildingId: r.buildingId, + buildingName: `${r.buildingShort} ${r.roomNo}`, + floor: r.floor, + bedCount: r.bedCount, + usedBeds: r.usedBeds, + supervisorName: r.supervisorName, + supervisorPhone: r.supervisorPhone, + monitorName: roster[String(r.roomId)]?.find((s: any) => s.isMonitor)?.studentName || "", + monitorStudentId: roster[String(r.roomId)]?.find((s: any) => s.isMonitor)?.studentId || 0, + })) + ); + }, + }, + { + url: "/api/dorm/RoomStudents", + method: "get", + response: ({ query }: any) => { + const stu = roster[String(query.roomId)] || []; + return ok( + stu.map(s => ({ + studentId: s.studentId, + studentName: s.studentName, + gender: s.gender, + bedNo: s.bedNo, + className: s.className, + guardianName: s.guardianName, + guardianPhone: s.guardianPhone, + relation: s.relation, + isMonitor: !!s.isMonitor, + })) + ); + }, + }, + + /* ---------- 床位安排 / 宿舍长(「人员进房间」的唯一入口) ---------- */ + { + // 待安排床位学生池(安排入住的数据源) + url: "/api/dorm/PendingStudents", + method: "get", + response: ({ query }: any) => { + let list = pendingPool.slice(); + if (query.gender) list = list.filter(s => s.gender === String(query.gender)); + if (query.keyword) { + const kw = String(query.keyword); + list = list.filter(s => s.studentName.includes(kw) || s.className.includes(kw) || String(s.studentId).includes(kw)); + } + return ok(list); + }, + }, + { + // 安排入住:把待安排学生调入指定房间,按空床位顺序落位 + url: "/api/dorm/AssignStudent", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const r = roomMap[String(b.roomId)]; + if (!r) return fail("房间不存在"); + const ids = String(b.studentIds || "") + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean); + if (!ids.length) return fail("请选择要安排入住的学生"); + if (!roster[String(r.roomId)]) roster[String(r.roomId)] = []; + const list = roster[String(r.roomId)]; + const empty = r.bedCount - list.length; + if (empty <= 0) return fail(`该房间已住满(${list.length}/${r.bedCount} 床)`); + if (ids.length > empty) return fail(`该房间仅剩 ${empty} 个空床位,无法安排 ${ids.length} 人`); + // 先整体校验再落位,避免"部分成功"的半截数据 + const picked: any[] = []; + for (const id of ids) { + const stu = pendingPool.find(s => String(s.studentId) === id); + if (!stu) return fail("所选学生不在待安排名单中,请刷新后重试"); + const conflict = genderConflict(r.buildingName, stu.gender); + if (conflict) return fail(`${stu.studentName}:${conflict}`); + picked.push(stu); + } + picked.forEach(stu => { + const used = new Set(list.map((s: any) => s.bedNo)); + let bed = 1; + while (used.has(`${bed}号床`)) bed++; + list.push({ ...stu, bedNo: `${bed}号床`, isMonitor: list.length === 0 }); + delete list[list.length - 1].sourceDesc; + pendingPool.splice(pendingPool.indexOf(stu), 1); + }); + r.usedBeds = list.length; + rebuildRoomIndex(); + return ok(null); + }, + }, + { + // 退宿:学生移出房间并退回待安排池 + url: "/api/dorm/RemoveStudent", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const r = roomMap[String(b.roomId)]; + if (!r) return fail("房间不存在"); + const list = roster[String(r.roomId)] || []; + const i = list.findIndex((s: any) => String(s.studentId) === String(b.studentId)); + if (i < 0) return fail("该学生不在本房间"); + const [stu] = list.splice(i, 1); + backToPool(stu, "退宿待重新安排"); + renumberBeds(list); + r.usedBeds = list.length; + rebuildRoomIndex(); + return ok(null); + }, + }, + { + // 调整宿舍:房内学生转到另一间房(占目标房空床位) + url: "/api/dorm/TransferStudent", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const from = roomMap[String(b.fromRoomId)]; + const to = roomMap[String(b.toRoomId)]; + if (!from || !to) return fail("房间不存在"); + if (String(from.roomId) === String(to.roomId)) return fail("原房间与目标房间相同"); + const src = roster[String(from.roomId)] || []; + const i = src.findIndex((s: any) => String(s.studentId) === String(b.studentId)); + if (i < 0) return fail("该学生不在原房间"); + if (!roster[String(to.roomId)]) roster[String(to.roomId)] = []; + const dst = roster[String(to.roomId)]; + if (dst.length >= to.bedCount) return fail(`${to.buildingName} 已住满(${dst.length}/${to.bedCount} 床)`); + const stu = src[i]; + const conflict = genderConflict(to.buildingName, stu.gender); + if (conflict) return fail(`${stu.studentName}:${conflict}`); + const wasMonitor = stu.isMonitor; + src.splice(i, 1); + const used = new Set(dst.map((s: any) => s.bedNo)); + let bed = 1; + while (used.has(`${bed}号床`)) bed++; + dst.push({ ...stu, bedNo: `${bed}号床`, isMonitor: dst.length === 1 }); + renumberBeds(src); + from.usedBeds = src.length; + to.usedBeds = dst.length; + rebuildRoomIndex(); + return ok({ wasMonitor, bedNo: `${bed}号床` }); + }, + }, + { + // 设置 / 取消宿舍长(studentId 传 0 表示取消) + url: "/api/dorm/SetMonitor", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const r = roomMap[String(b.roomId)]; + if (!r) return fail("房间不存在"); + const list = roster[String(r.roomId)] || []; + if (!list.length) return fail("该房间暂无住宿生,无法指定宿舍长"); + const sid = String(b.studentId ?? ""); + const valid = !!sid && sid !== "0"; + if (valid && !list.some((s: any) => String(s.studentId) === sid)) return fail("该学生不在本房间"); + list.forEach((s: any) => (s.isMonitor = valid && String(s.studentId) === sid)); + rebuildRoomIndex(); + return ok(null); + }, + }, + { + url: "/api/dorm/AddBuilding", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + if (!b.buildingName) return fail("楼栋名称不能为空"); + const bid = Math.max(...buildings.map(x => x.buildingId)) + 1; + const floors = Number(b.floorCount) || 1; + const per = Number(b.roomsPerFloor) || 1; + const beds = Number(b.bedCount) || 6; + buildings.push({ + buildingId: bid, + buildingName: b.buildingName, + short: `${bid}号楼`, + gender: b.gender || "男生", + floors, + roomsPerFloor: per, + beds, + supervisor: b.supervisorName || "待指定", + phone: b.supervisorPhone || "", + }); + // 同步铺房间与床位。新楼栋按「空楼」创建(usedBeds=0)—— + // 真实流程是「先建楼栋房间,再由床位安排流程写入住宿生」,mock 同理。 + for (let f = 1; f <= floors; f++) { + for (let i = 1; i <= per; i++) { + const roomId = bid * 10000 + f * 100 + i; + rooms.push({ + roomId, + roomNo: `${f}${String(i).padStart(2, "0")}`, + buildingId: bid, + buildingName: b.buildingName, + buildingShort: `${bid}号楼`, + floor: f, + bedCount: beds, + usedBeds: 0, + supervisorName: `${b.supervisorName || "待指定"}(${bid}号楼${f}层宿管)`, + supervisorPhone: b.supervisorPhone || "", + }); + roster[String(roomId)] = []; + } + } + rebuildRoomIndex(); + return ok(null); + }, + }, + { + url: "/api/dorm/EditBuilding", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const item = buildings.find(x => x.buildingId === Number(b.buildingId)); + if (!item) return fail("楼栋不存在"); + if (b.buildingName) item.buildingName = b.buildingName; + if (b.gender) item.gender = b.gender; + if (b.supervisorName) { + item.supervisor = b.supervisorName; + rooms.filter(r => r.buildingId === item.buildingId).forEach(r => (r.supervisorName = `${b.supervisorName}(${item.short}${r.floor}层宿管)`)); + } + if (b.supervisorPhone) { + item.phone = b.supervisorPhone; + rooms.filter(r => r.buildingId === item.buildingId).forEach(r => (r.supervisorPhone = b.supervisorPhone)); + } + rooms.filter(r => r.buildingId === item.buildingId).forEach(r => (r.buildingName = item.buildingName)); + rebuildRoomIndex(); + return ok(null); + }, + }, + { + url: "/api/dorm/DelBuilding", + method: "post", + response: ({ body, query }: any) => { + const bid = Number(bodyOf(body, query).buildingId); + const roomCount = rooms.filter(r => r.buildingId === bid).length; + if (roomCount) return fail(`该楼栋下仍有 ${roomCount} 间房间,请先清空房间后再删除`); + const i = buildings.findIndex(x => x.buildingId === bid); + if (i > -1) buildings.splice(i, 1); + rebuildRoomIndex(); + return ok(null); + }, + }, + { + url: "/api/dorm/AddRoom", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const bid = Number(b.buildingId); + const bd = buildings.find(x => x.buildingId === bid); + if (!bd) return fail("楼栋不存在"); + const floors = Number(b.floor) || 1; + const count = Number(b.roomCount) || 1; + const beds = Number(b.bedCount) || 6; + // 从该层已有房间号往后追加,避免房间号重复 + for (let k = 0; k < count; k++) { + let idx = rooms.filter(r => r.buildingId === bid && r.floor === floors).length + 1 + k; + const roomId = bid * 10000 + floors * 100 + idx; + rooms.push({ + roomId, + roomNo: `${floors}${String(idx).padStart(2, "0")}`, + buildingId: bid, + buildingName: bd.buildingName, + buildingShort: bd.short, + floor: floors, + bedCount: beds, + usedBeds: 0, + supervisorName: `${bd.supervisor}(${bd.short}${floors}层宿管)`, + supervisorPhone: bd.phone, + }); + roster[String(roomId)] = []; + } + rebuildRoomIndex(); + return ok(null); + }, + }, + { + url: "/api/dorm/EditRoom", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const r = roomMap[String(b.roomId)]; + if (!r) return fail("房间不存在"); + if (b.roomNo) r.roomNo = b.roomNo; + if (b.bedCount !== undefined) { + const beds = Math.max(1, Number(b.bedCount) || 1); + r.bedCount = beds; + // 床位收紧时装不下的学生由 rebuildRoomIndex 退回待安排池(不再凭空裁掉) + } + if (b.supervisorName) r.supervisorName = b.supervisorName; + if (b.supervisorPhone) r.supervisorPhone = b.supervisorPhone; + rebuildRoomIndex(); + return ok(null); + }, + }, + { + url: "/api/dorm/DelRoom", + method: "post", + response: ({ body, query }: any) => { + const rid = String(bodyOf(body, query).roomId); + const r = roomMap[rid]; + if (!r) return fail("房间不存在"); + if ((roster[rid] || []).length) return fail("该房间仍有学生入住,请先办理床位调整后再删除"); + const i = rooms.findIndex(x => String(x.roomId) === rid); + if (i > -1) rooms.splice(i, 1); + delete roomMap[rid]; + rebuildRoomIndex(); + return ok(null); + }, + }, + + /* ---------- 字典 ---------- */ + { + url: "/api/dict/List", + method: "get", + response: ({ query }: any) => { + const type = String(query.type || ""); + const src = type === "noticeType" ? noticeTypeDict : deptDict; + return ok(src.filter(d => d.Enable === 1).map(d => ({ k: type === "noticeType" ? d.Name : d.CodeID, v: d.Name }))); + }, + }, + { url: "/api/dict/Sync", method: "get", response: () => ok(true) }, + { + url: "/api/dict/ManageList", + method: "get", + response: ({ query }: any) => ok((String(query.type || "") === "dept" ? deptDict : noticeTypeDict).map(d => ({ ...d }))), + }, + { + url: "/api/dict/Add", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const name = b.name || "未命名"; + if (String(b.type) === "dept") { + deptDict.push({ SerID: deptSeq, CodeID: "D" + String(deptSeq).padStart(3, "0"), Name: name, Enable: 1 }); + deptSeq++; + } else { + if (noticeTypeDict.some(t => t.Name === name)) return fail("该通知类型已存在"); + noticeTypeDict.push({ SerID: typeSeq, CodeID: "T" + String(typeSeq).padStart(3, "0"), Name: name, Enable: 1 }); + typeSeq++; + } + return ok(null); + }, + }, + { + url: "/api/dict/Update", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const src = String(b.type) === "dept" ? deptDict : noticeTypeDict; + const item = src.find(d => d.SerID === Number(b.SerID)); + if (item) { + if (b.Name) item.Name = b.Name; + if (b.Enable !== undefined && b.Enable !== null && b.Enable !== "") item.Enable = Number(b.Enable); + } + return ok(null); + }, + }, + { + url: "/api/dict/Delete", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const src = String(b.type) === "dept" ? deptDict : noticeTypeDict; + const i = src.findIndex(d => d.SerID === Number(b.serid)); + if (i > -1) src.splice(i, 1); + return ok(null); + }, + }, + { + url: "/api/dict/Sort", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const src = String(b.type) === "dept" ? deptDict : noticeTypeDict; + const ids = String(b.ids || "") + .split(",") + .map(Number) + .filter(Boolean); + const sorted = ids.map(id => src.find(d => d.SerID === id)).filter(Boolean) as any[]; + if (sorted.length === src.length) src.splice(0, src.length, ...sorted); + return ok(null); + }, + }, + + /* ---------- 通知列表 ---------- */ + { + // 家长端收件箱:仅已发布、非未来定时、面向本生所在房间 + url: "/api/notice/List", + method: "get", + response: ({ query }: any) => { + const pageIndex = Number(query.pageIndex || 1); + const pageSize = Number(query.pageSize || 50); + let list = notices.filter(n => { + if (!isPublished(n) || isScheduledFuture(n)) return false; + return /^all$/i.test(String(n.receiveList).trim()) || targetRoomIds(n).includes(ME_ROOM); + }); + list = sortByTimeDesc(applyFilters(list, query)); + const unreadCount = list.filter(n => !isReadByMe(n)).length; + return ok({ ...paginate(list.map(toVo), pageIndex, pageSize), unreadCount }); + }, + }, + { + // 我发布的(scope=user) / 全部通知(不传) + url: "/api/notice/SendList", + method: "get", + response: ({ query }: any) => { + const pageIndex = Number(query.pageIndex || 1); + const pageSize = Number(query.pageSize || 10); + let list = notices.slice(); + if (query.scope === "user") list = list.filter(n => n.publisherAccount === ME.account); + list = sortByTimeDesc(applyFilters(list, query)); + return ok(paginate(list.map(toVo), pageIndex, pageSize)); + }, + }, + + /* ---------- 通知详情 ---------- */ + { + url: "/api/notice/Detail", + method: "get", + response: ({ query }: any) => { + const n = noticeOf(Number(query.serid)); + if (!n) return fail("通知不存在或已被删除"); + const c = viewerRoom(); + if (c && String(query.type) !== "0" && !(n.roomRead || []).includes(c)) { + n.roomRead.push(c); + // 同步把该房间学生的回执记录标为已读 + receiptsOf(n.id).forEach(s => { + if (String(s.roomId) === c) { + s.read = true; + if (!s.readAt) s.readAt = nowStr(); + } + }); + } + return ok(toDetail(n)); + }, + }, + { + url: "/api/notice/FormTemplates", + method: "get", + response: () => ok(FORM_TEMPLATE_LIST), + }, + { + url: "/api/notice/GetDetail", + method: "get", + response: ({ query }: any) => { + const n = noticeOf(Number(query.serid)); + if (!n) return fail("通知不存在或已被删除"); + return ok(toDetail(n)); + }, + }, + { + url: "/api/notice/GetNextPrev", + method: "get", + response: ({ query }: any) => { + const ids = sortByTimeDesc(notices.filter(isPublished)).map(n => n.id); + const i = ids.indexOf(Number(query.serid)); + return ok({ prevId: i > 0 ? ids[i - 1] : 0, nextId: i > -1 && i < ids.length - 1 ? ids[i + 1] : 0 }); + }, + }, + { + // 阅读情况:按楼栋分组,叶子为房间(读/未读) + url: "/api/notice/ReadDetail", + method: "get", + response: ({ query }: any) => { + const n = noticeOf(Number(query.serid)); + if (!n) return fail("通知不存在或已被删除"); + const readSet = new Set(n.roomRead || []); + const byBuilding: Record = {}; + targetRoomIds(n).forEach(id => { + const r = roomMap[id]; + if (!r) return; + (byBuilding[r.buildingName] = byBuilding[r.buildingName] || []).push({ + account: id, + teaname: `${r.buildingShort} ${r.roomNo}(${r.usedBeds} 人)`, + states: readSet.has(id) ? "read" : "unread", + }); + }); + return ok(Object.keys(byBuilding).map(name => ({ GroupName: name, teaslist: byBuilding[name] }))); + }, + }, + + /* ---------- 通知写入操作 ---------- */ + { + url: "/api/notice/Create", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + if (!b.title) return fail("通知标题不能为空"); + const dept = deptDict.find(d => d.CodeID === b.deptId || d.Name === b.deptName); + const id = noticeSeq++; + const n: any = { + id, + title: b.title, + noticeType: b.noticeType || "其他", + level: b.level || "normal", + deptId: dept?.CodeID || "D002", + deptName: dept?.Name || "德育处", + publisherAccount: ME.account, + publisherName: ME.name, + content: b.content || "", + effectiveTime: b.effectiveTime || null, + deadlineTime: b.deadlineTime || null, + receiptMode: b.receiptMode || "none", + needSupervisor: Number(b.needSupervisor) || 0, + receiveList: b.receiveList || "ALL", + roomRead: [], + files: b.fileIds || null, + topmost: Number(b.topmost) || 0, + isPulled: 0, + smsFlag: Number(b.smsFlag) || 0, + timingSendTime: b.timingSendTime || null, + noticeStatus: b.draft ? "draft" : "published", + createTime: nowStr(), + formType: b.receiptMode === "fill" ? b.formType || "general" : null, + formFields: b.receiptMode === "fill" ? buildFormFields(b.formType) : null, + }; + notices.unshift(n); + seedReceiptsFor(n); + return ok(n.id); + }, + }, + { + url: "/api/notice/Edit", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const n = noticeOf(Number(b.sourceId || b.id)); + if (!n) return fail("通知不存在或已被删除"); + if (b.title) n.title = b.title; + if (b.noticeType) n.noticeType = b.noticeType; + if (b.level) n.level = b.level; + if (b.content !== undefined) n.content = b.content; + if (b.effectiveTime !== undefined) n.effectiveTime = b.effectiveTime; + if (b.deadlineTime !== undefined) n.deadlineTime = b.deadlineTime; + if (b.receiptMode) n.receiptMode = b.receiptMode; + if (b.needSupervisor !== undefined) n.needSupervisor = Number(b.needSupervisor) || 0; + if (b.receiveList) n.receiveList = b.receiveList; + if (b.fileIds !== undefined) n.files = b.fileIds || null; + // 单据字段一律由后端按 formType 生成,前端不再上传字段定义 + if (n.receiptMode === "fill") { + n.formType = b.formType || n.formType || "general"; + n.formFields = buildFormFields(n.formType); + } else { + n.formType = null; + n.formFields = null; + } + if (b.deptId) { + const d = deptDict.find(x => x.CodeID === b.deptId); + n.deptId = b.deptId; + n.deptName = d?.Name || n.deptName; + } + if (b.timingSendTime) n.timingSendTime = b.timingSendTime; + n.noticeStatus = b.draft ? "draft" : "published"; + n.isPulled = 0; + // 回执方式由「仅通知」改成需回执时补齐回执记录 + seedReceiptsFor(n); + return ok(null); + }, + }, + { + url: "/api/notice/Delete", + method: "post", + response: ({ body }: any) => { + const ids: number[] = Array.isArray(body) ? body.map(Number) : parseBody(body).ids || []; + ids.forEach(id => { + const i = notices.findIndex(n => n.id === Number(id)); + if (i > -1) notices.splice(i, 1); + for (let k = receipts.length - 1; k >= 0; k--) if (receipts[k].noticeId === Number(id)) receipts.splice(k, 1); + }); + return ok(null); + }, + }, + { + url: "/api/notice/InitializeRed", + method: "post", + response: () => { + let count = 0; + notices.forEach(n => { + if (!isPublished(n)) return; + const targets = targetRoomIds(n); + if (targets.includes(ME_ROOM) && !n.roomRead.includes(ME_ROOM)) { + n.roomRead.push(ME_ROOM); + count++; + } + }); + return ok({ count }); + }, + }, + { + url: "/api/notice/UserPull", + method: "post", + response: ({ body, query }: any) => { + const n = noticeOf(Number(bodyOf(body, query).serid)); + if (n) { + n.isPulled = 1; + n.noticeStatus = "closed"; + } + return ok(null); + }, + }, + { + url: "/api/notice/SetTopmost", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const n = noticeOf(Number(b.serid)); + if (n) n.topmost = Number(b.topmost) === 0 ? 0 : 1; + return ok(null); + }, + }, + { + url: "/api/notice/Expedite", + method: "post", + response: ({ body, query }: any) => { + const n = noticeOf(Number(bodyOf(body, query).serid)); + if (!n) return fail("通知不存在"); + return ok({ reminded: receiptsOf(n.id).filter(s => !s.read).length }); + }, + }, + { + url: "/api/notice/ReceiveRooms", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const n = noticeOf(Number(b.serid)); + if (!n) return fail("通知不存在"); + const merged = new Set([...targetRoomIds(n), ...targetRoomIds({ receiveList: b.rooms })]); + n.receiveList = Array.from(merged).join(","); + seedReceiptsFor(n); + return ok(null); + }, + }, + + /* ---------- 回执 / 填报(家校核心闭环) ---------- */ + { + url: "/api/notice/Receipts", + method: "get", + response: ({ query }: any) => { + const n = noticeOf(Number(query.serid)); + if (!n) return fail("通知不存在或已被删除"); + const status = String(query.status || "all"); + const all = receiptsOf(n.id); + const filtered = status === "all" ? all : all.filter(s => s.status === status); + const byRoom: Record = {}; + filtered.forEach(s => { + const key = `${s.buildingName} · ${s.roomNo}`; + (byRoom[key] = byRoom[key] || []).push(toReceiptBrief(s)); + }); + return ok({ + noticeId: n.id, + title: n.title, + noticeType: n.noticeType, + level: n.level, + levelDesc: LEVEL_DESC[n.level] || "普通", + effectiveTime: n.effectiveTime, + deadlineTime: n.deadlineTime, + noticeStatus: n.noticeStatus, + noticeStatusDesc: NOTICE_STATUS_DESC[n.noticeStatus] || "已发布", + receiptMode: n.receiptMode, + receiptModeDesc: RECEIPT_MODE_DESC[n.receiptMode] || "仅通知", + formType: n.formType || null, + formTypeDesc: n.formType ? FORM_TYPE_DESC[n.formType] || "通用回执单" : "", + formFields: n.formFields || null, + summary: progressOf(n.id), + groups: Object.keys(byRoom).map(groupName => ({ + groupName, + roomNo: byRoom[groupName][0]?.roomNo || "", + students: byRoom[groupName], + })), + }); + }, + }, + { + url: "/api/notice/ReceiptDetail", + method: "get", + response: ({ query }: any) => { + const s = receipts.find(x => x.id === Number(query.receiptId)); + if (!s) return fail("回执记录不存在"); + const n = noticeOf(s.noticeId); + return ok({ + receiptId: s.id, + noticeId: s.noticeId, + noticeTitle: n?.title || "", + noticeType: n?.noticeType || "", + studentId: s.studentId, + studentName: s.studentName, + roomId: s.roomId, + roomNo: s.roomNo, + buildingName: s.buildingName, + bedNo: s.bedNo, + guardianName: s.guardianName, + guardianPhone: s.guardianPhone, + relation: s.relation, + status: s.status, + statusDesc: RECEIPT_STATUS_DESC[s.status] || s.status, + submittedAt: s.submittedAt, + read: s.read, + readAt: s.readAt, + fillData: s.fillData, + formType: n?.formType || null, + formTypeDesc: n?.formType ? FORM_TYPE_DESC[n.formType] || "通用回执单" : "", + formFields: n?.formFields || null, + remark: s.remark, + }); + }, + }, + { + url: "/api/notice/Review", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const s = receipts.find(x => x.id === Number(b.receiptId)); + if (!s) return fail("回执记录不存在"); + const n = noticeOf(s.noticeId); + const action = String(b.action || "confirm"); + if (action === "confirm") { + if (n?.receiptMode === "fill") { + // 代确认:把家长口头给出的结论写进单据,避免出现「已填报但内容为空」的空单 + s.fillData = { ...(s.fillData || {}), conclusion: (s.fillData || {}).conclusion || "同意" }; + s.status = "filled"; + } else { + s.status = "confirmed"; + } + } else if (action === "cancel") s.status = "canceled"; + else s.status = "none"; + if (b.remark !== undefined) s.remark = b.remark; + return ok(null); + }, + }, + { + url: "/api/notice/Remind", + method: "post", + response: ({ body, query }: any) => { + const n = noticeOf(Number(bodyOf(body, query).serid)); + if (!n) return fail("通知不存在"); + const pending = receiptsOf(n.id).filter(s => s.status === "none" || s.status === "canceled").length; + return ok({ reminded: pending }); + }, + }, + { + // 导出回执名单(返回 CSV 文本,前端 Blob 下载) + url: "/api/notice/ExportReceipts", + method: "get", + response: ({ query }: any) => { + const n = noticeOf(Number(query.serid)); + if (!n) return fail("通知不存在"); + // 表头统一:固定列 → 公共区(所有单据一致)→ 专属区(随单据类型)→ 回执信息。 + // 同类单据导出的列完全相同,可直接纵向合并;不同单据的差异只落在专属区那几列。 + const fields: any[] = n.formFields || []; + const common = fields.filter(f => f.group === "common"); + const specific = fields.filter(f => f.group === "specific"); + const rows = [ + [ + "房间", + "床位", + "学生姓名", + "班级", + "单据类型", + "家长姓名", + "与学生关系", + "联系电话", + ...common.map(f => f.label), + ...specific.map(f => f.label), + "回执状态", + "回执时间", + "备注", + ], + ]; + receiptsOf(n.id).forEach(s => { + rows.push([ + s.roomNo, + s.bedNo, + s.studentName, + s.className, + n.formType ? FORM_TYPE_DESC[n.formType] || "通用回执单" : "", + s.guardianName, + s.relation, + s.guardianPhone, + ...common.map(f => String((s.fillData || {})[f.key] ?? "")), + ...specific.map(f => String((s.fillData || {})[f.key] ?? "")), + RECEIPT_STATUS_DESC[s.status] || s.status, + s.submittedAt || "", + (s.remark || "").replace(/,/g, ","), + ]); + }); + const csv = "\ufeff" + rows.map(r => r.join(",")).join("\r\n"); + return ok(csv); + }, + }, + + /* ---------- 统计 ---------- */ + { + url: "/api/notice/Statis", + method: "get", + response: ({ query }: any) => { + let list = notices.slice(); + const st = query.startTime || query.starttime; + const et = query.endTime || query.endtime; + if (st) list = list.filter(n => String(n.createTime).slice(0, 10) >= String(st).slice(0, 10)); + if (et) list = list.filter(n => String(n.createTime).slice(0, 10) <= String(et).slice(0, 10)); + + const published = list.filter(n => n.noticeStatus !== "draft"); + const withReceipt = published.filter(n => n.receiptMode !== "none"); + const rates = withReceipt.map(n => progressOf(n.id).receiptRate); + + const byPublisherMap: Record = {}; + list.forEach(n => (byPublisherMap[n.publisherName] = (byPublisherMap[n.publisherName] || 0) + 1)); + + const byMonthMap: Record = {}; + list.forEach(n => { + const key = String(n.createTime).slice(0, 7); + byMonthMap[key] = (byMonthMap[key] || 0) + 1; + }); + + const byTypeMap: Record = {}; + list.forEach(n => (byTypeMap[n.noticeType] = (byTypeMap[n.noticeType] || 0) + 1)); + + const byBuilding = buildings.map(b => { + const hit = published.filter(n => targetRoomIds(n).some(id => roomMap[id]?.buildingId === b.buildingId)); + const beds = new Set(); + hit.forEach(n => targetRoomIds(n).forEach(id => roomMap[id]?.buildingId === b.buildingId && beds.add(id))); + return { + buildingName: b.buildingName, + noticeCount: hit.length, + beds: Array.from(beds).reduce((s, id) => s + (roomMap[id]?.usedBeds || 0), 0), + }; + }); + + const mine = list.filter(n => n.publisherAccount === ME.account); + + return ok({ + total: list.length, + publishedCount: published.length, + myNoticeCount: mine.length, + urgentCount: list.filter(n => n.level === "urgent" || n.level === "important").length, + totalReceipts: receipts.filter(s => list.some(n => n.id === s.noticeId) && (s.status === "confirmed" || s.status === "filled")).length, + avgReceiptRate: rates.length ? Math.round(rates.reduce((a, b) => a + b, 0) / rates.length) : 0, + totalCovered: published.reduce((s, n) => s + allowCountOf(n), 0), + pendingReview: receipts.filter(s => s.status === "none").length, + byPublisher: Object.keys(byPublisherMap).map(k => ({ publisherName: k, count: byPublisherMap[k] })), + byMonth: Object.keys(byMonthMap) + .sort() + .map(k => ({ year: Number(k.slice(0, 4)), month: Number(k.slice(5, 7)), count: byMonthMap[k] })), + byType: Object.keys(byTypeMap).map(k => ({ label: k, key: k, count: byTypeMap[k] })), + byBuilding, + byNotice: mine + .filter(n => n.noticeStatus !== "draft") + .map(n => ({ + id: n.id, + title: n.title, + noticeType: n.noticeType, + level: n.level, + levelDesc: LEVEL_DESC[n.level] || "普通", + noticeStatus: n.noticeStatus, + noticeStatusDesc: NOTICE_STATUS_DESC[n.noticeStatus] || "已发布", + ...progressOf(n.id), + })), + }); + }, + }, + + /* ---------- 家长端 ---------- */ + { + url: "/api/notice/MyNotices", + method: "get", + response: ({ query }: any) => { + const list = sortByTimeDesc( + applyFilters( + notices.filter(n => isPublished(n) && !isScheduledFuture(n) && (/^all$/i.test(String(n.receiveList).trim()) || targetRoomIds(n).includes(ME_ROOM))), + query + ) + ); + const vos = list.map(toParentVo); + return ok({ + buckets: { + todo: vos.filter(v => !v.viewOnly && (!v.my || v.my.status === "none" || v.my.status === "canceled") && v.noticeStatus === "published").length, + done: vos.filter(v => v.my && (v.my.status === "confirmed" || v.my.status === "filled")).length, + finished: vos.filter(v => v.noticeStatus === "finished" || v.noticeStatus === "closed").length, + all: vos.length, + }, + list: vos, + }); + }, + }, + { + url: "/api/notice/MyNotice", + method: "get", + response: ({ query }: any) => { + const n = noticeOf(Number(query.serid)); + if (!n) return fail("通知不存在或已被删除"); + if (!(n.roomRead || []).includes(ME_ROOM)) { + n.roomRead.push(ME_ROOM); + receiptsOf(n.id).forEach(s => { + if (String(s.roomId) === ME_ROOM) { + s.read = true; + if (!s.readAt) s.readAt = nowStr(); + } + }); + } + const vo = toParentVo(n); + return ok({ + notice: { + id: vo.id, + title: vo.title, + noticeType: vo.noticeType, + level: vo.level, + levelDesc: vo.levelDesc, + content: vo.content, + effectiveTime: vo.effectiveTime, + deadlineTime: vo.deadlineTime, + receiptMode: vo.receiptMode, + receiptModeDesc: vo.receiptModeDesc, + needSupervisor: n.needSupervisor, + publisherName: vo.publisherName, + deptName: vo.deptName, + roomNo: vo.roomNo, + buildingName: vo.buildingName, + bedNo: vo.bedNo, + noticeStatus: vo.noticeStatus, + noticeStatusDesc: vo.noticeStatusDesc, + files: fileItems(n), + }, + formType: n.formType || null, + formTypeDesc: n.formType ? FORM_TYPE_DESC[n.formType] || "通用回执单" : "", + formFields: n.formFields || null, + my: vo.my, + }); + }, + }, + { + // 家长提交回执 / 填报 + url: "/api/notice/SubmitReceipt", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const n = noticeOf(Number(b.serid)); + if (!n) return fail("通知不存在或已被删除"); + if (n.noticeStatus !== "published") return fail("该通知已截止或已撤回,无法提交"); + if (n.deadlineTime && n.deadlineTime < nowStr()) return fail("已超过回执截止时间,如需补交请联系宿管"); + // 需填报类:校验必填字段 + if (n.receiptMode === "fill") { + const fd = typeof b.fillData === "string" ? JSON.parse(b.fillData) : b.fillData || {}; + const missing = (n.formFields || []).filter((f: any) => f.required && !String(fd[f.key] ?? "").trim()); + if (missing.length) return fail(`请填写:${missing.map((f: any) => f.label).join("、")}`); + } + let s = receipts.find(x => x.noticeId === n.id && x.studentId === DEMO_STUDENT.studentId); + if (!s) { + s = { + id: receiptSeq++, + noticeId: n.id, + studentId: DEMO_STUDENT.studentId, + studentName: DEMO_STUDENT.studentName, + roomId: DEMO_STUDENT.roomId, + roomNo: `${DEMO_STUDENT.buildingShort} ${DEMO_STUDENT.roomNo}`, + buildingId: DEMO_STUDENT.buildingId, + buildingName: DEMO_STUDENT.buildingName, + floor: DEMO_STUDENT.floor, + bedNo: DEMO_STUDENT.bedNo, + className: DEMO_STUDENT.className, + guardianName: "", + guardianPhone: "", + relation: "", + status: "none", + read: true, + readAt: nowStr(), + submittedAt: null, + fillData: null, + remark: "", + }; + receipts.push(s); + } + s.guardianName = b.guardianName || DEMO_STUDENT.guardianName; + s.guardianPhone = b.guardianPhone || DEMO_STUDENT.guardianPhone; + s.relation = b.relation || DEMO_STUDENT.relation; + s.remark = b.remark !== undefined ? b.remark : s.remark; + if (n.receiptMode === "fill") { + s.fillData = typeof b.fillData === "string" ? JSON.parse(b.fillData) : b.fillData || {}; + s.status = "filled"; + } else { + s.status = "confirmed"; + } + s.submittedAt = nowStr(); + return ok({ receiptId: s.id, status: s.status }); + }, + }, + { + url: "/api/notice/CancelReceipt", + method: "post", + response: ({ body, query }: any) => { + const n = noticeOf(Number(bodyOf(body, query).serid)); + if (!n) return fail("通知不存在"); + const s = receipts.find(x => x.noticeId === n.id && x.studentId === DEMO_STUDENT.studentId); + if (!s) return fail("尚未提交回执"); + s.status = "canceled"; + return ok(null); + }, + }, + + /* ---------- 文件 ---------- */ + { + url: "/api/base/FileUpload", + method: "post", + response: () => { + const id = nextFileId++; + filesStore[id] = { id, filename: `宿舍通知附件_${id}.pdf`, fileTrueName: `宿舍通知附件_${id}.pdf`, size: 122880 }; + return ok({ fileid: id, name: filesStore[id].filename, host: "", fileurl: `/api/file/Preview?id=${id}`, type: "pdf", size: 122880 }); + }, + }, + { + url: "/api/file/UploadImage", + method: "post", + response: () => { + const id = nextFileId++; + filesStore[id] = { id, filename: `image_${id}.png`, fileTrueName: `image_${id}.png`, size: 4096 }; + return ok([{ id, fileid: id, fileTrueName: filesStore[id].fileTrueName, fileName: filesStore[id].filename, fileSize: 4096, fileType: "png", url: mockImageDataURL(id) }]); + }, + }, + { url: "/api/file/Preview", method: "get", response: () => ok({ id: 0, fileurl: mockImageDataURL() }) }, + { url: "/api/file/Down", method: "get", response: () => ok({ id: 0, fileurl: mockImageDataURL() }) }, + { url: "/api/notice/AllFileDown", method: "get", response: () => ok({ id: 0, fileurl: mockImageDataURL() }) }, + + /* ---------- 联系组(宿舍设置) ---------- */ + { + url: "/api/contacts/GetGroup", + method: "get", + response: ({ query }: any) => { + const page = Number(query.page || 1); + const count = Number(query.count || 10); + const all = groups.map(g => ({ serid: g.serid, GroupName: g.GroupName, count: g.members.length })); + const start = (page - 1) * count; + return ok({ list: all.slice(start, start + count), total: all.length }); + }, + }, + { + url: "/api/contacts/AddGroup", + method: "post", + response: ({ body, query }: any) => { + groups.push({ serid: groupSeq, GroupName: bodyOf(body, query).name || "未命名组", members: [] }); + groupSeq++; + return ok(null); + }, + }, + { + url: "/api/contacts/EditGroup", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const g = groups.find(x => x.serid === Number(b.id)); + if (g && b.name) g.GroupName = b.name; + return ok(null); + }, + }, + { + url: "/api/contacts/DelGroup", + method: "post", + response: ({ body, query }: any) => { + const ids = String(bodyOf(body, query).ids || "") + .split(",") + .map(Number); + ids.forEach(id => { + const i = groups.findIndex(g => g.serid === id); + if (i > -1) groups.splice(i, 1); + }); + return ok(null); + }, + }, + { + url: "/api/contacts/GetContactsTea", + method: "get", + response: ({ query }: any) => { + const g = groups.find(x => x.serid === Number(query.gid)); + const members = g?.members || []; + const list = members.map(id => ({ account: "t" + id, name: idNames[id] || "未知用户", userId: id, id })); + const page = Number(query.page || 0); + const count = Number(query.count || 0); + if (!page || !count) return ok({ list, total: list.length }); + const start = (page - 1) * count; + return ok({ list: list.slice(start, start + count), total: list.length }); + }, + }, + { + url: "/api/contacts/SetContactsTea", + method: "post", + response: ({ body, query }: any) => { + const b = bodyOf(body, query); + const g = groups.find(x => x.serid === Number(b.gid)); + if (g) g.members = String(b.ids || "").split(",").map(Number).filter(Boolean); + return ok(null); + }, + }, + { + url: "/api/contacts/GetGroupTree", + method: "get", + response: () => + ok( + groups.map(g => ({ + id: 2000 + g.serid, + name: g.GroupName, + isParent: true, + children: g.members.map(id => ({ id, name: idNames[id] || "未知用户", isParent: false })), + })) + ), + }, +]; + +/** 房间/花名册索引重建(楼栋房间增删改后调用) + * 注意:这里**不再按 usedBeds 凭空补学生**。床位占用只能由床位安排接口驱动, + * 否则新建房间 / 调大床位数会自动变出根本不存在的学生,与真实学籍流程不符。 + * usedBeds 一律以花名册长度为准(单一数据源)。 */ +function rebuildRoomIndex() { + Object.keys(roomMap).forEach(k => delete roomMap[k]); + rooms.forEach(r => (roomMap[String(r.roomId)] = r)); + rooms.forEach(r => { + const key = String(r.roomId); + const list = roster[key] || []; + // 床位收紧时裁掉超出部分,被裁学生退回待安排池(而不是凭空消失) + if (list.length > r.bedCount) { + const cut = list.slice(r.bedCount); + roster[key] = list.slice(0, r.bedCount); + cut.forEach((s: any) => backToPool(s, "床位缩减待重新安排")); + } + if (!roster[key]) roster[key] = []; + r.usedBeds = roster[key].length; + }); +} + +/** 占位图片(dataURL,避免 mock 环境访问网络图片 404) */ +function mockImageDataURL(id?: number) { + const seed = Number(id) || 1; + const hue = (seed * 47) % 360; + const svg = ` + + 宿舍通知附件 ${seed} + `; + return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg); +} + +/* 供页面下拉用的类型清单(避免每页硬编码) */ +export const DORM_NOTICE_TYPES = NOTICE_TYPE_NAMES; diff --git a/.backup/dormnotice-frontend-removed/env.mock.bak b/.backup/dormnotice-frontend-removed/env.mock.bak new file mode 100644 index 0000000..1899f25 --- /dev/null +++ b/.backup/dormnotice-frontend-removed/env.mock.bak @@ -0,0 +1,9 @@ +# 本地纯前端调试模式:仅 vite dev --mode mock 加载(build 产物不读任何 .env.*, +# 线上由 /api/env-config?target=front 运行时下发)。后端 SSO/base 不可达时启用内置 mock +# 启动:npm run dev:mock +VITE_BASE_HOST= +VITE_SSO_HOST= +VITE_SERVICE_HOST=http://localhost:8102 +VITE_ZIYUANKU_HOST= +VITE_ZHUANXIANG_HOST= +VITE_IS_MOCK=true diff --git a/.backup/dormnotice-frontend-removed/index.ts b/.backup/dormnotice-frontend-removed/index.ts new file mode 100644 index 0000000..e5646d8 --- /dev/null +++ b/.backup/dormnotice-frontend-removed/index.ts @@ -0,0 +1,6 @@ +import { createProdMockServer } from "vite-plugin-mock/es/createProdMockServer"; +import DormMock from "./dormapi"; + +export function setupProdMockServer() { + createProdMockServer([...DormMock]); +} diff --git a/.backup/dormnotice-frontend-removed/menu.ts b/.backup/dormnotice-frontend-removed/menu.ts new file mode 100644 index 0000000..0fa3310 --- /dev/null +++ b/.backup/dormnotice-frontend-removed/menu.ts @@ -0,0 +1,17 @@ +import type { IModule } from "@/store"; + +/** + * 家校宿舍通知 左侧菜单数据(对照后端 GetUserMod 现行模块)。 + * 当前由 layout 直接使用;接入后端 GetUserMod 后,以接口返回为准。 + */ +export function getMenuList(): Promise { + const menu: IModule[] = [ + { id: 2, modId: 2, name: "我发布的", page: "/list" }, + { id: 3, modId: 3, name: "全部通知", page: "/noticelist" }, + { id: 25, modId: 25, name: "回执情况", page: "/receipts" }, + { id: 4, modId: 4, name: "通知统计", page: "/stats" }, + { id: 21, modId: 21, name: "宿舍设置", page: "/setting" }, + { id: 31, modId: 31, name: "宿舍通知箱(家长端预览)", page: "/par/notices" }, + ]; + return Promise.resolve(menu); +} 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/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..5085978 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,18 @@ +# 由 projman 注入的 Spring Boot 运行镜像(单阶段,jar 预构建) +# CI 已先 mvn package,并在容器内把产物 cp 成固定名 target/app.jar +# 构建上下文 = 仓库根:docker build -f docker/Dockerfile . +# 健康检查依赖 curl(projman 部署用 curl 探 /actuator/health)——应用需含 spring-boot-starter-actuator +# 时区 Asia/Shanghai;JAVA_OPTS 由 projman 部署 env 注入(默认 -Xms512m -Xmx1024m) +FROM eclipse-temurin:8-jre + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl tzdata \ + && ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ + && echo "Asia/Shanghai" > /etc/timezone \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY target/app.jar /app/app.jar + +EXPOSE 10002 +ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/app.jar"] diff --git a/docker/application-prod.yml b/docker/application-prod.yml new file mode 100644 index 0000000..ba8bd26 --- /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/notice + context-path: /notice + +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;库名为 dorm_notice(需先在 mysql8 上建库) + url: jdbc:mysql://mysql8:3306/dorm_notice?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/notice + 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..1da625f --- /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/notice.jar \ + --spring.profiles.active=prod diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f5368e6 --- /dev/null +++ b/pom.xml @@ -0,0 +1,205 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + dormnotice + 0.0.1 + DormNotice + 宿舍通知子应用 + + 1.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 + + + + + + + + dormnotice + + + org.apache.maven.plugins + maven-compiler-plugin + + + false + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.dormnotice.DormNoticeApplication + + + + + repackage + + + + + + + + + + + nexus-public + bsch/nexus public + http://nexus.yunhaixingtong.com/repository/maven-public/ + true + + truenever + + + + + + ci + + C:/notice-build/notice + + + + + diff --git a/smoke/closed-loop.mjs b/smoke/closed-loop.mjs new file mode 100644 index 0000000..f964e45 --- /dev/null +++ b/smoke/closed-loop.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * 宿舍通知 家长端闭环自清理测试(P8 深测)。 + * 教师建临时楼/房 → 把登录学生分配到该房 → 发布 confirm + fill 两条通知到该房 → + * 学生 MyNotice(自动确认)/SubmitReceipt(填报) → 教师 Receipts/Statis 校验 → + * finally 逆序彻底清理:删通知(级联删回执) → 学生退宿 → 删房间 → 删楼栋。 + * + * 用法:TEACHER_COOKIE='…' STUDENT_COOKIE='…' [STUDENT_ID=10069] node smoke/closed-loop.mjs + */ +const BASE = (process.env.BASE || "http://localhost:8105").replace(/\/+$/, ""); +const T = process.env.TEACHER_COOKIE || ""; +const S = process.env.STUDENT_COOKIE || ""; +const SID = Number(process.env.STUDENT_ID || 10069); +if (!T || !S) { console.error("需 TEACHER_COOKIE 与 STUDENT_COOKIE"); process.exit(2); } + +const stamp = Date.now(); +const BLD = "闭环测试楼" + stamp; +let buildingId = null, roomId = null, noticeConfirm = null, noticeFill = null; +let assigned = false; +const log = (ok, name, detail = "") => console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); + +async function call(cookie, method, path, { body, form } = {}) { + const headers = { Accept: "application/json", Cookie: cookie }; + let payload; + if (body !== undefined) { headers["Content-Type"] = "application/json;charset=UTF-8"; payload = JSON.stringify(body); } + else if (form !== undefined) { headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8"; payload = new URLSearchParams(form).toString(); } + const r = await fetch(BASE + path, { method, headers, body: payload, redirect: "manual" }); + const txt = await r.text(); + let j = null; try { j = JSON.parse(txt); } catch { /* non-json */ } + if (r.status !== 200 || !j || j.code !== 0) throw new Error(`${method} ${path} → HTTP ${r.status} ${txt.slice(0, 160)}`); + return j.data; +} + +async function setup() { + // 楼栋 + await call(T, "POST", "/api/dorm/AddBuilding", { form: { buildingName: BLD, gender: "混合", floorCount: 1, roomsPerFloor: 1, bedsPerRoom: 2 } }); + const bl = await call(T, "GET", "/api/dorm/BuildingList"); + buildingId = (bl.find(x => x.buildingName === BLD) || {}).buildingId; + if (!buildingId) throw new Error("楼栋未创建成功"); + log(true, "dorm/AddBuilding", "buildingId=" + buildingId); + // 房间 + await call(T, "POST", "/api/dorm/AddRoom", { form: { buildingId, floor: 1, roomNo: "998", bedCount: 2 } }); + const rl = await call(T, "GET", "/api/dorm/RoomList?buildingId=" + buildingId); + roomId = (rl.find(x => x.roomNo === "998") || {}).roomId; + if (!roomId) throw new Error("房间未创建成功"); + log(true, "dorm/AddRoom", "roomId=" + roomId); + // 分配学生(需在 dorm_student 池内) + await call(T, "POST", "/api/dorm/AssignStudent", { form: { roomId, studentIds: String(SID) } }); + assigned = true; + const rs = await call(T, "GET", "/api/dorm/RoomStudents?roomId=" + roomId); + log(rs.some(s => s.studentId === SID), "dorm/AssignStudent", "房内=" + JSON.stringify(rs.map(s => s.studentId))); +} + +async function run() { + // confirm 通知 + noticeConfirm = await call(T, "POST", "/api/notice/Create", { + body: { title: "【闭环】需确认通知" + stamp, content: "

confirm

", noticeType: "安全排查", level: "important", + receiptMode: "confirm", deptId: "D001", receiveType: "room", receiveList: String(roomId), needSupervisor: 1, draft: false } }); + log(!!noticeConfirm, "notice/Create(confirm)", "id=" + noticeConfirm); + // fill 通知 + noticeFill = await call(T, "POST", "/api/notice/Create", { + body: { title: "【闭环】需填报通知" + stamp, content: "

fill

", noticeType: "留宿申请", level: "normal", + receiptMode: "fill", formType: "stay", deptId: "D001", receiveType: "room", receiveList: String(roomId), needSupervisor: 0, draft: false } }); + log(!!noticeFill, "notice/Create(fill/stay)", "id=" + noticeFill); + + // 学生收件箱应见两条 + const mn = await call(S, "GET", "/api/notice/MyNotices"); + const ids = (mn.list || []).map(x => x.id); + log(ids.includes(noticeConfirm) && ids.includes(noticeFill), "notice/MyNotices(学生可见两条)", "buckets=" + JSON.stringify(mn.buckets)); + + // 打开 confirm 通知 → 自动确认 + const mc = await call(S, "GET", "/api/notice/MyNotice?serid=" + noticeConfirm); + log(mc.my && mc.my.status === "confirmed", "notice/MyNotice(confirm 打开即自动确认)", "status=" + (mc.my && mc.my.status)); + + // 填报 fill 通知 + const sr = await call(S, "POST", "/api/notice/SubmitReceipt", { + body: { serid: noticeFill, guardianName: "测试家长", guardianPhone: "13900000000", relation: "父亲", + fillData: { conclusion: "同意", stayStart: "2026-10-01", stayEnd: "2026-10-03", stayCount: "1", stayReason: "家远不便往返" }, + remark: "闭环自测" } }); + log(sr && sr.status === "filled", "notice/SubmitReceipt(fill)", "status=" + (sr && sr.status)); + + // 教师看回执名单 + const rc = await call(T, "GET", "/api/notice/Receipts?serid=" + noticeFill + "&status=all"); + log(rc.summary.should >= 1 && rc.summary.filled >= 1, "notice/Receipts(fill 汇总)", "should=" + rc.summary.should + " filled=" + rc.summary.filled + " rate=" + rc.summary.receiptRate + "%"); + const rc2 = await call(T, "GET", "/api/notice/Receipts?serid=" + noticeConfirm + "&status=all"); + log(rc2.summary.confirmed >= 1, "notice/Receipts(confirm 汇总)", "confirmed=" + rc2.summary.confirmed); + + // 阅读情况(房间级) + const rd = await call(T, "GET", "/api/notice/ReadDetail?serid=" + noticeConfirm); + log(Array.isArray(rd) && rd.length >= 1, "notice/ReadDetail", JSON.stringify(rd).slice(0, 120)); + + // 统计 + const st = await call(T, "GET", "/api/notice/Statis"); + log(st.publishedCount >= 2, "notice/Statis", "publishedCount=" + st.publishedCount + " totalReceipts=" + st.totalReceipts); + + // 导出回执 CSV + const csv = await (await fetch(BASE + "/api/notice/ExportReceipts?serid=" + noticeFill, { headers: { Cookie: T } })).text(); + log(/回执状态|留宿/.test(csv), "notice/ExportReceipts", "len=" + csv.length); +} + +async function cleanup() { + const errs = []; + try { if (noticeConfirm || noticeFill) await call(T, "POST", "/api/notice/Delete", { body: [noticeConfirm, noticeFill].filter(Boolean) }); } catch (e) { errs.push("delNotice:" + e.message); } + try { if (assigned && roomId) await call(T, "POST", "/api/dorm/RemoveStudent", { form: { roomId, studentId: SID } }); } catch (e) { errs.push("removeStudent:" + e.message); } + try { if (roomId) await call(T, "POST", "/api/dorm/DelRoom", { form: { roomId } }); } catch (e) { errs.push("delRoom:" + e.message); } + try { if (buildingId) await call(T, "POST", "/api/dorm/DelBuilding", { form: { buildingId } }); } catch (e) { errs.push("delBuilding:" + e.message); } + // 校验清理干净 + try { + const bl = await call(T, "GET", "/api/dorm/BuildingList"); + const gone = !bl.some(x => x.buildingId === buildingId); + log(gone, "cleanup 楼栋已删", gone ? "" : "残留 buildingId=" + buildingId); + } catch (e) { errs.push("verify:" + e.message); } + if (errs.length) console.log("清理告警:", errs.join(" | ")); +} + +(async () => { + console.log(`\n=== 闭环自清理测试 @ ${BASE} · 学生 studentId=${SID} ===`); + let failed = 0; + try { await setup(); await run(); } + catch (e) { failed++; log(false, "流程异常", e.message); } + finally { console.log("\n--- 清理 ---"); await cleanup(); } + console.log(failed ? "\n结果:有失败" : "\n结果:闭环全部通过(数据已清理)"); + process.exit(failed ? 1 : 0); +})(); diff --git a/smoke/smoke.mjs b/smoke/smoke.mjs new file mode 100644 index 0000000..4d2624e --- /dev/null +++ b/smoke/smoke.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +/** + * 宿舍通知(DormitoryNotice)后端 P8 端到端冒烟脚本。 + * + * 背景:/api/* 受基础平台 CAS/SSO 过滤器保护,无登录态会返回 302→SSO,脚本无法自证身份。 + * 因此用「浏览器登录后复制 Cookie」的方式携带会话: + * 1) 起后端:先跑 sql/dormnotice_init.sql 建库+字典,再 `java -jar target/dormnotice.jar` + * (或 IDEA 运行 DormNoticeApplication,端口 8105)。 + * 2) 浏览器用教师账号 lijing 走 SSO 登录前端(http://localhost:8078 或后端直连), + * 打开 DevTools→Network→任一 /api 请求,复制其「Cookie」请求头整串值。 + * 3) 同样用学生账号(kegemo / csxs1)登录,复制一份学生 Cookie。 + * 4) 运行: + * TEACHER_COOKIE='<粘贴教师Cookie>' STUDENT_COOKIE='<粘贴学生Cookie>' \ + * node smoke/smoke.mjs + * 可选:BASE(默认 http://localhost:8105)。 + * + * 只读为主;Create 冒烟会创建一条草稿再删除,不留脏数据。退出码非 0 表示有必过项失败。 + */ + +const BASE = (process.env.BASE || "http://localhost:8105").replace(/\/+$/, ""); +const TEACHER = process.env.TEACHER_COOKIE || ""; +const STU = process.env.STUDENT_COOKIE || ""; +// 写链路默认执行(用一次性数据并自清理);设 WRITE=0 跳过所有增删改,仅跑只读冒烟。 +const WRITE = (process.env.WRITE || "1") !== "0"; + +const results = []; +let failed = 0; + +function hdr(cookie, extra = {}) { + const h = { Accept: "application/json", ...extra }; + if (cookie) h.Cookie = cookie; + return h; +} + +async function req(label, method, path, { cookie = TEACHER, body, form, json = true, must = true } = {}) { + const url = BASE + path; + const headers = hdr(cookie); + let payload; + if (body !== undefined) { headers["Content-Type"] = "application/json;charset=UTF-8"; payload = JSON.stringify(body); } + else if (form !== undefined) { headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8"; payload = new URLSearchParams(form).toString(); } + let status, data, raw; + try { + const resp = await fetch(url, { method, headers, body: payload, redirect: "manual" }); + status = resp.status; + raw = await resp.text(); + if (json) { try { data = JSON.parse(raw); } catch { data = null; } } + } catch (e) { + push(label, false, `网络错误 ${e.message}`, must); + return null; + } + if (status === 302 || status === 301) { + push(label, false, `${status}→SSO 重定向:Cookie 无效/未登录`, must); + return null; + } + push(label, status === 200 && (!json || (data && data.code === 0)), `HTTP ${status} code=${data && data.code} msg=${data && data.msg}`, must); + return data ? data.data : raw; +} + +function push(name, ok, detail, must) { + results.push({ name, ok, detail, must }); + if (!ok && must) failed++; + console.log(`${ok ? "PASS" : "FAIL"} ${name} — ${detail}`); +} + +async function main() { + console.log(`\n=== DormNotice 冒烟 @ ${BASE} ===`); + if (!TEACHER) { console.log("⚠ 未提供 TEACHER_COOKIE,鉴权类接口会 302。请先按脚本头注释获取。"); } + + // 0. 免鉴权健康(actuator 返回 {status:UP},非 {code} 信封,只验 HTTP 200) + await req("actuator/health", "GET", "/actuator/health", { must: false, json: false }); + + // 1. 平台代理 + await req("base/GetUserInfo", "GET", "/api/base/GetUserInfo"); + await req("base/GetUserMod", "GET", "/api/base/GetUserMod"); + await req("base/GetDormTree", "GET", "/api/base/GetDormTree", { must: false }); + await req("base/SetRole(parent)", "POST", "/api/base/SetRole", { cookie: STU || TEACHER, body: { as: "parent" }, must: false }); + + // 2. 字典(B2:type 参数化 + 无“文字重复”) + const types = await req("dict/List?noticeType", "GET", "/api/dict/List?type=noticeType"); + const depts = await req("dict/ManageList?dept", "GET", "/api/dict/ManageList?type=dept"); + await req("dict/Sync", "GET", "/api/dict/Sync", { must: false }); + // 断言无逗号重复名 + if (Array.isArray(types)) { + const dup = types.some(t => String(t.v || t.k || "").includes(",")); + push("字典名无“X,X”重复", !dup, dup ? "存在含逗号字典名,检查 B1 是否真后端未装" : "OK", true); + } + + // 3. 宿舍域 + const buildings = await req("dorm/BuildingList", "GET", "/api/dorm/BuildingList", { must: false }); + await req("dorm/RoomList", "GET", "/api/dorm/RoomList", { must: false }); + await req("dorm/PendingStudents", "GET", "/api/dorm/PendingStudents", { must: false }); + + // 4. 通知:FormTemplates / SendList / Create(草稿)→Detail→Delete + await req("notice/FormTemplates", "GET", "/api/notice/FormTemplates"); + await req("notice/SendList", "GET", "/api/notice/SendList?pageIndex=1&pageSize=10"); + await req("notice/Statis", "GET", "/api/notice/Statis", { must: false }); + + const deptCode = Array.isArray(depts) && depts[0] ? depts[0].CodeID : "D002"; + const receiveList = Array.isArray(buildings) && buildings[0] ? String(buildings[0].buildingId * 10000) : "ALL"; + // 4b. 通知写链路:Create(草稿) → Edit 改标题 → GetDetail 校验 → Delete 自清理 + if (WRITE && TEACHER) { + const nid = await req("notice/Create(draft)", "POST", "/api/notice/Create", { + body: { title: "【冒烟】通知A", content: "

smoke

", noticeType: "其他", + level: "normal", receiptMode: "none", deptId: deptCode, receiveType: "room", + receiveList, draft: true }, + }); + if (nid) { + await req("notice/Edit(改标题)", "POST", "/api/notice/Edit", { body: { id: nid, title: "【冒烟】通知B", receiptMode: "none" } }); + const d = await req("notice/GetDetail(校验)", "GET", `/api/notice/GetDetail?serid=${nid}`); + push("notice/Edit 生效(标题→B)", !!(d && /通知B/.test(String(d.title || ""))), `title=${d && d.title}`, false); + await req("notice/Delete(cleanup)", "POST", "/api/notice/Delete", { body: [nid] }); + } + } + + // 5. 家长端(学生 Cookie) + await req("notice/List(parent)", "GET", "/api/notice/List?pageIndex=1&pageSize=20", { cookie: STU, must: false }); + await req("notice/MyNotices(parent)", "GET", "/api/notice/MyNotices", { cookie: STU, must: false }); + + // 6. 写链路自清理(用一次性数据,跑完即删;设 WRITE=0 跳过) + if (WRITE && TEACHER) { + // 6.1 字典:Add(noticeType) → ManageList 查得 → Delete + const tn = "冒烟类型" + Date.now(); + await req("dict/Add(noticeType)", "POST", "/api/dict/Add", { form: { name: tn, type: "noticeType" } }); + const ml = await req("dict/ManageList(noticeType)", "GET", "/api/dict/ManageList?type=noticeType", { must: false }); + const dictItem = Array.isArray(ml) ? ml.find(x => x.Name === tn) : null; + push("dict/Add→ManageList 可查得", !!dictItem, dictItem ? "SerID=" + dictItem.SerID : "未找到新增项", false); + if (dictItem) await req("dict/Delete(cleanup)", "POST", "/api/dict/Delete", { form: { serid: dictItem.SerID, type: "noticeType" } }); + + // 6.2 联系组:AddGroup → GetGroup 查得 → DelGroup + const gn = "冒烟组" + Date.now(); + await req("contacts/AddGroup", "POST", "/api/contacts/AddGroup", { form: { name: gn } }); + const gp = await req("contacts/GetGroup", "GET", "/api/contacts/GetGroup?page=1&count=200", { must: false }); + const grp = gp && Array.isArray(gp.list) ? gp.list.find(x => x.GroupName === gn) : null; + push("contacts/AddGroup→GetGroup 可查得", !!grp, grp ? "serid=" + grp.serid : "未找到", false); + if (grp) await req("contacts/DelGroup(cleanup)", "POST", "/api/contacts/DelGroup", { form: { ids: grp.serid } }); + + // 6.3 宿舍:AddBuilding → BuildingList 查得 → AddRoom → RoomList 查得 → DelRoom → DelBuilding + const bn = "冒烟楼" + Date.now(); + await req("dorm/AddBuilding", "POST", "/api/dorm/AddBuilding", { form: { buildingName: bn, gender: "混合", floorCount: 1, roomsPerFloor: 1, bedsPerRoom: 2 } }); + const bl = await req("dorm/BuildingList", "GET", "/api/dorm/BuildingList", { must: false }); + const b = Array.isArray(bl) ? bl.find(x => x.buildingName === bn) : null; + push("dorm/AddBuilding→BuildingList 可查得", !!b, b ? "buildingId=" + b.buildingId : "未找到", false); + if (b) { + await req("dorm/AddRoom", "POST", "/api/dorm/AddRoom", { form: { buildingId: b.buildingId, floor: 1, roomNo: "901", bedCount: 2 } }); + const rl = await req("dorm/RoomList", "GET", "/api/dorm/RoomList?buildingId=" + b.buildingId, { must: false }); + const room = Array.isArray(rl) ? rl.find(x => x.roomNo === "901") : null; + push("dorm/AddRoom→RoomList 可查得", !!room, room ? "roomId=" + room.roomId : "未找到", false); + if (room) await req("dorm/DelRoom(cleanup)", "POST", "/api/dorm/DelRoom", { form: { roomId: room.roomId } }); + await req("dorm/DelBuilding(cleanup)", "POST", "/api/dorm/DelBuilding", { form: { buildingId: b.buildingId } }); + } + } else { + console.log("SKIP 写链路(WRITE=0 或缺 TEACHER_COOKIE)"); + } + + console.log("\n=== 汇总 ==="); + const pass = results.filter(r => r.ok).length; + console.log(`${pass}/${results.length} 通过;必过失败 ${failed} 项`); + process.exit(failed ? 1 : 0); +} + +main().catch(e => { console.error("脚本异常:", e); process.exit(2); }); diff --git a/src/main/java/com/yunhai/dormnotice/DormNoticeApplication.java b/src/main/java/com/yunhai/dormnotice/DormNoticeApplication.java new file mode 100644 index 0000000..0cef0c9 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/DormNoticeApplication.java @@ -0,0 +1,18 @@ +package com.yunhai.dormnotice; + +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.dormnotice") +@EnableTransactionManagement +public class DormNoticeApplication { + + public static void main(String[] args) { + // 开启 fastjson 安全模式 + ParserConfig.getGlobalInstance().setSafeMode(true); + SpringApplication.run(DormNoticeApplication.class, args); + } + +} diff --git a/src/main/java/com/yunhai/dormnotice/annotation/OperationLog.java b/src/main/java/com/yunhai/dormnotice/annotation/OperationLog.java new file mode 100644 index 0000000..dccb1ab --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/annotation/OperationLog.java @@ -0,0 +1,22 @@ +package com.yunhai.dormnotice.annotation; + +import java.lang.annotation.*; + +/** + * 标注在控制器方法上,声明一次需要落审计日志的操作(决策 D2「保留日志表」→ 统一操作日志)。 + *

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

+ */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface OperationLog { + + /** 业务模块,如 "宿舍通知" */ + String module() default ""; + + /** 操作类型:CREATE / UPDATE / DELETE / QUERY(缺省按 HTTP 方法推断) */ + String type() default ""; + + /** 日志级别:1 重要(删除) / 2 次要(新增/编辑) / 3 一般(查询)。缺省按 type 推断 */ + int level() default 0; +} diff --git a/src/main/java/com/yunhai/dormnotice/aspect/ControllerUserContextAspect.java b/src/main/java/com/yunhai/dormnotice/aspect/ControllerUserContextAspect.java new file mode 100644 index 0000000..79b5930 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/aspect/ControllerUserContextAspect.java @@ -0,0 +1,119 @@ +package com.yunhai.dormnotice.aspect; + +import com.yunhai.dormnotice.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.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; +import javax.servlet.http.HttpSession; + +/** + * 把基础平台的当前登录用户({@link BoaUser})复制到自有 {@link UserContext}(ThreadLocal),请求结束清理。 + *

参照 UserclassService 的 ControllerUserContextAspect:优先从控制器入参里的 {@link BoaUser} 取, + * 否则回退到框架 {@link BoaUserContext#get()}。端分离视角 role 取会话属性(SetRole 写入)。

+ */ +@Aspect +@Component +@Order(1) +public class ControllerUserContextAspect { + + private static final Logger log = LoggerFactory.getLogger(ControllerUserContextAspect.class); + + /** 会话中记录的家长/教师视角键(SetRole 写、本切面读)。 */ + public static final String SESSION_ROLE_KEY = "dorm_role"; + + @Around("execution(* com.yunhai.dormnotice.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); + } + } + info.role = currentViewRole(); + 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 String currentViewRole() { + HttpSession session = session(false); + if (session != null) { + Object r = session.getAttribute(SESSION_ROLE_KEY); + if (UserContext.ROLE_PARENT.equals(r)) { + return UserContext.ROLE_PARENT; + } + } + return UserContext.ROLE_TEACHER; + } + + private HttpSession session(boolean create) { + try { + HttpServletRequest req = request(); + return req == null ? null : req.getSession(create); + } catch (Exception e) { + 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/dormnotice/aspect/OperationLogAspect.java b/src/main/java/com/yunhai/dormnotice/aspect/OperationLogAspect.java new file mode 100644 index 0000000..8740449 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/aspect/OperationLogAspect.java @@ -0,0 +1,154 @@ +package com.yunhai.dormnotice.aspect; + +import com.yunhai.dormnotice.annotation.OperationLog; +import com.yunhai.dormnotice.entity.SysOperationLog; +import com.yunhai.dormnotice.repository.SysOperationLogRepository; +import com.yunhai.dormnotice.utils.UserContext; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +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; + +/** + * 统一操作日志切面:环绕 {@link OperationLog} 标注的方法,落 {@code sys_operation_log}。 + *

@Order 大于 {@link ControllerUserContextAspect},保证写日志时 {@link UserContext} 已填充。 + * 落库异常仅告警、不外抛,绝不因记日志影响主业务。

+ */ +@Aspect +@Component +@Order(10) +public class OperationLogAspect { + + private static final Logger log = LoggerFactory.getLogger(OperationLogAspect.class); + + @Autowired + private SysOperationLogRepository sysOperationLogRepository; + + @Pointcut("@annotation(com.yunhai.dormnotice.annotation.OperationLog)") + public void logPoint() { + } + + @Around("logPoint()") + public Object around(ProceedingJoinPoint pjp) throws Throwable { + long start = System.currentTimeMillis(); + int status = 0; + String errorMsg = null; + try { + return pjp.proceed(); + } catch (Throwable t) { + status = 1; + errorMsg = truncate(t.getMessage(), 2000); + throw t; + } finally { + try { + save(pjp, status, errorMsg, System.currentTimeMillis() - start); + } catch (Exception e) { + log.warn("[OperationLog] 写入操作日志失败: {}", e.getMessage()); + } + } + } + + private void save(ProceedingJoinPoint pjp, int status, String errorMsg, long cost) { + SysOperationLog entity = new SysOperationLog(); + MethodSignature sig = (MethodSignature) pjp.getSignature(); + OperationLog ann = sig.getMethod().getAnnotation(OperationLog.class); + entity.setClassMethod(sig.getDeclaringType().getSimpleName() + "#" + sig.getName()); + + 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() : inferType(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); + sysOperationLogRepository.save(entity); + } + + private String inferType(String httpMethod) { + if (httpMethod == null) { + return "QUERY"; + } + switch (httpMethod.toUpperCase()) { + case "POST": + return "CREATE"; + case "PUT": + return "UPDATE"; + case "DELETE": + return "DELETE"; + default: + return "QUERY"; + } + } + + 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) { + continue; + } + String s; + try { + s = a.getClass().isArray() ? Arrays.deepToString((Object[]) a) : String.valueOf(a); + } catch (Exception e) { + s = "[unprintable]"; + } + if (sb.length() > 0) { + sb.append("&"); + } + sb.append(s); + } + String out = sb.toString(); + out = out.replaceAll("(?i)(password|pwd|secret|token)=([^&]*)", "$1=***"); + return truncate(out, 2000); + } + + 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/dormnotice/common/listener/NoticeAuditListener.java b/src/main/java/com/yunhai/dormnotice/common/listener/NoticeAuditListener.java new file mode 100644 index 0000000..f2ea03e --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/common/listener/NoticeAuditListener.java @@ -0,0 +1,47 @@ +package com.yunhai.dormnotice.common.listener; + +import com.yunhai.dormnotice.entity.BaseEntity; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import org.springframework.stereotype.Component; + +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import java.time.LocalDateTime; + +/** + * JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(平台当前登录人)与时间戳。 + * 对应 {@link BaseEntity} 的 createBy / updateBy / createTime / updateTime 字段。 + * 当前登录人取自基础平台 {@link UserContextHolder}。 + */ +@Component +public class NoticeAuditListener { + + @PrePersist + public void prePersist(BaseEntity entity) { + LocalDateTime now = LocalDateTime.now(); + entity.setCreateTime(now); + entity.setUpdateTime(now); + String account = currentAccount(); + if (account != null) { + if (entity.getCreateBy() == null) { + entity.setCreateBy(account); + } + entity.setUpdateBy(account); + } + } + + @PreUpdate + public void preUpdate(BaseEntity entity) { + entity.setUpdateTime(LocalDateTime.now()); + String account = currentAccount(); + if (account != null) { + entity.setUpdateBy(account); + } + } + + private String currentAccount() { + LoginUser user = UserContextHolder.get(); + return user == null ? null : user.getLoginName(); + } +} diff --git a/src/main/java/com/yunhai/dormnotice/common/validation/ValidationGroups.java b/src/main/java/com/yunhai/dormnotice/common/validation/ValidationGroups.java new file mode 100644 index 0000000..dc279d1 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/common/validation/ValidationGroups.java @@ -0,0 +1,10 @@ +package com.yunhai.dormnotice.common.validation; + +/** + * JSR-303 分组校验标记接口(对齐 UserBase 规范)。 + * Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。 + */ +public interface ValidationGroups { + interface Create {} + interface Update {} +} diff --git a/src/main/java/com/yunhai/dormnotice/config/Config.java b/src/main/java/com/yunhai/dormnotice/config/Config.java new file mode 100644 index 0000000..0ab8a01 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/config/Config.java @@ -0,0 +1,11 @@ +package com.yunhai.dormnotice.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/dormnotice/config/DictSeedRunner.java b/src/main/java/com/yunhai/dormnotice/config/DictSeedRunner.java new file mode 100644 index 0000000..af7c32e --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/config/DictSeedRunner.java @@ -0,0 +1,70 @@ +package com.yunhai.dormnotice.config; + +import com.yunhai.dormnotice.entity.SysDict; +import com.yunhai.dormnotice.repository.SysDictRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 字典自举种子:启动时若 sys_dict 缺 dept / noticeType 则补入默认口径(幂等,仅在为空时插)。 + *

解决「发布部门/通知类型下拉为空」——不依赖 DBA 手动跑 dormnotice_init.sql。 + * 生产可用 {@code dormnotice.seed-dict=false} 关闭。

+ */ +@Component +public class DictSeedRunner implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(DictSeedRunner.class); + + private static final String[][] DEPTS = { + {"D001", "校办"}, {"D002", "德育处"}, {"D003", "生活管理科"}, {"D004", "教务处"}, + {"D005", "后勤保障部"}, {"D006", "校医务室"}, {"D007", "武术训练部"}, {"D008", "安全管理办公室"}, + }; + private static final String[][] NOTICE_TYPES = { + {"T001", "作息调整"}, {"T002", "宿舍调整"}, {"T003", "卫生检查"}, {"T004", "违规通报"}, + {"T005", "停水停电"}, {"T006", "安全排查"}, {"T007", "留宿申请"}, {"T008", "家长探视"}, + {"T009", "夜间查寝"}, {"T010", "物品清退"}, {"T011", "疫情防控"}, {"T012", "假期安排"}, {"T013", "其他"}, + }; + + @Autowired + private SysDictRepository sysDictRepository; + + @Value("${dormnotice.seed-dict:true}") + private boolean enabled; + + @Override + @Transactional + public void run(ApplicationArguments args) { + if (!enabled) { + return; + } + seed("dept", DEPTS); + seed("noticeType", NOTICE_TYPES); + } + + private void seed(String type, String[][] items) { + List existing = sysDictRepository.findByDictTypeOrderBySortOrderAsc(type); + if (!existing.isEmpty()) { + log.info("[DictSeed] 字典 {} 已有 {} 条,跳过种子", type, existing.size()); + return; + } + int order = 1; + for (String[] it : items) { + SysDict d = new SysDict(); + d.setDictType(type); + d.setDictKey(it[0]); + d.setDictValue(it[1]); + d.setSortOrder(order++); + d.setEnableFlag(1); + sysDictRepository.save(d); + } + log.info("[DictSeed] 字典 {} 已种入 {} 条", type, items.length); + } +} diff --git a/src/main/java/com/yunhai/dormnotice/config/JacksonConfig.java b/src/main/java/com/yunhai/dormnotice/config/JacksonConfig.java new file mode 100644 index 0000000..b032359 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/config/JacksonConfig.java @@ -0,0 +1,35 @@ +package com.yunhai.dormnotice.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/dormnotice/config/ReadBackfillRunner.java b/src/main/java/com/yunhai/dormnotice/config/ReadBackfillRunner.java new file mode 100644 index 0000000..4a12bd4 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/config/ReadBackfillRunner.java @@ -0,0 +1,67 @@ +package com.yunhai.dormnotice.config; + +import com.yunhai.dormnotice.entity.DormNoticeRead; +import com.yunhai.dormnotice.entity.Receipt; +import com.yunhai.dormnotice.repository.DormNoticeReadRepository; +import com.yunhai.dormnotice.repository.ReceiptRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 历史阅读记录回填(幂等):把已确认/已填报的学生补写进 dorm_notice_read。 + *

「已读人数」改为按学生级阅读记录统计后,旧数据(在读日志上线前打开/确认的)没有记录, + * 会出现「已读 0 但已回执 N」的矛盾。确认/填报必然先打开,故据回执回填阅读记录,使 已读 ≥ 已回执。 + * 不回填 receipt.read=true(那是旧的整房标记、被床位放大,回填会重现虚高)。

+ * 可用 {@code dormnotice.backfill-read=false} 关闭。 + */ +@Component +@Order(20) +public class ReadBackfillRunner implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(ReadBackfillRunner.class); + + @Autowired private ReceiptRepository receiptRepository; + @Autowired private DormNoticeReadRepository readRepository; + + @Value("${dormnotice.backfill-read:true}") + private boolean enabled; + + @Override + @Transactional + public void run(ApplicationArguments args) { + if (!enabled) { + return; + } + List acted = receiptRepository.findByStatusIn(java.util.Arrays.asList("confirmed", "filled")); + int added = 0; + for (Receipt r : acted) { + if (r.getNoticeId() == null || r.getStudentId() == null) { + continue; + } + if (readRepository.existsByNoticeIdAndStudentId(r.getNoticeId(), r.getStudentId())) { + continue; + } + DormNoticeRead rd = new DormNoticeRead(); + rd.setNoticeId(r.getNoticeId()); + rd.setStudentId(r.getStudentId()); + rd.setRoomId(r.getRoomId()); + rd.setReadAt(r.getSubmittedAt() != null ? r.getSubmittedAt() : LocalDateTime.now()); + rd.setSchId(r.getSchId()); + readRepository.save(rd); + added++; + } + if (added > 0) { + log.info("[ReadBackfill] 依据已确认/已填报回执回填阅读记录 {} 条", added); + } + } +} diff --git a/src/main/java/com/yunhai/dormnotice/config/SwaggerConfig.java b/src/main/java/com/yunhai/dormnotice/config/SwaggerConfig.java new file mode 100644 index 0000000..37c6b96 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/config/SwaggerConfig.java @@ -0,0 +1,22 @@ +package com.yunhai.dormnotice.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,替代旧 Springfox)。 + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI noticeOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("通知公告接口文档") + .description("通知公告子应用:发通知、列表、删除、编辑、管理员查看、统计(springdoc-openapi)") + .version("1.0")); + } +} diff --git a/src/main/java/com/yunhai/dormnotice/constant/DormEnums.java b/src/main/java/com/yunhai/dormnotice/constant/DormEnums.java new file mode 100644 index 0000000..4c22871 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/constant/DormEnums.java @@ -0,0 +1,142 @@ +package com.yunhai.dormnotice.constant; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 宿舍通知枚举文案 + 统一回执单模板(系统预置常量,落库不落表;决策 D3)。 + * 字段结构与前端 types/dorm.ts 的 FillField / ReceiptFormTemplate 一一对齐。 + */ +public final class DormEnums { + + private DormEnums() { + } + + // ============ 文案映射 ============ + public static final Map NOTICE_STATUS_DESC = desc("draft", "草稿", "published", "已发布", "closed", "已截止", "finished", "已结束"); + public static final Map RECEIPT_MODE_DESC = desc("none", "仅通知", "confirm", "需确认", "fill", "需填报"); + public static final Map LEVEL_DESC = desc("normal", "普通", "important", "重要", "urgent", "紧急"); + public static final Map RECEIPT_STATUS_DESC = desc("none", "未回执", "confirmed", "已确认", "filled", "已填报", "canceled", "已取消"); + public static final Map FORM_TYPE_DESC = desc("stay", "留宿申请单", "visit", "探视预约单", "adjust", "调宿申请单", "general", "通用回执单"); + + private static Map desc(String... kv) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i + 1 < kv.length; i += 2) { + m.put(kv[i], kv[i + 1]); + } + return m; + } + + public static String statusDesc(String v) { return NOTICE_STATUS_DESC.getOrDefault(v, "已发布"); } + public static String modeDesc(String v) { return RECEIPT_MODE_DESC.getOrDefault(v, "仅通知"); } + public static String levelDesc(String v) { return LEVEL_DESC.getOrDefault(v, "普通"); } + public static String receiptStatusDesc(String v) { return RECEIPT_STATUS_DESC.getOrDefault(v, v); } + public static String formTypeDesc(String v) { return FORM_TYPE_DESC.getOrDefault(v, "通用回执单"); } + + // ============ 公共区字段(所有单据一致) ============ + private static Map commonConclusion() { + Map f = new LinkedHashMap<>(); + f.put("key", "conclusion"); + f.put("label", "回执结论"); + f.put("type", "select"); + f.put("required", true); + f.put("group", "common"); + f.put("options", options( + opt("同意 / 确认办理", "同意"), + opt("不同意 / 不参与", "不同意"), + opt("需要电话沟通", "需沟通"))); + return f; + } + + // ============ 专属区字段(按单据类型) ============ + @SuppressWarnings("unchecked") + public static List> buildFormFields(String formType) { + List> fields = new ArrayList<>(); + fields.add(commonConclusion()); + fields.addAll(specificFields(formType)); + return fields; + } + + private static List> specificFields(String formType) { + List> list = new ArrayList<>(); + switch (formType == null ? "general" : formType) { + case "stay": + list.add(field("stayStart", "留宿开始日期", "date", true, "specific", "选择留宿开始日期", null, null)); + list.add(field("stayEnd", "留宿结束日期", "date", true, "specific", "选择留宿结束日期", null, null)); + list.add(field("stayCount", "留宿人数", "number", true, "specific", null, "人", null)); + list.add(field("stayReason", "留宿事由", "select", true, "specific", null, null, options( + opt("家远不便往返", "家远不便往返"), opt("赛前集训", "赛前集训"), + opt("伤病康复观察", "伤病康复观察"), opt("学业补差", "学业补差"), + opt("其他(请在补充说明中写明)", "其他")))); + break; + case "visit": + list.add(field("visitDate", "到访日期", "date", true, "specific", "选择到访日期", null, null)); + list.add(field("visitSlot", "到访时段", "select", true, "specific", null, null, options( + opt("上午 09:00-11:30", "上午 09:00-11:30"), opt("下午 14:00-17:00", "下午 14:00-17:00"), + opt("晚间 18:30-20:00", "晚间 18:30-20:00")))); + list.add(field("visitorCount", "到访人数", "number", true, "specific", null, "人", null)); + break; + case "adjust": + list.add(field("moveDate", "可配合搬迁日期", "date", true, "specific", "选择可配合搬迁的日期", null, null)); + list.add(field("adjustReason", "情况说明", "textarea", false, "specific", + "如孩子有腰伤需住下铺、需与同训练组同住、恐高等,请在此说明", null, null)); + break; + case "general": + default: + break; + } + return list; + } + + /** 单据模板列表(发布页选型用)。公共区由 buildFormFields 统一提供,此处只列专属区。 */ + public static List> formTemplates() { + List> list = new ArrayList<>(); + list.add(template("stay", "留宿申请单", "周末 / 假期学生留宿申请,需注明留宿起止与事由", specificFields("stay"))); + list.add(template("visit", "探视预约单", "家长到校探视预约,需注明到访日期、时段与人数", specificFields("visit"))); + list.add(template("adjust", "调宿申请单", "床位调整 / 宿舍调换确认,需注明可配合搬迁的日期", specificFields("adjust"))); + list.add(template("general", "通用回执单", "只需家长给出回执结论并说明情况,无额外登记项", specificFields("general"))); + return list; + } + + public static List formTypes() { + return Arrays.asList("stay", "visit", "adjust", "general"); + } + + // ============ 构造小工具 ============ + private static Map template(String type, String name, String desc, List> fields) { + Map m = new LinkedHashMap<>(); + m.put("type", type); + m.put("name", name); + m.put("desc", desc); + m.put("fields", fields); + return m; + } + + private static Map field(String key, String label, String type, boolean required, + String group, String placeholder, String unit, List> options) { + Map m = new LinkedHashMap<>(); + m.put("key", key); + m.put("label", label); + m.put("type", type); + m.put("required", required); + m.put("group", group); + if (placeholder != null) m.put("placeholder", placeholder); + if (unit != null) m.put("unit", unit); + if (options != null) m.put("options", options); + return m; + } + + private static List> options(Map... opts) { + return new ArrayList<>(Arrays.asList(opts)); + } + + private static Map opt(String label, String value) { + Map m = new LinkedHashMap<>(); + m.put("label", label); + m.put("value", value); + return m; + } +} diff --git a/src/main/java/com/yunhai/dormnotice/controller/api/BaseApiController.java b/src/main/java/com/yunhai/dormnotice/controller/api/BaseApiController.java new file mode 100644 index 0000000..092f01d --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/controller/api/BaseApiController.java @@ -0,0 +1,143 @@ +package com.yunhai.dormnotice.controller.api; + +import com.yunhai.dormnotice.aspect.ControllerUserContextAspect; +import com.yunhai.dormnotice.config.Config; +import com.yunhai.dormnotice.model.notice.UpFileResultVo; +import com.yunhai.dormnotice.service.IDormService; +import com.yunhai.dormnotice.service.IFileService; +import com.yunhai.dormnotice.service.IUserCenterService; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import com.yunhai.platform.utils.model.BoaUser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpSession; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 平台代理域接口(base/)。 + *

路径前缀:{@code Config.API_PATH + "/base/"}(原硬编码已修正)。 + * 承载平台代理接口:GetUserInfo / GetUserMod / GetSchoolAll / GetGroupUser / Logout, + * 业务逻辑已下沉 {@link IUserCenterService},本控制器仅做"取参 → 调 Service → 返回"。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/base/") +public class BaseApiController { + + private static final Logger log = LoggerFactory.getLogger(BaseApiController.class); + + @Autowired + private IUserCenterService userCenterService; + + @Autowired + private IFileService fileService; + + @Autowired + private IDormService dormService; + + // ============================ FileUpload ============================ + + /** + * 附件上传(单文件)。 + * 前端: POST base/FileUpload (form-data: file) + * 返回: { fileid, host, fileurl, name, type, size } + */ + @PostMapping("FileUpload") + public UpFileResultVo fileUpload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, "dormnotice"); + } + + // ============================ GetUserInfo ============================ + + /** + * 获取当前登录用户信息(CAS 会话注入)。 + */ + @GetMapping("GetUserInfo") + public BoaUser getUserInfo(BoaUser authUserWithDept) { + return authUserWithDept; + } + + // ============================ GetUserMod ============================ + + /** + * 获取当前用户已授权的模块列表(左侧菜单树)。 + * 模块树组装已下沉 IUserCenterService。 + */ + @GetMapping("GetUserMod") + public List> getUserMod(BoaUser authUserWithDept) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId); + } + + // ============================ GetSchoolAll ============================ + + /** + * 获取当前用户学校组及用户(树形结构)。 + * 组树组装/教师挂组/递归建树逻辑已下沉 IUserCenterService。 + */ + @GetMapping("GetSchoolAll") + public List> getSchoolAll(BoaUser authUserWithDept) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + return userCenterService.getSchoolAllTree(schId); + } + + // ============================ GetGroupUser ============================ + + /** + * 获取指定组内用户(教师 + 管理员)。 + * 空桩已改为显式报错(避免前端误以为组内无用户),由 IUserCenterService 统一处理。 + */ + @GetMapping("GetGroupUser") + public List> getGroupUser( + @RequestParam("gid") Integer gid, + @RequestParam(value = "role", defaultValue = "2") Integer role) { + return userCenterService.getGroupUser(gid, role); + } + + // ============================ GetDormTree ============================ + + /** 楼栋→楼层→房间树(宿舍通知发布对象,选到房间一级)。 */ + @GetMapping("GetDormTree") + public List> getDormTree() { + return dormService.dormTree(); + } + + // ============================ SetRole(端分离视角) ============================ + + /** + * 切换当前视角(teacher 发布端 / parent 家长端),落会话供 {@link ControllerUserContextAspect} 读取。 + * 家长即登录学生账号,不单列。 + */ + @PostMapping("SetRole") + public Map setRole(@RequestBody Map body, HttpSession session) { + String as = body == null ? null : String.valueOf(body.get("as")); + String role = com.yunhai.dormnotice.utils.UserContext.ROLE_PARENT.equals(as) + ? com.yunhai.dormnotice.utils.UserContext.ROLE_PARENT + : com.yunhai.dormnotice.utils.UserContext.ROLE_TEACHER; + session.setAttribute(ControllerUserContextAspect.SESSION_ROLE_KEY, role); + Map r = new LinkedHashMap<>(); + r.put("role", role); + return r; + } + + // ============================ Logout ============================ + + /** + * 退出登录。 + * 认证由平台网关处理,此处仅返回成功。 + */ + @PostMapping("Logout") + public void logout() { + LoginUser user = UserContextHolder.get(); + log.info("[logout] 用户退出登录: {}", user == null ? null : user.getLoginName()); + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/dormnotice/controller/api/ContactsController.java b/src/main/java/com/yunhai/dormnotice/controller/api/ContactsController.java new file mode 100644 index 0000000..b39b07b --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/controller/api/ContactsController.java @@ -0,0 +1,99 @@ +package com.yunhai.dormnotice.controller.api; + +import com.yunhai.dormnotice.config.Config; +import com.yunhai.dormnotice.service.IContactsService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +/** + * 联系组接口(contacts/)。 + *

路径前缀:{@code Config.API_PATH + "/contacts/"}(原硬编码已修正为 Config 拼接)。 + * 方法名已为 PascalCase 且写操作已是 POST,保持不动。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/contacts/") +public class ContactsController { + + @Autowired + private IContactsService contactsService; + + /** + * 添加联系组。 + */ + @PostMapping("AddGroup") + public void addGroup( + @RequestParam("name") String name, + @RequestParam(value = "type", required = false, defaultValue = "1") String type) { + contactsService.addGroup(name, Integer.parseInt(type)); + } + + /** + * 获取联系组列表。 + */ + @GetMapping("GetGroup") + public Map getGroup( + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "count", defaultValue = "0") Integer count, + @RequestParam(value = "type", defaultValue = "1") Integer type) { + return contactsService.getGroup(page, count, type); + } + + /** + * 编辑联系组。 + */ + @PostMapping("EditGroup") + public void editGroup( + @RequestParam("name") String name, + @RequestParam("id") String id, + @RequestParam(value = "type", required = false, defaultValue = "1") String type) { + contactsService.editGroup(Integer.parseInt(id), name, Integer.parseInt(type)); + } + + /** + * 删除联系组(支持批量)。 + */ + @PostMapping("DelGroup") + public void delGroup(@RequestParam("ids") String ids) { + contactsService.delGroup(ids); + } + + /** + * 设置联系组教师。 + */ + @PostMapping("SetContactsTea") + public void setContactsTea( + @RequestParam("gid") String gid, + @RequestParam(value = "ids", required = false) String ids) { + contactsService.setContactsTea(Integer.parseInt(gid), ids); + } + + /** + * 获取联系组教师列表。 + */ + @GetMapping("GetContactsTea") + public Map getContactsTea( + @RequestParam("gid") String gid, + @RequestParam(value = "page", defaultValue = "0") Integer page, + @RequestParam(value = "count", defaultValue = "0") Integer count) { + return contactsService.getContactsTea(gid, page, count); + } + + /** + * 获取联系组树。 + */ + @GetMapping("GetGroupTree") + public List> getGroupTree(@RequestParam("type") Integer type) { + return contactsService.getGroupTree(type); + } + + /** + * 获取部门角色组树。 + */ + @GetMapping("GetDeptRoleGroupTree") + public List> getDeptRoleGroupTree() { + return contactsService.getDeptRoleGroupTree(); + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/dormnotice/controller/api/DictController.java b/src/main/java/com/yunhai/dormnotice/controller/api/DictController.java new file mode 100644 index 0000000..0cc17df --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/controller/api/DictController.java @@ -0,0 +1,66 @@ +package com.yunhai.dormnotice.controller.api; + +import com.yunhai.dormnotice.annotation.OperationLog; +import com.yunhai.dormnotice.config.Config; +import com.yunhai.dormnotice.service.IDictService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 系统字典接口。@RequestParam 表单类接口(前端 postForm);List/ManageList/Sync 为 GET。 + * type 维度:dept / noticeType(缺省 noticeType)。入参名大小写严格对齐前端 dorm.ts。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/dict/") +public class DictController { + + @Autowired + private IDictService dictService; + + @GetMapping("List") + public List> list(@RequestParam(value = "type", required = false, defaultValue = "noticeType") String type) { + return dictService.list(type); + } + + @GetMapping("ManageList") + public List> manageList(@RequestParam(value = "type", required = false, defaultValue = "noticeType") String type) { + return dictService.manageList(type); + } + + @PostMapping("Add") + @OperationLog(module = "字典", type = "CREATE", level = 2) + public void add(@RequestParam("name") String name, + @RequestParam(value = "type", required = false, defaultValue = "noticeType") String type) { + dictService.add(name, type); + } + + @PostMapping("Update") + @OperationLog(module = "字典", type = "UPDATE", level = 2) + public void update(@RequestParam("SerID") Integer serId, + @RequestParam(value = "Name", required = false) String name, + @RequestParam(value = "Enable", required = false) Integer enable, + @RequestParam(value = "type", required = false, defaultValue = "noticeType") String type) { + dictService.update(serId, name, enable, type); + } + + @PostMapping("Delete") + @OperationLog(module = "字典", type = "DELETE", level = 1) + public void delete(@RequestParam("serid") Integer serId, + @RequestParam(value = "type", required = false, defaultValue = "noticeType") String type) { + dictService.delete(serId, type); + } + + @PostMapping("Sort") + public void sort(@RequestParam("ids") String ids, + @RequestParam(value = "type", required = false, defaultValue = "noticeType") String type) { + dictService.sort(ids, type); + } + + @GetMapping("Sync") + public Boolean sync() { + return dictService.isSmsEnabled(); + } +} diff --git a/src/main/java/com/yunhai/dormnotice/controller/api/DormController.java b/src/main/java/com/yunhai/dormnotice/controller/api/DormController.java new file mode 100644 index 0000000..ed133f0 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/controller/api/DormController.java @@ -0,0 +1,120 @@ +package com.yunhai.dormnotice.controller.api; + +import com.yunhai.dormnotice.annotation.OperationLog; +import com.yunhai.dormnotice.config.Config; +import com.yunhai.dormnotice.service.IDormService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 宿舍楼栋 / 房间 / 床位接口(/api/dorm/*)。写接口对齐前端 postForm(@RequestParam)。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/dorm/") +public class DormController { + + @Autowired + private IDormService dormService; + + @GetMapping("BuildingList") + public List> buildingList() { + return dormService.buildingList(); + } + + @GetMapping("RoomList") + public List> roomList(@RequestParam(required = false) Integer buildingId, + @RequestParam(required = false) String keyword) { + return dormService.roomList(buildingId, keyword); + } + + @PostMapping("AddBuilding") + @OperationLog(module = "宿舍设置", type = "CREATE", level = 2) + public void addBuilding(@RequestParam Map data) { + dormService.addBuilding(toObj(data)); + } + + @PostMapping("EditBuilding") + @OperationLog(module = "宿舍设置", type = "UPDATE", level = 2) + public void editBuilding(@RequestParam Map data) { + dormService.editBuilding(toObj(data)); + } + + @PostMapping("DelBuilding") + @OperationLog(module = "宿舍设置", type = "DELETE", level = 1) + public void delBuilding(@RequestParam Integer buildingId) { + dormService.delBuilding(buildingId); + } + + @PostMapping("AddRoom") + @OperationLog(module = "宿舍设置", type = "CREATE", level = 2) + public void addRoom(@RequestParam Map data) { + dormService.addRoom(toObj(data)); + } + + @PostMapping("EditRoom") + @OperationLog(module = "宿舍设置", type = "UPDATE", level = 2) + public void editRoom(@RequestParam Map data) { + dormService.editRoom(toObj(data)); + } + + @PostMapping("DelRoom") + @OperationLog(module = "宿舍设置", type = "DELETE", level = 1) + public void delRoom(@RequestParam Integer roomId) { + dormService.delRoom(roomId); + } + + @GetMapping("RoomStudents") + public List> roomStudents(@RequestParam Integer roomId) { + return dormService.roomStudents(roomId); + } + + @GetMapping("PendingStudents") + public List> pendingStudents(@RequestParam(required = false) String keyword, + @RequestParam(required = false) String gender) { + return dormService.pendingStudents(keyword, gender); + } + + @PostMapping("AssignStudent") + public void assignStudent(@RequestParam Integer roomId, @RequestParam String studentIds) { + List ids = new ArrayList<>(); + for (String s : studentIds.split(",")) { + if (!s.trim().isEmpty()) { + ids.add(Integer.parseInt(s.trim())); + } + } + dormService.assignStudent(roomId, ids); + } + + @PostMapping("RemoveStudent") + public void removeStudent(@RequestParam Integer roomId, @RequestParam Integer studentId) { + dormService.removeStudent(roomId, studentId); + } + + @PostMapping("TransferStudent") + public Map transferStudent(@RequestParam Integer studentId, + @RequestParam Integer fromRoomId, + @RequestParam Integer toRoomId) { + return dormService.transferStudent(studentId, fromRoomId, toRoomId); + } + + @PostMapping("SetMonitor") + public void setMonitor(@RequestParam Integer roomId, @RequestParam Integer studentId) { + dormService.setMonitor(roomId, studentId); + } + + /** 从 bschclass 学籍导入全校学生到待安排池(幂等),返回新增人数。 */ + @PostMapping("ImportStudents") + public Map importStudents() { + Map r = new java.util.LinkedHashMap<>(); + r.put("added", dormService.importStudentsFromBschclass()); + return r; + } + + private static Map toObj(Map src) { + return src.entrySet().stream().collect(java.util.stream.Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } +} diff --git a/src/main/java/com/yunhai/dormnotice/controller/api/FileController.java b/src/main/java/com/yunhai/dormnotice/controller/api/FileController.java new file mode 100644 index 0000000..66be368 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/controller/api/FileController.java @@ -0,0 +1,88 @@ +package com.yunhai.dormnotice.controller.api; + +import com.yunhai.dormnotice.config.Config; +import com.yunhai.dormnotice.model.notice.UpFileResultVo; +import com.yunhai.dormnotice.model.notice.UpFileVo; +import com.yunhai.dormnotice.service.IFileService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 附件域接口(file/)。 + *

路径前缀:{@code Config.API_PATH + "/file/"}(原 base/ 前缀已修正为独立文件域)。 + * 方法名统一 PascalCase;写操作一律 POST。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/file/") +public class FileController { + + @Autowired + private IFileService fileService; + + /** + * 附件上传(单文件)。 + * 前端: POST file/Upload (form-data) + * 返回: { fileid, host, fileurl, name, type, size } + */ + @PostMapping("Upload") + public UpFileResultVo upload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, "dormnotice"); + } + + /** + * 图片上传(多文件,返回数组)。 + * 前端: POST file/UploadImage (form-data, accept=image/*) + */ + @PostMapping("UploadImage") + public List uploadImage(@RequestParam("file") MultipartFile[] files) { + return fileService.uploadImages(files, "dormnotice"); + } + /** + * 文件下载(浏览器直接 location.href 跳转)。 + */ + @GetMapping("Down") + public void down(@RequestParam("id") Integer id, HttpServletResponse response) { + fileService.download(id, response); + } + + /** + * 文件在线预览(inline 输出,前端取 Blob 交给 open-file-viewer 渲染)。 + */ + @GetMapping("Preview") + public void preview(@RequestParam("id") Integer id, + @RequestParam(value = "type", required = false) Integer type, + HttpServletResponse response) { + fileService.preview(id, response); + } + + /** + * 删除文件。 + */ + @PostMapping("Delete") + public void delete(@RequestParam("fileId") Integer fileId) { + fileService.delete(fileId); + } + + /** + * 查询某通知下的附件列表。 + */ + @GetMapping("List") + public List list(@RequestParam("topicId") Integer topicId) { + return fileService.listByTopic("dormnotice", topicId); + } + + /** + * 批量绑定附件到通知。 + */ + @PostMapping("Bind") + public void bind(@RequestParam("fileIds") List fileIds, + @RequestParam("topicId") Integer topicId) { + fileService.bindToTopic(fileIds, topicId); + } + +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/dormnotice/controller/api/NoticeController.java b/src/main/java/com/yunhai/dormnotice/controller/api/NoticeController.java new file mode 100644 index 0000000..85334b1 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/controller/api/NoticeController.java @@ -0,0 +1,178 @@ +package com.yunhai.dormnotice.controller.api; + +import com.yunhai.dormnotice.annotation.OperationLog; +import com.yunhai.dormnotice.config.Config; +import com.yunhai.dormnotice.service.INoticeService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 宿舍通知接口(/api/notice/*)。Create/Edit/Delete/SubmitReceipt/Review 走 @RequestBody(JSON), + * 其余读接口 GET、写接口 postForm(@RequestParam)。响应由平台统一包装 {code,msg,data}。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/notice/") +public class NoticeController { + + @Autowired + private INoticeService noticeService; + + // ===== 列表 / 详情 ===== + + @GetMapping("List") + public Map list(@RequestParam Map q) { + return noticeService.list(toObj(q)); + } + + @GetMapping("SendList") + public Map sendList(@RequestParam Map q) { + return noticeService.sendList(toObj(q)); + } + + @GetMapping("Detail") + public Map detail(@RequestParam Integer serid, @RequestParam(required = false, defaultValue = "1") Integer type) { + return noticeService.detail(serid, type); + } + + @GetMapping("GetDetail") + public Map getDetail(@RequestParam Integer serid) { + return noticeService.getDetail(serid); + } + + @GetMapping("GetNextPrev") + public Map nextPrev(@RequestParam Integer serid, @RequestParam(required = false) String type) { + return noticeService.nextPrev(serid, type); + } + + @GetMapping("ReadDetail") + public List> readDetail(@RequestParam Integer serid) { + return noticeService.readDetail(serid); + } + + @GetMapping("FormTemplates") + public List> formTemplates() { + return noticeService.formTemplates(); + } + + // ===== 写入 ===== + + @PostMapping("Create") + @OperationLog(module = "宿舍通知", type = "CREATE", level = 2) + public Integer create(@RequestBody Map body) { + return noticeService.create(body); + } + + @PostMapping("Edit") + @OperationLog(module = "宿舍通知", type = "UPDATE", level = 2) + public void edit(@RequestBody Map body) { + noticeService.edit(body); + } + + @PostMapping("Delete") + @OperationLog(module = "宿舍通知", type = "DELETE", level = 1) + public void delete(@RequestBody List ids) { + noticeService.delete(ids); + } + + @PostMapping("UserPull") + public void userPull(@RequestParam Integer serid) { + noticeService.userPull(serid); + } + + @PostMapping("SetTopmost") + public void setTopmost(@RequestParam Integer serid, @RequestParam(required = false, defaultValue = "1") Integer topmost) { + noticeService.setTopmost(serid, topmost); + } + + @PostMapping("Expedite") + public Map expedite(@RequestParam Integer serid) { + return noticeService.expedite(serid); + } + + @PostMapping("ReceiveRooms") + public void receiveRooms(@RequestParam Integer serid, @RequestParam String rooms) { + noticeService.receiveRooms(serid, rooms); + } + + // ===== 回执 ===== + + @GetMapping("Receipts") + public Map receipts(@RequestParam Integer serid, @RequestParam(required = false, defaultValue = "all") String status) { + return noticeService.receipts(serid, status); + } + + @GetMapping("ReceiptDetail") + public Map receiptDetail(@RequestParam Integer receiptId) { + return noticeService.receiptDetail(receiptId); + } + + @PostMapping("Review") + public void review(@RequestBody Map body) { + noticeService.review(body); + } + + @PostMapping("Remind") + public Map remind(@RequestParam Integer serid) { + return noticeService.remind(serid); + } + + @GetMapping("ExportReceipts") + public String exportReceipts(@RequestParam Integer serid) { + return noticeService.exportReceipts(serid); + } + + @GetMapping("Statis") + public Map statis(@RequestParam(required = false) String startTime, @RequestParam(required = false) String endTime) { + return noticeService.statis(startTime, endTime); + } + + // ===== 家长端 ===== + + @GetMapping("MyNotices") + public Map myNotices(@RequestParam Map q) { + return noticeService.myNotices(toObj(q)); + } + + @GetMapping("MyNotice") + public Map myNotice(@RequestParam Integer serid) { + return noticeService.myNotice(serid); + } + + @PostMapping("SubmitReceipt") + public Map submitReceipt(@RequestBody Map body) { + return noticeService.submitReceipt(body); + } + + @PostMapping("CancelReceipt") + public void cancelReceipt(@RequestParam Integer serid) { + noticeService.cancelReceipt(serid); + } + + @PostMapping("InitializeRed") + public Map initializeRed() { + return noticeService.initializeRed(); + } + + @GetMapping("AllFileDown") + public void allFileDown(@RequestParam Integer dataid, javax.servlet.http.HttpServletResponse response) { + noticeService.allFileDown(dataid, response); + } + + @PostMapping("MarkRead") + public void markRead(@RequestParam Integer serid, @RequestParam Integer studentId) { + noticeService.markRead(serid, studentId); + } + + @PostMapping("RemindStudent") + public Map remindStudent(@RequestParam Integer serid, @RequestParam Integer studentId) { + return noticeService.remindStudent(serid, studentId); + } + + private static Map toObj(Map src) { + return new HashMap<>(src); + } +} diff --git a/src/main/java/com/yunhai/dormnotice/entity/BaseEntity.java b/src/main/java/com/yunhai/dormnotice/entity/BaseEntity.java new file mode 100644 index 0000000..1804dd5 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/entity/BaseEntity.java @@ -0,0 +1,49 @@ +package com.yunhai.dormnotice.entity; + +import com.yunhai.dormnotice.common.listener.NoticeAuditListener; +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.*; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link NoticeAuditListener} 自动填充)。 + */ +@MappedSuperclass +@Getter +@Setter +@EntityListeners(NoticeAuditListener.class) +public abstract class BaseEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + /** + * 创建人登录账号 + */ + @Column(name = "create_by", updatable = false) + private String createBy; + + /** + * 创建时间 + */ + @Column(name = "create_time", updatable = false) + private LocalDateTime createTime; + + /** + * 更新人登录账号 + */ + @Column(name = "update_by") + private String updateBy; + + /** + * 更新时间 + */ + @Column(name = "update_time") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/yunhai/dormnotice/entity/ContactsGroup.java b/src/main/java/com/yunhai/dormnotice/entity/ContactsGroup.java new file mode 100644 index 0000000..6f17e67 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/entity/ContactsGroup.java @@ -0,0 +1,28 @@ +package com.yunhai.dormnotice.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 联系组。对应原 .NET Contacts_Group 表。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "contacts_group") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class ContactsGroup extends BaseEntity { + + /** 组名称 */ + @Column(name = "group_name", length = 200) + private String groupName; + + /** 创建人用户ID(决策 D4:只存 id,不存账号) */ + @Column(name = "creator_id") + private Integer creatorId; +} diff --git a/src/main/java/com/yunhai/dormnotice/entity/ContactsGroupUser.java b/src/main/java/com/yunhai/dormnotice/entity/ContactsGroupUser.java new file mode 100644 index 0000000..bc41739 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/entity/ContactsGroupUser.java @@ -0,0 +1,35 @@ +package com.yunhai.dormnotice.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 联系组-用户关联。对应原 .NET Contacts_Tea 表。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "contacts_group_user", + uniqueConstraints = @UniqueConstraint(name = "uk_group_user", + columnNames = {"group_id", "user_id"})) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class ContactsGroupUser extends BaseEntity { + + /** 联系组ID */ + @Column(name = "group_id", nullable = false) + private Integer groupId; + + /** 用户ID(基础平台用户ID,决策 D4:身份只存 id) */ + @Column(name = "user_id", nullable = false) + private Integer userId; + + /** 用户姓名快照(仅展示用,可回写;账号不落库) */ + @Column(name = "user_name", length = 64) + private String userName; +} diff --git a/src/main/java/com/yunhai/dormnotice/entity/DormBuilding.java b/src/main/java/com/yunhai/dormnotice/entity/DormBuilding.java new file mode 100644 index 0000000..a605b7a --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/entity/DormBuilding.java @@ -0,0 +1,54 @@ +package com.yunhai.dormnotice.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 宿舍楼栋。房间数/床位数/入住数由房间与住宿生派生,不落库。 + * 对应前端 DormBuilding。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "dorm_building") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class DormBuilding extends BaseEntity { + + @Column(name = "building_name", nullable = false, length = 100) + private String buildingName; + + /** 男 / 女 / 混合 */ + @Column(name = "gender", length = 16) + private String gender; + + @Column(name = "floor_count") + private Integer floorCount = 0; + + /** 每层房间数(批量建房用) */ + @Column(name = "rooms_per_floor") + private Integer roomsPerFloor = 0; + + /** 每间床位数 */ + @Column(name = "beds_per_room") + private Integer bedsPerRoom = 0; + + /** 房间号前缀(如 "1" → roomId 以 1 开头) */ + @Column(name = "room_prefix", length = 16) + private String roomPrefix; + + /** 宿管负责人 */ + @Column(name = "supervisor_name", length = 64) + private String supervisorName; + + @Column(name = "supervisor_phone", length = 32) + private String supervisorPhone; + + /** 多租户隔离 */ + @Column(name = "sch_id") + private Integer schId; +} diff --git a/src/main/java/com/yunhai/dormnotice/entity/DormNoticeRead.java b/src/main/java/com/yunhai/dormnotice/entity/DormNoticeRead.java new file mode 100644 index 0000000..8661d3c --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/entity/DormNoticeRead.java @@ -0,0 +1,37 @@ +package com.yunhai.dormnotice.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.time.LocalDateTime; + +/** + * 通知阅读记录(学生级)。家长=登录学生本人,打开通知即落一条(notice_id + student_id 唯一)。 + * 用于「已读人数」按实际阅读的学生计数(区别于房间级 read_room_ids 的阅读情况分组)。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "dorm_notice_read", + uniqueConstraints = @UniqueConstraint(name = "uk_notice_student", columnNames = {"notice_id", "student_id"})) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class DormNoticeRead extends BaseEntity { + + @Column(name = "notice_id", nullable = false) + private Integer noticeId; + + /** 阅读学生平台用户 id(决策 D4:只存 id) */ + @Column(name = "student_id", nullable = false) + private Integer studentId; + + @Column(name = "room_id") + private Integer roomId; + + @Column(name = "read_at") + private LocalDateTime readAt; + + @Column(name = "sch_id") + private Integer schId; +} diff --git a/src/main/java/com/yunhai/dormnotice/entity/DormRoom.java b/src/main/java/com/yunhai/dormnotice/entity/DormRoom.java new file mode 100644 index 0000000..ff8d462 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/entity/DormRoom.java @@ -0,0 +1,47 @@ +package com.yunhai.dormnotice.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 宿舍房间(通知的接收对象,房间树叶子)。业务主键 roomId = buildingId*10000 + floor*100 + 序号。 + * 对应前端 DormRoom。usedBeds / monitorName 由住宿生派生,不落库。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "dorm_room") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class DormRoom extends BaseEntity { + + /** 业务房间号(全局唯一,接收对象按此匹配) */ + @Column(name = "room_id", nullable = false, unique = true) + private Integer roomId; + + @Column(name = "room_no", nullable = false, length = 32) + private String roomNo; + + @Column(name = "building_id", nullable = false) + private Integer buildingId; + + @Column(name = "floor") + private Integer floor = 0; + + /** 床位总数 */ + @Column(name = "bed_count") + private Integer bedCount = 0; + + @Column(name = "supervisor_name", length = 64) + private String supervisorName; + + @Column(name = "supervisor_phone", length = 32) + private String supervisorPhone; + + @Column(name = "sch_id") + private Integer schId; +} diff --git a/src/main/java/com/yunhai/dormnotice/entity/DormStudent.java b/src/main/java/com/yunhai/dormnotice/entity/DormStudent.java new file mode 100644 index 0000000..6f77128 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/entity/DormStudent.java @@ -0,0 +1,66 @@ +package com.yunhai.dormnotice.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 住宿生(床位安排的最小单位,1 床 1 生)。roomId 为空/0 表示「待安排池」(对应前端 PendingStudent), + * 非空表示在住(对应前端 DormStudent)。studentId 为平台学生数字 id(决策 D4:只存 id,不存账号)。 + * usedBeds 恒等于房内在住记录数(单一数据源,不反向凭空造学生)。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "dorm_student") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class DormStudent extends BaseEntity { + + /** 平台学生数字 id(唯一业务键,家长登录态据此匹配) */ + @Column(name = "student_id", nullable = false) + private Integer studentId; + + @Column(name = "student_name", length = 64) + private String studentName; + + /** 男 / 女 */ + @Column(name = "gender", length = 8) + private String gender; + + /** 文化班 / 武术班 */ + @Column(name = "class_name", length = 64) + private String className; + + /** 所在房间业务号;0/空=待安排 */ + @Column(name = "room_id") + private Integer roomId = 0; + + /** 床位号,如 3号床 */ + @Column(name = "bed_no", length = 16) + private String bedNo; + + @Column(name = "guardian_name", length = 64) + private String guardianName; + + @Column(name = "guardian_phone", length = 32) + private String guardianPhone; + + /** 与学生关系 */ + @Column(name = "relation", length = 16) + private String relation; + + /** 是否宿舍长(每间房至多 1 人) */ + @Column(name = "is_monitor") + private Boolean isMonitor = false; + + /** 待安排来源说明 */ + @Column(name = "source_desc", length = 128) + private String sourceDesc; + + @Column(name = "sch_id") + private Integer schId; +} diff --git a/src/main/java/com/yunhai/dormnotice/entity/Notice.java b/src/main/java/com/yunhai/dormnotice/entity/Notice.java new file mode 100644 index 0000000..d5c0080 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/entity/Notice.java @@ -0,0 +1,112 @@ +package com.yunhai.dormnotice.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import java.time.LocalDateTime; + +/** + * 宿舍通知主表。收件人为「宿舍房间」(receiveList = ALL 或房间 id 逗号串,可含楼栋前缀 xx0000)。 + * 应收/已读人数按覆盖房间的在住住宿生数派生。对应前端 NoticeVo / NoticeDetailVo。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "dorm_notice") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class Notice extends BaseEntity { + + @Column(name = "title", nullable = false, length = 200) + private String title; + + @Column(name = "content", columnDefinition = "LONGTEXT") + private String content; + + /** 通知类型(对应 sys_dict noticeType 的 Name) */ + @Column(name = "notice_type", length = 64) + private String noticeType; + + /** 紧急程度 normal/important/urgent */ + @Column(name = "level", length = 16) + private String level = "normal"; + + /** 生效时间 */ + @Column(name = "effective_time") + private LocalDateTime effectiveTime; + + /** 回执截止时间 */ + @Column(name = "deadline_time") + private LocalDateTime deadlineTime; + + /** 回执方式 none/confirm/fill */ + @Column(name = "receipt_mode", length = 16) + private String receiptMode = "none"; + + /** 单据类型 stay/visit/adjust/general(仅 receiptMode=fill) */ + @Column(name = "form_type", length = 16) + private String formType; + + /** 发布部门 id(sys_dict dept 的 CodeID) */ + @Column(name = "dept_id", length = 64) + private String deptId; + + @Column(name = "dept_name", length = 128) + private String deptName; + + /** 发布人平台用户 id(决策 D4:只存 id,不存账号) */ + @Column(name = "publisher_id") + private Integer publisherId; + + @Column(name = "publisher_name", length = 64) + private String publisherName; + + /** 是否需要宿管到场配合 */ + @Column(name = "need_supervisor") + private Integer needSupervisor = 0; + + /** 流程状态 draft/published/closed/finished */ + @Column(name = "notice_status", nullable = false, length = 16) + private String noticeStatus = "published"; + + @Column(name = "topmost") + private Integer topmost = 0; + + /** 是否撤回 0/1 */ + @Column(name = "is_pulled") + private Integer isPulled = 0; + + /** 定时发送时间 */ + @Column(name = "timing_send_time") + private LocalDateTime timingSendTime; + + /** 短信标记 0/1 */ + @Column(name = "sms_flag") + private Integer smsFlag = 0; + + /** 接收对象类型:room */ + @Column(name = "receive_type", length = 16) + private String receiveType = "room"; + + /** 接收列表:"ALL" 或房间 id 逗号串 */ + @Column(name = "receive_list", columnDefinition = "LONGTEXT") + private String receiveList; + + /** 附件 id 逗号串 */ + @Column(name = "file_ids", length = 2000) + private String fileIds; + + /** 转发/复用源通知 id */ + @Column(name = "source_id") + private Integer sourceId; + + /** 已读房间 id 逗号串(房间级阅读日志;驱动 roomRead / 阅读情况分组 / 家长端 read 标记) */ + @Column(name = "read_room_ids", columnDefinition = "LONGTEXT") + private String readRoomIds; + + @Column(name = "sch_id") + private Integer schId; +} diff --git a/src/main/java/com/yunhai/dormnotice/entity/Receipt.java b/src/main/java/com/yunhai/dormnotice/entity/Receipt.java new file mode 100644 index 0000000..2d96449 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/entity/Receipt.java @@ -0,0 +1,84 @@ +package com.yunhai.dormnotice.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import java.time.LocalDateTime; + +/** + * 回执 / 填报记录(住宿生 × 通知),并兼作阅读日志(read / readAt,见方案 R2 折中)。 + * 仅「需确认 / 需填报」类通知产生记录,草稿不产生。status:none/confirmed/filled/canceled。 + * 对应前端 ReceiptBrief / ReceiptDetailVo / MyReceiptInfo。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "dorm_receipt") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class Receipt extends BaseEntity { + + @Column(name = "notice_id", nullable = false) + private Integer noticeId; + + /** 学生平台用户 id(家长态据此匹配) */ + @Column(name = "student_id", nullable = false) + private Integer studentId; + + @Column(name = "student_name", length = 64) + private String studentName; + + @Column(name = "room_id") + private Integer roomId; + + @Column(name = "room_no", length = 32) + private String roomNo; + + @Column(name = "building_id") + private Integer buildingId; + + @Column(name = "building_name", length = 100) + private String buildingName; + + @Column(name = "bed_no", length = 16) + private String bedNo; + + @Column(name = "class_name", length = 64) + private String className; + + @Column(name = "guardian_name", length = 64) + private String guardianName; + + @Column(name = "guardian_phone", length = 32) + private String guardianPhone; + + @Column(name = "relation", length = 16) + private String relation; + + /** none/confirmed/filled/canceled */ + @Column(name = "status", nullable = false, length = 16) + private String status = "none"; + + /** 是否已读 */ + @Column(name = "read_flag") + private Boolean read = false; + + @Column(name = "read_at") + private LocalDateTime readAt; + + @Column(name = "submitted_at") + private LocalDateTime submittedAt; + + /** 填报内容 JSON(key→值) */ + @Column(name = "fill_data", columnDefinition = "TEXT") + private String fillData; + + @Column(name = "remark", length = 1000) + private String remark; + + @Column(name = "sch_id") + private Integer schId; +} diff --git a/src/main/java/com/yunhai/dormnotice/entity/SysDict.java b/src/main/java/com/yunhai/dormnotice/entity/SysDict.java new file mode 100644 index 0000000..620d93a --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/entity/SysDict.java @@ -0,0 +1,40 @@ +package com.yunhai.dormnotice.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 系统字典(发文部门等)。对应原 .NET Common_DeptDic 表。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "sys_dict") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysDict extends BaseEntity { + + /** 字典类型(如 "dept"=发文部门) */ + @Column(name = "dict_type", length = 50) + private String dictType; + + /** 字典 key(原 CodeID) */ + @Column(name = "dict_key", length = 100) + private String dictKey; + + /** 字典 value(显示名称,原 Name) */ + @Column(name = "dict_value", length = 200) + private String dictValue; + + /** 排序号 */ + @Column(name = "sort_order") + private Integer sortOrder = 0; + + /** 是否启用:0 禁用 / 1 启用(原 Enable) */ + @Column(name = "enable_flag") + private Integer enableFlag = 1; +} diff --git a/src/main/java/com/yunhai/dormnotice/entity/SysOperationLog.java b/src/main/java/com/yunhai/dormnotice/entity/SysOperationLog.java new file mode 100644 index 0000000..da70d1b --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/entity/SysOperationLog.java @@ -0,0 +1,83 @@ +package com.yunhai.dormnotice.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.dormnotice.aspect.OperationLogAspect} 对 {@code @OperationLog} 方法环绕落库。 + * 索引交由 ddl-auto / 建表脚本,实体不重复声明 @Table(indexes) 以免 update 时重复建索引告警。

+ */ +@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; + + /** 业务状态: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/dormnotice/entity/UpFile.java b/src/main/java/com/yunhai/dormnotice/entity/UpFile.java new file mode 100644 index 0000000..ea125db --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/entity/UpFile.java @@ -0,0 +1,47 @@ +package com.yunhai.dormnotice.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 附件文件记录。对应原 .NET Common_UpFiles 表。 + *

+ * 存储文件元信息(真实文件名、存储路径、大小、类型),物理文件存储在 + * application.yml 的 file.upload-path 目录下。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "up_file") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class UpFile extends BaseEntity { + + /** 应用类型(如 "notice"),用于区分不同业务的附件 */ + @Column(name = "topic_type", length = 50) + private String topicType; + + /** 关联主题ID(如通知 serid) */ + @Column(name = "topic_id") + private Integer topicId; + + /** 文件真实名称(用户上传时的原始文件名) */ + @Column(name = "file_true_name", length = 255) + private String fileTrueName; + + /** 文件存储路径(相对路径,如 notice/20260823/uuid.pdf) */ + @Column(name = "file_name", length = 500) + private String fileName; + + /** 文件大小(字节) */ + @Column(name = "file_size") + private Integer fileSize; + + /** 文件后缀名(如 pdf, docx, jpg) */ + @Column(name = "file_type", length = 20) + private String fileType; +} diff --git a/src/main/java/com/yunhai/dormnotice/model/BasePageQuery.java b/src/main/java/com/yunhai/dormnotice/model/BasePageQuery.java new file mode 100644 index 0000000..5ab8b4a --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/model/BasePageQuery.java @@ -0,0 +1,14 @@ +package com.yunhai.dormnotice.model; + +import lombok.Data; + +/** + * 统一分页查询入参。pageIndex 从 1 起。 + */ +@Data +public class BasePageQuery { + private int pageIndex = 1; + private int pageSize = 10; + /** 通用模糊搜索关键词 */ + private String keyword; +} diff --git a/src/main/java/com/yunhai/dormnotice/model/BasePageResult.java b/src/main/java/com/yunhai/dormnotice/model/BasePageResult.java new file mode 100644 index 0000000..fa69d77 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/model/BasePageResult.java @@ -0,0 +1,33 @@ +package com.yunhai.dormnotice.model; + +import lombok.Data; + +import java.util.List; + +/** + * 统一分页响应外壳。 + */ +@Data +public class BasePageResult { + /** 总记录数 */ + private int total; + /** 当前页数据 */ + private List content; + /** 当前页码(从 1 起) */ + private int pageIndex; + /** 每页条数 */ + private int pageSize; + /** 总页数 */ + private int totalPages; + /** 是否还有下一页 */ + private boolean hasNext; + + public BasePageResult(List content, int total, int pageIndex, int pageSize, int totalPages, boolean hasNext) { + this.content = content; + this.total = total; + this.pageIndex = pageIndex; + this.pageSize = pageSize; + this.totalPages = totalPages; + this.hasNext = hasNext; + } +} diff --git a/src/main/java/com/yunhai/dormnotice/model/SchTeaList.java b/src/main/java/com/yunhai/dormnotice/model/SchTeaList.java new file mode 100644 index 0000000..7add3cd --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/model/SchTeaList.java @@ -0,0 +1,106 @@ +package com.yunhai.dormnotice.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * 班级管理接口-教师(bschclass {@code /oapi/DbClass/GetSchteaList} 响应项)。 + * 对应 C# BestSchool.Entity.Model.SchTeaList。 + */ +public class SchTeaList { + + /** 学校 GUID(SchSerID) */ + @JsonProperty("SchSerID") + private String schSerID; + + @JsonProperty("TeaTel") + private String teaTel; + + /** 教师姓名 */ + @JsonProperty("TeaName") + private String teaName; + + /** 教师用户ID */ + @JsonProperty("UserTeaSerID") + private Integer userTeaSerID; + + @JsonProperty("TeaIDCard") + private String teaIDCard; + + /** 教师工号 */ + @JsonProperty("TeaNo") + private String teaNo; + + /** 教师所属组ID列表(兼容旧字段 GroupIds) */ + @JsonProperty("GroupIds") + private List groupIds; + + /** 教师所属组ID(接口现行字段 GroupID,单个) */ + @JsonProperty("GroupID") + private Integer groupId; + + public Integer getGroupId() { + return groupId; + } + + public void setGroupId(Integer groupId) { + this.groupId = groupId; + } + + public String getSchSerID() { + return schSerID; + } + + public void setSchSerID(String schSerID) { + this.schSerID = schSerID; + } + + public String getTeaTel() { + return teaTel; + } + + public void setTeaTel(String teaTel) { + this.teaTel = teaTel; + } + + public String getTeaName() { + return teaName; + } + + public void setTeaName(String teaName) { + this.teaName = teaName; + } + + public Integer getUserTeaSerID() { + return userTeaSerID; + } + + public void setUserTeaSerID(Integer userTeaSerID) { + this.userTeaSerID = userTeaSerID; + } + + public String getTeaIDCard() { + return teaIDCard; + } + + public void setTeaIDCard(String teaIDCard) { + this.teaIDCard = teaIDCard; + } + + public String getTeaNo() { + return teaNo; + } + + public void setTeaNo(String teaNo) { + this.teaNo = teaNo; + } + + public List getGroupIds() { + return groupIds; + } + + public void setGroupIds(List groupIds) { + this.groupIds = groupIds; + } +} diff --git a/src/main/java/com/yunhai/dormnotice/model/bsch/SchClassDto.java b/src/main/java/com/yunhai/dormnotice/model/bsch/SchClassDto.java new file mode 100644 index 0000000..6666dd7 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/model/bsch/SchClassDto.java @@ -0,0 +1,26 @@ +package com.yunhai.dormnotice.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 班级 DTO({@code /oapi/VSchClass/GetVClassList})。学生 ClassID 指向 classId。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchClassDto { + + @JsonProperty("ClassID") + private Integer classId; + + @JsonProperty("ClassName") + private String className; + + /** 年级ID */ + @JsonProperty("Levell") + private Integer gradeId; + + @JsonProperty("sx") + private Integer sort; +} diff --git a/src/main/java/com/yunhai/dormnotice/model/bsch/SchInfoDto.java b/src/main/java/com/yunhai/dormnotice/model/bsch/SchInfoDto.java new file mode 100644 index 0000000..ff979e4 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/model/bsch/SchInfoDto.java @@ -0,0 +1,25 @@ +package com.yunhai.dormnotice.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 学校信息 DTO({@code /oapi/SchInfo/GetUserSchFirst})。 + * 用户中心 schId → bschclass 侧 SchSerId(后续按校查学生/班级用)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchInfoDto { + + /** 学校 GUID(bschclass 侧 SchSerId,字符串) */ + @JsonProperty("SchSerId") + private String schSerId; + + /** 用户中心学校组 ID(与 schId 同域) */ + @JsonProperty("UserSchSerId") + private Integer userSchSerId; + + @JsonProperty("SchName") + private String schName; +} diff --git a/src/main/java/com/yunhai/dormnotice/model/bsch/SchStudentDto.java b/src/main/java/com/yunhai/dormnotice/model/bsch/SchStudentDto.java new file mode 100644 index 0000000..94c38e5 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/model/bsch/SchStudentDto.java @@ -0,0 +1,35 @@ +package com.yunhai.dormnotice.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 学生 DTO({@code /oapi/OAStu/*},SchStudent 视图)。 + * account 仅上游契约字段(兼容反序列化),本系统不落库、不作身份映射; + * 学生身份以 {@code stuId}(UserStuSerID,= 平台用户ID)+ stuName 承载(决策 D4)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchStudentDto { + + /** 学员登录账号(上游契约字段,仅兼容保留,不落库) */ + @JsonProperty("Account") + private String account; + + /** 学生ID(= 平台用户ID,家长登录态即此值) */ + @JsonProperty("UserStuSerID") + private Integer stuId; + + /** 学生姓名 */ + @JsonProperty("StuName") + private String stuName; + + /** 所在班级ID(sch_class.class_id) */ + @JsonProperty("ClassID") + private Integer classId; + + /** 所在组ID */ + @JsonProperty("GroupID") + private Integer groupId; +} diff --git a/src/main/java/com/yunhai/dormnotice/model/notice/InitializeRedVo.java b/src/main/java/com/yunhai/dormnotice/model/notice/InitializeRedVo.java new file mode 100644 index 0000000..3f44b1a --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/model/notice/InitializeRedVo.java @@ -0,0 +1,20 @@ +package com.yunhai.dormnotice.model.notice; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 一键已读结果视图对象(替代原 Map<String,Object> 返回)。 + */ +@Data +public class InitializeRedVo { + + @JsonProperty("count") + private Integer count; + + public InitializeRedVo() {} + + public InitializeRedVo(Integer count) { + this.count = count; + } +} diff --git a/src/main/java/com/yunhai/dormnotice/model/notice/UpFileResultVo.java b/src/main/java/com/yunhai/dormnotice/model/notice/UpFileResultVo.java new file mode 100644 index 0000000..c1c5a12 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/model/notice/UpFileResultVo.java @@ -0,0 +1,30 @@ +package com.yunhai.dormnotice.model.notice; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 文件上传结果视图对象(替代原 Map<String,Object> 返回)。 + * 使用 @JsonProperty 保持旧字段名兼容前端渲染。 + */ +@Data +public class UpFileResultVo { + + @JsonProperty("fileid") + private Integer fileid; + + @JsonProperty("name") + private String name; + + @JsonProperty("fileurl") + private String fileurl; + + @JsonProperty("host") + private String host; + + @JsonProperty("type") + private String type; + + @JsonProperty("size") + private Integer size; +} diff --git a/src/main/java/com/yunhai/dormnotice/model/notice/UpFileVo.java b/src/main/java/com/yunhai/dormnotice/model/notice/UpFileVo.java new file mode 100644 index 0000000..df49cb0 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/model/notice/UpFileVo.java @@ -0,0 +1,31 @@ +package com.yunhai.dormnotice.model.notice; + +import lombok.Data; + +/** + * 附件视图对象(对应原 .NET FileObj)。 + */ +@Data +public class UpFileVo { + + /** 文件记录ID */ + private Integer id; + + /** 文件真实名称 */ + private String fileTrueName; + + /** 文件存储路径(相对路径) */ + private String fileName; + + /** 文件大小(字节) */ + private Integer fileSize; + + /** 文件后缀名 */ + private String fileType; + + /** 关联主题ID */ + private Integer topicId; + + /** 应用类型 */ + private String topicType; +} diff --git a/src/main/java/com/yunhai/dormnotice/model/notice/UserModuleVo.java b/src/main/java/com/yunhai/dormnotice/model/notice/UserModuleVo.java new file mode 100644 index 0000000..8b311b9 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/model/notice/UserModuleVo.java @@ -0,0 +1,122 @@ +package com.yunhai.dormnotice.model.notice; + +import java.util.ArrayList; +import java.util.List; + +/** + * 用户可访问模块(左菜单)视图对象。 + *

将基础平台返回的扁平 {@code List} 转换为前端 layout(参考 UserBase)期望的树结构: + * {@code modId}(路由高亮匹配)/{@code page}(跳转路径)/{@code children}(子菜单)。

+ */ +public class UserModuleVo { + + /** 模块主键 id(基础平台 module id) */ + private Integer id; + + /** 菜单高亮匹配用:等价于后端模块 id */ + private Integer modId; + + /** 模块名称 */ + private String name; + + /** 父级模块 id(0 表示根节点) */ + private Integer parentId; + + /** 前端路由路径(由 BoaModule.path 映射而来) */ + private String page; + + /** 层级(0=根分组) */ + private Integer level; + + /** 菜单图标 */ + private String icon; + + /** 排序号 */ + private Integer orderNumber; + + /** 是否可用 */ + private Boolean usable; + + /** 子菜单 */ + private List children = new ArrayList<>(); + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getModId() { + return modId; + } + + public void setModId(Integer modId) { + this.modId = modId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getParentId() { + return parentId; + } + + public void setParentId(Integer parentId) { + this.parentId = parentId; + } + + public String getPage() { + return page; + } + + public void setPage(String page) { + this.page = page; + } + + public Integer getLevel() { + return level; + } + + public void setLevel(Integer level) { + this.level = level; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public Integer getOrderNumber() { + return orderNumber; + } + + public void setOrderNumber(Integer orderNumber) { + this.orderNumber = orderNumber; + } + + public Boolean getUsable() { + return usable; + } + + public void setUsable(Boolean usable) { + this.usable = usable; + } + + public List getChildren() { + return children; + } + + public void setChildren(List children) { + this.children = children; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/dormnotice/repository/ContactsGroupRepository.java b/src/main/java/com/yunhai/dormnotice/repository/ContactsGroupRepository.java new file mode 100644 index 0000000..fceb411 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/repository/ContactsGroupRepository.java @@ -0,0 +1,27 @@ +package com.yunhai.dormnotice.repository; + +import com.yunhai.dormnotice.entity.ContactsGroup; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ContactsGroupRepository extends JpaRepository { + Page findAllByOrderByCreateTimeDesc(Pageable pageable); + + /** + * 按创建人用户ID查询联系组列表。 + */ + List findByCreatorId(Integer creatorId); + + /** + * 按创建人用户ID查询联系组列表(分页)。 + */ + Page findByCreatorId(Integer creatorId, Pageable pageable); + + /** + * 查询全部联系组(按创建时间降序)。 + */ + List findAllByOrderByCreateTimeDesc(); +} diff --git a/src/main/java/com/yunhai/dormnotice/repository/ContactsGroupUserRepository.java b/src/main/java/com/yunhai/dormnotice/repository/ContactsGroupUserRepository.java new file mode 100644 index 0000000..411aff0 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/repository/ContactsGroupUserRepository.java @@ -0,0 +1,42 @@ +package com.yunhai.dormnotice.repository; + +import com.yunhai.dormnotice.entity.ContactsGroupUser; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ContactsGroupUserRepository extends JpaRepository { + Page findByGroupId(Integer groupId, Pageable pageable); + List findByGroupId(Integer groupId); + + /** + * 按组ID批量删除成员(JPQL bulk delete,SQL 立即执行)。 + * 注意:不能用 derived delete——其 DELETE 延迟到 flush 才执行, + * 同事务内先 insert 会触发 uk_group_user 唯一约束冲突。 + */ + @Modifying(clearAutomatically = true) + @Query("delete from ContactsGroupUser g where g.groupId = :groupId") + void deleteByGroupIdBulk(@Param("groupId") Integer groupId); + + /** + * 按多个组ID批量删除成员。 + */ + @Modifying(clearAutomatically = true) + @Query("delete from ContactsGroupUser g where g.groupId in :groupIds") + void deleteByGroupIdInBulk(@Param("groupIds") List groupIds); + + /** + * 按多个组ID查询所有成员(用于统计每组人数、组树构建)。 + */ + List findByGroupIdIn(List groupIds); + + /** + * 按组ID和用户ID查询(判断是否已存在)。 + */ + boolean existsByGroupIdAndUserId(Integer groupId, Integer userId); +} diff --git a/src/main/java/com/yunhai/dormnotice/repository/DormBuildingRepository.java b/src/main/java/com/yunhai/dormnotice/repository/DormBuildingRepository.java new file mode 100644 index 0000000..d6327fa --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/repository/DormBuildingRepository.java @@ -0,0 +1,12 @@ +package com.yunhai.dormnotice.repository; + +import com.yunhai.dormnotice.entity.DormBuilding; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface DormBuildingRepository extends JpaRepository { + List findAllByOrderByIdAsc(); + + List findBySchIdOrderByIdAsc(Integer schId); +} diff --git a/src/main/java/com/yunhai/dormnotice/repository/DormNoticeReadRepository.java b/src/main/java/com/yunhai/dormnotice/repository/DormNoticeReadRepository.java new file mode 100644 index 0000000..0401cc2 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/repository/DormNoticeReadRepository.java @@ -0,0 +1,18 @@ +package com.yunhai.dormnotice.repository; + +import com.yunhai.dormnotice.entity.DormNoticeRead; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface DormNoticeReadRepository extends JpaRepository { + long countByNoticeId(Integer noticeId); + + boolean existsByNoticeIdAndStudentId(Integer noticeId, Integer studentId); + + List findByNoticeId(Integer noticeId); + + List findByNoticeIdAndStudentIdIn(Integer noticeId, List studentIds); + + void deleteByNoticeId(Integer noticeId); +} diff --git a/src/main/java/com/yunhai/dormnotice/repository/DormRoomRepository.java b/src/main/java/com/yunhai/dormnotice/repository/DormRoomRepository.java new file mode 100644 index 0000000..dca3e65 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/repository/DormRoomRepository.java @@ -0,0 +1,19 @@ +package com.yunhai.dormnotice.repository; + +import com.yunhai.dormnotice.entity.DormRoom; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface DormRoomRepository extends JpaRepository { + Optional findByRoomId(Integer roomId); + + List findByRoomIdIn(List roomIds); + + List findByBuildingIdOrderByIdAsc(Integer buildingId); + + List findAllByOrderByIdAsc(); + + long countByBuildingId(Integer buildingId); +} diff --git a/src/main/java/com/yunhai/dormnotice/repository/DormStudentRepository.java b/src/main/java/com/yunhai/dormnotice/repository/DormStudentRepository.java new file mode 100644 index 0000000..04df1b7 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/repository/DormStudentRepository.java @@ -0,0 +1,22 @@ +package com.yunhai.dormnotice.repository; + +import com.yunhai.dormnotice.entity.DormStudent; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface DormStudentRepository extends JpaRepository { + /** 在住:roomId>0;待安排池:roomId=0 */ + List findByRoomIdOrderByIdAsc(Integer roomId); + + List findByRoomIdIn(List roomIds); + + Optional findByStudentId(Integer studentId); + + List findByStudentIdIn(List studentIds); + + long countByRoomId(Integer roomId); + + List findByRoomIdAndIsMonitorTrue(Integer roomId); +} diff --git a/src/main/java/com/yunhai/dormnotice/repository/NoticeRepository.java b/src/main/java/com/yunhai/dormnotice/repository/NoticeRepository.java new file mode 100644 index 0000000..92affa9 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/repository/NoticeRepository.java @@ -0,0 +1,13 @@ +package com.yunhai.dormnotice.repository; + +import com.yunhai.dormnotice.entity.Notice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import java.util.List; + +public interface NoticeRepository extends JpaRepository, JpaSpecificationExecutor { + List findByNoticeStatusIn(List statuses); + + List findByPublisherId(Integer publisherId); +} diff --git a/src/main/java/com/yunhai/dormnotice/repository/ReceiptRepository.java b/src/main/java/com/yunhai/dormnotice/repository/ReceiptRepository.java new file mode 100644 index 0000000..91ec53e --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/repository/ReceiptRepository.java @@ -0,0 +1,25 @@ +package com.yunhai.dormnotice.repository; + +import com.yunhai.dormnotice.entity.Receipt; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ReceiptRepository extends JpaRepository { + List findByNoticeId(Integer noticeId); + + List findByNoticeIdIn(List noticeIds); + + Optional findByNoticeIdAndStudentId(Integer noticeId, Integer studentId); + + List findByNoticeIdAndRoomId(Integer noticeId, Integer roomId); + + List findByNoticeIdAndStatus(Integer noticeId, String status); + + List findByStatusIn(List statuses); + + List findByStudentId(Integer studentId); + + long countByNoticeIdAndStatus(Integer noticeId, String status); +} diff --git a/src/main/java/com/yunhai/dormnotice/repository/SysDictRepository.java b/src/main/java/com/yunhai/dormnotice/repository/SysDictRepository.java new file mode 100644 index 0000000..dceff6b --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/repository/SysDictRepository.java @@ -0,0 +1,15 @@ +package com.yunhai.dormnotice.repository; + +import com.yunhai.dormnotice.entity.SysDict; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface SysDictRepository extends JpaRepository { + List findByDictTypeOrderBySortOrderAsc(String dictType); + List findByDictTypeAndEnableFlagOrderBySortOrderAsc(String dictType, Integer enableFlag); + + /** 按类型+key 取单条字典(发文部门名称解析用) */ + Optional findByDictTypeAndDictKey(String dictType, String dictKey); +} diff --git a/src/main/java/com/yunhai/dormnotice/repository/SysOperationLogRepository.java b/src/main/java/com/yunhai/dormnotice/repository/SysOperationLogRepository.java new file mode 100644 index 0000000..5a5d0a0 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/repository/SysOperationLogRepository.java @@ -0,0 +1,10 @@ +package com.yunhai.dormnotice.repository; + +import com.yunhai.dormnotice.entity.SysOperationLog; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * 操作日志仓储。 + */ +public interface SysOperationLogRepository extends JpaRepository { +} diff --git a/src/main/java/com/yunhai/dormnotice/repository/UpFileRepository.java b/src/main/java/com/yunhai/dormnotice/repository/UpFileRepository.java new file mode 100644 index 0000000..70c605e --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/repository/UpFileRepository.java @@ -0,0 +1,26 @@ +package com.yunhai.dormnotice.repository; + +import com.yunhai.dormnotice.entity.UpFile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +/** + * 附件文件仓储。 + */ +public interface UpFileRepository extends JpaRepository { + + /** + * 按主题类型和主题ID查询附件列表。 + */ + List findByTopicTypeAndTopicId(String topicType, Integer topicId); + + /** + * 按主题类型和多个主题ID批量查询附件(消除 N+1)。 + */ + List findByTopicTypeAndTopicIdIn(String topicType, List topicIds); + + /** + * 按主题类型和主题ID删除附件(通知删除时级联清理)。 + */ + void deleteByTopicTypeAndTopicId(String topicType, Integer topicId); +} diff --git a/src/main/java/com/yunhai/dormnotice/service/IClassService.java b/src/main/java/com/yunhai/dormnotice/service/IClassService.java new file mode 100644 index 0000000..6bc138c --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/service/IClassService.java @@ -0,0 +1,31 @@ +package com.yunhai.dormnotice.service; + +import com.yunhai.dormnotice.model.SchTeaList; +import com.yunhai.dormnotice.model.bsch.SchClassDto; +import com.yunhai.dormnotice.model.bsch.SchInfoDto; +import com.yunhai.dormnotice.model.bsch.SchStudentDto; + +import java.util.List; + +/** + * 班级管理(bschclass)开放接口客户端门面({@code /oapi/**} 免签,独立 RestTemplate 直连)。 + *

参照 ClassHomeWork 的 ClassService:教师名单 + 学生名单。学生身份以 {@code UserStuSerID} + * (= 平台用户 id)承载(决策 D4:只认 id 不认账号)。schId→SchSerId 经 {@link #getSchFirst} 桥接。

+ */ +public interface IClassService { + + /** 全部教师:GET {bschclass}/oapi/DbClass/GetSchteaList?Group={schId} */ + List getSchTeaList(int schId); + + /** 用户中心 schId → bschclass 学校信息(含 SchSerId):GET /oapi/SchInfo/GetUserSchFirst?UserSchID= */ + SchInfoDto getSchFirst(int schId); + + /** 全校学生(待安排池/圈人数据源):GET /oapi/OAStu/GetSchStulist?schserid={schSerId} */ + List getSchStuList(String schSerId); + + /** 按学生 id 批量查:GET /oapi/OAStu/GetStuIdslist?stuids=1,2,3 */ + List getStuIdsList(List stuIds); + + /** 全校班级(classId→className 映射):GET /oapi/VSchClass/GetVClassList?SchSerID= */ + List getSchClassList(String schSerId); +} diff --git a/src/main/java/com/yunhai/dormnotice/service/IContactsService.java b/src/main/java/com/yunhai/dormnotice/service/IContactsService.java new file mode 100644 index 0000000..bc8468f --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/service/IContactsService.java @@ -0,0 +1,79 @@ +package com.yunhai.dormnotice.service; + +import java.util.List; +import java.util.Map; + +/** + * 联系组业务接口。 + * 对应前端 Contacts 组接口:AddGroup/GetGroup/EditGroup/DelGroup/SetContactsTea/GetContactsTea/GetGroupTree/GetDeptRoleGroupTree/Import/Export。 + */ +public interface IContactsService { + + /** + * 添加联系组。 + * + * @param name 组名 + * @param type 类型(1 自定义组, 2 明信片) + */ + void addGroup(String name, Integer type); + + /** + * 获取联系组列表(含每组人数,分页)。 + * + * @param page 页码(从1起) + * @param count 每页条数 + * @param type 类型(1 自定义组, 2 明信片) + * @return Map: { list, total } + */ + Map getGroup(Integer page, Integer count, Integer type); + + /** + * 编辑联系组。 + * + * @param id 组ID + * @param name 组名 + * @param type 类型 + */ + void editGroup(Integer id, String name, Integer type); + + /** + * 删除联系组(支持批量,同时删除组内成员)。 + * + * @param ids 逗号分隔的组ID + */ + void delGroup(String ids); + + /** + * 设置联系组教师(全量替换:先删后增)。 + * + * @param gid 组ID + * @param ids 逗号分隔的用户ID + */ + void setContactsTea(Integer gid, String ids); + + /** + * 获取联系组教师列表(分页)。 + * + * @param gid 组ID + * @param page 页码 + * @param count 每页条数 + * @return Map: { list, total } + */ + Map getContactsTea(String gid, Integer page, Integer count); + + /** + * 获取联系组树(组节点+用户节点,递归构建)。 + * + * @param type 类型(1 自定义组, 2 明信片) + * @return 树形结构 List<Map> + */ + List> getGroupTree(Integer type); + + /** + * 获取部门角色组树。 + * C# 从外部API获取部门树+用户角色映射。Java版预留。 + * + * @return 树形结构 List<Map> + */ + List> getDeptRoleGroupTree(); +} diff --git a/src/main/java/com/yunhai/dormnotice/service/IDictService.java b/src/main/java/com/yunhai/dormnotice/service/IDictService.java new file mode 100644 index 0000000..52a754b --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/service/IDictService.java @@ -0,0 +1,35 @@ +package com.yunhai.dormnotice.service; + +import java.util.List; +import java.util.Map; + +/** + * 系统字典业务接口(宿舍通知版)。对应前端 dict 组:List / ManageList / Add / Update / Delete / Sort / Sync。 + * 字典分两类:{@code dept} 发布部门、{@code noticeType} 通知类型,均落 sys_dict 表(dict_type 区分)。 + */ +public interface IDictService { + + /** 下拉:type=dept → {k:CodeID,v:Name};type=noticeType → {k:Name,v:Name};仅启用项。 */ + List> list(String type); + + /** 管理列表:{SerID,CodeID,Name,Enable}。 */ + List> manageList(String type); + + /** 新增字典项(noticeType 重名校验)。 */ + void add(String name, String type); + + /** 更新字典项(名称、启用状态)。 */ + void update(Integer serId, String name, Integer enable, String type); + + /** 删除字典项。 */ + void delete(Integer serId, String type); + + /** 排序:ids 逗号串按序落 sortOrder。 */ + void sort(String ids, String type); + + /** 是否开通短信(占位,默认 false)。 */ + Boolean isSmsEnabled(); + + /** 按 dept 字典 CodeID 解析部门名称;未命中返回 null。 */ + String resolveDeptName(String deptId); +} diff --git a/src/main/java/com/yunhai/dormnotice/service/IDormService.java b/src/main/java/com/yunhai/dormnotice/service/IDormService.java new file mode 100644 index 0000000..8e7c652 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/service/IDormService.java @@ -0,0 +1,46 @@ +package com.yunhai.dormnotice.service; + +import java.util.List; +import java.util.Map; + +/** + * 宿舍设置业务(楼栋 / 房间 / 床位安排),对应前端 dormApi(/api/dorm/*)。 + * 读接口返回与 mock 对齐的 Map 结构(派生字段 roomCount/bedCount/usedBeds 实时算,不落库)。 + * 决策 D4:学生只认 studentId(平台用户 id),不存账号。 + */ +public interface IDormService { + + List> buildingList(); + + List> roomList(Integer buildingId, String keyword); + + void addBuilding(Map data); + + void editBuilding(Map data); + + void delBuilding(Integer buildingId); + + void addRoom(Map data); + + void editRoom(Map data); + + void delRoom(Integer roomId); + + List> roomStudents(Integer roomId); + + List> pendingStudents(String keyword, String gender); + + void assignStudent(Integer roomId, List studentIds); + + void removeStudent(Integer roomId, Integer studentId); + + Map transferStudent(Integer studentId, Integer fromRoomId, Integer toRoomId); + + void setMonitor(Integer roomId, Integer studentId); + + /** 楼栋→楼层→房间树(ztree,供发布选房间;对应 base/GetDormTree)。 */ + List> dormTree(); + + /** 从 bschclass 学籍导入全校学生到「待安排池」(roomId=0),幂等;返回新增人数。 */ + int importStudentsFromBschclass(); +} diff --git a/src/main/java/com/yunhai/dormnotice/service/IFileService.java b/src/main/java/com/yunhai/dormnotice/service/IFileService.java new file mode 100644 index 0000000..c9d2e07 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/service/IFileService.java @@ -0,0 +1,99 @@ +package com.yunhai.dormnotice.service; + +import com.yunhai.dormnotice.entity.UpFile; +import com.yunhai.dormnotice.model.notice.UpFileVo; +import com.yunhai.dormnotice.model.notice.UpFileResultVo; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 文件上传/下载/删除业务接口。 + */ +public interface IFileService { + /** + * 上传单个文件。 + * + * @param file 上传的文件 + * @param appType 应用类型(如 "notice") + * @param topicId 关联主题ID(可为空,通知保存后再绑定) + * @return 文件视图对象 + */ + UpFileVo upload(MultipartFile file, String appType, Integer topicId); + + /** + * 将已上传但未绑定主题的附件绑定到指定通知。 + * + * @param fileIds 文件记录ID列表 + * @param topicId 通知ID + */ + void bindToTopic(List fileIds, Integer topicId); + + /** + * 查询某主题下的附件列表。 + * + * @param appType 应用类型 + * @param topicId 主题ID + * @return 附件列表 + */ + List listByTopic(String appType, Integer topicId); + + /** + * 下载文件。 + * + * @param fileId 文件记录ID + * @param response HTTP 响应 + */ + void download(Integer fileId, HttpServletResponse response); + + /** + * 在线预览文件(Content-Disposition: inline,按扩展名输出真实 Content-Type, + * 供前端 open-file-viewer 取 Blob / video 播放)。 + * + * @param fileId 文件记录ID + * @param response HTTP 响应 + */ + void preview(Integer fileId, HttpServletResponse response); + + /** + * 删除文件(同时删除物理文件和数据库记录)。 + * + * @param fileId 文件记录ID + */ + void delete(Integer fileId); + + /** + * 打包下载多个附件为 ZIP。 + * + * @param files 附件记录列表(调用方按 fileIds 查好) + * @param zipName 压缩包显示名(自动追加 .zip) + * @param response HTTP 响应 + */ + void downloadZip(List files, String zipName, HttpServletResponse response); + + /** + * 按主题类型和主题ID打包下载附件为 ZIP(Controller 便捷方法,内部自行查附件列表)。 + * + * @param topicType 主题类型(如 "notice") + * @param topicId 主题ID + * @param zipName 压缩包显示名 + * @param response HTTP 响应 + */ + void downloadZipByTopic(String topicType, Integer topicId, String zipName, HttpServletResponse response); + + /** + * 按附件ID集合打包下载为 ZIP(通知 AllFileDown 用:通知存的是 fileIds 逗号串)。 + */ + void downloadZipByIds(List fileIds, String zipName, HttpServletResponse response); + + /** + * 批量上传图片(循环上传,返回每个文件的视图对象列表)。 + */ + List uploadImages(MultipartFile[] files, String appType); + + /** + * 上传单个文件并返回前端兼容的结果 VO(含 fileid/name/fileurl/host/type/size)。 + */ + UpFileResultVo uploadToResult(MultipartFile file, String appType); +} diff --git a/src/main/java/com/yunhai/dormnotice/service/INoticeService.java b/src/main/java/com/yunhai/dormnotice/service/INoticeService.java new file mode 100644 index 0000000..6307d98 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/service/INoticeService.java @@ -0,0 +1,83 @@ +package com.yunhai.dormnotice.service; + +import java.util.List; +import java.util.Map; + +/** + * 宿舍通知业务(通知 / 回执 / 家长端),对应前端 noticeApi(/api/notice/*)。 + * 读接口返回与 mock toVo/toDetail/toParentVo 对齐的 Map(详情保留旧字段名 serid/Contents/DeptID…)。 + * 身份(决策 D4 + 本轮澄清):家长端无独立家长账号,家长=登录学生本人,student_id = UserContext.getUserId()。 + */ +public interface INoticeService { + + /** 家长端收件箱(分页 + unreadCount)。 */ + Map list(Map query); + + /** 我发布的(scope=user) / 全部通知(分页)。 */ + Map sendList(Map query); + + /** 查看详情(type=1 标读家长本房间)。 */ + Map detail(Integer serid, Integer type); + + /** 编辑回显(不标读)。 */ + Map getDetail(Integer serid); + + /** 上一篇/下一篇。 */ + Map nextPrev(Integer serid, String type); + + /** 阅读情况:按楼栋分组,叶子=房间(read/unread)。 */ + List> readDetail(Integer serid); + + /** 统一回执单模板清单。 */ + List> formTemplates(); + + Integer create(Map body); + + void edit(Map body); + + void delete(List ids); + + void userPull(Integer serid); + + void setTopmost(Integer serid, Integer topmost); + + Map expedite(Integer serid); + + void receiveRooms(Integer serid, String rooms); + + /** 某通知回执名单(按房间分组)+ 汇总。status: all/confirmed/filled/canceled/none。 */ + Map receipts(Integer serid, String status); + + Map receiptDetail(Integer receiptId); + + void review(Map body); + + Map remind(Integer serid); + + /** 导出回执名单 CSV(含 BOM)。 */ + String exportReceipts(Integer serid); + + Map statis(String startTime, String endTime); + + /** 家长端:本生所在房间可见通知(分桶计数 + list)。 */ + Map myNotices(Map query); + + /** 家长端:某通知详情 + 我的回执 + 填报字段。 */ + Map myNotice(Integer serid); + + Map submitReceipt(Map body); + + void cancelReceipt(Integer serid); + + /** 家长端一键已读本房间可见的未读通知。 */ + Map initializeRed(); + + /** 某通知全部附件打包下载为 ZIP(对应 GET /notice/AllFileDown?dataid=)。 */ + void allFileDown(Integer serid, javax.servlet.http.HttpServletResponse response); + + /** 管理员代标记某学生已读该通知(写入学生级阅读记录)。 */ + void markRead(Integer serid, Integer studentId); + + /** 向某学生的家长催办该通知(短信通道未接入时为记录/回执占位)。 */ + Map remindStudent(Integer serid, Integer studentId); +} diff --git a/src/main/java/com/yunhai/dormnotice/service/IUserCenterService.java b/src/main/java/com/yunhai/dormnotice/service/IUserCenterService.java new file mode 100644 index 0000000..798a09c --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/service/IUserCenterService.java @@ -0,0 +1,51 @@ +package com.yunhai.dormnotice.service; + +import java.util.List; +import java.util.Map; + +/** + * 用户中心门面服务(对齐 UserBase {@code UserCenterService} 模式)。 + *

封装 {@code YschBaseOpenApi} / {@code OpenApiUserClient} 静态门面调用, + * 把原本散落在 Controller 的组树组装、教师挂组、成员解析等业务逻辑下沉到 Service 层, + * Controller 只保留"取参 → 调 Service → 返回"的薄壳。

+ */ +public interface IUserCenterService { + + /** + * 获取当前用户学校组及用户(树形结构)。 + *

组装规则(从原 BaseApiController.getSchoolAll 下沉):

+ *
    + *
  1. userbase 读取当前学校整棵组树(getGroupById(schId, withSub=true)),剔除学生组(type=8)及其子组;
  2. + *
  3. bschclass 获取该校全部教师(/oapi/DbClass/GetSchteaList?Group=schId),按 GroupIds 挂到对应组下, + * 无有效组(如所属组全被剔除)的教师兜底挂学校根节点;
  4. + *
  5. 扁平节点按 pid 递归组装为树(空组剔除,同 C# FormatUserTree)。
  6. + *
+ * + * @param schId 学校ID(用户当前角色所属学校) + * @return 用户树形结构(组节点 type=1 / 教师节点 type=2) + */ + List> getSchoolAllTree(Integer schId); + + /** + * 获取指定组内用户(教师 + 管理员)。 + *

对应前端 base/GetGroupUser。原实现为空桩返回空列表,现按 C# 语义 + * (ITeainfoBLL.GetGroupTeainfo + IAdminfoBLL.GetGroupAdminfo)接入用户中心。 + * 若用户中心不可用或未配置对应开放接口,则抛出带语义的异常(显式报错而非静默失能)。

+ * + * @param gid 组ID + * @param role 角色(默认 2=教师;1=管理员等) + * @return 组内用户列表 + */ + List> getGroupUser(Integer gid, Integer role); + + /** + * 获取当前用户可访问的模块树(左侧菜单)。 + *

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

+ * + * @param userId 用户ID + * @param schId 学校ID(可为 null) + * @return 模块树(顶层 List,含 children 嵌套) + */ + List> getUserModuleTree(Integer userId, Integer schId); +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/dormnotice/service/impl/ClassService.java b/src/main/java/com/yunhai/dormnotice/service/impl/ClassService.java new file mode 100644 index 0000000..b3f72a9 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/service/impl/ClassService.java @@ -0,0 +1,123 @@ +package com.yunhai.dormnotice.service.impl; + +import com.yunhai.dormnotice.model.SchTeaList; +import com.yunhai.dormnotice.model.bsch.SchClassDto; +import com.yunhai.dormnotice.model.bsch.SchInfoDto; +import com.yunhai.dormnotice.model.bsch.SchStudentDto; +import com.yunhai.dormnotice.service.IClassService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * bschclass 开放接口实现(教师 + 学生名单)。参照 ClassHomeWork 同名服务。 + */ +@Service +public class ClassService implements IClassService { + + private static final Logger log = LoggerFactory.getLogger(ClassService.class); + + /** 班级管理服务基址,如 http://base.yunhaixingtong.com/bschclass */ + @Value("${yh.open.bschclass:}") + private String bschClassHost; + + private final RestTemplate restTemplate; + + public ClassService() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(10000); + this.restTemplate = new RestTemplate(factory); + } + + @Override + public List getSchTeaList(int schId) { + return exchangeList(bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId, + new ParameterizedTypeReference>() { + }, "获取教师数据", "schId=" + schId); + } + + @Override + public SchInfoDto getSchFirst(int schId) { + requireHost(); + String url = bschClassHost + "/oapi/SchInfo/GetUserSchFirst?UserSchID=" + schId; + try { + ResponseEntity resp = restTemplate.getForEntity(URI.create(url), SchInfoDto.class); + return resp.getBody(); + } catch (RestClientException e) { + log.error("[getSchFirst] 请求 bschclass 失败, url={}", url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取学校信息失败"); + } + } + + @Override + public List getSchStuList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/OAStu/GetSchStulist?schserid=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学校学生", "schSerId=" + schSerId); + } + + @Override + public List getStuIdsList(List stuIds) { + String csv = joinIds(stuIds); + return exchangeList(bschClassHost + "/oapi/OAStu/GetStuIdslist?stuids=" + csv, + new ParameterizedTypeReference>() { + }, "获取指定学生", "stuIds=" + csv); + } + + @Override + public List getSchClassList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/VSchClass/GetVClassList?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取班级列表", "schSerId=" + schSerId); + } + + // ============================ 通用调用 ============================ + + private List exchangeList(String url, ParameterizedTypeReference> type, + String action, String arg) { + requireHost(); + try { + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + ResponseEntity> resp = restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), type); + List list = resp.getBody(); + log.info("[bschclass] {} 完成, {} 返回 {} 条", action, arg, list == null ? 0 : list.size()); + return list != null ? list : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[bschclass] {} 失败, {}, url={}", action, arg, url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, action + "失败"); + } + } + + private void requireHost() { + if (!StringUtils.hasText(bschClassHost)) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)"); + } + } + + private String joinIds(List ids) { + if (ids == null || ids.isEmpty()) { + return ""; + } + return ids.stream().map(String::valueOf).collect(Collectors.joining(",")); + } +} diff --git a/src/main/java/com/yunhai/dormnotice/service/impl/ContactsService.java b/src/main/java/com/yunhai/dormnotice/service/impl/ContactsService.java new file mode 100644 index 0000000..b988b5c --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/service/impl/ContactsService.java @@ -0,0 +1,403 @@ +package com.yunhai.dormnotice.service.impl; + +import com.yunhai.dormnotice.entity.ContactsGroup; +import com.yunhai.dormnotice.entity.ContactsGroupUser; +import com.yunhai.dormnotice.repository.ContactsGroupRepository; +import com.yunhai.dormnotice.repository.ContactsGroupUserRepository; +import com.yunhai.dormnotice.service.IContactsService; +import com.yunhai.dormnotice.utils.UserContext; +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.BoaUser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 联系组服务实现。 + * 对应 C# ContactsController + ManageContacts.cs。 + */ +@Service +public class ContactsService implements IContactsService { + + private static final Logger log = LoggerFactory.getLogger(ContactsService.class); + + @Autowired + private ContactsGroupRepository groupRepository; + + @Autowired + private ContactsGroupUserRepository groupUserRepository; + + // ============================ AddGroup ============================ + + @Override + @Transactional + public void addGroup(String name, Integer type) { + if (!StringUtils.hasText(name)) { + throw new BizException(ErrorCode.PARAM_ERROR, "请输入自定义组的名字"); + } + if (type == null) { + type = 1; + } + Integer uid = currentUserId(); + + // 查重:同创建人下同名同类型组不重复 + List existing = groupRepository.findByCreatorId(uid); + boolean duplicate = existing.stream().anyMatch(g -> + name.equals(g.getGroupName())); + if (duplicate) { + throw new BizException(ErrorCode.FORBIDDEN, "已存在该联系组!"); + } + + ContactsGroup group = new ContactsGroup(); + group.setGroupName(name); + group.setCreatorId(uid); + groupRepository.save(group); + } + + // ============================ GetGroup ============================ + + @Override + public Map getGroup(Integer page, Integer count, Integer type) { + if (type == null) { + type = 1; + } + Integer uid = currentUserId(); + + // 查询当前用户创建的全部联系组 + List allGroups = groupRepository.findByCreatorId(uid); + + // 统计每组人数 + List groupIds = allGroups.stream().map(ContactsGroup::getId).collect(Collectors.toList()); + List allMembers = groupIds.isEmpty() ? Collections.emptyList() + : groupUserRepository.findByGroupIdIn(groupIds); + Map countByGroup = allMembers.stream() + .collect(Collectors.groupingBy(ContactsGroupUser::getGroupId, Collectors.counting())); + + // 构建列表数据 + List> listData = allGroups.stream().map(g -> { + Map item = new LinkedHashMap<>(); + item.put("serid", g.getId()); + item.put("GroupName", g.getGroupName()); + item.put("count", countByGroup.getOrDefault(g.getId(), 0L).intValue()); + return item; + }).collect(Collectors.toList()); + + // 分页 + int total = listData.size(); + if (count != null && count > 0 && page != null && page > 0) { + int fromIndex = (page - 1) * count; + int toIndex = Math.min(fromIndex + count, total); + if (fromIndex < total) { + listData = listData.subList(fromIndex, toIndex); + } else { + listData = Collections.emptyList(); + } + } + + Map result = new LinkedHashMap<>(); + result.put("list", listData); + result.put("total", total); + return result; + } + + // ============================ EditGroup ============================ + + @Override + @Transactional + public void editGroup(Integer id, String name, Integer type) { + if (!StringUtils.hasText(name)) { + throw new BizException(ErrorCode.PARAM_ERROR, "联系组名称不能为空"); + } + if (id == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!"); + } + Integer uid = currentUserId(); + + // 查重(排除自身) + List existing = groupRepository.findByCreatorId(uid); + boolean duplicate = existing.stream().anyMatch(g -> + !g.getId().equals(id) && name.equals(g.getGroupName())); + if (duplicate) { + throw new BizException(ErrorCode.FORBIDDEN, "已存在该联系组"); + } + + ContactsGroup group = groupRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "联系组不存在")); + group.setGroupName(name); + groupRepository.save(group); + } + + // ============================ DelGroup ============================ + + @Override + @Transactional + public void delGroup(String ids) { + if (!StringUtils.hasText(ids)) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误,删除失败"); + } + List idList = Arrays.stream(ids.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Integer::parseInt) + .collect(Collectors.toList()); + + // 先删成员再删组 + groupUserRepository.deleteByGroupIdInBulk(idList); + groupRepository.findAllById(idList).forEach(groupRepository::delete); + } + + // ============================ SetContactsTea ============================ + + @Override + @Transactional + public void setContactsTea(Integer gid, String ids) { + if (gid == null || gid == 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + // 全量替换:bulk delete 立即执行 SQL,避免 derived delete 延迟 flush + // 导致同事务 insert 先行触发 uk_group_user(参考 PartyLearn GroupService.memberEdit) + groupUserRepository.deleteByGroupIdBulk(gid); + + if (StringUtils.hasText(ids)) { + List userIds = Arrays.stream(ids.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Integer::parseInt) + .distinct() + .collect(Collectors.toList()); + for (Integer userId : userIds) { + ContactsGroupUser member = new ContactsGroupUser(); + member.setGroupId(gid); + member.setUserId(userId); + // 姓名/账号快照落库(对齐 PartyLearn memberEdit 保存 userName 的做法), + // 查询时 fillUserInfo 仅对缺失值兜底,避免组树/分页反复远程调用 + try { + BoaUser user = YschBaseOpenApi.getUserInfo(userId); + if (user != null) { + member.setUserName(user.getName()); + } + } catch (Exception e) { + log.warn("[setContactsTea] 查询用户信息失败, userId={}", userId, e); + } + groupUserRepository.save(member); + } + } + } + + // ============================ GetContactsTea ============================ + + @Override + public Map getContactsTea(String gid, Integer page, Integer count) { + Integer groupId = 0; + try { + groupId = Integer.parseInt(gid); + } catch (NumberFormatException e) { + groupId = 0; + } + if (groupId == 0) { + Map empty = new LinkedHashMap<>(); + empty.put("list", Collections.emptyList()); + empty.put("total", 0); + return empty; + } + + List allMembers = groupUserRepository.findByGroupId(groupId); + int total = allMembers.size(); + + // 先分页再补全用户信息(避免为未分页数据调用用户中心) + List pageMembers = allMembers; + if (count != null && count > 0 && page != null && page > 0) { + int fromIndex = (page - 1) * count; + int toIndex = Math.min(fromIndex + count, total); + pageMembers = fromIndex < total ? allMembers.subList(fromIndex, toIndex) : Collections.emptyList(); + } + + List> listData = pageMembers.stream().map(m -> { + Map item = new LinkedHashMap<>(); + item.put("sid", m.getId()); + item.put("id", m.getUserId()); + fillUserInfo(item, m); + return item; + }).collect(Collectors.toList()); + + Map result = new LinkedHashMap<>(); + result.put("list", listData); + result.put("total", total); + return result; + } + + /** + * 补全成员的账号/姓名:落库值为空时从用户中心按 userId 反查。 + * 对应 C# 保存成员时同步写入 UserName/UserAccount 的行为。 + * 查到的值会回写落库自愈,避免历史空数据每次读取都重复远程调用。 + */ + /** + * 解析成员展示信息:姓名取快照,缺失时从用户中心反查(可回写姓名快照); + * account 为前端展示字段,运行时给出(用户中心账号,兜底用 userId 字符串),不落库(决策 D4)。 + */ + private void fillUserInfo(Map item, ContactsGroupUser m) { + String name = m.getUserName(); + String account = null; + if (m.getUserId() != null) { + BoaUser user = null; + try { + user = YschBaseOpenApi.getUserInfo(m.getUserId()); + } catch (Exception e) { + log.warn("[fillUserInfo] 查询用户中心用户失败, userId={}", m.getUserId(), e); + } + if (user != null) { + account = user.getAccount(); + if (!StringUtils.hasText(name)) { + name = user.getName(); + if (StringUtils.hasText(name)) { + m.setUserName(name); + try { + groupUserRepository.save(m); + } catch (Exception e) { + log.warn("[fillUserInfo] 回写姓名快照失败, userId={}", m.getUserId(), e); + } + } + } + } else { + log.warn("[fillUserInfo] 用户中心未查到用户(历史脏数据?), userId={}", m.getUserId()); + } + } + item.put("name", name != null ? name : ""); + item.put("account", StringUtils.hasText(account) ? account : String.valueOf(m.getUserId())); + } + + // ============================ GetGroupTree ============================ + + @Override + public List> getGroupTree(Integer type) { + if (type == null) { + type = 1; + } + Integer uid = currentUserId(); + + // 查询当前用户的联系组 + List groups = groupRepository.findByCreatorId(uid); + List groupIds = groups.stream().map(ContactsGroup::getId).collect(Collectors.toList()); + + // 查询全部成员 + List allMembers = groupIds.isEmpty() ? Collections.emptyList() + : groupUserRepository.findByGroupIdIn(groupIds); + + // 构建树节点 + List> treeNodes = new ArrayList<>(); + + // 组节点 (type=1, pid=0) + for (ContactsGroup g : groups) { + Map node = new LinkedHashMap<>(); + node.put("id", g.getId()); + node.put("name", g.getGroupName()); + node.put("pid", 0); + node.put("no", 0); + node.put("type", 1); + node.put("isParent", true); + node.put("photo", ""); + node.put("children", new ArrayList>()); + treeNodes.add(node); + } + + // 用户节点 (type=2, pid=groupId),姓名缺失时从用户中心补全 + for (ContactsGroupUser m : allMembers) { + Map node = new LinkedHashMap<>(); + node.put("id", m.getUserId()); + node.put("name", ""); + node.put("pid", m.getGroupId()); + node.put("no", 0); + node.put("type", 2); + node.put("isParent", false); + node.put("photo", ""); + fillUserInfo(node, m); + treeNodes.add(node); + } + + // 递归构建树:各自定义组为顶级节点(空组自动剔除),组合成森林 + return formatUserTree(treeNodes, 0); + } + + // ============================ GetDeptRoleGroupTree ============================ + + @Override + public List> getDeptRoleGroupTree() { + // C# 从外部API获取部门树+用户角色映射。 + // Java版暂未接入用户中心OpenAPI,返回空列表。 + log.info("[getDeptRoleGroupTree] 暂未接入用户中心API,返回空列表"); + return Collections.emptyList(); + } + + // ============================ 辅助方法 ============================ + + /** + * 递归构建用户树(对应 C# HttpUserApi.FormatUserTree)。 + */ + @SuppressWarnings("unchecked") + private List> formatUserTree(List> treeList, int pid) { + List> data; + if (pid == 0) { + // 根层:取没有父节点引用的节点(pid=0 或 pid不在列表中的) + Set allIds = treeList.stream() + .map(n -> (Integer) n.get("id")) + .collect(Collectors.toSet()); + data = treeList.stream() + .filter(n -> { + int p = (Integer) n.get("pid"); + return p == 0 || !allIds.contains(p); + }) + .collect(Collectors.toList()); + } else { + data = treeList.stream() + .filter(n -> (Integer) n.get("pid") == pid) + .collect(Collectors.toList()); + } + + List> result = new ArrayList<>(); + for (Map item : data) { + int itemId = (Integer) item.get("id"); + int itemType = (Integer) item.get("type"); + + List> children = formatUserTree(treeList, itemId); + + // type=1(组) 且无子节点 → 不加入(C# 逻辑) + if (itemType == 1 && children.isEmpty()) { + continue; + } + + Map node = new LinkedHashMap<>(item); + // 仅组节点携带 children;叶子不带该字段,避免 ztree 误判 isParent + if (itemType == 1) { + node.put("children", children); + } + result.add(node); + } + + // 按 no 排序 + result.sort(Comparator.comparingInt(n -> { + Object no = n.get("no"); + if (no instanceof Integer) return (Integer) no; + if (no instanceof String) { + try { return Integer.parseInt((String) no); } catch (NumberFormatException e) { return 9999; } + } + return 9999; + })); + + return result; + } + + /** + * 当前登录人平台用户ID(决策 D4:联系组按创建人用户ID隔离,不存账号)。 + */ + private Integer currentUserId() { + return UserContext.getUserId(); + } +} diff --git a/src/main/java/com/yunhai/dormnotice/service/impl/DictService.java b/src/main/java/com/yunhai/dormnotice/service/impl/DictService.java new file mode 100644 index 0000000..71c4de9 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/service/impl/DictService.java @@ -0,0 +1,175 @@ +package com.yunhai.dormnotice.service.impl; + +import com.yunhai.dormnotice.entity.SysDict; +import com.yunhai.dormnotice.repository.SysDictRepository; +import com.yunhai.dormnotice.service.IDictService; +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 org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 系统字典服务(宿舍通知版)。type 维度:dept 发布部门 / noticeType 通知类型。 + * dict_key 存 CodeID(D001/T001…),dict_value 存展示名(Name)。 + */ +@Service +public class DictService implements IDictService { + + private static final String DEPT = "dept"; + private static final String NOTICE_TYPE = "noticeType"; + + @Autowired + private SysDictRepository sysDictRepository; + + private String normalizeType(String type) { + if (!StringUtils.hasText(type)) { + return NOTICE_TYPE; + } + return DEPT.equalsIgnoreCase(type) ? DEPT : NOTICE_TYPE; + } + + private String codePrefix(String type) { + return DEPT.equals(type) ? "D" : "T"; + } + + // ============================ List(下拉) ============================ + + @Override + public List> list(String type) { + String t = normalizeType(type); + List items = sysDictRepository.findByDictTypeAndEnableFlagOrderBySortOrderAsc(t, 1); + List> out = new ArrayList<>(); + for (SysDict d : items) { + Map m = new LinkedHashMap<>(); + m.put("k", NOTICE_TYPE.equals(t) ? d.getDictValue() : d.getDictKey()); + m.put("v", d.getDictValue()); + out.add(m); + } + return out; + } + + // ============================ ManageList(管理) ============================ + + @Override + public List> manageList(String type) { + String t = normalizeType(type); + return sysDictRepository.findByDictTypeOrderBySortOrderAsc(t).stream().map(d -> { + Map m = new LinkedHashMap<>(); + m.put("SerID", d.getId()); + m.put("CodeID", d.getDictKey()); + m.put("Name", d.getDictValue()); + m.put("Enable", d.getEnableFlag()); + return m; + }).collect(Collectors.toList()); + } + + // ============================ Add ============================ + + @Override + @Transactional + public void add(String name, String type) { + if (!StringUtils.hasText(name)) { + throw new BizException(ErrorCode.PARAM_ERROR, "输入字符串格式不正确!"); + } + String trimmed = name.trim(); + String t = normalizeType(type); + List existing = sysDictRepository.findByDictTypeOrderBySortOrderAsc(t); + boolean dup = existing.stream().anyMatch(d -> trimmed.equals(d.getDictValue())); + if (dup) { + throw new BizException(ErrorCode.FORBIDDEN, NOTICE_TYPE.equals(t) ? "该通知类型已存在" : "已存在该分类,请重新填写!"); + } + int maxSort = existing.stream().mapToInt(d -> d.getSortOrder() == null ? 0 : d.getSortOrder()).max().orElse(0); + SysDict dict = new SysDict(); + dict.setDictType(t); + dict.setDictValue(trimmed); + dict.setSortOrder(maxSort + 1); + dict.setEnableFlag(1); + dict.setDictKey(codePrefix(t) + "000"); // 占位,save 后按 id 生成 CodeID + SysDict saved = sysDictRepository.save(dict); + saved.setDictKey(codePrefix(t) + String.format("%03d", saved.getId())); + sysDictRepository.save(saved); + } + + // ============================ Update ============================ + + @Override + @Transactional + public void update(Integer serId, String name, Integer enable, String type) { + if (serId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!"); + } + SysDict dict = sysDictRepository.findById(serId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在")); + if (StringUtils.hasText(name)) { + String t = normalizeType(type); + boolean dup = sysDictRepository.findByDictTypeOrderBySortOrderAsc(t).stream() + .anyMatch(d -> !d.getId().equals(serId) && name.trim().equals(d.getDictValue())); + if (dup) { + throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!"); + } + dict.setDictValue(name.trim()); + } + if (enable != null) { + dict.setEnableFlag(enable); + } + sysDictRepository.save(dict); + } + + // ============================ Delete ============================ + + @Override + @Transactional + public void delete(Integer serId, String type) { + SysDict dict = sysDictRepository.findById(serId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在")); + // 通知引用校验在 NoticeService 侧按 deptId/noticeType 命中判定;此处直接删字典项。 + sysDictRepository.delete(dict); + } + + // ============================ Sort ============================ + + @Override + @Transactional + public void sort(String ids, String type) { + if (!StringUtils.hasText(ids)) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + int[] idArr = Arrays.stream(ids.split(",")) + .map(String::trim).filter(s -> !s.isEmpty()).mapToInt(Integer::parseInt).toArray(); + int index = idArr.length; // 递减赋序,保持前端顺序 + for (int id : idArr) { + final int order = index--; + sysDictRepository.findById(id).ifPresent(d -> { + d.setSortOrder(order); + sysDictRepository.save(d); + }); + } + } + + // ============================ Sync ============================ + + @Override + public Boolean isSmsEnabled() { + return false; + } + + // ============================ 部门名解析 ============================ + + @Override + public String resolveDeptName(String deptId) { + if (!StringUtils.hasText(deptId)) { + return null; + } + return sysDictRepository.findByDictTypeAndDictKey(DEPT, deptId.trim()) + .map(SysDict::getDictValue).orElse(null); + } +} diff --git a/src/main/java/com/yunhai/dormnotice/service/impl/DormService.java b/src/main/java/com/yunhai/dormnotice/service/impl/DormService.java new file mode 100644 index 0000000..12847d2 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/service/impl/DormService.java @@ -0,0 +1,532 @@ +package com.yunhai.dormnotice.service.impl; + +import com.yunhai.dormnotice.entity.DormBuilding; +import com.yunhai.dormnotice.entity.DormRoom; +import com.yunhai.dormnotice.entity.DormStudent; +import com.yunhai.dormnotice.model.bsch.SchClassDto; +import com.yunhai.dormnotice.model.bsch.SchInfoDto; +import com.yunhai.dormnotice.model.bsch.SchStudentDto; +import com.yunhai.dormnotice.repository.DormBuildingRepository; +import com.yunhai.dormnotice.repository.DormRoomRepository; +import com.yunhai.dormnotice.repository.DormStudentRepository; +import com.yunhai.dormnotice.service.IClassService; +import com.yunhai.dormnotice.service.IDormService; +import com.yunhai.dormnotice.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 宿舍设置业务实现。房间树 id 规则:roomId = buildingId*10000 + floor*100 + 序号。 + * usedBeds 恒等于房内在住学生数(单一数据源);床位号随入住/退宿重排为 1..n。 + */ +@Service +public class DormService implements IDormService { + + private static final Logger log = LoggerFactory.getLogger(DormService.class); + + @Autowired + private DormBuildingRepository buildingRepository; + @Autowired + private DormRoomRepository roomRepository; + @Autowired + private DormStudentRepository studentRepository; + @Autowired + private IClassService classService; + + // ============================ 读 ============================ + + @Override + public List> buildingList() { + List buildings = buildingsOfSch(); + List rooms = roomRepository.findAllByOrderByIdAsc(); + Map> byBldg = rooms.stream().collect(Collectors.groupingBy(DormRoom::getBuildingId)); + List> out = new ArrayList<>(); + for (DormBuilding b : buildings) { + List br = byBldg.getOrDefault(b.getId(), Collections.emptyList()); + int bedCount = br.stream().mapToInt(r -> nz(r.getBedCount())).sum(); + int used = br.stream().mapToInt(r -> usedBeds(r.getRoomId())).sum(); + Map m = new LinkedHashMap<>(); + m.put("buildingId", b.getId()); + m.put("buildingName", b.getBuildingName()); + m.put("gender", b.getGender()); + m.put("floorCount", nz(b.getFloorCount())); + m.put("supervisorName", b.getSupervisorName()); + m.put("supervisorPhone", b.getSupervisorPhone()); + m.put("roomCount", br.size()); + m.put("bedCount", bedCount); + m.put("usedBeds", used); + m.put("roomPrefix", b.getRoomPrefix()); + out.add(m); + } + return out; + } + + @Override + public List> roomList(Integer buildingId, String keyword) { + List rooms = buildingId != null && buildingId > 0 + ? roomRepository.findByBuildingIdOrderByIdAsc(buildingId) + : roomRepository.findAllByOrderByIdAsc(); + List> out = new ArrayList<>(); + for (DormRoom r : rooms) { + DormBuilding b = buildingRepository.findById(r.getBuildingId()).orElse(null); + String bn = b == null ? "" : b.getBuildingName(); + if (StringUtils.hasText(keyword)) { + String hay = (bn + " " + r.getRoomNo()); + if (!hay.contains(keyword)) { + continue; + } + } + Map m = new LinkedHashMap<>(); + m.put("roomId", r.getRoomId()); + m.put("roomNo", r.getRoomNo()); + m.put("buildingId", r.getBuildingId()); + m.put("buildingName", bn); + m.put("floor", nz(r.getFloor())); + m.put("bedCount", nz(r.getBedCount())); + m.put("usedBeds", usedBeds(r.getRoomId())); + m.put("supervisorName", r.getSupervisorName()); + m.put("supervisorPhone", r.getSupervisorPhone()); + m.put("monitorName", monitorName(r.getRoomId())); + out.add(m); + } + return out; + } + + @Override + public List> roomStudents(Integer roomId) { + return studentRepository.findByRoomIdOrderByIdAsc(roomId).stream().map(this::toStudentVo).collect(Collectors.toList()); + } + + @Override + public List> pendingStudents(String keyword, String gender) { + // 待安排池:dorm_student 中 roomId=0/null。池空时惰性从 bschclass 学籍导入一次, + // 让「安排入住」有数据源;导入失败(平台不可达等)不影响页面,返回空池。 + List raw = studentRepository.findByRoomIdOrderByIdAsc(0); + if (raw.isEmpty()) { + try { + int n = importStudentsFromBschclass(); + if (n > 0) { + raw = studentRepository.findByRoomIdOrderByIdAsc(0); + } + } catch (Exception e) { + log.warn("[pendingStudents] 从 bschclass 导入学生失败,返回空池: {}", e.getMessage()); + } + } + List pool = raw.stream() + .filter(s -> !StringUtils.hasText(gender) || gender.equals(s.getGender())) + .filter(s -> !StringUtils.hasText(keyword) + || (s.getStudentName() + s.getClassName() + s.getGuardianName()).contains(keyword)) + .collect(Collectors.toList()); + List> out = new ArrayList<>(); + for (DormStudent s : pool) { + Map m = new LinkedHashMap<>(); + m.put("studentId", s.getStudentId()); + m.put("studentName", s.getStudentName()); + m.put("gender", s.getGender()); + m.put("className", s.getClassName()); + m.put("guardianName", s.getGuardianName()); + m.put("guardianPhone", s.getGuardianPhone()); + m.put("sourceDesc", s.getSourceDesc()); + out.add(m); + } + return out; + } + + @Override + public List> dormTree() { + List buildings = buildingsOfSch(); + List rooms = roomRepository.findAllByOrderByIdAsc(); + List> tree = new ArrayList<>(); + for (DormBuilding b : buildings) { + List br = rooms.stream().filter(r -> Objects.equals(r.getBuildingId(), b.getId())) + .collect(Collectors.toList()); + Map> byFloor = br.stream() + .collect(Collectors.groupingBy(r -> nz(r.getFloor()), TreeMap::new, Collectors.toList())); + List> floorNodes = new ArrayList<>(); + byFloor.forEach((fl, rlist) -> { + List> roomNodes = rlist.stream().map(r -> { + Map rn = new LinkedHashMap<>(); + rn.put("id", r.getRoomId()); + rn.put("name", shortName(b) + " " + r.getRoomNo() + "(" + usedBeds(r.getRoomId()) + "/" + nz(r.getBedCount()) + " 床)"); + rn.put("isParent", false); + rn.put("bedCount", nz(r.getBedCount())); + rn.put("usedBeds", usedBeds(r.getRoomId())); + rn.put("supervisorName", r.getSupervisorName()); + return rn; + }).collect(Collectors.toList()); + Map fn = new LinkedHashMap<>(); + fn.put("id", b.getId() * 10000 + fl * 100); + fn.put("name", shortName(b) + " " + fl + " 层"); + fn.put("isParent", true); + fn.put("children", roomNodes); + floorNodes.add(fn); + }); + Map bn = new LinkedHashMap<>(); + bn.put("id", b.getId()); + bn.put("name", b.getBuildingName()); + bn.put("isParent", true); + bn.put("children", floorNodes); + tree.add(bn); + } + return tree; + } + + // ============================ 楼栋写 ============================ + + @Override + @Transactional + public void addBuilding(Map data) { + DormBuilding b = new DormBuilding(); + b.setBuildingName(str(data.get("buildingName"))); + b.setGender(str(data.get("gender"))); + b.setFloorCount(intg(data.get("floorCount"), 0)); + b.setRoomsPerFloor(intg(data.get("roomsPerFloor"), 0)); + b.setBedsPerRoom(intg(data.get("bedsPerRoom"), intg(data.get("beds"), 0))); + b.setRoomPrefix(str(data.get("roomPrefix"))); + b.setSupervisorName(str(data.get("supervisorName"))); + b.setSupervisorPhone(str(data.get("supervisorPhone"))); + b.setSchId(UserContext.getSchId()); + if (!StringUtils.hasText(b.getBuildingName())) { + throw new BizException(ErrorCode.PARAM_ERROR, "楼栋名称不能为空"); + } + buildingRepository.save(b); + } + + @Override + @Transactional + public void editBuilding(Map data) { + Integer id = intg(data.get("buildingId"), null); + if (id == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "楼栋ID不能为空"); + } + DormBuilding b = buildingRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "楼栋不存在")); + if (data.containsKey("buildingName")) b.setBuildingName(str(data.get("buildingName"))); + if (data.containsKey("gender")) b.setGender(str(data.get("gender"))); + if (data.containsKey("floorCount")) b.setFloorCount(intg(data.get("floorCount"), b.getFloorCount())); + if (data.containsKey("supervisorName")) b.setSupervisorName(str(data.get("supervisorName"))); + if (data.containsKey("supervisorPhone")) b.setSupervisorPhone(str(data.get("supervisorPhone"))); + buildingRepository.save(b); + } + + @Override + @Transactional + public void delBuilding(Integer buildingId) { + if (roomRepository.countByBuildingId(buildingId) > 0) { + throw new BizException(ErrorCode.FORBIDDEN, "请先清空该楼栋下的房间"); + } + buildingRepository.deleteById(buildingId); + } + + // ============================ 房间写 ============================ + + @Override + @Transactional + public void addRoom(Map data) { + Integer buildingId = intg(data.get("buildingId"), null); + if (buildingId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "楼栋不能为空"); + } + DormBuilding b = buildingRepository.findById(buildingId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "楼栋不存在")); + int floor = intg(data.get("floor"), 1); + int bedCount = intg(data.get("bedCount"), intg(data.get("beds"), nz(b.getBedsPerRoom()))); + int count = Math.max(intg(data.get("roomCount"), 1), 1); // 新增间数:一次追加 count 间 + Integer explicitRoomId = intg(data.get("roomId"), null); + // 该层已有房间数,向后追加序号避免重号 + int seq = (int) roomRepository.findByBuildingIdOrderByIdAsc(buildingId).stream() + .filter(r -> nz(r.getFloor()) == floor).count(); + for (int i = 0; i < count; i++) { + int roomId; + if (explicitRoomId != null && count == 1) { + roomId = explicitRoomId; + if (roomRepository.findByRoomId(roomId).isPresent()) { + throw new BizException(ErrorCode.FORBIDDEN, "房间号已存在:" + roomId); + } + } else { + do { + seq++; + roomId = buildingId * 10000 + floor * 100 + seq; + } while (roomRepository.findByRoomId(roomId).isPresent()); + } + DormRoom r = new DormRoom(); + r.setRoomId(roomId); + r.setRoomNo(count == 1 && StringUtils.hasText(str(data.get("roomNo"))) + ? str(data.get("roomNo")) + : floor + String.format("%02d", roomId % 100)); + r.setBuildingId(buildingId); + r.setFloor(floor); + r.setBedCount(bedCount); + r.setSupervisorName(StringUtils.hasText(str(data.get("supervisorName"))) ? str(data.get("supervisorName")) : b.getSupervisorName()); + r.setSupervisorPhone(b.getSupervisorPhone()); + r.setSchId(UserContext.getSchId()); + roomRepository.save(r); + } + } + + @Override + @Transactional + public void editRoom(Map data) { + Integer roomId = intg(data.get("roomId"), null); + if (roomId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "房间不能为空"); + } + DormRoom r = roomRepository.findByRoomId(roomId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "房间不存在")); + if (data.containsKey("roomNo")) r.setRoomNo(str(data.get("roomNo"))); + if (data.containsKey("supervisorName")) r.setSupervisorName(str(data.get("supervisorName"))); + if (data.containsKey("supervisorPhone")) r.setSupervisorPhone(str(data.get("supervisorPhone"))); + if (data.containsKey("bedCount")) { + int bc = intg(data.get("bedCount"), r.getBedCount()); + long used = studentRepository.countByRoomId(roomId); + if (bc < used) { + // 床位收紧:超员学生退回待安排池 + List list = studentRepository.findByRoomIdOrderByIdAsc(roomId); + for (int i = bc; i < list.size(); i++) { + backToPool(list.get(i), "床位缩减待重新安排"); + } + renumberBeds(roomId); + } + r.setBedCount(bc); + } + roomRepository.save(r); + } + + @Override + @Transactional + public void delRoom(Integer roomId) { + if (studentRepository.countByRoomId(roomId) > 0) { + throw new BizException(ErrorCode.FORBIDDEN, "房间内仍有入住学生,无法删除"); + } + roomRepository.findByRoomId(roomId).ifPresent(roomRepository::delete); + } + + // ============================ 床位安排 ============================ + + @Override + @Transactional + public void assignStudent(Integer roomId, List studentIds) { + DormRoom room = roomRepository.findByRoomId(roomId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "房间不存在")); + for (Integer sid : studentIds) { + DormStudent s = studentRepository.findByStudentId(sid) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "学生不存在:" + sid)); + if (nz(s.getRoomId()) != 0) { + throw new BizException(ErrorCode.FORBIDDEN, s.getStudentName() + " 已安排床位"); + } + checkGender(room, s); + if (studentRepository.countByRoomId(roomId) >= nz(room.getBedCount())) { + throw new BizException(ErrorCode.FORBIDDEN, roomShort(room) + " 床位已满"); + } + s.setRoomId(roomId); + s.setSourceDesc(null); + studentRepository.save(s); + } + renumberBeds(roomId); + } + + @Override + @Transactional + public void removeStudent(Integer roomId, Integer studentId) { + studentRepository.findByStudentId(studentId).ifPresent(s -> { + if (Objects.equals(s.getRoomId(), roomId)) { + backToPool(s, "退宿待重新安排"); + renumberBeds(roomId); + } + }); + } + + @Override + @Transactional + public Map transferStudent(Integer studentId, Integer fromRoomId, Integer toRoomId) { + DormStudent s = studentRepository.findByStudentId(studentId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "学生不存在")); + DormRoom to = roomRepository.findByRoomId(toRoomId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "目标房间不存在")); + checkGender(to, s); + if (studentRepository.countByRoomId(toRoomId) >= nz(to.getBedCount())) { + throw new BizException(ErrorCode.FORBIDDEN, roomShort(to) + " 床位已满"); + } + boolean wasMonitor = Boolean.TRUE.equals(s.getIsMonitor()); + s.setRoomId(toRoomId); + s.setIsMonitor(false); + studentRepository.save(s); + renumberBeds(fromRoomId); + renumberBeds(toRoomId); + Map r = new LinkedHashMap<>(); + r.put("wasMonitor", wasMonitor); + r.put("bedNo", studentRepository.findByStudentId(studentId).map(DormStudent::getBedNo).orElse("")); + return r; + } + + @Override + @Transactional + public void setMonitor(Integer roomId, Integer studentId) { + List list = studentRepository.findByRoomIdOrderByIdAsc(roomId); + for (DormStudent s : list) { + s.setIsMonitor(studentId != null && studentId > 0 && s.getStudentId().equals(studentId)); + studentRepository.save(s); + } + } + + // ============================ bschclass 学籍导入 ============================ + + @Override + @Transactional + public int importStudentsFromBschclass() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "未获取到学校上下文,无法导入学生"); + } + SchInfoDto info = classService.getSchFirst(schId); + if (info == null || !StringUtils.hasText(info.getSchSerId())) { + log.warn("[importStudents] schId={} 未取到 bschclass SchSerId", schId); + return 0; + } + String schSerId = info.getSchSerId(); + List stus = classService.getSchStuList(schSerId); + if (stus == null || stus.isEmpty()) { + return 0; + } + Map classNames = new HashMap<>(); + try { + for (SchClassDto c : classService.getSchClassList(schSerId)) { + classNames.put(c.getClassId(), c.getClassName()); + } + } catch (Exception e) { + log.warn("[importStudents] 取班级名单失败(班级名留空): {}", e.getMessage()); + } + int added = 0; + for (SchStudentDto s : stus) { + if (s.getStuId() == null) { + continue; + } + if (studentRepository.findByStudentId(s.getStuId()).isPresent()) { + continue; // 已存在(在住或已在池)跳过,幂等 + } + DormStudent d = new DormStudent(); + d.setStudentId(s.getStuId()); + d.setStudentName(s.getStuName()); + d.setClassName(classNames.get(s.getClassId())); + d.setRoomId(0); + d.setIsMonitor(false); + d.setSourceDesc("学籍在册未分配床位"); + d.setSchId(schId); + studentRepository.save(d); + added++; + } + log.info("[importStudents] schId={} schSerId={} 学籍 {} 人,新导入待安排 {} 人", schId, schSerId, stus.size(), added); + return added; + } + + // ============================ 辅助 ============================ + + private List buildingsOfSch() { + Integer schId = UserContext.getSchId(); + return schId != null ? buildingRepository.findBySchIdOrderByIdAsc(schId) : buildingRepository.findAllByOrderByIdAsc(); + } + + private void backToPool(DormStudent s, String source) { + s.setRoomId(0); + s.setBedNo(null); + s.setIsMonitor(false); + s.setSourceDesc(source); + studentRepository.save(s); + } + + /** 床位号重排为 1..n 连续;若无人任宿舍长,首位默认宿舍长。 */ + private void renumberBeds(Integer roomId) { + List list = studentRepository.findByRoomIdOrderByIdAsc(roomId); + boolean hasMonitor = list.stream().anyMatch(s -> Boolean.TRUE.equals(s.getIsMonitor())); + int i = 1; + for (DormStudent s : list) { + s.setBedNo(i++ + "号床"); + studentRepository.save(s); + } + if (!list.isEmpty() && !hasMonitor) { + DormStudent first = list.get(0); + first.setIsMonitor(true); + studentRepository.save(first); + } + } + + private void checkGender(DormRoom room, DormStudent s) { + DormBuilding b = buildingRepository.findById(room.getBuildingId()).orElse(null); + if (b == null || s.getGender() == null) { + return; + } + String g = b.getGender(); + if (g != null && g.contains("女") && !"女".equals(s.getGender())) { + throw new BizException(ErrorCode.FORBIDDEN, "女生宿舍楼不能安排男生"); + } + if (g != null && g.contains("男") && !"男".equals(s.getGender()) && !g.contains("混合")) { + throw new BizException(ErrorCode.FORBIDDEN, "男生宿舍楼不能安排女生"); + } + } + + private String monitorName(Integer roomId) { + return studentRepository.findByRoomIdAndIsMonitorTrue(roomId).stream() + .map(DormStudent::getStudentName).findFirst().orElse(null); + } + + private int usedBeds(Integer roomId) { + return (int) studentRepository.countByRoomId(roomId); + } + + private Map toStudentVo(DormStudent s) { + Map m = new LinkedHashMap<>(); + m.put("studentId", s.getStudentId()); + m.put("studentName", s.getStudentName()); + m.put("gender", s.getGender()); + m.put("bedNo", s.getBedNo()); + m.put("className", s.getClassName()); + m.put("guardianName", s.getGuardianName()); + m.put("guardianPhone", s.getGuardianPhone()); + m.put("relation", s.getRelation()); + m.put("isMonitor", Boolean.TRUE.equals(s.getIsMonitor())); + return m; + } + + private String roomShort(DormRoom r) { + DormBuilding b = buildingRepository.findById(r.getBuildingId()).orElse(null); + return (b == null ? "" : shortName(b)) + " " + r.getRoomNo(); + } + + private String shortName(DormBuilding b) { + if (StringUtils.hasText(b.getRoomPrefix())) { + return b.getRoomPrefix() + "号楼"; + } + return b.getBuildingName(); + } + + private int nz(Integer v) { + return v == null ? 0 : v; + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static Integer intg(Object o, Integer dft) { + if (o == null) { + return dft; + } + if (o instanceof Number) { + return ((Number) o).intValue(); + } + try { + return Integer.parseInt(String.valueOf(o).trim()); + } catch (NumberFormatException e) { + return dft; + } + } +} diff --git a/src/main/java/com/yunhai/dormnotice/service/impl/FileService.java b/src/main/java/com/yunhai/dormnotice/service/impl/FileService.java new file mode 100644 index 0000000..231eaa1 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/service/impl/FileService.java @@ -0,0 +1,335 @@ +package com.yunhai.dormnotice.service.impl; + +import com.yunhai.dormnotice.entity.UpFile; +import com.yunhai.dormnotice.model.notice.UpFileVo; +import com.yunhai.dormnotice.model.notice.UpFileResultVo; +import com.yunhai.dormnotice.repository.UpFileRepository; +import com.yunhai.dormnotice.service.IFileService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.FileInputStream; +import java.io.OutputStream; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.ArrayList; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 文件上传/下载/删除业务实现。 + *

+ * 存储路径规则:{uploadPath}/{appType}/{yyyyMMdd}/{uuid}.{ext} + * 与 C# UpLoadFile.FileSave 逻辑等价,但去除分片上传和 Office 转 PDF(后续按需补充)。 + */ +@Service +public class FileService implements IFileService { + + private static final Logger log = LoggerFactory.getLogger(FileService.class); + + @Autowired + private UpFileRepository upFileRepository; + + @Value("${file.upload-path}") + private String uploadPath; + + private static final String DEFAULT_APP_TYPE = "dormnotice"; + + // ============================ 上传 ============================ + + @Override + @Transactional + public UpFileVo upload(MultipartFile file, String appType, Integer topicId) { + if (file == null || file.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不得为空"); + } + String originalName = file.getOriginalFilename(); + if (!StringUtils.hasText(originalName)) { + throw new BizException(ErrorCode.PARAM_ERROR, "文件名为空"); + } + // 提取后缀 + String ext = ""; + int dotIdx = originalName.lastIndexOf('.'); + if (dotIdx >= 0) { + ext = originalName.substring(dotIdx + 1).toLowerCase(); + } + + // 构造存储子路径:{appType}/{yyyyMMdd}/{uuid}.{ext} + String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String savedName = UUID.randomUUID().toString().replace("-", "") + (ext.isEmpty() ? "" : "." + ext); + String relativePath = (appType == null ? DEFAULT_APP_TYPE : appType) + "/" + dateDir + "/" + savedName; + + // 物理写入 + Path fullPath = Paths.get(uploadPath, relativePath); + try { + Files.createDirectories(fullPath.getParent()); + file.transferTo(fullPath.toFile()); + } catch (Exception e) { + log.error("[upload] 文件保存失败: {}", fullPath, e); + throw new BizException(ErrorCode.ERROR, "文件保存失败", e); + } + + // 入库 + UpFile entity = new UpFile(); + entity.setTopicType(appType == null ? DEFAULT_APP_TYPE : appType); + entity.setTopicId(topicId); + entity.setFileTrueName(originalName); + entity.setFileName(relativePath); + entity.setFileSize((int) file.getSize()); + entity.setFileType(ext); + upFileRepository.save(entity); + + return toVo(entity); + } + + // ============================ 绑定主题 ============================ + + @Override + @Transactional + public void bindToTopic(List fileIds, Integer topicId) { + if (fileIds == null || fileIds.isEmpty() || topicId == null) { + return; + } + for (Integer fileId : fileIds) { + upFileRepository.findById(fileId).ifPresent(f -> { + f.setTopicId(topicId); + upFileRepository.save(f); + }); + } + } + + // ============================ 查询附件列表 ============================ + + @Override + public List listByTopic(String appType, Integer topicId) { + String type = appType == null ? DEFAULT_APP_TYPE : appType; + return upFileRepository.findByTopicTypeAndTopicId(type, topicId).stream() + .map(this::toVo) + .collect(Collectors.toList()); + } + + // ============================ 下载 ============================ + + @Override + public void download(Integer fileId, HttpServletResponse response) { + writeFile(fileId, response, false); + } + + @Override + public void preview(Integer fileId, HttpServletResponse response) { + writeFile(fileId, response, true); + } + + /** + * 文件输出。inline=true 时 Content-Disposition 用 inline 并按扩展名输出真实 Content-Type + * (浏览器/前端组件可直接渲染),否则 attachment 强制下载。 + */ + private void writeFile(Integer fileId, HttpServletResponse response, boolean inline) { + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId)); + Path fullPath = Paths.get(uploadPath, file.getFileName()); + if (!Files.exists(fullPath)) { + throw new BizException(ErrorCode.FORBIDDEN, "物理文件不存在: " + file.getFileName()); + } + try (FileInputStream fis = new FileInputStream(fullPath.toFile()); + OutputStream os = response.getOutputStream()) { + String encodedName = URLEncoder.encode(file.getFileTrueName(), "UTF-8") + .replaceAll("\\+", "%20"); + if (inline) { + response.setContentType(resolveContentType(file.getFileName())); + response.setHeader("Content-Disposition", "inline; filename=\"" + encodedName + "\""); + } else { + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedName + "\""); + } + response.setContentLengthLong(file.getFileSize()); + byte[] buffer = new byte[4096]; + int len; + while ((len = fis.read(buffer)) != -1) { + os.write(buffer, 0, len); + } + os.flush(); + } catch (Exception e) { + log.error("[writeFile] 文件输出失败: {}", file.getFileName(), e); + throw new BizException(ErrorCode.ERROR, "文件下载失败", e); + } + } + + /** + * 按扩展名推断 Content-Type(JDK 自带表 + 常见办公/图片格式兜底),未知类型回退流格式。 + */ + private String resolveContentType(String fileName) { + String name = String.valueOf(fileName).toLowerCase(); + Map table = new HashMap<>(); + table.put("pdf", "application/pdf"); + table.put("png", "image/png"); + table.put("jpg", "image/jpeg"); + table.put("jpeg", "image/jpeg"); + table.put("gif", "image/gif"); + table.put("bmp", "image/bmp"); + table.put("webp", "image/webp"); + table.put("svg", "image/svg+xml"); + table.put("txt", "text/plain;charset=UTF-8"); + table.put("mp4", "video/mp4"); + table.put("webm", "video/webm"); + table.put("ogg", "video/ogg"); + table.put("doc", "application/msword"); + table.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + table.put("xls", "application/vnd.ms-excel"); + table.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + table.put("ppt", "application/vnd.ms-powerpoint"); + table.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"); + String type = table.get(name.substring(name.lastIndexOf('.') + 1)); + if (type != null) { + return type; + } + String guessed = URLConnection.guessContentTypeFromName(name); + return guessed != null ? guessed : "application/octet-stream"; + } + + // ============================ 删除 ============================ + + @Override + @Transactional + public void delete(Integer fileId) { + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId)); + // 删物理文件 + Path fullPath = Paths.get(uploadPath, file.getFileName()); + try { + Files.deleteIfExists(fullPath); + } catch (Exception e) { + log.warn("[delete] 物理文件删除失败: {}(数据库记录仍会删除)", fullPath, e); + } + // 删数据库记录 + upFileRepository.delete(file); + } + + // ============================ 打包下载 ZIP ============================ + + @Override + public void downloadZip(List files, String zipName, HttpServletResponse response) { + if (files == null || files.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载"); + } + try { + response.setContentType("application/zip"); + String name = URLEncoder.encode(zipName == null ? "附件" : zipName, "UTF-8") + .replaceAll("\\+", "%20"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + name + ".zip\""); + try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) { + Set used = new HashSet<>(); + for (UpFile f : files) { + Path p = Paths.get(uploadPath, f.getFileName()); + if (!Files.exists(p)) { + log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName()); + continue; + } + String entryName = sanitizeEntryName(f.getFileTrueName()); + if (used.contains(entryName)) { + entryName = f.getId() + "_" + entryName; + } + used.add(entryName); + zos.putNextEntry(new ZipEntry(entryName)); + Files.copy(p, zos); + zos.closeEntry(); + } + } + } catch (Exception e) { + log.error("[downloadZip] 打包下载失败", e); + throw new BizException(ErrorCode.ERROR, "打包下载失败", e); + } + } + + /** + * 净化 ZIP 条目名:去除路径分隔符等非法字符,防止 Zip Slip。 + */ + private String sanitizeEntryName(String name) { + if (name == null || name.trim().isEmpty()) { + return "file"; + } + return name.replaceAll("[\\\\/:*?\"<>|]", "_"); + } + + // ============================ 按主题打包下载 ============================ + + @Override + public void downloadZipByTopic(String topicType, Integer topicId, String zipName, + HttpServletResponse response) { + String type = topicType == null ? DEFAULT_APP_TYPE : topicType; + List files = upFileRepository.findByTopicTypeAndTopicId(type, topicId); + downloadZip(files, zipName, response); + } + + @Override + public void downloadZipByIds(List fileIds, String zipName, HttpServletResponse response) { + if (fileIds == null || fileIds.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载"); + } + List files = upFileRepository.findAllById(fileIds); + downloadZip(files, zipName, response); + } + + // ============================ 批量上传图片 ============================ + + @Override + @Transactional + public List uploadImages(MultipartFile[] files, String appType) { + List results = new ArrayList<>(); + if (files == null || files.length == 0) { + return results; + } + String type = appType != null ? appType : DEFAULT_APP_TYPE; + for (MultipartFile file : files) { + results.add(upload(file, type, null)); + } + return results; + } + + // ============================ 上传(返回前端兼容 VO) ============================ + + @Override + @Transactional + public UpFileResultVo uploadToResult(MultipartFile file, String appType) { + UpFileVo vo = upload(file, appType, null); + UpFileResultVo result = new UpFileResultVo(); + result.setFileid(vo.getId()); + result.setName(vo.getFileTrueName()); + result.setFileurl(vo.getFileName()); + result.setHost(""); + result.setType(vo.getFileType()); + result.setSize(vo.getFileSize()); + return result; + } + + // ============================ 转换 ============================ + + private UpFileVo toVo(UpFile entity) { + UpFileVo vo = new UpFileVo(); + BeanUtils.copyProperties(entity, vo); + return vo; + } +} diff --git a/src/main/java/com/yunhai/dormnotice/service/impl/NoticeService.java b/src/main/java/com/yunhai/dormnotice/service/impl/NoticeService.java new file mode 100644 index 0000000..7553f33 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/service/impl/NoticeService.java @@ -0,0 +1,1285 @@ +package com.yunhai.dormnotice.service.impl; + +import com.alibaba.fastjson.JSON; +import com.yunhai.dormnotice.constant.DormEnums; +import com.yunhai.dormnotice.entity.*; +import com.yunhai.dormnotice.repository.*; +import com.yunhai.dormnotice.service.IDictService; +import com.yunhai.dormnotice.service.INoticeService; +import com.yunhai.dormnotice.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 org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 宿舍通知业务实现。读接口返回与前端 mock 对齐的 Map;身份用 UserContext(student_id/userId,不存账号)。 + */ +@Service +public class NoticeService implements INoticeService { + + private static final DateTimeFormatter DTF = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final List PUBLISHED_STATUS = Arrays.asList("published", "closed", "finished"); + + @Autowired private NoticeRepository noticeRepository; + @Autowired private ReceiptRepository receiptRepository; + @Autowired private DormRoomRepository roomRepository; + @Autowired private DormStudentRepository studentRepository; + @Autowired private DormBuildingRepository buildingRepository; + @Autowired private IDictService dictService; + @Autowired private UpFileRepository upFileRepository; + @Autowired private com.yunhai.dormnotice.service.IFileService fileService; + @Autowired private DormNoticeReadRepository dormNoticeReadRepository; + + // ==================== 家长端收件箱 ==================== + + @Override + public Map list(Map query) { + Integer viewerRoom = viewerRoomId(); + List base = publishedNotices().stream() + .filter(n -> viewerRoom != null && targetsRoom(n, viewerRoom)) + .collect(Collectors.toList()); + List filtered = sortByUrgencyTime(applyFilters(base, query)); + int pageIndex = intg(query.get("pageIndex"), 1); + int pageSize = intg(query.get("pageSize"), 50); + List> vos = filtered.stream().map(n -> toVo(n, viewerRoom)).collect(Collectors.toList()); + Map r = paginate(vos, pageIndex, pageSize); + r.put("unreadCount", filtered.stream().filter(n -> !isRoomRead(n, viewerRoom)).count()); + return r; + } + + // ==================== 我发布的 / 全部 ==================== + + @Override + public Map sendList(Map query) { + List base = noticeRepository.findAll(); + if ("user".equals(str(query.get("scope")))) { + Integer uid = UserContext.getUserId(); + base = base.stream().filter(n -> Objects.equals(n.getPublisherId(), uid)).collect(Collectors.toList()); + } + List filtered = sortByUrgencyTime(applyFilters(base, query)); + int pageIndex = intg(query.get("pageIndex"), 1); + int pageSize = intg(query.get("pageSize"), 10); + List> vos = filtered.stream().map(n -> toVo(n, null)).collect(Collectors.toList()); + return paginate(vos, pageIndex, pageSize); + } + + // ==================== 详情 ==================== + + @Override + @Transactional + public Map detail(Integer serid, Integer type) { + Notice n = get(serid); + Integer viewerRoom = viewerRoomId(); + if (viewerRoom != null && !Integer.valueOf(0).equals(type)) { + markRoomRead(n, viewerRoom); + } + return toDetail(n); + } + + @Override + public Map getDetail(Integer serid) { + return toDetail(get(serid)); + } + + @Override + public Map nextPrev(Integer serid, String type) { + List ids = publishedNotices().stream().map(Notice::getId).collect(Collectors.toList()); + int i = ids.indexOf(serid); + Map r = new LinkedHashMap<>(); + r.put("prevId", i > 0 ? ids.get(i - 1) : 0); + r.put("nextId", i >= 0 && i < ids.size() - 1 ? ids.get(i + 1) : 0); + return r; + } + + @Override + public List> readDetail(Integer serid) { + Notice n = get(serid); + Set readStudents = dormNoticeReadRepository.findByNoticeId(serid).stream() + .map(DormNoticeRead::getStudentId).filter(Objects::nonNull).collect(Collectors.toSet()); + Map>> byBldg = new LinkedHashMap<>(); + for (DormRoom room : targetRooms(n)) { + DormBuilding b = buildingRepository.findById(room.getBuildingId()).orElse(null); + String bn = b == null ? "未分区" : b.getBuildingName(); + for (DormStudent stu : studentRepository.findByRoomIdOrderByIdAsc(room.getRoomId())) { + Map item = new LinkedHashMap<>(); + item.put("account", String.valueOf(stu.getStudentId())); + item.put("studentId", stu.getStudentId()); + item.put("teaname", room.getRoomNo() + " " + (stu.getStudentName() == null ? "" : stu.getStudentName())); + item.put("className", stu.getClassName()); + item.put("guardianName", stu.getGuardianName()); + item.put("guardianPhone", stu.getGuardianPhone()); + item.put("relation", stu.getRelation()); + item.put("states", readStudents.contains(stu.getStudentId()) ? "read" : "unread"); + byBldg.computeIfAbsent(bn, k -> new ArrayList<>()).add(item); + } + } + return byBldg.entrySet().stream().map(e -> { + Map g = new LinkedHashMap<>(); + g.put("GroupName", e.getKey()); + g.put("teaslist", e.getValue()); + return g; + }).collect(Collectors.toList()); + } + + @Override + public List> formTemplates() { + return DormEnums.formTemplates(); + } + + // ==================== 写入 ==================== + + @Override + @Transactional + public Integer create(Map body) { + if (!StringUtils.hasText(str(body.get("title")))) { + throw new BizException(ErrorCode.PARAM_ERROR, "通知标题不能为空"); + } + Notice n = new Notice(); + applyRequest(n, body); + n.setPublisherId(UserContext.getUserId()); + n.setPublisherName(UserContext.getUserName()); + n.setSchId(UserContext.getSchId()); + n.setNoticeStatus(bool(body.get("draft")) ? "draft" : "published"); + if (n.getReadRoomIds() == null) { + n.setReadRoomIds(""); + } + Notice saved = noticeRepository.save(n); + // 部门名兜底 + if (!StringUtils.hasText(saved.getDeptName()) && StringUtils.hasText(saved.getDeptId())) { + saved.setDeptName(dictService.resolveDeptName(saved.getDeptId())); + saved = noticeRepository.save(saved); + } + if (!"draft".equals(saved.getNoticeStatus())) { + ensureReceipts(saved); + } + return saved.getId(); + } + + @Override + @Transactional + public void edit(Map body) { + Integer id = intg(body.get("id"), intg(body.get("sourceId"), null)); + Notice n = get(id); + applyRequest(n, body); + n.setNoticeStatus(bool(body.get("draft")) ? "draft" : "published"); + n.setIsPulled(0); + noticeRepository.save(n); + if (!"draft".equals(n.getNoticeStatus())) { + ensureReceipts(n); + } + } + + private void applyRequest(Notice n, Map b) { + if (b.containsKey("title")) n.setTitle(str(b.get("title"))); + if (b.containsKey("content")) n.setContent(str(b.get("content"))); + if (b.containsKey("noticeType")) n.setNoticeType(str(b.get("noticeType"))); + if (b.containsKey("level")) n.setLevel(str(b.get("level"))); + if (b.containsKey("deptId")) n.setDeptId(str(b.get("deptId"))); + if (b.containsKey("deptName")) n.setDeptName(str(b.get("deptName"))); + if (b.containsKey("needSupervisor")) n.setNeedSupervisor(intg(b.get("needSupervisor"), 0)); + if (b.containsKey("topmost")) n.setTopmost(intg(b.get("topmost"), 0)); + if (b.containsKey("smsFlag")) n.setSmsFlag(intg(b.get("smsFlag"), 0)); + if (b.containsKey("receiveList")) n.setReceiveList(str(b.get("receiveList"))); + if (b.containsKey("receiveType")) n.setReceiveType(str(b.get("receiveType"))); + if (b.containsKey("fileIds")) n.setFileIds(str(b.get("fileIds"))); + if (b.containsKey("effectiveTime")) n.setEffectiveTime(parseDt(b.get("effectiveTime"))); + if (b.containsKey("deadlineTime")) n.setDeadlineTime(parseDt(b.get("deadlineTime"))); + if (b.containsKey("timingSendTime")) n.setTimingSendTime(parseDt(b.get("timingSendTime"))); + if (b.containsKey("receiptMode")) n.setReceiptMode(str(b.get("receiptMode"))); + if ("fill".equals(n.getReceiptMode())) { + String ft = str(b.get("formType")); + n.setFormType(StringUtils.hasText(ft) ? ft : (StringUtils.hasText(n.getFormType()) ? n.getFormType() : "general")); + } else { + n.setFormType(null); + } + if (b.containsKey("sourceId")) n.setSourceId(intg(b.get("sourceId"), n.getSourceId())); + } + + @Override + @Transactional + public void delete(List ids) { + if (ids == null) { + return; + } + for (Integer id : ids) { + if (id == null) { + continue; + } + receiptRepository.findByNoticeId(id).forEach(receiptRepository::delete); + dormNoticeReadRepository.deleteByNoticeId(id); + noticeRepository.deleteById(id); + } + } + + @Override + @Transactional + public void userPull(Integer serid) { + Notice n = get(serid); + n.setIsPulled(1); + n.setNoticeStatus("closed"); + noticeRepository.save(n); + } + + @Override + @Transactional + public void setTopmost(Integer serid, Integer topmost) { + Notice n = get(serid); + n.setTopmost(Integer.valueOf(0).equals(topmost) ? 0 : 1); + noticeRepository.save(n); + } + + @Override + public Map expedite(Integer serid) { + Notice n = get(serid); + long reminded = receiptRepository.findByNoticeId(serid).stream().filter(r -> !Boolean.TRUE.equals(r.getRead())).count(); + Map r = new LinkedHashMap<>(); + r.put("reminded", reminded); + return r; + } + + @Override + @Transactional + public void receiveRooms(Integer serid, String rooms) { + Notice n = get(serid); + Set merged = new LinkedHashSet<>(); + if (!"ALL".equalsIgnoreCase(str(n.getReceiveList()))) { + merged.addAll(splitCsv(n.getReceiveList())); + } + if ("ALL".equalsIgnoreCase(rooms)) { + n.setReceiveList("ALL"); + } else { + merged.addAll(splitCsv(rooms)); + n.setReceiveList(String.join(",", merged)); + } + noticeRepository.save(n); + ensureReceipts(n); + } + + // ==================== 回执 ==================== + + @Override + public Map receipts(Integer serid, String status) { + Notice n = get(serid); + List all = receiptRepository.findByNoticeId(serid); + List filtered = "all".equals(status) || !StringUtils.hasText(status) + ? all : all.stream().filter(r -> status.equals(r.getStatus())).collect(Collectors.toList()); + Map>> byRoom = new LinkedHashMap<>(); + for (Receipt r : filtered) { + String key = r.getBuildingName() + " · " + r.getRoomNo(); + byRoom.computeIfAbsent(key, k -> new ArrayList<>()).add(toReceiptBrief(r)); + } + Map out = new LinkedHashMap<>(); + out.put("noticeId", n.getId()); + out.put("title", n.getTitle()); + out.put("noticeType", n.getNoticeType()); + out.put("level", n.getLevel()); + out.put("levelDesc", DormEnums.levelDesc(n.getLevel())); + out.put("effectiveTime", fmt(n.getEffectiveTime())); + out.put("deadlineTime", fmt(n.getDeadlineTime())); + out.put("noticeStatus", n.getNoticeStatus()); + out.put("noticeStatusDesc", DormEnums.statusDesc(n.getNoticeStatus())); + out.put("receiptMode", n.getReceiptMode()); + out.put("receiptModeDesc", DormEnums.modeDesc(n.getReceiptMode())); + out.put("formType", n.getFormType()); + out.put("formTypeDesc", n.getFormType() == null ? "" : DormEnums.formTypeDesc(n.getFormType())); + out.put("formFields", "fill".equals(n.getReceiptMode()) ? DormEnums.buildFormFields(n.getFormType()) : null); + out.put("summary", progress(serid, n)); + List> groups = byRoom.entrySet().stream().map(e -> { + Map g = new LinkedHashMap<>(); + g.put("groupName", e.getKey()); + g.put("roomNo", e.getValue().isEmpty() ? "" : e.getValue().get(0).get("roomNo")); + g.put("students", e.getValue()); + return g; + }).collect(Collectors.toList()); + out.put("groups", groups); + return out; + } + + @Override + public Map receiptDetail(Integer receiptId) { + Receipt r = receiptRepository.findById(receiptId).orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "回执记录不存在")); + Notice n = get(r.getNoticeId()); + Map m = new LinkedHashMap<>(); + m.put("receiptId", r.getId()); + m.put("noticeId", r.getNoticeId()); + m.put("noticeTitle", n.getTitle()); + m.put("noticeType", n.getNoticeType()); + m.put("studentId", r.getStudentId()); + m.put("studentName", r.getStudentName()); + m.put("roomId", r.getRoomId()); + m.put("roomNo", r.getRoomNo()); + m.put("buildingName", r.getBuildingName()); + m.put("bedNo", r.getBedNo()); + m.put("guardianName", r.getGuardianName()); + m.put("guardianPhone", r.getGuardianPhone()); + m.put("relation", r.getRelation()); + m.put("status", r.getStatus()); + m.put("statusDesc", DormEnums.receiptStatusDesc(r.getStatus())); + m.put("submittedAt", fmt(r.getSubmittedAt())); + m.put("read", Boolean.TRUE.equals(r.getRead())); + m.put("readAt", fmt(r.getReadAt())); + m.put("fillData", parseFill(r.getFillData())); + m.put("formType", n.getFormType()); + m.put("formTypeDesc", n.getFormType() == null ? "" : DormEnums.formTypeDesc(n.getFormType())); + m.put("formFields", "fill".equals(n.getReceiptMode()) ? DormEnums.buildFormFields(n.getFormType()) : null); + m.put("remark", r.getRemark()); + return m; + } + + @Override + @Transactional + public void review(Map body) { + Receipt r = receiptRepository.findById(intg(body.get("receiptId"), 0)) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "回执记录不存在")); + Notice n = get(r.getNoticeId()); + String action = str(body.get("action")); + if ("confirm".equals(action)) { + if ("fill".equals(n.getReceiptMode())) { + Map fd = parseFill(r.getFillData()); + if (fd == null) { + fd = new LinkedHashMap<>(); + } + fd.putIfAbsent("conclusion", "同意"); + r.setFillData(JSON.toJSONString(fd)); + r.setStatus("filled"); + } else { + r.setStatus("confirmed"); + } + r.setSubmittedAt(LocalDateTime.now()); + } else if ("cancel".equals(action)) { + r.setStatus("canceled"); + } else { + r.setStatus("none"); + r.setSubmittedAt(null); + } + if (body.containsKey("remark")) { + r.setRemark(str(body.get("remark"))); + } + receiptRepository.save(r); + } + + @Override + public Map remind(Integer serid) { + Notice n = get(serid); + long pending = receiptRepository.findByNoticeId(serid).stream() + .filter(r -> "none".equals(r.getStatus()) || "canceled".equals(r.getStatus())).count(); + Map r = new LinkedHashMap<>(); + r.put("reminded", pending); + return r; + } + + @Override + public String exportReceipts(Integer serid) { + Notice n = get(serid); + List> fields = "fill".equals(n.getReceiptMode()) ? DormEnums.buildFormFields(n.getFormType()) : new ArrayList<>(); + List> common = fields.stream().filter(f -> "common".equals(f.get("group"))).collect(Collectors.toList()); + List> specific = fields.stream().filter(f -> "specific".equals(f.get("group"))).collect(Collectors.toList()); + List rows = new ArrayList<>(); + List head = new ArrayList<>(Arrays.asList("房间", "床位", "学生姓名", "班级", "单据类型", "家长姓名", "与学生关系", "联系电话")); + common.forEach(f -> head.add(str(f.get("label")))); + specific.forEach(f -> head.add(str(f.get("label")))); + head.addAll(Arrays.asList("回执状态", "回执时间", "备注")); + rows.add(String.join(",", head)); + for (Receipt r : receiptRepository.findByNoticeId(serid)) { + Map fd = parseFill(r.getFillData()); + List line = new ArrayList<>(Arrays.asList( + safe(r.getRoomNo()), safe(r.getBedNo()), safe(r.getStudentName()), safe(r.getClassName()), + n.getFormType() == null ? "" : DormEnums.formTypeDesc(n.getFormType()), + safe(r.getGuardianName()), safe(r.getRelation()), safe(r.getGuardianPhone()))); + common.forEach(f -> line.add(csv(fd == null ? "" : safe(str(fd.get(f.get("key"))))))); + specific.forEach(f -> line.add(csv(fd == null ? "" : safe(str(fd.get(f.get("key"))))))); + line.add(DormEnums.receiptStatusDesc(r.getStatus())); + line.add(safe(fmt(r.getSubmittedAt()))); + line.add(csv(safe(r.getRemark()))); + rows.add(String.join(",", line)); + } + return "" + String.join("\r\n", rows); + } + + // ==================== 统计 ==================== + + @Override + public Map statis(String startTime, String endTime) { + List list = noticeRepository.findAll(); + LocalDateTime st = parseDt(startTime), et = parseDt(endTime); + if (st != null) list = list.stream().filter(n -> !nz(n.getCreateTime()).isBefore(st)).collect(Collectors.toList()); + if (et != null) list = list.stream().filter(n -> !nz(n.getCreateTime()).isAfter(et)).collect(Collectors.toList()); + + List published = list.stream().filter(n -> !"draft".equals(n.getNoticeStatus())).collect(Collectors.toList()); + List withReceipt = published.stream().filter(n -> !"none".equals(n.getReceiptMode())).collect(Collectors.toList()); + Integer uid = UserContext.getUserId(); + List mine = list.stream().filter(n -> Objects.equals(n.getPublisherId(), uid)).collect(Collectors.toList()); + + List rates = withReceipt.stream().map(n -> (Integer) progress(n.getId(), n).get("receiptRate")).collect(Collectors.toList()); + List allReceipts = receiptRepository.findByNoticeIdIn(published.stream().map(Notice::getId).collect(Collectors.toList())); + + Map out = new LinkedHashMap<>(); + out.put("total", list.size()); + out.put("publishedCount", published.size()); + out.put("myNoticeCount", mine.size()); + out.put("urgentCount", list.stream().filter(n -> "urgent".equals(n.getLevel()) || "important".equals(n.getLevel())).count()); + out.put("totalReceipts", allReceipts.stream().filter(r -> "confirmed".equals(r.getStatus()) || "filled".equals(r.getStatus())).count()); + out.put("avgReceiptRate", rates.isEmpty() ? 0 : Math.round(rates.stream().mapToInt(Integer::intValue).average().orElse(0))); + out.put("totalCovered", published.stream().mapToInt(this::allowCount).sum()); + out.put("pendingReview", receiptRepository.findByNoticeIdIn(list.stream().map(Notice::getId).collect(Collectors.toList())).stream().filter(r -> "none".equals(r.getStatus())).count()); + + out.put("byPublisher", groupCount(list, n -> safe(n.getPublisherName())).entrySet().stream() + .map(e -> kv("publisherName", e.getKey(), "count", e.getValue())).collect(Collectors.toList())); + Map monthMap = new TreeMap<>(); + list.forEach(n -> monthMap.merge(n.getCreateTime() == null ? "" : n.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy-MM")), 1L, Long::sum)); + out.put("byMonth", monthMap.entrySet().stream().map(e -> { + Map m = new LinkedHashMap<>(); + m.put("year", e.getKey().length() >= 4 ? Integer.parseInt(e.getKey().substring(0, 4)) : 0); + m.put("month", e.getKey().length() >= 7 ? Integer.parseInt(e.getKey().substring(5, 7)) : 0); + m.put("count", e.getValue()); + return m; + }).collect(Collectors.toList())); + out.put("byType", groupCount(list, n -> safe(n.getNoticeType())).entrySet().stream() + .map(e -> kv("label", e.getKey(), "key", e.getKey(), "count", e.getValue())).collect(Collectors.toList())); + + List buildings = buildingRepository.findAllByOrderByIdAsc(); + List> byBuilding = new ArrayList<>(); + for (DormBuilding b : buildings) { + List hit = published.stream().filter(n -> targetRooms(n).stream().anyMatch(r -> Objects.equals(r.getBuildingId(), b.getId()))) + .collect(Collectors.toList()); + int beds = hit.stream().flatMap(n -> targetRooms(n).stream()).filter(r -> Objects.equals(r.getBuildingId(), b.getId())) + .map(DormRoom::getRoomId).distinct().mapToInt(this::usedBeds).sum(); + Map m = new LinkedHashMap<>(); + m.put("buildingName", b.getBuildingName()); + m.put("noticeCount", hit.size()); + m.put("beds", beds); + byBuilding.add(m); + } + out.put("byBuilding", byBuilding); + + List> byNotice = mine.stream().filter(n -> !"draft".equals(n.getNoticeStatus())).map(n -> { + Map m = new LinkedHashMap<>(); + m.put("id", n.getId()); + m.put("title", n.getTitle()); + m.put("noticeType", n.getNoticeType()); + m.put("level", n.getLevel()); + m.put("levelDesc", DormEnums.levelDesc(n.getLevel())); + m.put("noticeStatus", n.getNoticeStatus()); + m.put("noticeStatusDesc", DormEnums.statusDesc(n.getNoticeStatus())); + m.putAll(progress(n.getId(), n)); + return m; + }).collect(Collectors.toList()); + out.put("byNotice", byNotice); + return out; + } + + // ==================== 家长端 ==================== + + @Override + public Map myNotices(Map query) { + Integer viewerRoom = viewerRoomId(); + List base = viewerRoom == null ? Collections.emptyList() : publishedNotices().stream() + .filter(n -> targetsRoom(n, viewerRoom)).collect(Collectors.toList()); + List sorted = sortByUrgencyTime(applyFilters(base, query)); + List> vos = sorted.stream().map(n -> toParentVo(n, viewerRoom)).collect(Collectors.toList()); + int todo = 0, done = 0, finished = 0; + for (Notice n : sorted) { + boolean viewOnly = "none".equals(n.getReceiptMode()); + Receipt my = viewerRoom == null ? null : receiptRepository.findByNoticeIdAndStudentId(n.getId(), StudentId()).orElse(null); + boolean acted = my != null && ("confirmed".equals(my.getStatus()) || "filled".equals(my.getStatus())); + if (n.getNoticeStatus().equals("closed") || n.getNoticeStatus().equals("finished")) { + finished++; + } + if (!viewOnly && !acted && "published".equals(n.getNoticeStatus())) { + todo++; + } + if (acted) { + done++; + } + } + Map buckets = new LinkedHashMap<>(); + buckets.put("todo", todo); + buckets.put("done", done); + buckets.put("finished", finished); + buckets.put("all", vos.size()); + Map out = new LinkedHashMap<>(); + out.put("buckets", buckets); + out.put("list", vos); + return out; + } + + @Override + @Transactional + public Map myNotice(Integer serid) { + Notice n = get(serid); + Integer viewerRoom = viewerRoomId(); + Integer sid = StudentId(); + if (viewerRoom != null) { + markRoomRead(n, viewerRoom); + } + Receipt my = sid == null ? null : receiptRepository.findByNoticeIdAndStudentId(serid, sid).orElse(null); + // confirm:打开即自动确认 + if ("confirm".equals(n.getReceiptMode()) && my != null && "none".equals(my.getStatus())) { + my.setStatus("confirmed"); + my.setSubmittedAt(LocalDateTime.now()); + receiptRepository.save(my); + } + Map notice = new LinkedHashMap<>(); + notice.put("id", n.getId()); + notice.put("title", n.getTitle()); + notice.put("noticeType", n.getNoticeType()); + notice.put("level", n.getLevel()); + notice.put("levelDesc", DormEnums.levelDesc(n.getLevel())); + notice.put("content", n.getContent()); + notice.put("effectiveTime", fmt(n.getEffectiveTime())); + notice.put("deadlineTime", fmt(n.getDeadlineTime())); + notice.put("receiptMode", n.getReceiptMode()); + notice.put("receiptModeDesc", DormEnums.modeDesc(n.getReceiptMode())); + notice.put("needSupervisor", nz(n.getNeedSupervisor())); + notice.put("publisherName", n.getPublisherName()); + notice.put("deptName", n.getDeptName()); + notice.put("roomNo", viewerRoom == null ? "" : roomLabel(viewerRoom)); + notice.put("buildingName", viewerRoom == null ? "" : buildingNameOfRoom(viewerRoom)); + notice.put("bedNo", studentBedNo(sid)); + notice.put("noticeStatus", n.getNoticeStatus()); + notice.put("noticeStatusDesc", DormEnums.statusDesc(n.getNoticeStatus())); + notice.put("files", fileItems(n.getFileIds())); + Map out = new LinkedHashMap<>(); + out.put("notice", notice); + out.put("formType", n.getFormType()); + out.put("formTypeDesc", n.getFormType() == null ? "" : DormEnums.formTypeDesc(n.getFormType())); + out.put("formFields", "fill".equals(n.getReceiptMode()) ? DormEnums.buildFormFields(n.getFormType()) : null); + out.put("my", my == null ? null : myReceiptInfo(my)); + return out; + } + + @Override + @Transactional + public Map submitReceipt(Map body) { + Notice n = get(intg(body.get("serid"), 0)); + Integer sid = StudentId(); + if (sid == null) { + throw new BizException(ErrorCode.FORBIDDEN, "当前账号未匹配到住宿生档案"); + } + if (!"published".equals(n.getNoticeStatus())) { + throw new BizException(ErrorCode.FORBIDDEN, "该通知已截止或已撤回,无法提交"); + } + if (n.getDeadlineTime() != null && n.getDeadlineTime().isBefore(LocalDateTime.now())) { + throw new BizException(ErrorCode.FORBIDDEN, "已超过回执截止时间,如需补交请联系宿管"); + } + Map fd = parseFill(body.get("fillData")); + if ("fill".equals(n.getReceiptMode())) { + List missing = new ArrayList<>(); + for (Map f : DormEnums.buildFormFields(n.getFormType())) { + if (Boolean.TRUE.equals(f.get("required"))) { + Object v = fd == null ? null : fd.get(f.get("key")); + if (v == null || !StringUtils.hasText(String.valueOf(v).trim())) { + missing.add(str(f.get("label"))); + } + } + } + if (!missing.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请填写:" + String.join("、", missing)); + } + } + Integer viewerRoom = viewerRoomId(); + Receipt r = receiptRepository.findByNoticeIdAndStudentId(n.getId(), sid) + .orElseGet(() -> newReceipt(n.getId(), sid, viewerRoom, n.getSchId())); + r.setGuardianName(pick(str(body.get("guardianName")), r.getGuardianName())); + r.setGuardianPhone(pick(str(body.get("guardianPhone")), r.getGuardianPhone())); + r.setRelation(pick(str(body.get("relation")), r.getRelation())); + if (body.containsKey("remark")) { + r.setRemark(str(body.get("remark"))); + } + if ("fill".equals(n.getReceiptMode())) { + r.setFillData(fd == null ? null : JSON.toJSONString(fd)); + r.setStatus("filled"); + } else { + r.setStatus("confirmed"); + } + r.setRead(true); + if (r.getReadAt() == null) { + r.setReadAt(LocalDateTime.now()); + } + r.setSubmittedAt(LocalDateTime.now()); + receiptRepository.save(r); + Map out = new LinkedHashMap<>(); + out.put("receiptId", r.getId()); + out.put("status", r.getStatus()); + return out; + } + + @Override + @Transactional + public void cancelReceipt(Integer serid) { + Notice n = get(serid); + Integer sid = StudentId(); + receiptRepository.findByNoticeIdAndStudentId(serid, sid).ifPresent(r -> { + r.setStatus("canceled"); + receiptRepository.save(r); + }); + } + + @Override + @Transactional + public Map initializeRed() { + Integer viewerRoom = viewerRoomId(); + int count = 0; + if (viewerRoom != null) { + for (Notice n : publishedNotices()) { + if (targetsRoom(n, viewerRoom) && !isRoomRead(n, viewerRoom)) { + markRoomRead(n, viewerRoom); + count++; + } + } + } + Map r = new LinkedHashMap<>(); + r.put("count", count); + return r; + } + + @Override + public void allFileDown(Integer serid, javax.servlet.http.HttpServletResponse response) { + Notice n = get(serid); + List ids = splitCsv(n.getFileIds()).stream().map(s -> intg(s, null)) + .filter(Objects::nonNull).collect(Collectors.toList()); + String zip = StringUtils.hasText(n.getTitle()) ? n.getTitle() : ("通知附件" + serid); + fileService.downloadZipByIds(ids, zip, response); + } + + @Override + @Transactional + public void markRead(Integer serid, Integer studentId) { + Notice n = get(serid); + if (studentId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "学生不能为空"); + } + Integer roomId = studentRepository.findByStudentId(studentId).map(DormStudent::getRoomId).orElse(null); + if (!dormNoticeReadRepository.existsByNoticeIdAndStudentId(serid, studentId)) { + DormNoticeRead rd = new DormNoticeRead(); + rd.setNoticeId(serid); + rd.setStudentId(studentId); + rd.setRoomId(roomId); + rd.setReadAt(LocalDateTime.now()); + rd.setSchId(n.getSchId()); + dormNoticeReadRepository.save(rd); + } + receiptRepository.findByNoticeIdAndStudentId(serid, studentId).ifPresent(r -> { + if (!Boolean.TRUE.equals(r.getRead())) { + r.setRead(true); + r.setReadAt(LocalDateTime.now()); + receiptRepository.save(r); + } + }); + } + + @Override + public Map remindStudent(Integer serid, Integer studentId) { + get(serid); // 校验通知存在 + Map r = new LinkedHashMap<>(); + r.put("reminded", studentId != null ? 1 : 0); + // 短信通道未接入:此处仅确认催办请求,真实下发待接入 DefaultSmsSender 后补 + return r; + } + + // ==================== 内部工具 ==================== + + private Notice get(Integer id) { + return noticeRepository.findById(id).orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在或已被删除")); + } + + private List publishedNotices() { + LocalDateTime now = LocalDateTime.now(); + return noticeRepository.findAll().stream() + .filter(n -> !Integer.valueOf(1).equals(n.getIsPulled()) && PUBLISHED_STATUS.contains(n.getNoticeStatus())) + .filter(n -> n.getTimingSendTime() == null || !n.getTimingSendTime().isAfter(now)) + .collect(Collectors.toList()); + } + + private Integer StudentId() { + return UserContext.getUserId(); + } + + private DormStudent viewerStudent() { + Integer sid = StudentId(); + if (sid == null) { + return null; + } + DormStudent s = studentRepository.findByStudentId(sid).orElse(null); + return s != null && nz(s.getRoomId()) > 0 ? s : null; + } + + private Integer viewerRoomId() { + DormStudent s = viewerStudent(); + return s == null ? null : s.getRoomId(); + } + + private String studentBedNo(Integer sid) { + if (sid == null) { + return ""; + } + return studentRepository.findByStudentId(sid).map(DormStudent::getBedNo).orElse(""); + } + + private boolean targetsRoom(Notice n, Integer roomId) { + if (roomId == null) { + return false; + } + if ("ALL".equalsIgnoreCase(str(n.getReceiveList()).trim())) { + return true; + } + return targetRoomIds(n).contains(roomId); + } + + private Set targetRoomIds(Notice n) { + return targetRooms(n).stream().map(DormRoom::getRoomId).collect(Collectors.toCollection(LinkedHashSet::new)); + } + + /** 展开接收列表为房间集合:ALL / 楼栋前缀(xx0000) / 具体房间。 */ + private List targetRooms(Notice n) { + List all = roomRepository.findAllByOrderByIdAsc(); + String raw = str(n.getReceiveList()) == null ? "" : str(n.getReceiveList()).trim(); + if ("ALL".equalsIgnoreCase(raw)) { + return all; + } + LinkedHashSet ids = new LinkedHashSet<>(); + for (String tok : splitCsv(raw)) { + try { + int id = Integer.parseInt(tok); + if (id % 10000 == 0 && id >= 10000) { + int bid = id / 10000; + all.stream().filter(r -> Objects.equals(r.getBuildingId(), bid)).forEach(r -> ids.add(r.getRoomId())); + } else { + ids.add(id); + } + } catch (NumberFormatException ignore) { + // 非数字 token 忽略 + } + } + return all.stream().filter(r -> ids.contains(r.getRoomId())).collect(Collectors.toList()); + } + + private int allowCount(Notice n) { + return targetRooms(n).stream().mapToInt(r -> usedBeds(r.getRoomId())).sum(); + } + + private int usedBeds(Integer roomId) { + return (int) studentRepository.countByRoomId(roomId); + } + + private Set readRooms(Notice n) { + Set set = new HashSet<>(); + for (String s : splitCsv(n.getReadRoomIds())) { + try { + set.add(Integer.parseInt(s)); + } catch (NumberFormatException ignore) { + } + } + return set; + } + + private boolean isRoomRead(Notice n, Integer roomId) { + return roomId == null || readRooms(n).contains(roomId); + } + + @Transactional + public void markRoomRead(Notice n, Integer roomId) { + Integer sid = StudentId(); + // 房间级阅读(阅读情况分组用) + Set rooms = readRooms(n); + if (roomId != null && rooms.add(roomId)) { + n.setReadRoomIds(rooms.stream().map(String::valueOf).collect(Collectors.joining(","))); + noticeRepository.save(n); + } + // 学生级阅读记录(已读人数按实际打开的学生计) + if (roomId != null && sid != null && !dormNoticeReadRepository.existsByNoticeIdAndStudentId(n.getId(), sid)) { + DormNoticeRead rd = new DormNoticeRead(); + rd.setNoticeId(n.getId()); + rd.setStudentId(sid); + rd.setRoomId(roomId); + rd.setReadAt(LocalDateTime.now()); + rd.setSchId(n.getSchId()); + dormNoticeReadRepository.save(rd); + } + // 打开者本人回执标记已读(学生级,不再整房标记,避免已读被床位放大) + if (roomId != null && sid != null) { + receiptRepository.findByNoticeIdAndStudentId(n.getId(), sid).ifPresent(r -> { + if (!Boolean.TRUE.equals(r.getRead())) { + r.setRead(true); + r.setReadAt(LocalDateTime.now()); + receiptRepository.save(r); + } + }); + } + } + + /** 发布需确认/需填报类时,为覆盖房间的在住学生补齐应收回执行(状态 none)。 */ + private void ensureReceipts(Notice n) { + if ("none".equals(n.getReceiptMode()) || "draft".equals(n.getNoticeStatus())) { + return; + } + for (DormRoom room : targetRooms(n)) { + for (DormStudent s : studentRepository.findByRoomIdOrderByIdAsc(room.getRoomId())) { + if (!receiptRepository.findByNoticeIdAndStudentId(n.getId(), s.getStudentId()).isPresent()) { + receiptRepository.save(newReceipt(n.getId(), s.getStudentId(), room.getRoomId(), n.getSchId())); + } + } + } + } + + private Receipt newReceipt(Integer noticeId, Integer studentId, Integer roomId, Integer schId) { + DormStudent s = studentRepository.findByStudentId(studentId).orElse(null); + DormRoom room = roomId == null ? null : roomRepository.findByRoomId(roomId).orElse(null); + DormBuilding b = room == null ? null : buildingRepository.findById(room.getBuildingId()).orElse(null); + Receipt r = new Receipt(); + r.setNoticeId(noticeId); + r.setStudentId(studentId); + r.setStudentName(s == null ? "" : s.getStudentName()); + r.setRoomId(roomId); + r.setRoomNo(room == null ? "" : roomLabel(roomId)); + r.setBuildingId(room == null ? null : room.getBuildingId()); + r.setBuildingName(b == null ? "" : b.getBuildingName()); + r.setBedNo(s == null ? "" : s.getBedNo()); + r.setClassName(s == null ? "" : s.getClassName()); + r.setGuardianName(s == null ? "" : s.getGuardianName()); + r.setGuardianPhone(s == null ? "" : s.getGuardianPhone()); + r.setRelation(s == null ? "" : s.getRelation()); + r.setStatus("none"); + r.setRead(s != null && Boolean.TRUE.equals(s.getIsMonitor()) && false); + r.setSchId(schId); + return r; + } + + private Map progress(Integer noticeId, Notice n) { + List list = receiptRepository.findByNoticeId(noticeId); + int should = list.size(); + int confirmed = (int) list.stream().filter(r -> "confirmed".equals(r.getStatus())).count(); + int filled = (int) list.stream().filter(r -> "filled".equals(r.getStatus())).count(); + int canceled = (int) list.stream().filter(r -> "canceled".equals(r.getStatus())).count(); + int acted = confirmed + filled; + Map p = new LinkedHashMap<>(); + p.put("should", should); + p.put("confirmed", confirmed); + p.put("filled", filled); + p.put("canceled", canceled); + p.put("unsigned", Math.max(should - acted - canceled, 0)); + p.put("receiptRate", should == 0 ? 0 : (int) Math.round(acted * 100.0 / should)); + p.put("rooms", targetRooms(n).size()); + p.put("beds", allowCount(n)); + return p; + } + + private int readCount(Notice n) { + // 已读人数 = 实际打开该通知的学生数(学生级阅读记录),非「已读房间 × 床位」 + return (int) dormNoticeReadRepository.countByNoticeId(n.getId()); + } + + private Map toVo(Notice n, Integer viewerRoom) { + Map m = new LinkedHashMap<>(); + m.put("id", n.getId()); + m.put("title", n.getTitle()); + m.put("content", n.getContent()); + m.put("noticeType", n.getNoticeType()); + m.put("level", n.getLevel()); + m.put("levelDesc", DormEnums.levelDesc(n.getLevel())); + m.put("effectiveTime", fmt(n.getEffectiveTime())); + m.put("deadlineTime", fmt(n.getDeadlineTime())); + m.put("receiptMode", n.getReceiptMode()); + m.put("receiptModeDesc", DormEnums.modeDesc(n.getReceiptMode())); + m.put("publisherAccount", n.getPublisherId()); + m.put("publisherName", n.getPublisherName()); + m.put("deptId", n.getDeptId()); + m.put("deptName", n.getDeptName()); + m.put("needSupervisor", nz(n.getNeedSupervisor())); + m.put("status", isRoomRead(n, viewerRoom) ? 2 : 1); + m.put("statusDesc", isRoomRead(n, viewerRoom) ? "已读" : "未读"); + m.put("noticeStatus", n.getNoticeStatus()); + m.put("noticeStatusDesc", DormEnums.statusDesc(n.getNoticeStatus())); + m.put("topmost", nz(n.getTopmost())); + m.put("readCount", readCount(n)); + m.put("allowCount", allowCount(n)); + m.put("read", isRoomRead(n, viewerRoom)); + m.put("isPulled", nz(n.getIsPulled())); + m.put("timingSendTime", fmt(n.getTimingSendTime())); + m.put("smsFlag", nz(n.getSmsFlag())); + m.put("receiveType", "room"); + m.put("receiveList", n.getReceiveList()); + m.put("receiveText", receiveText(n)); + m.put("roomCount", targetRooms(n).size()); + m.put("bedCount", allowCount(n)); + m.put("progress", "draft".equals(n.getNoticeStatus()) ? null : progress(n.getId(), n)); + m.put("createBy", n.getCreateBy()); + m.put("createTime", fmt(n.getCreateTime())); + m.put("updateBy", n.getUpdateBy()); + m.put("updateTime", fmt(n.getUpdateTime())); + return m; + } + + private Map toDetail(Notice n) { + Map m = new LinkedHashMap<>(); + m.put("serid", n.getId()); + m.put("title", n.getTitle()); + m.put("Contents", n.getContent()); + m.put("DeptID", n.getDeptId()); + m.put("DeptName", n.getDeptName()); + m.put("TeaName", n.getPublisherName()); + m.put("EditDate", fmt(n.getCreateTime())); + m.put("numcount", allowCount(n)); + m.put("readnumcount", readCount(n)); + m.put("ReceiveList", n.getReceiveList()); + m.put("DsfsTime", fmt(n.getTimingSendTime())); + m.put("isDsfs", n.getTimingSendTime() != null); + m.put("noticeType", n.getNoticeType()); + m.put("level", n.getLevel()); + m.put("levelDesc", DormEnums.levelDesc(n.getLevel())); + m.put("effectiveTime", fmt(n.getEffectiveTime())); + m.put("deadlineTime", fmt(n.getDeadlineTime())); + m.put("receiptMode", n.getReceiptMode()); + m.put("receiptModeDesc", DormEnums.modeDesc(n.getReceiptMode())); + m.put("needSupervisor", nz(n.getNeedSupervisor())); + m.put("noticeStatus", n.getNoticeStatus()); + m.put("noticeStatusDesc", DormEnums.statusDesc(n.getNoticeStatus())); + m.put("progress", "draft".equals(n.getNoticeStatus()) ? null : progress(n.getId(), n)); + m.put("ChaoSend", fileItems(n.getFileIds())); + m.put("tlist", "ALL".equalsIgnoreCase(str(n.getReceiveList()).trim()) ? "all" : new ArrayList<>(targetRoomIds(n))); + m.put("receiveType", "room"); + m.put("receiveText", receiveText(n)); + m.put("formType", n.getFormType()); + m.put("formTypeDesc", n.getFormType() == null ? "" : DormEnums.formTypeDesc(n.getFormType())); + m.put("formFields", "fill".equals(n.getReceiptMode()) ? DormEnums.buildFormFields(n.getFormType()) : null); + return m; + } + + private Map toParentVo(Notice n, Integer viewerRoom) { + Integer sid = StudentId(); + Receipt my = sid == null ? null : receiptRepository.findByNoticeIdAndStudentId(n.getId(), sid).orElse(null); + boolean deadlinePassed = n.getDeadlineTime() != null && n.getDeadlineTime().isBefore(LocalDateTime.now()); + Map m = new LinkedHashMap<>(); + m.put("id", n.getId()); + m.put("title", n.getTitle()); + m.put("noticeType", n.getNoticeType()); + m.put("level", n.getLevel()); + m.put("levelDesc", DormEnums.levelDesc(n.getLevel())); + m.put("effectiveTime", fmt(n.getEffectiveTime())); + m.put("deadlineTime", fmt(n.getDeadlineTime())); + m.put("receiptMode", n.getReceiptMode()); + m.put("receiptModeDesc", DormEnums.modeDesc(n.getReceiptMode())); + m.put("content", n.getContent()); + m.put("publisherName", n.getPublisherName()); + m.put("deptName", n.getDeptName()); + m.put("roomNo", viewerRoom == null ? "" : roomLabel(viewerRoom)); + m.put("buildingName", viewerRoom == null ? "" : buildingNameOfRoom(viewerRoom)); + m.put("bedNo", studentBedNo(sid)); + m.put("createTime", fmt(n.getCreateTime())); + m.put("noticeStatus", n.getNoticeStatus()); + m.put("noticeStatusDesc", DormEnums.statusDesc(n.getNoticeStatus())); + m.put("viewOnly", "none".equals(n.getReceiptMode())); + m.put("canReceipt", "published".equals(n.getNoticeStatus()) && !deadlinePassed); + m.put("my", my == null ? null : myReceiptInfo(my)); + return m; + } + + private Map myReceiptInfo(Receipt r) { + Map m = new LinkedHashMap<>(); + m.put("receiptId", r.getId()); + m.put("status", r.getStatus()); + m.put("statusDesc", DormEnums.receiptStatusDesc(r.getStatus())); + m.put("fillData", parseFill(r.getFillData())); + m.put("guardianName", r.getGuardianName()); + m.put("guardianPhone", r.getGuardianPhone()); + m.put("relation", r.getRelation()); + m.put("remark", r.getRemark()); + m.put("submittedAt", fmt(r.getSubmittedAt())); + return m; + } + + private Map toReceiptBrief(Receipt r) { + Map m = new LinkedHashMap<>(); + m.put("receiptId", r.getId()); + m.put("studentId", r.getStudentId()); + m.put("studentName", r.getStudentName()); + m.put("roomNo", r.getRoomNo()); + m.put("buildingName", r.getBuildingName()); + m.put("bedNo", r.getBedNo()); + m.put("guardianName", r.getGuardianName()); + m.put("guardianPhone", r.getGuardianPhone()); + m.put("relation", r.getRelation()); + m.put("status", r.getStatus()); + m.put("statusDesc", DormEnums.receiptStatusDesc(r.getStatus())); + m.put("hasReceipt", "confirmed".equals(r.getStatus()) || "filled".equals(r.getStatus())); + m.put("submittedAt", fmt(r.getSubmittedAt())); + m.put("fillData", parseFill(r.getFillData())); + m.put("remark", r.getRemark()); + return m; + } + + private List> fileItems(String fileIds) { + List ids = splitCsv(fileIds).stream().map(s -> intg(s, null)) + .filter(Objects::nonNull).collect(Collectors.toList()); + if (ids.isEmpty()) { + return new ArrayList<>(); + } + Map byId = upFileRepository.findAllById(ids).stream() + .collect(Collectors.toMap(UpFile::getId, u -> u, (a, b) -> a)); + List> out = new ArrayList<>(); + for (Integer id : ids) { + UpFile u = byId.get(id); + Map f = new LinkedHashMap<>(); + f.put("id", id); + f.put("name", u == null ? "附件" + id : u.getFileTrueName()); + f.put("size", u == null || u.getFileSize() == null ? 0 : u.getFileSize()); + f.put("fileid", id); + f.put("filename", u == null ? "" : u.getFileName()); + out.add(f); + } + return out; + } + + private String receiveText(Notice n) { + List rooms = targetRooms(n); + if ("ALL".equalsIgnoreCase(str(n.getReceiveList()).trim()) || rooms.size() == roomRepository.count()) { + int beds = rooms.stream().mapToInt(r -> usedBeds(r.getRoomId())).sum(); + return "全部宿舍(" + rooms.size() + " 间 / " + beds + " 个床位)"; + } + // 按楼栋分组:楼栋名(房间号、房间号…),避免同楼栋重复列名 + Map> byBld = new LinkedHashMap<>(); + for (DormRoom r : rooms) { + DormBuilding b = buildingRepository.findById(r.getBuildingId()).orElse(null); + String bn = b == null ? "未分区" : b.getBuildingName(); + byBld.computeIfAbsent(bn, k -> new ArrayList<>()).add(String.valueOf(r.getRoomNo())); + } + String names = byBld.entrySet().stream() + .map(e -> e.getKey() + "(" + String.join("、", e.getValue()) + ")") + .collect(Collectors.joining("、")); + return names.length() > 60 ? names.substring(0, 60) + " …" : names; + } + + private String roomLabel(Integer roomId) { + DormRoom room = roomRepository.findByRoomId(roomId).orElse(null); + if (room == null) { + return String.valueOf(roomId); + } + DormBuilding b = buildingRepository.findById(room.getBuildingId()).orElse(null); + return shortName(b) + " " + room.getRoomNo(); + } + + private String buildingNameOfRoom(Integer roomId) { + DormRoom room = roomRepository.findByRoomId(roomId).orElse(null); + DormBuilding b = room == null ? null : buildingRepository.findById(room.getBuildingId()).orElse(null); + return b == null ? "" : b.getBuildingName(); + } + + private String shortName(DormBuilding b) { + if (b == null) { + return ""; + } + return StringUtils.hasText(b.getRoomPrefix()) ? b.getRoomPrefix() + "号楼" : b.getBuildingName(); + } + + // ---- 过滤与排序 ---- + + private List applyFilters(List list, Map q) { + List out = new ArrayList<>(list); + String kw = firstNonBlank(str(q.get("keyword")), str(q.get("Name"))); + if (StringUtils.hasText(kw)) { + out = out.stream().filter(n -> (safe(n.getTitle()) + safe(n.getNoticeType()) + safe(n.getPublisherName()) + + safe(n.getDeptName()) + receiveText(n)).contains(kw)).collect(Collectors.toList()); + } + String dept = firstNonBlank(str(q.get("deptId")), str(q.get("Depttype"))); + if (StringUtils.hasText(dept)) { + out = out.stream().filter(n -> dept.equals(n.getDeptId())).collect(Collectors.toList()); + } + if (StringUtils.hasText(str(q.get("noticeType")))) { + out = out.stream().filter(n -> str(q.get("noticeType")).equals(n.getNoticeType())).collect(Collectors.toList()); + } + String level = str(q.get("level")); + if (StringUtils.hasText(level) && !"all".equals(level)) { + out = out.stream().filter(n -> level.equals(n.getLevel())).collect(Collectors.toList()); + } + Integer buildingId = intg(q.get("buildingId"), null); + if (buildingId != null) { + out = out.stream().filter(n -> targetRooms(n).stream().anyMatch(r -> Objects.equals(r.getBuildingId(), buildingId))) + .collect(Collectors.toList()); + } + String ns = str(q.get("noticeStatus")); + if (StringUtils.hasText(ns) && !"all".equals(ns)) { + out = out.stream().filter(n -> ns.equals(n.getNoticeStatus())).collect(Collectors.toList()); + } + LocalDateTime st = parseDt(q.get("startTime")), et = parseDt(q.get("endTime")); + if (st != null) out = out.stream().filter(n -> !nz(n.getEffectiveTime() == null ? n.getCreateTime() : n.getEffectiveTime()).isBefore(st)).collect(Collectors.toList()); + if (et != null) out = out.stream().filter(n -> !nz(n.getEffectiveTime() == null ? n.getCreateTime() : n.getEffectiveTime()).isAfter(et)).collect(Collectors.toList()); + int rt = intg(q.get("readType"), 0); + if (rt == 1) { + Integer vr = viewerRoomId(); + out = out.stream().filter(n -> vr != null && !isRoomRead(n, vr)).collect(Collectors.toList()); + } else if (rt == 2) { + Integer vr = viewerRoomId(); + out = out.stream().filter(n -> vr != null && isRoomRead(n, vr)).collect(Collectors.toList()); + } + return out; + } + + private List sortByUrgencyTime(List list) { + List out = new ArrayList<>(list); + out.sort((a, b) -> { + int lw = levelWeight(b.getLevel()) - levelWeight(a.getLevel()); + if (lw != 0) return lw; + int tp = nz(b.getTopmost()) - nz(a.getTopmost()); + if (tp != 0) return tp; + return nz(b.getCreateTime()).compareTo(nz(a.getCreateTime())); + }); + return out; + } + + private int levelWeight(String l) { + return "urgent".equals(l) ? 2 : "important".equals(l) ? 1 : 0; + } + + private Map paginate(List> content, int pageIndex, int pageSize) { + int total = content.size(); + int totalPages = pageSize == 0 ? 0 : (int) Math.ceil(total * 1.0 / pageSize); + int from = Math.max((pageIndex - 1) * pageSize, 0); + int to = Math.min(from + pageSize, total); + List> slice = from >= total ? Collections.emptyList() : content.subList(from, to); + Map r = new LinkedHashMap<>(); + r.put("content", slice); + r.put("total", total); + r.put("pageIndex", pageIndex); + r.put("pageSize", pageSize); + r.put("totalPages", totalPages); + r.put("hasNext", pageIndex < totalPages); + return r; + } + + private Map groupCount(List list, java.util.function.Function key) { + Map m = new LinkedHashMap<>(); + for (Notice n : list) { + m.merge(key.apply(n), 1L, Long::sum); + } + return m; + } + + private Map kv(Object... kv) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i + 1 < kv.length; i += 2) { + m.put(String.valueOf(kv[i]), kv[i + 1]); + } + return m; + } + + // ---- 类型/解析小工具 ---- + + private String fmt(LocalDateTime t) { + return t == null ? null : t.format(DTF); + } + + private LocalDateTime parseDt(Object o) { + if (o == null) { + return null; + } + String s = String.valueOf(o).trim(); + if (s.isEmpty() || "null".equals(s)) { + return null; + } + try { + if (s.length() == 10) { + return java.time.LocalDate.parse(s).atStartOfDay(); + } + return LocalDateTime.parse(s.replace('T', ' ').substring(0, Math.min(19, s.replace('T', ' ').length())), DTF); + } catch (Exception e) { + return null; + } + } + + private Map parseFill(Object o) { + if (o == null) { + return null; + } + try { + if (o instanceof Map) { + @SuppressWarnings("unchecked") + Map m = (Map) o; + return m; + } + String s = String.valueOf(o).trim(); + if (s.isEmpty() || "null".equals(s)) { + return null; + } + @SuppressWarnings("unchecked") + Map m = JSON.parseObject(s, Map.class); + return m; + } catch (Exception e) { + return null; + } + } + + private List splitCsv(String s) { + List out = new ArrayList<>(); + if (!StringUtils.hasText(s)) { + return out; + } + for (String part : s.split(",")) { + String t = part.trim(); + if (!t.isEmpty()) { + out.add(t); + } + } + return out; + } + + private String firstNonBlank(String a, String b) { + return StringUtils.hasText(a) ? a : b; + } + + private String pick(String a, String b) { + return StringUtils.hasText(a) ? a : b; + } + + private LocalDateTime nz(LocalDateTime t) { + return t == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : t; + } + + private int nz(Integer v) { + return v == null ? 0 : v; + } + + private String safe(String s) { + return s == null ? "" : s; + } + + private String csv(String s) { + return s == null ? "" : s.replace(",", ",").replace("\r", " ").replace("\n", " "); + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static boolean bool(Object o) { + if (o == null) { + return false; + } + if (o instanceof Boolean) { + return (Boolean) o; + } + String s = String.valueOf(o).trim(); + return "true".equalsIgnoreCase(s) || "1".equals(s); + } + + private static Integer intg(Object o, Integer dft) { + if (o == null) { + return dft; + } + if (o instanceof Number) { + return ((Number) o).intValue(); + } + try { + return Integer.parseInt(String.valueOf(o).trim()); + } catch (NumberFormatException e) { + return dft; + } + } +} diff --git a/src/main/java/com/yunhai/dormnotice/service/impl/UserCenterService.java b/src/main/java/com/yunhai/dormnotice/service/impl/UserCenterService.java new file mode 100644 index 0000000..800f235 --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/service/impl/UserCenterService.java @@ -0,0 +1,268 @@ +package com.yunhai.dormnotice.service.impl; + +import com.yunhai.dormnotice.model.SchTeaList; +import com.yunhai.dormnotice.service.IClassService; +import com.yunhai.dormnotice.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.BoaGroup; +import com.yunhai.platform.utils.model.BoaModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 用户中心门面服务实现。 + *

原 BaseApiController.getSchoolAll 的组树组装、教师挂组、递归建树、排序逻辑整体下沉至此; + * GetGroupUser 空桩改为显式报错(未接入用户中心对应开放接口时提示,而非静默返回空列表)。 + * 跨仓调用统一收敛到 {@link YschBaseOpenApi} 门面,Controller 不再直接静态调用。

+ */ +@Service +public class UserCenterService implements IUserCenterService { + + private static final Logger log = LoggerFactory.getLogger(UserCenterService.class); + + /** 学生组(UserBase GroupTypeEnum.STUDENT_GROUP),组树中剔除 */ + private static final int GROUP_TYPE_STUDENT = 8; + + @Autowired + private IClassService classService; + + // ============================ 学校组织树 ============================ + + @Override + public List> getSchoolAllTree(Integer schId) { + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + + // 1. userbase 组树(整棵学校子树),剔除学生组 + List> flatNodes = new ArrayList<>(); + BoaGroup root = YschBaseOpenApi.getGroupById(schId, true); + if (root == null) { + log.warn("[getSchoolAll] 用户中心未找到学校组, schId={}", schId); + return Collections.emptyList(); + } + flattenGroup(root, 0, flatNodes); + + // 2. bschclass 全部教师,按 GroupIds 挂到对应组下;无有效组的兜底挂学校根节点 + Set treeGroupIds = new HashSet<>(); + for (Map node : flatNodes) { + treeGroupIds.add((Integer) node.get("id")); + } + Map teacherMap = new LinkedHashMap<>(); + Map> teacherPids = new LinkedHashMap<>(); + List teachers = classService.getSchTeaList(schId); + for (SchTeaList tea : teachers) { + if (tea.getUserTeaSerID() == null) { + continue; + } + // 同一教师多行(多组/接口重复数据)合并,按 (教师, 组) 去重 + teacherMap.putIfAbsent(tea.getUserTeaSerID(), tea); + // 接口现行字段为单个 GroupID,兼容旧字段 GroupIds + List teaGroupIds = tea.getGroupIds(); + if ((teaGroupIds == null || teaGroupIds.isEmpty()) && tea.getGroupId() != null) { + teaGroupIds = Collections.singletonList(tea.getGroupId()); + } + if (teaGroupIds != null) { + for (Integer gid : teaGroupIds) { + if (gid != null && treeGroupIds.contains(gid)) { + teacherPids.computeIfAbsent(tea.getUserTeaSerID(), k -> new HashSet<>()).add(gid); + } + } + } + } + for (Map.Entry entry : teacherMap.entrySet()) { + int teaId = entry.getKey(); + SchTeaList tea = entry.getValue(); + Set pids = teacherPids.get(teaId); + if (pids == null || pids.isEmpty()) { + // 教师所属组均被剔除(如学生组)或无组信息,挂学校根节点 + pids = new HashSet<>(Collections.singletonList(treeGroupIds.contains(schId) ? schId : 0)); + } + for (Integer pid : pids) { + Map node = new LinkedHashMap<>(); + node.put("id", teaId); + node.put("name", tea.getTeaName() != null ? tea.getTeaName() : ""); + node.put("pid", pid); + node.put("no", tea.getTeaNo() != null ? tea.getTeaNo() : 0); + node.put("type", 2); + node.put("isParent", false); + node.put("photo", ""); + flatNodes.add(node); + } + } + + // 3. 扁平节点递归组装为树 + return formatUserTree(flatNodes, 0); + } + + /** + * 将 BoaGroup 子树扁平化为组节点列表(type=1);学生组(type=8)整棵剔除。 + * isParent=true 标识组节点,前端 ztree 据此渲染为文件夹。 + */ + private void flattenGroup(BoaGroup group, int pid, List> nodes) { + if (group == null || group.getId() == null) { + return; + } + if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) { + return; + } + Map node = new LinkedHashMap<>(); + node.put("id", group.getId()); + node.put("name", group.getName() != null ? group.getName() : ""); + node.put("pid", pid); + node.put("no", group.getSort() != null ? group.getSort() : 0); + node.put("type", 1); + node.put("isParent", true); + node.put("photo", ""); + nodes.add(node); + + if (group.getChildren() != null) { + for (BoaGroup child : group.getChildren()) { + flattenGroup(child, group.getId(), nodes); + } + } + } + + /** + * 递归构建用户树(对应 C# HttpUserApi.FormatUserTree)。 + * 根层取 pid=0 或父节点不在列表中的节点;type=1 且无子节点的空组剔除。 + */ + private List> formatUserTree(List> treeList, int pid) { + List> data; + if (pid == 0) { + Set allIds = new HashSet<>(); + for (Map n : treeList) { + allIds.add((Integer) n.get("id")); + } + data = new ArrayList<>(); + for (Map n : treeList) { + int p = (Integer) n.get("pid"); + if (p == 0 || !allIds.contains(p)) { + data.add(n); + } + } + } else { + data = new ArrayList<>(); + for (Map n : treeList) { + if ((Integer) n.get("pid") == pid) { + data.add(n); + } + } + } + + List> result = new ArrayList<>(); + for (Map item : data) { + int itemId = (Integer) item.get("id"); + int itemType = (Integer) item.get("type"); + + List> children = formatUserTree(treeList, itemId); + + // type=1(组) 且无子节点 → 不加入(C# 逻辑) + if (itemType == 1 && children.isEmpty()) { + continue; + } + + Map node = new LinkedHashMap<>(item); + // 仅组节点携带 children;叶子不带该字段,避免 ztree 误判 isParent + if (itemType == 1) { + node.put("children", children); + } + result.add(node); + } + + // 按 no 排序(数字优先,无法解析的排最后) + result.sort(Comparator.comparingInt(n -> { + Object no = n.get("no"); + if (no instanceof Integer) { + return (Integer) no; + } + if (no instanceof String) { + try { + return Integer.parseInt((String) no); + } catch (NumberFormatException e) { + return 9999; + } + } + return 9999; + })); + + return result; + } + + // ============================ 组内用户 ============================ + + @Override + public List> getGroupUser(Integer gid, Integer role) { + if (gid == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "组ID不得为空"); + } + log.info("[getGroupUser] gid={}, role={}, 用户中心组内用户开放接口尚未接入", gid, role); + // 显式报错而非静默返回空列表:原空桩会让前端误以为组内无用户 + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "组内用户查询接口暂未接入用户中心,请联系管理员配置后重试"); + } + + // ============================ 用户模块树 ============================ + + @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()) { + return Collections.emptyList(); + } + // 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望 + return buildModuleTree(modules); + } + + /** + * 将扁平模块列表组装为树(对应原 NoticeController.buildModuleTree)。 + * 顶层取 parentId=0 或父节点不在列表中的节点,子节点按 orderNumber 排序。 + */ + private List> buildModuleTree(List modules) { + Map moduleMap = modules.stream() + .collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a)); + + // 每个模块节点(含 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 || !moduleMap.containsKey(parentId)) { + roots.add(node); + } else { + @SuppressWarnings("unchecked") + List> children = (List>) nodeMap.get(parentId).get("children"); + children.add(node); + } + } + + // 保持 SDK(用户中心)返回顺序,不再按 orderNumber 重排 + return roots; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/dormnotice/utils/PageUtils.java b/src/main/java/com/yunhai/dormnotice/utils/PageUtils.java new file mode 100644 index 0000000..f64320f --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/utils/PageUtils.java @@ -0,0 +1,37 @@ +package com.yunhai.dormnotice.utils; + +import com.yunhai.dormnotice.model.BasePageQuery; +import com.yunhai.dormnotice.model.BasePageResult; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +/** + * 分页辅助类:BasePageQuery <-> Pageable,Page <-> BasePageResult。 + */ +public class PageUtils { + + private PageUtils() { + } + + public static Pageable toPageable(BasePageQuery query, Sort sort) { + int page = Math.max(query.getPageIndex() - 1, 0); + int size = Math.max(query.getPageSize(), 1); + if (sort == null) { + sort = Sort.unsorted(); + } + return PageRequest.of(page, size, sort); + } + + public static BasePageResult toResult(Page page) { + return new BasePageResult( + page.getContent(), + (int) page.getTotalElements(), + page.getNumber() + 1, + page.getSize(), + page.getTotalPages(), + page.hasNext() + ); + } +} diff --git a/src/main/java/com/yunhai/dormnotice/utils/UserContext.java b/src/main/java/com/yunhai/dormnotice/utils/UserContext.java new file mode 100644 index 0000000..f08112e --- /dev/null +++ b/src/main/java/com/yunhai/dormnotice/utils/UserContext.java @@ -0,0 +1,105 @@ +package com.yunhai.dormnotice.utils; + +/** + * 当前请求的用户上下文(ThreadLocal),参照 UserclassService 的自有 UserContext。 + *

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

+ * + *

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

    + *
  • {@link #getUserId()} —— 平台用户数字 id;教师发布人存此值,家长(登录学生)也存此值作为 studentId;
  • + *
  • {@link #getRole()} —— 端分离视角:teacher 发布端 / parent 家长端(家长即登录学生账号,不单列),经 SetRole 落会话后由切面回填;
  • + *
  • {@link #getSchId()} —— 当前身份所属学校,多租户隔离。
  • + *
+ */ +public final class UserContext { + + /** 家长端视角标识 */ + public static final String ROLE_TEACHER = "teacher"; + public static final String ROLE_PARENT = "parent"; + + 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; + } + + /** 当前视角:teacher / parent(缺省 teacher) */ + public static String getRole() { + UserInfo i = HOLDER.get(); + return i == null || i.role == null ? ROLE_TEACHER : i.role; + } + + public static boolean isParent() { + return ROLE_PARENT.equals(getRole()); + } + + 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 String role; + public Integer schId; + public Integer roleId; + public Integer type; + public String ip; + + public UserInfo() { + } + + public UserInfo(Integer userId, String userName, String role, Integer schId, Integer roleId, Integer type, String ip) { + this.userId = userId; + this.userName = userName; + this.role = role; + 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..3b640b2 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,72 @@ +server: + port: 8105 + +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/dorm_notice?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 + +# 文件上传存储路径(Docker 部署时与 volume 挂载一致) +file: + upload-path: D:/data/upload + +# 短信模块配置 +sms: + enabled: true + module-id: notice + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + sso: + server-url-prefix: http://sso.yunhaixingtong.com + client-host-url: http://localhost:8105 + allow-headers: all + allow-origins: all + url-pattern: /api/* + mobi-api-pattern: /mobiapi/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用) + app-key: 100011 + app-secret: 992d257f83704b97a2d9821adffa98ca + 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..a4168a7 --- /dev/null +++ b/src/main/resources/bootstrap.yml @@ -0,0 +1,33 @@ +spring: + application: + name: dormnotice + 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