From 05cc9cf89a35a9482ba0ec5449598f730a184655 Mon Sep 17 00:00:00 2001 From: wanghu Date: Sun, 20 Sep 2026 09:13:32 +0800 Subject: [PATCH] commit --- .gitignore | 5 + docker/application-prod.yml | 57 + docker/run-app.sh | 9 + pom.xml | 198 +++ .../yunhai/homework/HomeworkApplication.java | 18 + .../listener/HomeworkAuditListener.java | 42 + .../common/validation/ValidationGroups.java | 10 + .../com/yunhai/homework/config/Config.java | 11 + .../yunhai/homework/config/JacksonConfig.java | 35 + .../homework/config/SchedulingConfig.java | 13 + .../yunhai/homework/config/SwaggerConfig.java | 22 + .../homework/config/UserContextConfig.java | 52 + .../controller/api/BaseApiController.java | 110 ++ .../controller/api/ContactsController.java | 99 ++ .../controller/api/DictController.java | 85 ++ .../controller/api/FileController.java | 88 ++ .../controller/api/HomeworkController.java | 217 +++ .../api/NotificationController.java | 53 + .../controller/api/SmsController.java | 88 ++ .../api/StudentHomeworkController.java | 79 ++ .../api/TeacherHomeworkV2Controller.java | 219 +++ .../controller/api/TemplateController.java | 88 ++ .../yunhai/homework/entity/BaseEntity.java | 49 + .../yunhai/homework/entity/ClassStudent.java | 59 + .../yunhai/homework/entity/ContactsGroup.java | 28 + .../homework/entity/ContactsGroupUser.java | 35 + .../com/yunhai/homework/entity/Homework.java | 208 +++ .../homework/entity/HomeworkAttachment.java | 44 + .../yunhai/homework/entity/HomeworkClass.java | 35 + .../yunhai/homework/entity/HomeworkRead.java | 49 + .../homework/entity/HomeworkReadRecord.java | 58 + .../homework/entity/HomeworkReceiver.java | 45 + .../homework/entity/HomeworkSubmission.java | 98 ++ .../homework/entity/HomeworkTemplate.java | 40 + .../yunhai/homework/entity/Notification.java | 71 + .../yunhai/homework/entity/SchClassTeach.java | 46 + .../com/yunhai/homework/entity/SmsModule.java | 28 + .../homework/entity/SmsSchoolConfig.java | 37 + .../homework/entity/SubmissionAttachment.java | 54 + .../com/yunhai/homework/entity/SysDict.java | 40 + .../yunhai/homework/entity/TemplateType.java | 28 + .../com/yunhai/homework/entity/UpFile.java | 47 + .../homework/enums/HomeworkStatusEnum.java | 34 + .../homework/enums/HomeworkTypeEnum.java | 33 + .../yunhai/homework/enums/HwStatusEnum.java | 48 + .../homework/enums/NotificationTypeEnum.java | 52 + .../homework/enums/ReviewStatusEnum.java | 39 + .../homework/enums/SubmitStatusEnum.java | 50 + .../homework/job/HomeworkScheduledJobs.java | 161 +++ .../yunhai/homework/model/BasePageQuery.java | 14 + .../yunhai/homework/model/BasePageResult.java | 33 + .../com/yunhai/homework/model/SchTeaList.java | 106 ++ .../homework/model/bsch/SchClassDto.java | 29 + .../homework/model/bsch/SchGradeDto.java | 25 + .../homework/model/bsch/SchInfoDto.java | 25 + .../homework/model/bsch/SchStudentDto.java | 35 + .../homework/model/bsch/SchSubjectDto.java | 29 + .../homework/model/bsch/SchSystemDto.java | 25 + .../homework/model/bsch/SchTeachDto.java | 44 + .../homework/model/bsch/SchTreeNodeDto.java | 41 + .../model/homework/HomeworkDetailVo.java | 81 ++ .../model/homework/HomeworkPageResult.java | 26 + .../model/homework/HomeworkQuery.java | 48 + .../model/homework/HomeworkRequest.java | 65 + .../model/homework/HomeworkStatisticsVo.java | 113 ++ .../homework/HomeworkUserListResult.java | 34 + .../homework/model/homework/HomeworkVo.java | 92 ++ .../model/homework/InitializeRedVo.java | 20 + .../model/homework/UpFileResultVo.java | 30 + .../homework/model/homework/UpFileVo.java | 31 + .../homework/model/homework/UserModuleVo.java | 122 ++ .../homework/model/v2/AttachmentItem.java | 25 + .../yunhai/homework/model/v2/ClassItem.java | 18 + .../homework/model/v2/ClassTreeNode.java | 28 + .../homework/model/v2/ExtendRequest.java | 16 + .../homework/model/v2/HomeworkStatsVo.java | 36 + .../homework/model/v2/HomeworkV2Request.java | 48 + .../homework/model/v2/HomeworkV2Vo.java | 73 + .../model/v2/NotificationPageResult.java | 22 + .../homework/model/v2/NotificationQuery.java | 19 + .../homework/model/v2/NotificationVo.java | 34 + .../yunhai/homework/model/v2/ProgressVo.java | 44 + .../homework/model/v2/RemindRequest.java | 20 + .../homework/model/v2/ReturnRequest.java | 15 + .../homework/model/v2/ReviewRequest.java | 22 + .../homework/model/v2/ScheduleRequest.java | 25 + .../model/v2/StudentHomeworkDetailVo.java | 29 + .../homework/model/v2/StudentHomeworkVo.java | 69 + .../homework/model/v2/StudentMatrixQuery.java | 25 + .../homework/model/v2/StudentMatrixVo.java | 57 + .../homework/model/v2/StudentResultVo.java | 22 + .../model/v2/StudentSubmitRequest.java | 20 + .../yunhai/homework/model/v2/SubjectDto.java | 16 + .../model/v2/SubmissionActionRequest.java | 22 + .../model/v2/SubmissionVersionVo.java | 63 + .../model/v2/TeacherHomeworkQuery.java | 19 + .../homework/model/v2/TeachingClassVo.java | 16 + .../repository/ClassStudentRepository.java | 31 + .../repository/ContactsGroupRepository.java | 27 + .../ContactsGroupUserRepository.java | 42 + .../HomeworkAttachmentRepository.java | 16 + .../repository/HomeworkClassRepository.java | 26 + .../HomeworkReadRecordRepository.java | 23 + .../repository/HomeworkReadRepository.java | 52 + .../HomeworkReceiverRepository.java | 31 + .../repository/HomeworkRepository.java | 139 ++ .../HomeworkSubmissionRepository.java | 48 + .../HomeworkTemplateRepository.java | 20 + .../repository/NotificationRepository.java | 49 + .../repository/SchClassTeachRepository.java | 22 + .../repository/SmsModuleRepository.java | 13 + .../repository/SmsSchoolConfigRepository.java | 25 + .../SubmissionAttachmentRepository.java | 22 + .../repository/SysDictRepository.java | 15 + .../repository/TemplateTypeRepository.java | 20 + .../homework/repository/UpFileRepository.java | 26 + .../homework/service/IClassService.java | 116 ++ .../homework/service/IContactsService.java | 79 ++ .../yunhai/homework/service/IDictService.java | 75 ++ .../yunhai/homework/service/IFileService.java | 94 ++ .../homework/service/IHomeworkService.java | 142 ++ .../yunhai/homework/service/ISmsService.java | 77 ++ .../homework/service/ITemplateService.java | 69 + .../homework/service/IUserCenterService.java | 51 + .../homework/service/impl/ClassService.java | 217 +++ .../service/impl/ClassStudentService.java | 81 ++ .../service/impl/ContactsService.java | 388 ++++++ .../homework/service/impl/DictService.java | 270 ++++ .../homework/service/impl/FileService.java | 326 +++++ .../impl/HomeworkProgressCalculator.java | 175 +++ .../service/impl/HomeworkService.java | 1184 +++++++++++++++++ .../service/impl/HomeworkStateService.java | 93 ++ .../service/impl/NotificationService.java | 223 ++++ .../homework/service/impl/SmsService.java | 284 ++++ .../service/impl/StudentHomeworkService.java | 488 +++++++ .../impl/TeacherHomeworkV2Service.java | 1172 ++++++++++++++++ .../service/impl/TemplateService.java | 264 ++++ .../service/impl/UserCenterService.java | 268 ++++ .../service/sms/DefaultSmsSender.java | 22 + .../homework/service/sms/SmsSender.java | 22 + .../com/yunhai/homework/utils/PageUtils.java | 37 + .../yunhai/homework/utils/SubmitTypes.java | 82 ++ .../yunhai/homework/utils/UserContext.java | 251 ++++ src/main/resources/application.yml | 77 ++ src/main/resources/bootstrap.yml | 33 + src/main/resources/logback-spring.xml | 35 + 146 files changed, 12327 insertions(+) create mode 100644 .gitignore create mode 100644 docker/application-prod.yml create mode 100644 docker/run-app.sh create mode 100644 pom.xml create mode 100644 src/main/java/com/yunhai/homework/HomeworkApplication.java create mode 100644 src/main/java/com/yunhai/homework/common/listener/HomeworkAuditListener.java create mode 100644 src/main/java/com/yunhai/homework/common/validation/ValidationGroups.java create mode 100644 src/main/java/com/yunhai/homework/config/Config.java create mode 100644 src/main/java/com/yunhai/homework/config/JacksonConfig.java create mode 100644 src/main/java/com/yunhai/homework/config/SchedulingConfig.java create mode 100644 src/main/java/com/yunhai/homework/config/SwaggerConfig.java create mode 100644 src/main/java/com/yunhai/homework/config/UserContextConfig.java create mode 100644 src/main/java/com/yunhai/homework/controller/api/BaseApiController.java create mode 100644 src/main/java/com/yunhai/homework/controller/api/ContactsController.java create mode 100644 src/main/java/com/yunhai/homework/controller/api/DictController.java create mode 100644 src/main/java/com/yunhai/homework/controller/api/FileController.java create mode 100644 src/main/java/com/yunhai/homework/controller/api/HomeworkController.java create mode 100644 src/main/java/com/yunhai/homework/controller/api/NotificationController.java create mode 100644 src/main/java/com/yunhai/homework/controller/api/SmsController.java create mode 100644 src/main/java/com/yunhai/homework/controller/api/StudentHomeworkController.java create mode 100644 src/main/java/com/yunhai/homework/controller/api/TeacherHomeworkV2Controller.java create mode 100644 src/main/java/com/yunhai/homework/controller/api/TemplateController.java create mode 100644 src/main/java/com/yunhai/homework/entity/BaseEntity.java create mode 100644 src/main/java/com/yunhai/homework/entity/ClassStudent.java create mode 100644 src/main/java/com/yunhai/homework/entity/ContactsGroup.java create mode 100644 src/main/java/com/yunhai/homework/entity/ContactsGroupUser.java create mode 100644 src/main/java/com/yunhai/homework/entity/Homework.java create mode 100644 src/main/java/com/yunhai/homework/entity/HomeworkAttachment.java create mode 100644 src/main/java/com/yunhai/homework/entity/HomeworkClass.java create mode 100644 src/main/java/com/yunhai/homework/entity/HomeworkRead.java create mode 100644 src/main/java/com/yunhai/homework/entity/HomeworkReadRecord.java create mode 100644 src/main/java/com/yunhai/homework/entity/HomeworkReceiver.java create mode 100644 src/main/java/com/yunhai/homework/entity/HomeworkSubmission.java create mode 100644 src/main/java/com/yunhai/homework/entity/HomeworkTemplate.java create mode 100644 src/main/java/com/yunhai/homework/entity/Notification.java create mode 100644 src/main/java/com/yunhai/homework/entity/SchClassTeach.java create mode 100644 src/main/java/com/yunhai/homework/entity/SmsModule.java create mode 100644 src/main/java/com/yunhai/homework/entity/SmsSchoolConfig.java create mode 100644 src/main/java/com/yunhai/homework/entity/SubmissionAttachment.java create mode 100644 src/main/java/com/yunhai/homework/entity/SysDict.java create mode 100644 src/main/java/com/yunhai/homework/entity/TemplateType.java create mode 100644 src/main/java/com/yunhai/homework/entity/UpFile.java create mode 100644 src/main/java/com/yunhai/homework/enums/HomeworkStatusEnum.java create mode 100644 src/main/java/com/yunhai/homework/enums/HomeworkTypeEnum.java create mode 100644 src/main/java/com/yunhai/homework/enums/HwStatusEnum.java create mode 100644 src/main/java/com/yunhai/homework/enums/NotificationTypeEnum.java create mode 100644 src/main/java/com/yunhai/homework/enums/ReviewStatusEnum.java create mode 100644 src/main/java/com/yunhai/homework/enums/SubmitStatusEnum.java create mode 100644 src/main/java/com/yunhai/homework/job/HomeworkScheduledJobs.java create mode 100644 src/main/java/com/yunhai/homework/model/BasePageQuery.java create mode 100644 src/main/java/com/yunhai/homework/model/BasePageResult.java create mode 100644 src/main/java/com/yunhai/homework/model/SchTeaList.java create mode 100644 src/main/java/com/yunhai/homework/model/bsch/SchClassDto.java create mode 100644 src/main/java/com/yunhai/homework/model/bsch/SchGradeDto.java create mode 100644 src/main/java/com/yunhai/homework/model/bsch/SchInfoDto.java create mode 100644 src/main/java/com/yunhai/homework/model/bsch/SchStudentDto.java create mode 100644 src/main/java/com/yunhai/homework/model/bsch/SchSubjectDto.java create mode 100644 src/main/java/com/yunhai/homework/model/bsch/SchSystemDto.java create mode 100644 src/main/java/com/yunhai/homework/model/bsch/SchTeachDto.java create mode 100644 src/main/java/com/yunhai/homework/model/bsch/SchTreeNodeDto.java create mode 100644 src/main/java/com/yunhai/homework/model/homework/HomeworkDetailVo.java create mode 100644 src/main/java/com/yunhai/homework/model/homework/HomeworkPageResult.java create mode 100644 src/main/java/com/yunhai/homework/model/homework/HomeworkQuery.java create mode 100644 src/main/java/com/yunhai/homework/model/homework/HomeworkRequest.java create mode 100644 src/main/java/com/yunhai/homework/model/homework/HomeworkStatisticsVo.java create mode 100644 src/main/java/com/yunhai/homework/model/homework/HomeworkUserListResult.java create mode 100644 src/main/java/com/yunhai/homework/model/homework/HomeworkVo.java create mode 100644 src/main/java/com/yunhai/homework/model/homework/InitializeRedVo.java create mode 100644 src/main/java/com/yunhai/homework/model/homework/UpFileResultVo.java create mode 100644 src/main/java/com/yunhai/homework/model/homework/UpFileVo.java create mode 100644 src/main/java/com/yunhai/homework/model/homework/UserModuleVo.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/AttachmentItem.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/ClassItem.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/ClassTreeNode.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/ExtendRequest.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/HomeworkStatsVo.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/HomeworkV2Request.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/HomeworkV2Vo.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/NotificationPageResult.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/NotificationQuery.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/NotificationVo.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/ProgressVo.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/RemindRequest.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/ReturnRequest.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/ReviewRequest.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/ScheduleRequest.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/StudentHomeworkDetailVo.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/StudentHomeworkVo.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/StudentMatrixQuery.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/StudentMatrixVo.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/StudentResultVo.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/StudentSubmitRequest.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/SubjectDto.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/SubmissionActionRequest.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/SubmissionVersionVo.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/TeacherHomeworkQuery.java create mode 100644 src/main/java/com/yunhai/homework/model/v2/TeachingClassVo.java create mode 100644 src/main/java/com/yunhai/homework/repository/ClassStudentRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/ContactsGroupRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/ContactsGroupUserRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/HomeworkAttachmentRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/HomeworkClassRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/HomeworkReadRecordRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/HomeworkReadRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/HomeworkReceiverRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/HomeworkRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/HomeworkSubmissionRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/HomeworkTemplateRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/NotificationRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/SchClassTeachRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/SmsModuleRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/SmsSchoolConfigRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/SubmissionAttachmentRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/SysDictRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/TemplateTypeRepository.java create mode 100644 src/main/java/com/yunhai/homework/repository/UpFileRepository.java create mode 100644 src/main/java/com/yunhai/homework/service/IClassService.java create mode 100644 src/main/java/com/yunhai/homework/service/IContactsService.java create mode 100644 src/main/java/com/yunhai/homework/service/IDictService.java create mode 100644 src/main/java/com/yunhai/homework/service/IFileService.java create mode 100644 src/main/java/com/yunhai/homework/service/IHomeworkService.java create mode 100644 src/main/java/com/yunhai/homework/service/ISmsService.java create mode 100644 src/main/java/com/yunhai/homework/service/ITemplateService.java create mode 100644 src/main/java/com/yunhai/homework/service/IUserCenterService.java create mode 100644 src/main/java/com/yunhai/homework/service/impl/ClassService.java create mode 100644 src/main/java/com/yunhai/homework/service/impl/ClassStudentService.java create mode 100644 src/main/java/com/yunhai/homework/service/impl/ContactsService.java create mode 100644 src/main/java/com/yunhai/homework/service/impl/DictService.java create mode 100644 src/main/java/com/yunhai/homework/service/impl/FileService.java create mode 100644 src/main/java/com/yunhai/homework/service/impl/HomeworkProgressCalculator.java create mode 100644 src/main/java/com/yunhai/homework/service/impl/HomeworkService.java create mode 100644 src/main/java/com/yunhai/homework/service/impl/HomeworkStateService.java create mode 100644 src/main/java/com/yunhai/homework/service/impl/NotificationService.java create mode 100644 src/main/java/com/yunhai/homework/service/impl/SmsService.java create mode 100644 src/main/java/com/yunhai/homework/service/impl/StudentHomeworkService.java create mode 100644 src/main/java/com/yunhai/homework/service/impl/TeacherHomeworkV2Service.java create mode 100644 src/main/java/com/yunhai/homework/service/impl/TemplateService.java create mode 100644 src/main/java/com/yunhai/homework/service/impl/UserCenterService.java create mode 100644 src/main/java/com/yunhai/homework/service/sms/DefaultSmsSender.java create mode 100644 src/main/java/com/yunhai/homework/service/sms/SmsSender.java create mode 100644 src/main/java/com/yunhai/homework/utils/PageUtils.java create mode 100644 src/main/java/com/yunhai/homework/utils/SubmitTypes.java create mode 100644 src/main/java/com/yunhai/homework/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/.gitignore b/.gitignore new file mode 100644 index 0000000..6a46b5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +.idea/ +*.iml +*.log +.DS_Store diff --git a/docker/application-prod.yml b/docker/application-prod.yml new file mode 100644 index 0000000..b7c86b5 --- /dev/null +++ b/docker/application-prod.yml @@ -0,0 +1,57 @@ +# ClassHomework 生产配置(线上以 nacos 的 classhomework.yaml 为准,本文件是 nacos 不可达时的兜底) +# 真正上线请在 Nacos 新建 dataId=classhomework.yaml、group=OA_APP 的配置覆盖以下项 +# (尤其 datasource 的库名/账号、yh.sso.client-host-url)。 +server: + port: 8070 + servlet: + # 对外经 caddy 暴露为 http://base.yunhaixingtong.com/classhomework + context-path: /classhomework + +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;库名为 classhomework(需先在 mysql8 上建库) + url: jdbc:mysql://mysql8:3306/classhomework?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: + env-config: + source: + # 前端运行时配置(JSON 字符串),线上由 Nacos/projman 下发。 + # 端点 /api/env-config 由平台 yh-env-config-spring-boot-starter 提供(免登录 SSO 旁路)。 + front: "" + sso: + server-url-prefix: http://sso.yunhaixingtong.com + client-host-url: http://base.yunhaixingtong.com/classhomework + 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..df00715 --- /dev/null +++ b/docker/run-app.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# ClassHomework 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml) +# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到 +# 镜像内烤好的 application-prod.yml 兜底默认。 +set -e + +echo "[run-app] starting ClassHomework (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..." +exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/classhomework.jar \ + --spring.profiles.active=prod diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..1280970 --- /dev/null +++ b/pom.xml @@ -0,0 +1,198 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + classhomework + 0.0.1 + ClassHomework + 课堂作业子应用 + + 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-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 + + + + + + + + classhomework + + + org.apache.maven.plugins + maven-compiler-plugin + + + false + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.homework.HomeworkApplication + + + + + repackage + + + + + + + + + + + nexus-public + bsch/nexus public + http://nexus.yunhaixingtong.com/repository/maven-public/ + true + true + + + + + + ci + + C:/classhomework-build/classhomework + + + + + diff --git a/src/main/java/com/yunhai/homework/HomeworkApplication.java b/src/main/java/com/yunhai/homework/HomeworkApplication.java new file mode 100644 index 0000000..3cb5b11 --- /dev/null +++ b/src/main/java/com/yunhai/homework/HomeworkApplication.java @@ -0,0 +1,18 @@ +package com.yunhai.homework; + +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.homework") +@EnableTransactionManagement +public class HomeworkApplication { + + public static void main(String[] args) { + // 开启 fastjson 安全模式 + ParserConfig.getGlobalInstance().setSafeMode(true); + SpringApplication.run(HomeworkApplication.class, args); + } + +} diff --git a/src/main/java/com/yunhai/homework/common/listener/HomeworkAuditListener.java b/src/main/java/com/yunhai/homework/common/listener/HomeworkAuditListener.java new file mode 100644 index 0000000..67cbf7c --- /dev/null +++ b/src/main/java/com/yunhai/homework/common/listener/HomeworkAuditListener.java @@ -0,0 +1,42 @@ +package com.yunhai.homework.common.listener; + +import com.yunhai.homework.entity.BaseEntity; +import com.yunhai.homework.utils.UserContext; +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 UserContext}(用户中心数字ID,字符串形式落审计列)。 + */ +@Component +public class HomeworkAuditListener { + + @PrePersist + public void prePersist(BaseEntity entity) { + LocalDateTime now = LocalDateTime.now(); + entity.setCreateTime(now); + entity.setUpdateTime(now); + Integer userId = UserContext.getUserId(); + if (userId != null) { + if (entity.getCreateBy() == null) { + entity.setCreateBy(String.valueOf(userId)); + } + entity.setUpdateBy(String.valueOf(userId)); + } + } + + @PreUpdate + public void preUpdate(BaseEntity entity) { + entity.setUpdateTime(LocalDateTime.now()); + Integer userId = UserContext.getUserId(); + if (userId != null) { + entity.setUpdateBy(String.valueOf(userId)); + } + } + +} diff --git a/src/main/java/com/yunhai/homework/common/validation/ValidationGroups.java b/src/main/java/com/yunhai/homework/common/validation/ValidationGroups.java new file mode 100644 index 0000000..8021929 --- /dev/null +++ b/src/main/java/com/yunhai/homework/common/validation/ValidationGroups.java @@ -0,0 +1,10 @@ +package com.yunhai.homework.common.validation; + +/** + * JSR-303 分组校验标记接口(对齐 UserBase 规范)。 + * Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。 + */ +public interface ValidationGroups { + interface Create {} + interface Update {} +} diff --git a/src/main/java/com/yunhai/homework/config/Config.java b/src/main/java/com/yunhai/homework/config/Config.java new file mode 100644 index 0000000..6eb628a --- /dev/null +++ b/src/main/java/com/yunhai/homework/config/Config.java @@ -0,0 +1,11 @@ +package com.yunhai.homework.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/homework/config/JacksonConfig.java b/src/main/java/com/yunhai/homework/config/JacksonConfig.java new file mode 100644 index 0000000..5dd094b --- /dev/null +++ b/src/main/java/com/yunhai/homework/config/JacksonConfig.java @@ -0,0 +1,35 @@ +package com.yunhai.homework.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/homework/config/SchedulingConfig.java b/src/main/java/com/yunhai/homework/config/SchedulingConfig.java new file mode 100644 index 0000000..1264f19 --- /dev/null +++ b/src/main/java/com/yunhai/homework/config/SchedulingConfig.java @@ -0,0 +1,13 @@ +package com.yunhai.homework.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 开启 @Scheduled 定时任务(作业定时发布 / 到点截止 / 截止提醒 / 名册同步)。 + * 单实例假设;多实例部署前需引入分布式锁(计划 §13-7)。 + */ +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/src/main/java/com/yunhai/homework/config/SwaggerConfig.java b/src/main/java/com/yunhai/homework/config/SwaggerConfig.java new file mode 100644 index 0000000..160f8a4 --- /dev/null +++ b/src/main/java/com/yunhai/homework/config/SwaggerConfig.java @@ -0,0 +1,22 @@ +package com.yunhai.homework.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 homeworkOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("课程作业接口文档") + .description("课程作业子应用:发作业、列表、删除、编辑、管理员查看、统计(springdoc-openapi)") + .version("1.0")); + } +} diff --git a/src/main/java/com/yunhai/homework/config/UserContextConfig.java b/src/main/java/com/yunhai/homework/config/UserContextConfig.java new file mode 100644 index 0000000..3ea0e45 --- /dev/null +++ b/src/main/java/com/yunhai/homework/config/UserContextConfig.java @@ -0,0 +1,52 @@ +package com.yunhai.homework.config; + +import com.yunhai.homework.utils.UserContext; +import com.yunhai.platform.auth.spring.CurrentBoaUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import javax.annotation.PostConstruct; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * {@link UserContext} 生命周期配置: + * 启动时向 UserContext 注入平台 BoaUser 供应器(取值在首次访问时惰性解析), + * 并注册拦截器在每个请求结束后清理 ThreadLocal(Tomcat 线程复用防串号)。 + */ +@Configuration +public class UserContextConfig { + + @Autowired + private CurrentBoaUser currentBoaUser; + + @PostConstruct + public void init() { + UserContext.init(currentBoaUser); + } + + @Bean + public HandlerInterceptor userContextCleanInterceptor() { + return new HandlerInterceptor() { + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) { + UserContext.clear(); + } + }; + } + + @Bean + public WebMvcConfigurer userContextConfigurer(HandlerInterceptor userContextCleanInterceptor) { + return new WebMvcConfigurer() { + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(userContextCleanInterceptor).addPathPatterns("/**"); + } + }; + } +} diff --git a/src/main/java/com/yunhai/homework/controller/api/BaseApiController.java b/src/main/java/com/yunhai/homework/controller/api/BaseApiController.java new file mode 100644 index 0000000..5ef98ed --- /dev/null +++ b/src/main/java/com/yunhai/homework/controller/api/BaseApiController.java @@ -0,0 +1,110 @@ +package com.yunhai.homework.controller.api; + +import com.yunhai.homework.config.Config; +import com.yunhai.homework.model.homework.UpFileResultVo; +import com.yunhai.homework.service.IFileService; +import com.yunhai.homework.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 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; + + // ============================ 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, "homework"); + } + + // ============================ 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); + } + + // ============================ 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/homework/controller/api/ContactsController.java b/src/main/java/com/yunhai/homework/controller/api/ContactsController.java new file mode 100644 index 0000000..e627a04 --- /dev/null +++ b/src/main/java/com/yunhai/homework/controller/api/ContactsController.java @@ -0,0 +1,99 @@ +package com.yunhai.homework.controller.api; + +import com.yunhai.homework.config.Config; +import com.yunhai.homework.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/homework/controller/api/DictController.java b/src/main/java/com/yunhai/homework/controller/api/DictController.java new file mode 100644 index 0000000..ecb1544 --- /dev/null +++ b/src/main/java/com/yunhai/homework/controller/api/DictController.java @@ -0,0 +1,85 @@ +package com.yunhai.homework.controller.api; + +import com.yunhai.homework.config.Config; +import com.yunhai.homework.service.IDictService; +import com.yunhai.platform.utils.model.BoaUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 系统字典接口(发文部门域)。 + *

路径前缀:{@code Config.API_PATH + "/dict/"}(原 base/ 前缀已修正)。 + * 写操作一律 POST(原 DelDept/OrdbyDicIds 为 GET 写操作,已 POST 化)。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/dict/") +public class DictController { + + @Autowired + private IDictService dictService; + + /** + * 获取系统字典(下拉框用)。 + */ + @GetMapping("List") + public List> list(@RequestParam(value = "type", defaultValue = "0") Integer type) { + return dictService.getSysDic(type); + } + + /** + * 获取系统字典管理列表。 + */ + @GetMapping("ManageList") + public List> manageList(@RequestParam(value = "type", defaultValue = "dept") String type) { + return dictService.getDictMangeList(type); + } + + /** + * 添加发文单位。 + */ + @PostMapping("Add") + public void add(@RequestParam("name") String name) { + dictService.addDeptDic(name); + } + + /** + * 更新发文单位。 + */ + @PostMapping("Update") + public void update( + @RequestParam("SerID") Integer serID, + @RequestParam("Name") String name, + @RequestParam(value = "Enable", required = false, defaultValue = "1") Integer enable) { + dictService.updateDeptDic(serID, name, enable); + } + + /** + * 删除发文单位(原 GET DelDept,已 POST 化)。 + */ + @PostMapping("Delete") + public void delete(@RequestParam("serid") Integer serID) { + dictService.delDept(serID); + } + + /** + * 发文单位排序(原 GET OrdbyDicIds,已 POST 化)。 + */ + @PostMapping("Sort") + public void sort(@RequestParam("ids") String ids) { + dictService.ordbyDicIds(ids); + } + + /** + * 本校是否开通短信模块(前端用作"本校是否开通短信")。 + * 取当前登录用户角色所属学校,查 sms_school_config 是否开通本应用模块。 + */ + @GetMapping("Sync") + public Boolean sync(BoaUser authUserWithDept) { + Integer schId = authUserWithDept != null && authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + return dictService.getSchModuleIsSms(schId); + } +} diff --git a/src/main/java/com/yunhai/homework/controller/api/FileController.java b/src/main/java/com/yunhai/homework/controller/api/FileController.java new file mode 100644 index 0000000..6edad5e --- /dev/null +++ b/src/main/java/com/yunhai/homework/controller/api/FileController.java @@ -0,0 +1,88 @@ +package com.yunhai.homework.controller.api; + +import com.yunhai.homework.config.Config; +import com.yunhai.homework.model.homework.UpFileResultVo; +import com.yunhai.homework.model.homework.UpFileVo; +import com.yunhai.homework.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, "homework"); + } + + /** + * 图片上传(多文件,返回数组)。 + * 前端: POST file/UploadImage (form-data, accept=image/*) + */ + @PostMapping("UploadImage") + public List uploadImage(@RequestParam("file") MultipartFile[] files) { + return fileService.uploadImages(files, "homework"); + } + /** + * 文件下载(浏览器直接 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("homework", 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/homework/controller/api/HomeworkController.java b/src/main/java/com/yunhai/homework/controller/api/HomeworkController.java new file mode 100644 index 0000000..ee424c3 --- /dev/null +++ b/src/main/java/com/yunhai/homework/controller/api/HomeworkController.java @@ -0,0 +1,217 @@ +package com.yunhai.homework.controller.api; + +import com.yunhai.homework.config.Config; +import com.yunhai.homework.common.validation.ValidationGroups; +import com.yunhai.homework.model.BasePageResult; +import com.yunhai.homework.model.homework.*; +import com.yunhai.homework.service.IHomeworkService; +import com.yunhai.homework.utils.PageUtils; +import com.yunhai.platform.utils.model.BoaUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletResponse; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 课程作业接口(合并 HomeworkApiController + 旧 HomeworkController)。 + *

路径前缀:{@code Config.API_PATH + "/homework/"}(platform = api / manage / mobiapi)。 + * 方法动词式 PascalCase(对齐 UserBase);写操作一律 POST。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/homework/") +public class HomeworkController { + + @Autowired + private IHomeworkService homeworkService; + + // ============================ 收件箱列表 ============================ + + /** + * 已收作业列表(用户端)。 + */ + @GetMapping("List") + public HomeworkPageResult list(HomeworkQuery query, + @SortDefault(sort = {"topmost", "createTime"}, + direction = Sort.Direction.DESC) Sort sort) { + return homeworkService.userList(query, PageUtils.toPageable(query, sort)); + } + + // ============================ 已发/全部作业列表 ============================ + + /** + * 已发/全部作业列表(管理端)。type=user 只看自己。 + */ + @GetMapping("SendList") + public BasePageResult sendList(HomeworkQuery query, + @SortDefault(sort = "createTime", + direction = Sort.Direction.DESC) Sort sort) { + return homeworkService.adminList(query, PageUtils.toPageable(query, sort)); + } + + // ============================ 查看详情(标读) ============================ + + /** + * 查看作业详情(type=1 普通查看标记已读, type=0 编辑模式不标记已读)。 + */ + @GetMapping("Detail") + public HomeworkDetailVo detail(@RequestParam("serid") Integer serid, + @RequestParam(value = "type", defaultValue = "1") Integer type) { + return homeworkService.detailMap(serid, type); + } + + // ============================ 编辑回显(不标读) ============================ + + /** + * 获取作业详情(编辑/转发用,不标记已读)。 + */ + @GetMapping("GetDetail") + public HomeworkDetailVo getDetail(@RequestParam("serid") Integer serid) { + return homeworkService.getDetailMap(serid); + } + + // ============================ 新建 ============================ + + /** + * 新建作业(JSON body)。fileIds 为附件ID逗号串,smsFlag 为短信标记。 + */ + @PostMapping("Create") + public Integer create(@RequestBody @Validated(ValidationGroups.Create.class) HomeworkRequest request) { + return homeworkService.publish(request); + } + + // ============================ 编辑 ============================ + + /** + * 编辑作业(JSON body,含 id)。 + */ + @PostMapping("Edit") + public void edit(@RequestBody @Validated(ValidationGroups.Update.class) HomeworkRequest request) { + homeworkService.edit(request.getSourceId() != null ? request.getSourceId() : 0, request); + } + + // ============================ 批量删除 ============================ + + /** + * 批量删除作业(ids 数组)。 + */ + @PostMapping("Delete") + public void delete(@RequestBody List ids) { + homeworkService.deleteBatch(ids); + } + + // ============================ 撤回 ============================ + + /** + * 撤回作业。 + */ + @PostMapping("UserPull") + public void userPull(@RequestParam("serid") Integer serid) { + homeworkService.pull(serid); + } + + // ============================ 置顶 ============================ + + /** + * 置顶/取消置顶。 + */ + @PostMapping("SetTopmost") + public void setTopmost(@RequestParam("serid") Integer serid, + @RequestParam(value = "topmost", defaultValue = "1") Integer topmost) { + homeworkService.setTopmost(serid, topmost); + } + + // ============================ 追加接收人 ============================ + + /** + * 追加接收人。 + */ + @PostMapping("ReceiveUsers") + public void receiveUsers(@RequestParam("serid") Integer serid, + @RequestParam("users") String users) { + homeworkService.receiveUsers(serid, users); + } + + // ============================ 催收 ============================ + + /** + * 催收未读。 + */ + @PostMapping("Expedite") + public void expedite(@RequestParam("serid") Integer serid) { + homeworkService.expedite(serid); + } + + // ============================ 一键已读 ============================ + + /** + * 一键全部已读(POST 化,原为 GET 写操作)。 + */ + @PostMapping("InitializeRed") + public InitializeRedVo initializeRed() { + return homeworkService.initializeRedResult(); + } + + // ============================ 阅读情况 ============================ + + /** + * 阅读情况(按已读/未读分组)。 + */ + @GetMapping("ReadDetail") + public List> readDetail(@RequestParam("serid") Integer serid) { + return homeworkService.getReadDetailGrouped(serid); + } + + // ============================ 上一篇/下一篇 ============================ + + /** + * 上一篇/下一篇作业ID。 + */ + @GetMapping("GetNextPrev") + public Map getNextPrev(@RequestParam("serid") Integer serid, + @RequestParam("type") String type) { + return homeworkService.getNextPrev(serid, type); + } + + // ============================ 附件打包下载 ============================ + + /** + * 批量下载作业全部附件(打包 zip)。 + */ + @GetMapping("AllFileDown") + public void allFileDown(@RequestParam("dataid") Integer dataid, + HttpServletResponse response) { + homeworkService.downloadAllFiles(dataid, response); + } + + // ============================ 统计 ============================ + + /** + * 作业统计。 + */ + @GetMapping("Statis") + public HomeworkStatisticsVo statis( + @RequestParam(value = "starttime", required = false) + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime start, + @RequestParam(value = "endtime", required = false) + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime end) { + return homeworkService.statistics(new HomeworkQuery(), start, end); + } + + // ============================ 转发(合并旧 REST 壳 forward) ============================ + + /** + * 转发作业(基于源作业创建新作业)。sourceId 在 request body 中传递。 + */ + @PostMapping("Forward") + public Integer forward(@RequestBody @Validated(ValidationGroups.Create.class) HomeworkRequest request) { + return homeworkService.forward(request.getSourceId(), request); + } +} diff --git a/src/main/java/com/yunhai/homework/controller/api/NotificationController.java b/src/main/java/com/yunhai/homework/controller/api/NotificationController.java new file mode 100644 index 0000000..e82ba9c --- /dev/null +++ b/src/main/java/com/yunhai/homework/controller/api/NotificationController.java @@ -0,0 +1,53 @@ +package com.yunhai.homework.controller.api; + +import com.yunhai.homework.config.Config; +import com.yunhai.homework.model.v2.NotificationPageResult; +import com.yunhai.homework.model.v2.NotificationQuery; +import com.yunhai.homework.service.impl.NotificationService; +import com.yunhai.homework.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.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 站内通知接口(PRD §18 未列,按计划 §7.3 补充)。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/notifications") +public class NotificationController { + + @Autowired + private NotificationService notificationService; + + /** 我的通知分页(含未读数;type/isRead 过滤) */ + @GetMapping + public NotificationPageResult page(NotificationQuery query) { + return notificationService.page(currentUserId(), query); + } + + /** 标记单条已读 */ + @PostMapping("/{id}/read") + public void markRead(@PathVariable("id") Integer id) { + notificationService.markRead(id, currentUserId()); + } + + /** 全部已读 */ + @PostMapping("/read-all") + public void markAllRead() { + notificationService.markAllRead(currentUserId()); + } + + /** 当前登录人ID(用户中心数字ID),无登录态时拒绝 */ + private static Integer currentUserId() { + Integer userId = UserContext.getUserId(); + if (userId == null) { + throw new BizException(ErrorCode.FORBIDDEN, "未获取到登录身份"); + } + return userId; + } +} diff --git a/src/main/java/com/yunhai/homework/controller/api/SmsController.java b/src/main/java/com/yunhai/homework/controller/api/SmsController.java new file mode 100644 index 0000000..25d4311 --- /dev/null +++ b/src/main/java/com/yunhai/homework/controller/api/SmsController.java @@ -0,0 +1,88 @@ +package com.yunhai.homework.controller.api; + +import com.yunhai.homework.config.Config; +import com.yunhai.homework.service.ISmsService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 短信设置接口(短信域)。 + *

路径前缀:{@code Config.API_PATH + "/sms/"}(原 schsms/ 已修正)。 + * 方法名 PascalCase(原全小写已修正);写操作一律 POST(原 setschallsms/setschmodulesms 为 GET 写操作,已 POST 化)。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/sms/") +public class SmsController { + + @Autowired + private ISmsService smsService; + + /** + * 获取短信模块列表。 + */ + @GetMapping("GetModule") + public List> getModule() { + return smsService.getModuleList(); + } + + /** + * 获取学校父类组列表。 + */ + @GetMapping("GetGroup") + public List> getGroup() { + return smsService.getSchParentGroup(); + } + + /** + * 分页获取学校短信开关列表。 + */ + @GetMapping("GetSchoolSms") + public Map getSchoolSms( + @RequestParam(value = "schpid", required = false) String schpid, + @RequestParam("PageSize") Integer pageSize, + @RequestParam("PageIndex") Integer pageIndex, + @RequestParam(value = "SchName", required = false, defaultValue = "") String schName) { + return smsService.getSchIsSmsList(pageSize, pageIndex, schName); + } + + /** + * 获取学校列表。 + */ + @GetMapping("GetSchool") + public List> getSchool() { + return smsService.getSchList(); + } + + /** + * 设置单校单模块短信开关。 + */ + @PostMapping("SetSchoolSms") + public void setSchoolSms(@RequestParam("itemsModu") String itemsModuJson) { + smsService.setSchSmsFromJson(itemsModuJson); + } + + /** + * 设置某校全部模块短信开关(原 GET setschallsms,已 POST 化)。 + */ + @PostMapping("SetAllSchoolSms") + public void setAllSchoolSms( + @RequestParam("SchSerID") Integer schSerID, + @RequestParam("IsSms") Boolean isSms) { + smsService.setSchSmsAll(schSerID, isSms); + } + + /** + * 批量设置多校多模块短信开关(原 GET setschmodulesms,已 POST 化)。 + */ + @PostMapping("SetSchoolModuleSms") + public void setSchoolModuleSms( + @RequestParam("SchSerID") String schSerID, + @RequestParam("Module") String module, + @RequestParam("IsSms") Boolean isSms) { + smsService.setSchModuleSms(schSerID, module, isSms); + } + +} diff --git a/src/main/java/com/yunhai/homework/controller/api/StudentHomeworkController.java b/src/main/java/com/yunhai/homework/controller/api/StudentHomeworkController.java new file mode 100644 index 0000000..56a37b0 --- /dev/null +++ b/src/main/java/com/yunhai/homework/controller/api/StudentHomeworkController.java @@ -0,0 +1,79 @@ +package com.yunhai.homework.controller.api; + +import com.yunhai.homework.config.Config; +import com.yunhai.homework.model.BasePageQuery; +import com.yunhai.homework.model.BasePageResult; +import com.yunhai.homework.model.v2.StudentHomeworkDetailVo; +import com.yunhai.homework.model.v2.StudentHomeworkVo; +import com.yunhai.homework.model.v2.StudentResultVo; +import com.yunhai.homework.model.v2.StudentSubmitRequest; +import com.yunhai.homework.model.v2.SubmissionActionRequest; +import com.yunhai.homework.service.impl.StudentHomeworkService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 课堂作业 V2 学生端接口(PRD §18 路径一字对齐:/student/homeworks…)。 + *

+ * 身份:当前登录用户ID ↔ class_student 名册;仅可见本人班级作业,禁 IDOR。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/student/homeworks") +public class StudentHomeworkController { + + @Autowired + private StudentHomeworkService studentHomeworkService; + + /** 我的作业列表(tab=all/todo/done/closed) */ + @GetMapping + public BasePageResult list( + @RequestParam(value = "tab", defaultValue = "all") String tab, + BasePageQuery query) { + return studentHomeworkService.list(tab, query); + } + + /** 作业详情 */ + @GetMapping("/{id}") + public StudentHomeworkDetailVo detail(@PathVariable("id") Integer id) { + return studentHomeworkService.detail(id); + } + + /** 阅读上报(前端停留 ≥3 秒后调用;幂等) */ + @PostMapping("/{id}/read") + public void read(@PathVariable("id") Integer id) { + studentHomeworkService.read(id); + } + + /** 提交作业,返回新提交行 id */ + @PostMapping("/{id}/submissions") + public Map submit(@PathVariable("id") Integer id, + @RequestBody @Validated StudentSubmitRequest request) { + Integer submissionId = studentHomeworkService.submit(id, request); + return java.util.Collections.singletonMap("submissionId", submissionId); + } + + /** 撤回 / 打回后重交(action=withdraw|resubmit),返回新提交行 id */ + @PutMapping("/{id}/submissions/{submissionId}") + public Map action(@PathVariable("id") Integer id, + @PathVariable("submissionId") Integer submissionId, + @RequestBody @Validated SubmissionActionRequest request) { + Integer newId = studentHomeworkService.action(id, submissionId, request); + return java.util.Collections.singletonMap("submissionId", newId); + } + + /** 批阅结果(当前版本 + 历史版本) */ + @GetMapping("/{id}/result") + public StudentResultVo result(@PathVariable("id") Integer id) { + return studentHomeworkService.result(id); + } +} diff --git a/src/main/java/com/yunhai/homework/controller/api/TeacherHomeworkV2Controller.java b/src/main/java/com/yunhai/homework/controller/api/TeacherHomeworkV2Controller.java new file mode 100644 index 0000000..5335083 --- /dev/null +++ b/src/main/java/com/yunhai/homework/controller/api/TeacherHomeworkV2Controller.java @@ -0,0 +1,219 @@ +package com.yunhai.homework.controller.api; + +import com.yunhai.homework.config.Config; +import com.yunhai.homework.model.BasePageResult; +import com.yunhai.homework.model.v2.ClassTreeNode; +import com.yunhai.homework.model.v2.ExtendRequest; +import com.yunhai.homework.model.v2.HomeworkStatsVo; +import com.yunhai.homework.model.v2.HomeworkV2Request; +import com.yunhai.homework.model.v2.HomeworkV2Vo; +import com.yunhai.homework.model.v2.RemindRequest; +import com.yunhai.homework.model.v2.ReturnRequest; +import com.yunhai.homework.model.v2.ReviewRequest; +import com.yunhai.homework.model.v2.ScheduleRequest; +import com.yunhai.homework.model.v2.StudentMatrixQuery; +import com.yunhai.homework.model.v2.StudentMatrixVo; +import com.yunhai.homework.model.v2.SubmissionVersionVo; +import com.yunhai.homework.model.v2.SubjectDto; +import com.yunhai.homework.model.v2.TeacherHomeworkQuery; +import com.yunhai.homework.model.v2.TeachingClassVo; +import com.yunhai.homework.service.impl.ClassStudentService; +import com.yunhai.homework.service.impl.TeacherHomeworkV2Service; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.SortDefault; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * 课堂作业 V2 教师端接口(PRD §18 路径一字对齐:/teacher/homeworks…)。 + *

+ * 与 notice 语义的旧接口(/{platform}/homework/*)并存:旧接口冻结维护, + * 新业务一律走本控制器;返回裸对象由 ApiResponseAdvice 包装 {code,msg,data}。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/teacher/homeworks") +public class TeacherHomeworkV2Controller { + + @Autowired + private TeacherHomeworkV2Service teacherHomeworkV2Service; + + @Autowired + private ClassStudentService classStudentService; + + // ============================ 页 1:发布作业 ============================ + + /** 创建作业(落草稿) */ + @PostMapping + public Integer create(@RequestBody @Validated HomeworkV2Request request) { + return teacherHomeworkV2Service.create(request); + } + + /** 编辑作业(仅草稿) */ + @PutMapping("/{id}") + public void edit(@PathVariable("id") Integer id, + @RequestBody @Validated HomeworkV2Request request) { + teacherHomeworkV2Service.edit(id, request); + } + + /** 删除作业(仅草稿) */ + @DeleteMapping("/{id}") + public void delete(@PathVariable("id") Integer id) { + teacherHomeworkV2Service.delete(id); + } + + /** 立即发布(草稿 → 进行中) */ + @PostMapping("/{id}/publish") + public void publish(@PathVariable("id") Integer id) { + teacherHomeworkV2Service.publish(id); + } + + /** 定时发布 / 取消定时(body: {scheduledAt} 或 {cancel:true}) */ + @PostMapping("/{id}/schedule") + public void schedule(@PathVariable("id") Integer id, @RequestBody ScheduleRequest request) { + teacherHomeworkV2Service.schedule(id, request); + } + + // ============================ 页 2:草稿箱 ============================ + + /** 草稿箱分页 */ + @GetMapping("/drafts") + public BasePageResult drafts(TeacherHomeworkQuery query, + @SortDefault(sort = "updateTime", direction = Sort.Direction.DESC) Sort sort) { + return teacherHomeworkV2Service.drafts(query, sort); + } + + // ============================ 页 3:已发布列表 ============================ + + /** 已发布列表分页(含应交/已读/已交/未读未交聚合) */ + @GetMapping("/published") + public BasePageResult published(TeacherHomeworkQuery query, + @SortDefault(sort = "publishedAt", direction = Sort.Direction.DESC) Sort sort) { + return teacherHomeworkV2Service.published(query, sort); + } + + /** 提醒未读/未交学生,返回实际提醒人数 */ + @PostMapping("/{id}/remind") + public Map remind(@PathVariable("id") Integer id, @RequestBody RemindRequest request) { + int reminded = teacherHomeworkV2Service.remind(id, request); + return java.util.Collections.singletonMap("reminded", reminded); + } + + /** 延长截止时间 */ + @PostMapping("/{id}/extend") + public void extend(@PathVariable("id") Integer id, @RequestBody @Validated ExtendRequest request) { + teacherHomeworkV2Service.extend(id, request); + } + + /** 复制为新草稿,返回新作业 id */ + @PostMapping("/{id}/copy") + public Integer copy(@PathVariable("id") Integer id) { + return teacherHomeworkV2Service.copy(id); + } + + /** 归档(只读,不再触发任何提醒) */ + @PostMapping("/{id}/archive") + public void archive(@PathVariable("id") Integer id) { + teacherHomeworkV2Service.archive(id); + } + + /** 导出学生矩阵 Excel */ + @GetMapping("/{id}/export") + public void export(@PathVariable("id") Integer id, HttpServletResponse response) throws IOException { + teacherHomeworkV2Service.export(id, response); + } + + // ============================ 页 4:详情 / 统计 ============================ + + /** 作业详情 */ + @GetMapping("/{id}") + public HomeworkV2Vo detail(@PathVariable("id") Integer id) { + return teacherHomeworkV2Service.detail(id); + } + + /** 汇总统计卡片(总进度 + 分班进度) */ + @GetMapping("/{id}/stats") + public HomeworkStatsVo stats(@PathVariable("id") Integer id) { + return teacherHomeworkV2Service.stats(id); + } + + // ============================ 页 5:学生矩阵 ============================ + + /** 学生矩阵分页(阅读/提交/批阅状态,未交学生生成占位行) */ + @GetMapping("/{id}/students") + public BasePageResult students(@PathVariable("id") Integer id, + StudentMatrixQuery query) { + return teacherHomeworkV2Service.students(id, query); + } + + // ============================ 页 6:批阅 / 打回 / 版本 ============================ + + /** 保存批阅(→ REVIEWED,推送批阅完成通知) */ + @PostMapping("/{id}/submissions/{studentId}/review") + public void review(@PathVariable("id") Integer id, @PathVariable("studentId") Integer studentId, + @RequestBody @Validated ReviewRequest request) { + teacherHomeworkV2Service.review(id, studentId, request); + } + + /** 打回(→ RETURNED,推送打回通知) */ + @PostMapping("/{id}/submissions/{studentId}/return") + public void returnSubmission(@PathVariable("id") Integer id, @PathVariable("studentId") Integer studentId, + @RequestBody @Validated ReturnRequest request) { + teacherHomeworkV2Service.returnSubmission(id, studentId, request); + } + + /** 历史提交版本(批阅弹窗切换版本) */ + @GetMapping("/{id}/submissions/{studentId}/versions") + public List versions(@PathVariable("id") Integer id, + @PathVariable("studentId") Integer studentId) { + return teacherHomeworkV2Service.versions(id, studentId); + } + + // ============================ 辅助:学科 ============================ + + /** 我布置过的学科(本人已发布及之后作业的学科 ∪ 我的任教学科,合并去重;草稿不计) */ + @GetMapping("/subjects/assigned") + public List assignedSubjects() { + return teacherHomeworkV2Service.assignedSubjects(); + } + + /** 我的任教学科(bschclass 任教关系去重,名称取本校学科字典) */ + @GetMapping("/subjects/teaching") + public List teachingSubjects() { + return teacherHomeworkV2Service.teachingSubjects(); + } + + /** 按学科筛选我的任教班级(subId=学科编码;班级名称取学校班级列表) */ + @GetMapping("/subjects/classes") + public List teachingClasses(@RequestParam("subId") String subId) { + return teacherHomeworkV2Service.teachingClasses(subId); + } + + // ============================ 辅助:班级树 / 名册同步 ============================ + + /** 年级→班级树(发布选班;名册快照聚合) */ + @GetMapping("/classes") + public List classTree() { + return teacherHomeworkV2Service.classTree(); + } + + /** 手动触发名册同步(bschclass 接口就绪前为空操作) */ + @PostMapping("/classes/sync") + public Map syncClasses() { + int synced = classStudentService.syncAll(); + return java.util.Collections.singletonMap("synced", synced); + } +} diff --git a/src/main/java/com/yunhai/homework/controller/api/TemplateController.java b/src/main/java/com/yunhai/homework/controller/api/TemplateController.java new file mode 100644 index 0000000..e9c593c --- /dev/null +++ b/src/main/java/com/yunhai/homework/controller/api/TemplateController.java @@ -0,0 +1,88 @@ +package com.yunhai.homework.controller.api; + +import com.yunhai.homework.config.Config; +import com.yunhai.homework.service.ITemplateService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 作业模板接口(模板域)。 + *

路径前缀:{@code Config.API_PATH + "/template/"}(原 homework/Template/ 三级路径已修正)。 + * 写操作一律 POST(原 EditType/DelTemplate 为 GET 写操作,已 POST 化)。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/template/") +public class TemplateController { + + @Autowired + private ITemplateService templateService; + + /** + * 获取模板类型列表。 + */ + @GetMapping("Typelist") + public List> getTypelist() { + return templateService.getTypelist(); + } + + /** + * 新增/编辑模板类型(原 GET EditType,已 POST 化)。 + */ + @PostMapping("TypeEdit") + public void typeEdit( + @RequestParam("typeid") Integer typeid, + @RequestParam("TypeName") String typeName) { + templateService.editType(typeid, typeName); + } + + /** + * 删除模板类型(原 GET DelType,已 POST 化)。 + */ + @PostMapping("TypeDelete") + public void typeDelete(@RequestParam("typeid") Integer typeid) { + templateService.delType(typeid); + } + + /** + * 分页获取模板列表。 + */ + @GetMapping("List") + public Map list( + @RequestParam("PageSize") Integer pageSize, + @RequestParam("PageIndex") Integer pageIndex, + @RequestParam(value = "typeid", defaultValue = "0") Integer typeid) { + return templateService.getTemplatelist(pageSize, pageIndex, typeid); + } + + /** + * 新增/编辑模板。 + */ + @PostMapping("TemplateEdit") + public void templateEdit( + @RequestParam(value = "SerID", required = false) Integer serID, + @RequestParam("Title") String title, + @RequestParam("Contents") String contents, + @RequestParam(value = "Type", required = false) Integer type, + @RequestParam(value = "Cover", required = false) Integer cover) { + templateService.editTemplate(serID, title, contents, type, cover); + } + + /** + * 删除模板(原 GET DelTemplate,已 POST 化)。 + */ + @PostMapping("TemplateDelete") + public void templateDelete(@RequestParam("SerID") Integer serid) { + templateService.delTemplate(serid); + } + + /** + * 获取单个模板详情。 + */ + @GetMapping("TemplateFirst") + public Map templateFirst(@RequestParam("serid") Integer serid) { + return templateService.getTemplateFist(serid); + } +} diff --git a/src/main/java/com/yunhai/homework/entity/BaseEntity.java b/src/main/java/com/yunhai/homework/entity/BaseEntity.java new file mode 100644 index 0000000..5f98e8c --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/BaseEntity.java @@ -0,0 +1,49 @@ +package com.yunhai.homework.entity; + +import com.yunhai.homework.common.listener.HomeworkAuditListener; +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.*; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link HomeworkAuditListener} 自动填充)。 + */ +@MappedSuperclass +@Getter +@Setter +@EntityListeners(HomeworkAuditListener.class) +public abstract class BaseEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + /** + * 创建人ID(平台用户ID) + */ + @Column(name = "create_by", updatable = false) + private String createBy; + + /** + * 创建时间 + */ + @Column(name = "create_time", updatable = false) + private LocalDateTime createTime; + + /** + * 更新人ID(平台用户ID) + */ + @Column(name = "update_by") + private String updateBy; + + /** + * 更新时间 + */ + @Column(name = "update_time") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/yunhai/homework/entity/ClassStudent.java b/src/main/java/com/yunhai/homework/entity/ClassStudent.java new file mode 100644 index 0000000..793e295 --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/ClassStudent.java @@ -0,0 +1,59 @@ +package com.yunhai.homework.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; +import java.time.LocalDateTime; + +/** + * 班级学生名册快照(PRD class_student)。 + *

+ * 由同步任务从 bschclass 拉取(接口未就绪前用种子数据),每日全量刷 + 手动触发; + * 同步不物理删,转出置 status=0。学生身份口径:用户中心数字ID(UserContext.getUserId()) + * ↔ student_id(SchStudent.UserStuSerID),名册仅存学生ID + 姓名快照。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "class_student", + uniqueConstraints = @UniqueConstraint(name = "uk_class_student", columnNames = {"class_id", "student_id"})) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class ClassStudent extends BaseEntity { + + /** 学校ID */ + @Column(name = "school_id", nullable = false) + private Integer schoolId; + + /** 班级ID */ + @Column(name = "class_id", nullable = false, length = 64) + private String classId; + + /** 班级名快照 */ + @Column(name = "class_name", length = 50) + private String className; + + /** 年级名快照 */ + @Column(name = "grade_name", length = 50) + private String gradeName; + + /** 学生ID(用户中心数字ID,即 SchStudent.UserStuSerID) */ + @Column(name = "student_id", nullable = false) + private Integer studentId; + + /** 学生姓名 */ + @Column(name = "student_name", nullable = false, length = 50) + private String studentName; + + /** 在班状态:1 在班 / 0 已转出退课 */ + @Column(name = "status", nullable = false) + private Integer status = 1; + + /** 离班时间 */ + @Column(name = "leave_at") + private LocalDateTime leaveAt; +} diff --git a/src/main/java/com/yunhai/homework/entity/ContactsGroup.java b/src/main/java/com/yunhai/homework/entity/ContactsGroup.java new file mode 100644 index 0000000..a002e29 --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/ContactsGroup.java @@ -0,0 +1,28 @@ +package com.yunhai.homework.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(用户中心数字ID,同教师ID空间) */ + @Column(name = "creator_id") + private Integer creatorId; +} diff --git a/src/main/java/com/yunhai/homework/entity/ContactsGroupUser.java b/src/main/java/com/yunhai/homework/entity/ContactsGroupUser.java new file mode 100644 index 0000000..eb07763 --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/ContactsGroupUser.java @@ -0,0 +1,35 @@ +package com.yunhai.homework.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) */ + @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/homework/entity/Homework.java b/src/main/java/com/yunhai/homework/entity/Homework.java new file mode 100644 index 0000000..6e615f2 --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/Homework.java @@ -0,0 +1,208 @@ +package com.yunhai.homework.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; + +/** + * 课程作业主表。对应原 .NET Notify_Message(已剔除短信/模板/门户等无关字段)。 + *

+ * 阅读状态不落主表:采用「懒标记」模型——读者首次查看时写入 {@link HomeworkRead}, + * 不存在阅读记录即视为未读,从而无需在发作业时枚举全校用户预插接收行。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "homework") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class Homework extends BaseEntity { + + /** + * 作业标题 + */ + @Column(name = "title", nullable = false, length = 200) + private String title; + + /** + * 学校ID(多租户隔离:发布时从当前用户角色填充,查询时按校过滤)。 + */ + @Column(name = "sch_id") + private Integer schId; + + /** + * 作业内容 + */ + @Column(name = "content", columnDefinition = "LONGTEXT") + private String content; + + /** + * 作业类型:1 普通 / 2 重要(见 {@link com.yunhai.homework.enums.HomeworkTypeEnum}) + */ + @Column(name = "type", nullable = false) + private Integer type = 1; + + /** + * 发布人ID(用户中心数字ID,同教师ID空间) + */ + @Column(name = "publisher_id") + private Integer publisherId; + + /** + * 发布人姓名 + */ + @Column(name = "publisher_name", length = 64) + private String publisherName; + + /** + * 发布部门ID(原 DeptID) + */ + @Column(name = "dept_id", length = 64) + private String deptId; + + /** + * 发布部门名称 + */ + @Column(name = "dept_name", length = 128) + private String deptName; + + /** + * 状态:1 已发布(无草稿态,见 {@link com.yunhai.homework.enums.HomeworkStatusEnum}) + */ + @Column(name = "status", nullable = false) + private Integer status = 1; + + /** + * 是否置顶:0 否 / 1 是(原 topmost) + */ + @Column(name = "topmost") + private Integer topmost = 0; + + // ==================== 迁移补全字段(对应 C# Notify_Message) ==================== + + /** + * 接收人列表(JSON 数组,如 ["10012","10013"] 或接收组ID列表)。 + * 对应 C# Notify_Message.ReceiveList + */ + @Column(name = "receive_list", columnDefinition = "LONGTEXT") + private String receiveList; + + /** + * 密码发送(对应 C# Notify_Message.PwdSend) + */ + @Column(name = "pwd_send", length = 255) + private String pwdSend; + + /** + * 抄送人列表(对应 C# Notify_Message.ChaoSend) + */ + @Column(name = "chao_send", length = 2000) + private String chaoSend; + + /** + * 已读数(发送时初始化为 0,也可由 HomeworkRead 表 COUNT 实时计算)。 + * 对应 C# Notify_Message.readcount + */ + @Column(name = "read_count") + private Integer readCount = 0; + + /** + * 应读数(接收人总数)。对应 C# Notify_Message.allowcount + */ + @Column(name = "allow_count") + private Integer allowCount = 0; + + /** + * 父作业ID(转发时关联原作业)。对应 C# Notify_Message.fkid + */ + @Column(name = "parent_id") + private Integer parentId; + + /** + * 回复ID。对应 C# Notify_Message.reid + */ + @Column(name = "reply_id", length = 255) + private String replyId; + + /** + * 是否撤回:0 否 / 1 是。对应 C# Notify_Message.ispull + */ + @Column(name = "is_pulled") + private Integer isPulled = 0; + + /** + * 定时发送时间(到时间后自动发布)。对应 C# Notify_Message.DsfsTime。 + * PRD 语义中的 scheduled_at 复用本列。 + */ + @Column(name = "timing_send_time") + private LocalDateTime timingSendTime; + + /** + * 短信发送标记:0 不发短信 / 1 发短信。对应 C# Notify_Message.dxbit + */ + @Column(name = "sms_flag") + private Integer smsFlag = 0; + + // ==================== 课堂作业 V3(PRD V1.0)扩展列 ==================== + + /** + * 学科(字典值)。 + */ + @Column(name = "subject", length = 50) + private String subject; + + /** + * 截止时间(精确到分钟;空 = 仅查阅型,不设截止,到点关闭任务跳过)。 + */ + @Column(name = "deadline_at") + private LocalDateTime deadlineAt; + + /** + * 作业流程状态(PRD 五态,见 {@link com.yunhai.homework.enums.HwStatusEnum})。 + * notice 原 status 列继续承载发布/撤回语义,两者并存。 + */ + @Column(name = "hw_status", nullable = false, length = 20) + private String hwStatus = com.yunhai.homework.enums.HwStatusEnum.DRAFT_UNPUBLISHED.getCode(); + + /** + * 发布时间(立即发布或定时到点发布时写入)。 + */ + @Column(name = "published_at") + private LocalDateTime publishedAt; + + /** + * 提交方式,JSON 数组文本,如 ["image","attach"]。 + * 取值:text / image / attach / none(PRD 的 图片/文件/两者 映射为 + * ["image"] / ["attach"] / ["image","attach"])。 + */ + @Column(name = "submit_type", length = 100) + private String submitType; + + /** + * 截止后是否允许逾期提交:0 否 / 1 是。 + */ + @Column(name = "allow_late", nullable = false) + private Integer allowLate = 0; + + /** + * 提醒设置:截止前 1 天提醒(0 关 / 1 开)。 + */ + @Column(name = "remind_1d", nullable = false) + private Integer remind1d = 1; + + /** + * 提醒设置:截止前 1 小时提醒(0 关 / 1 开)。 + */ + @Column(name = "remind_1h", nullable = false) + private Integer remind1h = 1; + + /** + * 归档时间(教师手动归档时写入;归档后只读,所有提醒任务跳过)。 + */ + @Column(name = "archived_at") + private LocalDateTime archivedAt; +} diff --git a/src/main/java/com/yunhai/homework/entity/HomeworkAttachment.java b/src/main/java/com/yunhai/homework/entity/HomeworkAttachment.java new file mode 100644 index 0000000..6fc7804 --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/HomeworkAttachment.java @@ -0,0 +1,44 @@ +package com.yunhai.homework.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 作业附件表(PRD homework_attachment)。文件实体仍存 up_file(file_id 关联 + file_url 冗余)。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "homework_attachment") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class HomeworkAttachment extends BaseEntity { + + /** 作业ID */ + @Column(name = "homework_id", nullable = false) + private Integer homeworkId; + + /** up_file.id(可空:允许外部 URL 附件) */ + @Column(name = "file_id") + private Integer fileId; + + /** 文件访问 URL */ + @Column(name = "file_url", nullable = false, length = 500) + private String fileUrl; + + /** 文件类型:image / file / ... */ + @Column(name = "file_type", length = 20) + private String fileType; + + /** 文件名 */ + @Column(name = "file_name", length = 200) + private String fileName; + + /** 排序号(越小越靠前) */ + @Column(name = "sort", nullable = false) + private Integer sort = 0; +} diff --git a/src/main/java/com/yunhai/homework/entity/HomeworkClass.java b/src/main/java/com/yunhai/homework/entity/HomeworkClass.java new file mode 100644 index 0000000..c01cefb --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/HomeworkClass.java @@ -0,0 +1,35 @@ +package com.yunhai.homework.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; + +/** + * 作业-班级关联表(PRD homework_class)。 + * 发布/编辑时由 receiveList("all" 或班级 id 串)解析写入,是应交名单与统计的权威关联。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "homework_class", + uniqueConstraints = @UniqueConstraint(name = "uk_hw_class", columnNames = {"homework_id", "class_id"})) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class HomeworkClass extends BaseEntity { + + /** 作业ID */ + @Column(name = "homework_id", nullable = false) + private Integer homeworkId; + + /** 班级ID */ + @Column(name = "class_id", nullable = false, length = 64) + private String classId; + + /** 班级名快照 */ + @Column(name = "class_name", length = 50) + private String className; +} diff --git a/src/main/java/com/yunhai/homework/entity/HomeworkRead.java b/src/main/java/com/yunhai/homework/entity/HomeworkRead.java new file mode 100644 index 0000000..35b773a --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/HomeworkRead.java @@ -0,0 +1,49 @@ +package com.yunhai.homework.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.*; +import java.time.LocalDateTime; + +/** + * 作业阅读记录(懒标记)。对应原 .NET Notify_ReceiveList 的「逐用户阅读状态」语义, + * 但仅在读者首次查看时落库,避免发作业时预插全员。 + *

+ * 唯一约束 (homework_id, reader_id):同一用户对同一作业只记一条已读。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "homework_read", + uniqueConstraints = @UniqueConstraint( + name = "uk_homework_reader", + columnNames = {"homework_id", "reader_id"})) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class HomeworkRead extends BaseEntity { + + /** + * 作业ID(关联 homework.id) + */ + @Column(name = "homework_id", nullable = false) + private Integer homeworkId; + + /** + * 读者ID(用户中心数字ID,同教师ID空间) + */ + @Column(name = "reader_id", nullable = false) + private Integer readerId; + + /** + * 读者姓名 + */ + @Column(name = "reader_name", length = 64) + private String readerName; + + /** + * 阅读时间 + */ + @Column(name = "read_time") + private LocalDateTime readTime; +} diff --git a/src/main/java/com/yunhai/homework/entity/HomeworkReadRecord.java b/src/main/java/com/yunhai/homework/entity/HomeworkReadRecord.java new file mode 100644 index 0000000..8179e79 --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/HomeworkReadRecord.java @@ -0,0 +1,58 @@ +package com.yunhai.homework.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; +import java.time.LocalDateTime; + +/** + * 学生作业阅读记录(PRD homework_read_record)。 + *

+ * 学生维度,含首次/最近阅读时间,与 notice 语义的 {@link HomeworkRead}(教师/阅读者已读)互不影响。 + * 行仅在判定已读时写入;未读名单 = 名册左连本表(无记录或 is_read=0)。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "homework_read_record", + uniqueConstraints = @UniqueConstraint(name = "uk_hw_student", columnNames = {"homework_id", "student_id"})) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class HomeworkReadRecord extends BaseEntity { + + /** 作业ID */ + @Column(name = "homework_id", nullable = false) + private Integer homeworkId; + + /** 学生ID(用户中心数字ID,即 SchStudent.UserStuSerID) */ + @Column(name = "student_id", nullable = false) + private Integer studentId; + + /** 学生姓名快照 */ + @Column(name = "student_name", length = 50) + private String studentName; + + /** 班级ID */ + @Column(name = "class_id", length = 64) + private String classId; + + /** 阅读状态:0 UNREAD / 1 READ */ + @Column(name = "is_read", nullable = false) + private Integer isRead = 0; + + /** 首次阅读时间(写入后不再变更) */ + @Column(name = "first_read_at") + private LocalDateTime firstReadAt; + + /** 最近阅读时间(每次上报刷新) */ + @Column(name = "last_read_at") + private LocalDateTime lastReadAt; + + /** 阅读次数 */ + @Column(name = "read_count", nullable = false) + private Integer readCount = 0; +} diff --git a/src/main/java/com/yunhai/homework/entity/HomeworkReceiver.java b/src/main/java/com/yunhai/homework/entity/HomeworkReceiver.java new file mode 100644 index 0000000..97e068b --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/HomeworkReceiver.java @@ -0,0 +1,45 @@ +package com.yunhai.homework.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; + +/** + * 作业接收人关联表(替代旧 receive_list JSON 字符串存储)。 + *

+ * 每条记录代表一个作业的一个接收人(用户ID + 姓名快照,不含账号)。 + * 全员作业(receive_list="ALL")不写明细行, + * 读取时按 receive_list="ALL" 标识走旧逻辑兜底。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "homework_receiver", + indexes = { + @Index(name = "idx_homework_id", columnList = "homework_id"), + @Index(name = "idx_receiver", columnList = "receiver_id") + }) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class HomeworkReceiver extends BaseEntity { + + /** 作业ID(关联 homework.id) */ + @Column(name = "homework_id", nullable = false) + private Integer homeworkId; + + /** 接收人ID(用户中心数字ID) */ + @Column(name = "receiver_id", nullable = false) + private Integer receiverId; + + /** 接收人姓名快照 */ + @Column(name = "receiver_name", length = 64) + private String receiverName; + + /** 接收类型:1 接收 / 2 抄送 */ + @Column(name = "receive_type", nullable = false) + private Integer receiveType = 1; +} diff --git a/src/main/java/com/yunhai/homework/entity/HomeworkSubmission.java b/src/main/java/com/yunhai/homework/entity/HomeworkSubmission.java new file mode 100644 index 0000000..4626044 --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/HomeworkSubmission.java @@ -0,0 +1,98 @@ +package com.yunhai.homework.entity; + +import com.yunhai.homework.enums.ReviewStatusEnum; +import com.yunhai.homework.enums.SubmitStatusEnum; +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; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 作业提交表,多版本建模(PRD homework_submission)。 + *

+ * 同一学生 (homework_id, student_id) 下 version 单调递增;提交/打回重交/撤回均新增行, + * 旧行 is_latest 置 0 保留历史;is_latest=1 的行是当前有效版本。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "homework_submission", + uniqueConstraints = @UniqueConstraint(name = "uk_hw_student_ver", + columnNames = {"homework_id", "student_id", "version"})) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class HomeworkSubmission extends BaseEntity { + + /** 作业ID */ + @Column(name = "homework_id", nullable = false) + private Integer homeworkId; + + /** 学生ID(用户中心数字ID,即 SchStudent.UserStuSerID) */ + @Column(name = "student_id", nullable = false) + private Integer studentId; + + /** 学生姓名快照 */ + @Column(name = "student_name", nullable = false, length = 50) + private String studentName; + + /** 班级ID */ + @Column(name = "class_id", nullable = false, length = 64) + private String classId; + + /** 班级名快照 */ + @Column(name = "class_name", nullable = false, length = 50) + private String className; + + /** 提交版本,同一学生单调递增(撤回/重交均 +1) */ + @Column(name = "version", nullable = false) + private Integer version = 1; + + /** 是否当前版本:1 是 / 0 历史 */ + @Column(name = "is_latest", nullable = false) + private Integer isLatest = 1; + + /** 提交状态(见 {@link SubmitStatusEnum}) */ + @Column(name = "status", nullable = false, length = 20) + private String status = SubmitStatusEnum.SUBMITTED.getCode(); + + /** 是否逾期:0 否 / 1 是 */ + @Column(name = "is_late", nullable = false) + private Integer isLate = 0; + + /** 提交时间(NOT_SUBMITTED 空行为空) */ + @Column(name = "submitted_at") + private LocalDateTime submittedAt; + + /** 文本作业内容 */ + @Column(name = "text", columnDefinition = "TEXT") + private String text; + + /** 评分 */ + @Column(name = "score", precision = 6, scale = 2) + private BigDecimal score; + + /** 评语(打回时 = 订正原因,必填) */ + @Column(name = "comment", columnDefinition = "TEXT") + private String comment; + + /** 批阅状态(见 {@link ReviewStatusEnum}) */ + @Column(name = "review_status", nullable = false, length = 20) + private String reviewStatus = ReviewStatusEnum.PENDING.getCode(); + + /** 批阅人ID(用户中心数字ID,同教师ID空间) */ + @Column(name = "reviewed_by") + private Integer reviewedBy; + + /** 批阅人姓名快照 */ + @Column(name = "reviewed_by_name", length = 64) + private String reviewedByName; + + /** 批阅时间 */ + @Column(name = "reviewed_at") + private LocalDateTime reviewedAt; +} diff --git a/src/main/java/com/yunhai/homework/entity/HomeworkTemplate.java b/src/main/java/com/yunhai/homework/entity/HomeworkTemplate.java new file mode 100644 index 0000000..f088c92 --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/HomeworkTemplate.java @@ -0,0 +1,40 @@ +package com.yunhai.homework.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 作业模板。对应原 .NET Notify_Template 表。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "homework_template") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class HomeworkTemplate extends BaseEntity { + + /** 模板标题 */ + @Column(name = "title", length = 200) + private String title; + + /** 模板内容(HTML) */ + @Column(name = "content", columnDefinition = "LONGTEXT") + private String content; + + /** 封面图片文件ID */ + @Column(name = "cover") + private Integer cover; + + /** 引用次数 */ + @Column(name = "ref_count") + private Integer refCount = 0; + + /** 模板类型ID */ + @Column(name = "type_id") + private Integer typeId; +} diff --git a/src/main/java/com/yunhai/homework/entity/Notification.java b/src/main/java/com/yunhai/homework/entity/Notification.java new file mode 100644 index 0000000..2a931a1 --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/Notification.java @@ -0,0 +1,71 @@ +package com.yunhai.homework.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import java.time.LocalDateTime; + +/** + * 站内通知(PRD notification)。六类通知的统一落表 + 已读状态。 + * 通知已读独立于作业阅读记录(本表 is_read 只反映通知列表已读)。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "notification", + indexes = { + @Index(name = "idx_receiver", columnList = "receiver_id, is_read"), + @Index(name = "idx_hw_type", columnList = "homework_id, type") + }) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class Notification extends BaseEntity { + + /** 通知类型(见 {@link com.yunhai.homework.enums.NotificationTypeEnum}) */ + @Column(name = "type", nullable = false, length = 30) + private String type; + + /** 关联作业ID */ + @Column(name = "homework_id") + private Integer homeworkId; + + /** 关联业务ID(如 submissionId) */ + @Column(name = "related_id") + private Integer relatedId; + + /** 通知标题 */ + @Column(name = "title", nullable = false, length = 200) + private String title; + + /** 通知内容 */ + @Column(name = "content", length = 1000) + private String content; + + /** 接收人ID(学生 = student_id,教师 = 用户中心数字ID,同一 ID 空间) */ + @Column(name = "receiver_id", nullable = false) + private Integer receiverId; + + /** 接收人姓名快照 */ + @Column(name = "receiver_name", length = 64) + private String receiverName; + + /** 接收人角色:student / teacher */ + @Column(name = "receiver_role", nullable = false, length = 20) + private String receiverRole = "student"; + + /** 是否已读:0 未读 / 1 已读 */ + @Column(name = "is_read", nullable = false) + private Integer isRead = 0; + + /** 已读时间 */ + @Column(name = "read_at") + private LocalDateTime readAt; + + /** 发送通道:inapp 站内 / sms 短信 */ + @Column(name = "send_channel", nullable = false, length = 20) + private String sendChannel = "inapp"; +} diff --git a/src/main/java/com/yunhai/homework/entity/SchClassTeach.java b/src/main/java/com/yunhai/homework/entity/SchClassTeach.java new file mode 100644 index 0000000..d1a091f --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/SchClassTeach.java @@ -0,0 +1,46 @@ +package com.yunhai.homework.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; + +/** + * 班级任教关系快照(bschclass sch_class_teach 本地镜像,PRD 口径同 class_student)。 + *

+ * 由同步任务从 bschclass 拉取(接口未就绪前用种子数据);bschclass 任教接口 + * 不可用时,任教学科链路降级读本表。教师口径:用户中心数字ID(UserContext.getUserId()); + * class_id 与 class_student.class_id 同一取值域。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "sch_class_teach", + uniqueConstraints = @UniqueConstraint(name = "uk_sch_class_teach", columnNames = {"school_id", "class_id", "tea_id", "sub_id"})) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SchClassTeach extends BaseEntity { + + /** 学校ID */ + @Column(name = "school_id", nullable = false) + private Integer schoolId; + + /** 班级ID(与 class_student.class_id 同一取值域) */ + @Column(name = "class_id", nullable = false, length = 64) + private String classId; + + /** 教师ID(用户中心数字ID) */ + @Column(name = "tea_id", nullable = false) + private Integer teaId; + + /** 学科编码(bschclass SubID;本地无字典时编码即展示名) */ + @Column(name = "sub_id", nullable = false, length = 64) + private String subId; + + /** 有效状态:1 有效 / 0 失效 */ + @Column(name = "status", nullable = false) + private Integer status = 1; +} diff --git a/src/main/java/com/yunhai/homework/entity/SmsModule.java b/src/main/java/com/yunhai/homework/entity/SmsModule.java new file mode 100644 index 0000000..3f4d444 --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/SmsModule.java @@ -0,0 +1,28 @@ +package com.yunhai.homework.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 短信模块配置。对应原 .NET SchSms 表。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "sms_module") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SmsModule extends BaseEntity { + + /** 模块名称 */ + @Column(name = "module_name", length = 100) + private String moduleName; + + /** 模块标识 */ + @Column(name = "module_code", length = 50) + private String moduleCode; +} diff --git a/src/main/java/com/yunhai/homework/entity/SmsSchoolConfig.java b/src/main/java/com/yunhai/homework/entity/SmsSchoolConfig.java new file mode 100644 index 0000000..428d271 --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/SmsSchoolConfig.java @@ -0,0 +1,37 @@ +package com.yunhai.homework.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 学校短信开通配置。对应原 .NET SchSms_Set 表。 + * 记录某学校某模块是否开通短信。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "sms_school_config") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SmsSchoolConfig extends BaseEntity { + + /** 学校ID */ + @Column(name = "school_id", nullable = false) + private Integer schoolId; + + /** 学校名称 */ + @Column(name = "school_name", length = 200) + private String schoolName; + + /** 模块ID */ + @Column(name = "module_id") + private Integer moduleId; + + /** 是否开通短信 */ + @Column(name = "is_sms") + private Boolean isSms = false; +} diff --git a/src/main/java/com/yunhai/homework/entity/SubmissionAttachment.java b/src/main/java/com/yunhai/homework/entity/SubmissionAttachment.java new file mode 100644 index 0000000..85df7ba --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/SubmissionAttachment.java @@ -0,0 +1,54 @@ +package com.yunhai.homework.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; + +/** + * 提交附件表(PRD submission_attachment)。替代 JSON 方案,支撑矩阵缩略图与批阅大图预览。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "submission_attachment", + indexes = @Index(name = "idx_sub", columnList = "submission_id")) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SubmissionAttachment extends BaseEntity { + + /** 提交行ID(homework_submission.id) */ + @Column(name = "submission_id", nullable = false) + private Integer submissionId; + + /** 作业ID(冗余,便于统计与清理) */ + @Column(name = "homework_id", nullable = false) + private Integer homeworkId; + + /** 学生ID(用户中心数字ID) */ + @Column(name = "student_id") + private Integer studentId; + + /** up_file.id(可空:允许外部 URL) */ + @Column(name = "file_id") + private Integer fileId; + + /** 文件访问 URL */ + @Column(name = "file_url", nullable = false, length = 500) + private String fileUrl; + + /** 文件类型:image / file */ + @Column(name = "file_type", length = 20) + private String fileType; + + /** 文件名 */ + @Column(name = "file_name", length = 200) + private String fileName; + + /** 排序号 */ + @Column(name = "sort", nullable = false) + private Integer sort = 0; +} diff --git a/src/main/java/com/yunhai/homework/entity/SysDict.java b/src/main/java/com/yunhai/homework/entity/SysDict.java new file mode 100644 index 0000000..3091084 --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/SysDict.java @@ -0,0 +1,40 @@ +package com.yunhai.homework.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/homework/entity/TemplateType.java b/src/main/java/com/yunhai/homework/entity/TemplateType.java new file mode 100644 index 0000000..aab8163 --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/TemplateType.java @@ -0,0 +1,28 @@ +package com.yunhai.homework.entity; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 作业模板类型。对应原 .NET Notify_TemplateType 表。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "template_type") +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class TemplateType extends BaseEntity { + + /** 类型名称 */ + @Column(name = "type_name", length = 100) + private String typeName; + + /** 排序号 */ + @Column(name = "sort_order") + private Integer sortOrder = 0; +} diff --git a/src/main/java/com/yunhai/homework/entity/UpFile.java b/src/main/java/com/yunhai/homework/entity/UpFile.java new file mode 100644 index 0000000..38248c7 --- /dev/null +++ b/src/main/java/com/yunhai/homework/entity/UpFile.java @@ -0,0 +1,47 @@ +package com.yunhai.homework.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 { + + /** 应用类型(如 "homework"),用于区分不同业务的附件 */ + @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; + + /** 文件存储路径(相对路径,如 homework/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/homework/enums/HomeworkStatusEnum.java b/src/main/java/com/yunhai/homework/enums/HomeworkStatusEnum.java new file mode 100644 index 0000000..124bec9 --- /dev/null +++ b/src/main/java/com/yunhai/homework/enums/HomeworkStatusEnum.java @@ -0,0 +1,34 @@ +package com.yunhai.homework.enums; + +import lombok.Getter; + +/** + * 作业状态。无草稿态:发作业一律置为「已发布」, + * 定时发送通过 timingSendTime + 查询侧「已到点」过滤控制可见性。 + * 对应原 .NET Notify_Message.status(原系统已发布=2,此处归一化为 1)。 + */ +@Getter +public enum HomeworkStatusEnum { + + PUBLISHED(1, "已发布"); + + private final int code; + private final String desc; + + HomeworkStatusEnum(int code, String desc) { + this.code = code; + this.desc = desc; + } + + public static HomeworkStatusEnum of(Integer code) { + if (code == null) { + return PUBLISHED; + } + for (HomeworkStatusEnum e : values()) { + if (e.code == code) { + return e; + } + } + return PUBLISHED; + } +} diff --git a/src/main/java/com/yunhai/homework/enums/HomeworkTypeEnum.java b/src/main/java/com/yunhai/homework/enums/HomeworkTypeEnum.java new file mode 100644 index 0000000..077396d --- /dev/null +++ b/src/main/java/com/yunhai/homework/enums/HomeworkTypeEnum.java @@ -0,0 +1,33 @@ +package com.yunhai.homework.enums; + +import lombok.Getter; + +/** + * 作业类型。对应原 .NET Notify_Message.type(原系统作业类=2,此处归一化)。 + */ +@Getter +public enum HomeworkTypeEnum { + + NORMAL(1, "普通作业"), + IMPORTANT(2, "重要作业"); + + private final int code; + private final String desc; + + HomeworkTypeEnum(int code, String desc) { + this.code = code; + this.desc = desc; + } + + public static HomeworkTypeEnum of(Integer code) { + if (code == null) { + return NORMAL; + } + for (HomeworkTypeEnum e : values()) { + if (e.code == code) { + return e; + } + } + return NORMAL; + } +} diff --git a/src/main/java/com/yunhai/homework/enums/HwStatusEnum.java b/src/main/java/com/yunhai/homework/enums/HwStatusEnum.java new file mode 100644 index 0000000..8b10083 --- /dev/null +++ b/src/main/java/com/yunhai/homework/enums/HwStatusEnum.java @@ -0,0 +1,48 @@ +package com.yunhai.homework.enums; + +import lombok.Getter; + +/** + * 作业流程状态(PRD 五态,存 homework.hw_status)。 + *

+ * 与 notice 原 status(Integer,发布/撤回语义)并存:本枚举承载课堂作业业务流转, + * 流转收敛在 HomeworkStateService,条件更新保证幂等。 + */ +@Getter +public enum HwStatusEnum { + + /** 草稿 - 未发布 */ + DRAFT_UNPUBLISHED("DRAFT_UNPUBLISHED", "草稿-未发布"), + /** 草稿 - 定时待发布 */ + DRAFT_SCHEDULED("DRAFT_SCHEDULED", "草稿-定时待发布"), + /** 已发布 - 进行中 */ + PUBLISHED("PUBLISHED", "已发布-进行中"), + /** 已截止 */ + CLOSED("CLOSED", "已截止"), + /** 已归档(只读,不再触发任何提醒) */ + ARCHIVED("ARCHIVED", "已归档"); + + private final String code; + private final String desc; + + HwStatusEnum(String code, String desc) { + this.code = code; + this.desc = desc; + } + + public static HwStatusEnum of(String code) { + if (code == null) { + return DRAFT_UNPUBLISHED; + } + for (HwStatusEnum e : values()) { + if (e.code.equals(code)) { + return e; + } + } + return DRAFT_UNPUBLISHED; + } + + public boolean isDraft() { + return this == DRAFT_UNPUBLISHED || this == DRAFT_SCHEDULED; + } +} diff --git a/src/main/java/com/yunhai/homework/enums/NotificationTypeEnum.java b/src/main/java/com/yunhai/homework/enums/NotificationTypeEnum.java new file mode 100644 index 0000000..df75af1 --- /dev/null +++ b/src/main/java/com/yunhai/homework/enums/NotificationTypeEnum.java @@ -0,0 +1,52 @@ +package com.yunhai.homework.enums; + +import lombok.Getter; + +/** + * 站内通知类型(PRD 六类,存 notification.type)。 + */ +@Getter +public enum NotificationTypeEnum { + + /** 作业发布(对应班级学生) */ + HOMEWORK_PUBLISHED("HOMEWORK_PUBLISHED", "作业发布"), + /** 截止提醒(截止前 1 天 / 1 小时) */ + DEADLINE_REMINDER("DEADLINE_REMINDER", "截止提醒"), + /** 未交提醒(教师手动) */ + NOT_SUBMITTED("NOT_SUBMITTED", "未交提醒"), + /** 未读提醒(教师手动) */ + NOT_READ("NOT_READ", "未读提醒"), + /** 批阅完成 */ + REVIEW_DONE("REVIEW_DONE", "批阅完成"), + /** 打回通知 */ + RETURNED("RETURNED", "打回通知"), + /** + * 定时发布失败回退(PRD §17.1 教师泳道要求通知教师,但 PRD 枚举表未列—— + * 按 classwork-homework-plan-v3 §13-2 建议补充,待产品确认命名)。 + */ + SCHEDULE_FAILED("SCHEDULE_FAILED", "定时发布失败"), + /** + * 截止时间变更(PRD §17.1 延长截止要求通知学生,PRD 枚举表未列——同上补充)。 + */ + DEADLINE_CHANGED("DEADLINE_CHANGED", "截止时间变更"); + + private final String code; + private final String desc; + + NotificationTypeEnum(String code, String desc) { + this.code = code; + this.desc = desc; + } + + public static NotificationTypeEnum of(String code) { + if (code == null) { + return null; + } + for (NotificationTypeEnum e : values()) { + if (e.code.equals(code)) { + return e; + } + } + return null; + } +} diff --git a/src/main/java/com/yunhai/homework/enums/ReviewStatusEnum.java b/src/main/java/com/yunhai/homework/enums/ReviewStatusEnum.java new file mode 100644 index 0000000..031eddc --- /dev/null +++ b/src/main/java/com/yunhai/homework/enums/ReviewStatusEnum.java @@ -0,0 +1,39 @@ +package com.yunhai.homework.enums; + +import lombok.Getter; + +/** + * 批阅状态(PRD 三态,存 homework_submission.review_status)。 + * 与 SubmitStatusEnum 同源联动:批阅保存 → status=REVIEWED + review_status=REVIEWED; + * 打回 → status=RETURNED + review_status=RETURNED。 + */ +@Getter +public enum ReviewStatusEnum { + + /** 待批阅 */ + PENDING("PENDING", "待批阅"), + /** 已批阅 */ + REVIEWED("REVIEWED", "已批阅"), + /** 已打回 */ + RETURNED("RETURNED", "已打回"); + + private final String code; + private final String desc; + + ReviewStatusEnum(String code, String desc) { + this.code = code; + this.desc = desc; + } + + public static ReviewStatusEnum of(String code) { + if (code == null) { + return PENDING; + } + for (ReviewStatusEnum e : values()) { + if (e.code.equals(code)) { + return e; + } + } + return PENDING; + } +} diff --git a/src/main/java/com/yunhai/homework/enums/SubmitStatusEnum.java b/src/main/java/com/yunhai/homework/enums/SubmitStatusEnum.java new file mode 100644 index 0000000..7d6aaca --- /dev/null +++ b/src/main/java/com/yunhai/homework/enums/SubmitStatusEnum.java @@ -0,0 +1,50 @@ +package com.yunhai.homework.enums; + +import lombok.Getter; + +/** + * 学生提交状态(PRD 五态,存 homework_submission.status)。 + *

+ * NOT_SUBMITTED 空行仅由「撤回」产生;未提交学生本身不落行(统计时由名册左连补位)。 + */ +@Getter +public enum SubmitStatusEnum { + + /** 未提交(撤回后产生的空提交行) */ + NOT_SUBMITTED("NOT_SUBMITTED", "未提交"), + /** 已提交(含打回后重新提交) */ + SUBMITTED("SUBMITTED", "已提交"), + /** 逾期提交(截止后提交,is_late=1) */ + LATE_SUBMITTED("LATE_SUBMITTED", "逾期提交"), + /** 已打回(待重新提交) */ + RETURNED("RETURNED", "已打回"), + /** 已批阅 */ + REVIEWED("REVIEWED", "已批阅"); + + private final String code; + private final String desc; + + SubmitStatusEnum(String code, String desc) { + this.code = code; + this.desc = desc; + } + + public static SubmitStatusEnum of(String code) { + if (code == null) { + return NOT_SUBMITTED; + } + for (SubmitStatusEnum e : values()) { + if (e.code.equals(code)) { + return e; + } + } + return NOT_SUBMITTED; + } + + /** + * 是否计入「已交」口径:打回未重交也算已交(PRD 学生矩阵口径)。 + */ + public boolean countsAsSubmitted() { + return this != NOT_SUBMITTED; + } +} diff --git a/src/main/java/com/yunhai/homework/job/HomeworkScheduledJobs.java b/src/main/java/com/yunhai/homework/job/HomeworkScheduledJobs.java new file mode 100644 index 0000000..5377254 --- /dev/null +++ b/src/main/java/com/yunhai/homework/job/HomeworkScheduledJobs.java @@ -0,0 +1,161 @@ +package com.yunhai.homework.job; + +import com.yunhai.homework.entity.Homework; +import com.yunhai.homework.enums.HwStatusEnum; +import com.yunhai.homework.enums.NotificationTypeEnum; +import com.yunhai.homework.repository.HomeworkClassRepository; +import com.yunhai.homework.repository.HomeworkRepository; +import com.yunhai.homework.service.impl.ClassStudentService; +import com.yunhai.homework.service.impl.HomeworkStateService; +import com.yunhai.homework.service.impl.NotificationService; +import com.yunhai.homework.service.impl.TeacherHomeworkV2Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 课堂作业定时任务(PRD §17.1 调度泳道)。 + *

+ * 全部基于状态机 CAS 条件更新,天然幂等,可安全重复执行; + * ARCHIVED 作业被显式跳过(归档后不再触发任何提醒)。 + */ +@Component +public class HomeworkScheduledJobs { + + private static final Logger log = LoggerFactory.getLogger(HomeworkScheduledJobs.class); + + @Autowired + private HomeworkRepository homeworkRepository; + + @Autowired + private HomeworkClassRepository homeworkClassRepository; + + @Autowired + private HomeworkStateService stateService; + + @Autowired + private NotificationService notificationService; + + @Autowired + private TeacherHomeworkV2Service teacherHomeworkV2Service; + + @Autowired + private ClassStudentService classStudentService; + + /** + * 定时发布:每分钟扫描到点的 DRAFT_SCHEDULED 作业 → PUBLISHED + 作业发布通知; + * 失败回退 DRAFT_UNPUBLISHED 并站内通知教师。 + */ + @Scheduled(cron = "0 * * * * ?") + public void scheduledPublish() { + List due = homeworkRepository + .findByHwStatusAndTimingSendTimeLessThanEqual(HwStatusEnum.DRAFT_SCHEDULED.getCode(), + LocalDateTime.now()); + if (due.isEmpty()) { + return; + } + log.info("[scheduledPublish] 到点作业 {} 个", due.size()); + for (Homework homework : due) { + Integer id = homework.getId(); + try { + teacherHomeworkV2Service.publishInternal(id, HwStatusEnum.DRAFT_SCHEDULED); + log.info("[scheduledPublish] 发布成功 homeworkId={}", id); + } catch (Exception e) { + log.error("[scheduledPublish] 发布失败,回退草稿 homeworkId={}", id, e); + try { + boolean rolledBack = stateService.rollbackFromSchedule(id); + if (rolledBack) { + notificationService.send(NotificationTypeEnum.SCHEDULE_FAILED, id, null, + homework.getPublisherId(), homework.getPublisherName(), + NotificationService.ROLE_TEACHER, + "定时发布失败:《" + homework.getTitle() + "》", + "定时发布任务执行异常,作业已回退为「草稿-未发布」,请重新发布。"); + } + } catch (Exception ex) { + log.error("[scheduledPublish] 回退失败 homeworkId={}", id, ex); + } + } + } + } + + /** + * 到点截止:每分钟扫描已过截止时间的 PUBLISHED 作业 → CLOSED(无对应通知类型,不推送)。 + */ + @Scheduled(cron = "30 * * * * ?") + public void deadlineClose() { + List overdue = homeworkRepository + .findByHwStatusAndDeadlineAtBefore(HwStatusEnum.PUBLISHED.getCode(), LocalDateTime.now()); + for (Homework homework : overdue) { + if (stateService.cas(homework.getId(), HwStatusEnum.PUBLISHED, HwStatusEnum.CLOSED)) { + log.info("[deadlineClose] 作业已截止 homeworkId={}", homework.getId()); + } + } + } + + /** + * 截止提醒:每分钟扫描截止时间在 24h 内的 PUBLISHED 作业, + * 命中「1 天档 / 1 小时档」窗口且开关开启时向未交学生推送 DEADLINE_REMINDER; + * 档位去重(related_id:1=1 天档 2=1 小时档),每作业每档至多发一轮。 + */ + @Scheduled(cron = "40 * * * * ?") + public void deadlineReminder() { + LocalDateTime now = LocalDateTime.now(); + List window = homeworkRepository + .findByHwStatusAndDeadlineAtBetween(HwStatusEnum.PUBLISHED.getCode(), now, now.plusHours(24)); + for (Homework homework : window) { + if (homework.getDeadlineAt() == null) { + continue; + } + // 1 小时档:截止前 [55, 60) 分钟窗口 + if (homework.getRemind1h() != null && homework.getRemind1h() == 1 + && inWindow(homework.getDeadlineAt(), now, 55, 60) + && !notificationService.alreadySentSlot(homework.getId(), NotificationTypeEnum.DEADLINE_REMINDER, 2)) { + sendReminder(homework, 2, "1 小时"); + } + // 1 天档:截止前 [23, 24) 小时窗口 + if (homework.getRemind1d() != null && homework.getRemind1d() == 1 + && inWindow(homework.getDeadlineAt(), now, 23 * 60, 24 * 60) + && !notificationService.alreadySentSlot(homework.getId(), NotificationTypeEnum.DEADLINE_REMINDER, 1)) { + sendReminder(homework, 1, "1 天"); + } + } + } + + private boolean inWindow(LocalDateTime deadline, LocalDateTime now, int fromMinutes, int toMinutes) { + long minutes = java.time.Duration.between(now, deadline).toMinutes(); + return minutes >= fromMinutes && minutes < toMinutes; + } + + private void sendReminder(Homework homework, int slot, String slotDesc) { + // 通知接收人直接使用学生ID(名册口径:student_id = 平台用户ID) + List targets = teacherHomeworkV2Service.notSubmittedStudentIds(homework.getId()).stream() + .distinct() + .collect(Collectors.toList()); + if (targets.isEmpty()) { + return; + } + int sent = notificationService.sendBatch(NotificationTypeEnum.DEADLINE_REMINDER, homework.getId(), slot, + NotificationService.distinct(targets), + NotificationService.deadlineReminderTitle(homework), + NotificationService.deadlineReminderContent(homework) + "(剩余不足 " + slotDesc + ")"); + log.info("[deadlineReminder] homeworkId={}, slot={}, sent={}", homework.getId(), slot, sent); + } + + /** + * 名册同步:每日 02:00 全量刷新 class_student(bschclass 接口就绪前仅日志)。 + */ + @Scheduled(cron = "${homework.sync.class-data-cron:0 0 2 * * ?}") + public void classDataSync() { + try { + classStudentService.syncAll(); + } catch (Exception e) { + log.error("[classDataSync] 名册同步失败", e); + } + } +} diff --git a/src/main/java/com/yunhai/homework/model/BasePageQuery.java b/src/main/java/com/yunhai/homework/model/BasePageQuery.java new file mode 100644 index 0000000..7f46326 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/BasePageQuery.java @@ -0,0 +1,14 @@ +package com.yunhai.homework.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/homework/model/BasePageResult.java b/src/main/java/com/yunhai/homework/model/BasePageResult.java new file mode 100644 index 0000000..058c96c --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/BasePageResult.java @@ -0,0 +1,33 @@ +package com.yunhai.homework.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/homework/model/SchTeaList.java b/src/main/java/com/yunhai/homework/model/SchTeaList.java new file mode 100644 index 0000000..80f9b9c --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/SchTeaList.java @@ -0,0 +1,106 @@ +package com.yunhai.homework.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/homework/model/bsch/SchClassDto.java b/src/main/java/com/yunhai/homework/model/bsch/SchClassDto.java new file mode 100644 index 0000000..cf9c417 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/bsch/SchClassDto.java @@ -0,0 +1,29 @@ +package com.yunhai.homework.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 班级 DTO(/oapi/VSchClass/GetVClassList,SchClass 视图)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchClassDto { + + /** 班级业务ID(sch_class.class_id,scopeType=班级 的 scopeId;学生 ClassID 指向它) */ + @JsonProperty("ClassID") + private Integer classId; + + /** 班级名称 */ + @JsonProperty("ClassName") + private String className; + + /** 年级ID(= gradeId) */ + @JsonProperty("Levell") + private Integer gradeId; + + /** 排序号 */ + @JsonProperty("sx") + private Integer sort; +} diff --git a/src/main/java/com/yunhai/homework/model/bsch/SchGradeDto.java b/src/main/java/com/yunhai/homework/model/bsch/SchGradeDto.java new file mode 100644 index 0000000..01ff15b --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/bsch/SchGradeDto.java @@ -0,0 +1,25 @@ +package com.yunhai.homework.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 年级 DTO(/oapi/Schoolen/GetSchLenLevellist,BaseSchsystemGrade)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchGradeDto { + + /** 年级ID(=gradeId,scopeType=年级 的 scopeId;班级 Levell 指向它) */ + @JsonProperty("Id") + private Integer id; + + /** 年级名称(gradeNickname,如「初一」) */ + @JsonProperty("Name") + private String name; + + /** 所属学段(学制)ID */ + @JsonProperty("OwnerId") + private Integer ownerId; +} diff --git a/src/main/java/com/yunhai/homework/model/bsch/SchInfoDto.java b/src/main/java/com/yunhai/homework/model/bsch/SchInfoDto.java new file mode 100644 index 0000000..06185b6 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/bsch/SchInfoDto.java @@ -0,0 +1,25 @@ +package com.yunhai.homework.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 学校信息 DTO(/oapi/SchInfo/GetUserSchFirst)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchInfoDto { + + /** 学校 GUID(bschclass 侧 SchSerId) */ + @JsonProperty("SchSerId") + private String schSerId; + + /** 用户中心学校组ID(与 CampusNotice schId 同域) */ + @JsonProperty("UserSchSerId") + private Integer userSchSerId; + + /** 学校名称 */ + @JsonProperty("SchName") + private String schName; +} diff --git a/src/main/java/com/yunhai/homework/model/bsch/SchStudentDto.java b/src/main/java/com/yunhai/homework/model/bsch/SchStudentDto.java new file mode 100644 index 0000000..f96913d --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/bsch/SchStudentDto.java @@ -0,0 +1,35 @@ +package com.yunhai.homework.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 学生 DTO(/oapi/OAStu/*,SchStudent 视图)。 + * account 仅为上游接口契约字段(兼容反序列化),本系统落库/身份映射不再使用; + * 学生身份以 stuId(UserStuSerID,= 平台用户ID)+ stuName 承载。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchStudentDto { + + /** 学员登录账号(上游契约字段,仅兼容保留,不落库) */ + @JsonProperty("Account") + private String account; + + /** 学生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/homework/model/bsch/SchSubjectDto.java b/src/main/java/com/yunhai/homework/model/bsch/SchSubjectDto.java new file mode 100644 index 0000000..ff7add8 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/bsch/SchSubjectDto.java @@ -0,0 +1,29 @@ +package com.yunhai.homework.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 学校学科 DTO(/oapi/SubInfo/GetSchSublist)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchSubjectDto { + + /** 学校 GUID(bschclass 侧 SchSerId) */ + @JsonProperty("SchSerID") + private String schSerId; + + /** 学科ID(学科编码;字典无编码时为字典ID字符串) */ + @JsonProperty("SubID") + private String subId; + + /** 学科名称 */ + @JsonProperty("SubName") + private String subName; + + /** 排序号 */ + @JsonProperty("No") + private Integer no; +} diff --git a/src/main/java/com/yunhai/homework/model/bsch/SchSystemDto.java b/src/main/java/com/yunhai/homework/model/bsch/SchSystemDto.java new file mode 100644 index 0000000..f0df157 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/bsch/SchSystemDto.java @@ -0,0 +1,25 @@ +package com.yunhai.homework.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 学段(学制)DTO(/oapi/Schoolen/GetSchLenlist,BaseSchsystem)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchSystemDto { + + /** 学制ID(scopeType=学部 的 scopeId) */ + @JsonProperty("Id") + private Integer id; + + /** 学段名称(如「小学部」「初中部」) */ + @JsonProperty("Name") + private String name; + + /** 学制类型 */ + @JsonProperty("Type") + private Integer type; +} diff --git a/src/main/java/com/yunhai/homework/model/bsch/SchTeachDto.java b/src/main/java/com/yunhai/homework/model/bsch/SchTeachDto.java new file mode 100644 index 0000000..86ec6f5 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/bsch/SchTeachDto.java @@ -0,0 +1,44 @@ +package com.yunhai.homework.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.Date; + +/** + * bschclass 任教关系 DTO(/oapi/TeachInfo/*,sch_class_teach 视图)。 + * 一条记录 = 某学校某班级中某教师任教某学科。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchTeachDto { + + /** 任教记录ID(sch_class_teach.id) */ + @JsonProperty("SerID") + private Integer serId; + + /** 学校 GUID(bschclass 侧 SchSerId) */ + @JsonProperty("SchSerID") + private String schSerId; + + /** 教师ID(= 平台用户ID) */ + @JsonProperty("UserTeaSerID") + private Integer teaId; + + /** 学科ID(学科编码) */ + @JsonProperty("SubID") + private String subId; + + /** 班级业务ID(sch_class.class_id) */ + @JsonProperty("ClassID") + private Integer classId; + + /** 班级ID(与 ClassID 同值,上游兼容字段) */ + @JsonProperty("UserClassID") + private Integer userClassId; + + /** 创建时间 */ + @JsonProperty("CreationTime") + private Date creationTime; +} diff --git a/src/main/java/com/yunhai/homework/model/bsch/SchTreeNodeDto.java b/src/main/java/com/yunhai/homework/model/bsch/SchTreeNodeDto.java new file mode 100644 index 0000000..29623ad --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/bsch/SchTreeNodeDto.java @@ -0,0 +1,41 @@ +package com.yunhai.homework.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * bschclass 组织树节点 DTO(/oapi/SchGroup/GetTeaTree、GetStuTree,TeaTreeVO 视图)。 + * type=1 组节点(学校/学段/年级/班级、教师组),type=2 人员叶子(学生/教师)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchTreeNodeDto { + + /** 节点ID(学校/学段/年级/班级为对应业务ID,叶子为学生 stuId 或教师 teaId) */ + private Integer id; + + /** 节点名称 */ + private String name; + + /** 父节点ID(根节点为 0) */ + private Integer pid; + + /** 排序号(组取 sort,叶子固定 0) */ + private Integer no; + + /** 1=组节点,2=人员叶子 */ + private Integer type; + + /** 是否父节点(组 true,叶子 false) */ + @JsonProperty("isParent") + private Boolean isParent; + + /** 头像(当前固定空串) */ + private String photo; + + /** 子节点(叶子为 null,组节点非 null) */ + private List children; +} diff --git a/src/main/java/com/yunhai/homework/model/homework/HomeworkDetailVo.java b/src/main/java/com/yunhai/homework/model/homework/HomeworkDetailVo.java new file mode 100644 index 0000000..7621af2 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/homework/HomeworkDetailVo.java @@ -0,0 +1,81 @@ +package com.yunhai.homework.model.homework; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * 作业详情视图对象(替代原 Map<String,Object> 返回)。 + * 使用 @JsonProperty 保持旧字段名兼容前端渲染。 + */ +@Data +public class HomeworkDetailVo { + + @JsonProperty("serid") + private Integer serid; + + @JsonProperty("title") + private String title; + + @JsonProperty("Contents") + private String contents; + + @JsonProperty("DeptID") + private String deptId; + + @JsonProperty("DeptName") + private String deptName; + + @JsonProperty("TeaName") + private String publisherName; + + @JsonProperty("EditDate") + private String editDate; + + @JsonProperty("numcount") + private Integer numCount; + + @JsonProperty("readnumcount") + private Long readNumCount; + + @JsonProperty("ReceiveList") + private String receiveList; + + @JsonProperty("DsfsTime") + private String dsfsTime; + + @JsonProperty("isDsfs") + private Boolean isDsfs; + + @JsonProperty("ChaoSend") + private List chaoSend; + + /** 接收人列表:"all" 或用户ID列表 */ + @JsonProperty("tlist") + private Object tlist; + + /** + * 接收对象可读文本("全部班级" 或班级名顿号串)。 + * 详情页「接收班级」标签直接展示,避免前端拿 ReceiveList 的 id 串渲染。 + */ + @JsonProperty("receiveText") + private String receiveText; + + /** + * 附件项(旧 ChaoSend 数组元素)。 + */ + @Data + public static class FileItem { + @JsonProperty("id") + private Integer id; + @JsonProperty("name") + private String name; + @JsonProperty("size") + private Integer size; + @JsonProperty("fileid") + private Integer fileid; + @JsonProperty("filename") + private String filename; + } +} diff --git a/src/main/java/com/yunhai/homework/model/homework/HomeworkPageResult.java b/src/main/java/com/yunhai/homework/model/homework/HomeworkPageResult.java new file mode 100644 index 0000000..e1c2fa2 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/homework/HomeworkPageResult.java @@ -0,0 +1,26 @@ +package com.yunhai.homework.model.homework; + +import com.yunhai.homework.model.BasePageResult; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 作业分页结果(扩展 BasePageResult,额外携带 unreadCount)。 + *

+ * 对齐方案 M7:unreadCount 并入分页外壳扩展子类,不改基类契约。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class HomeworkPageResult extends BasePageResult { + + /** 当前用户未读作业总数 */ + private long unreadCount; + + public HomeworkPageResult(List content, int total, int pageIndex, int pageSize, + int totalPages, boolean hasNext, long unreadCount) { + super(content, total, pageIndex, pageSize, totalPages, hasNext); + this.unreadCount = unreadCount; + } +} diff --git a/src/main/java/com/yunhai/homework/model/homework/HomeworkQuery.java b/src/main/java/com/yunhai/homework/model/homework/HomeworkQuery.java new file mode 100644 index 0000000..afea9a6 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/homework/HomeworkQuery.java @@ -0,0 +1,48 @@ +package com.yunhai.homework.model.homework; + +import com.yunhai.homework.model.BasePageQuery; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +/** + * 作业列表查询条件。 + *

+ * - 管理端(adminList):用 status / type / deptId / publisherId / 时间范围过滤; + * - 用户端(userList):额外用 readType 过滤未读/已读(0 全部 / 1 未读 / 2 已读)。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class HomeworkQuery extends BasePageQuery { + + /** 状态过滤:null 不过滤;1 已发布 */ + private Integer status; + + /** 类型过滤:1 普通 / 2 重要 */ + private Integer type; + + /** 列表范围(管理端):"user" 只看自己发布的,null 看全部 */ + private String scope; + + /** 发布部门ID */ + private String deptId; + + /** 发布人ID(用户中心数字ID,管理端按发布人查) */ + private Integer publisherId; + + /** 开始时间(创建时间 >=,前端传 yyyy-MM-dd 短日期) */ + @DateTimeFormat(pattern = "yyyy-MM-dd") + private LocalDate startTime; + + /** 结束时间(创建时间 <= 当天 23:59:59,前端传 yyyy-MM-dd 短日期) */ + @DateTimeFormat(pattern = "yyyy-MM-dd") + private LocalDate endTime; + + /** 阅读状态过滤(仅用户端):0 全部 / 1 未读 / 2 已读 */ + private Integer readType = 0; + + /** 排序:1 时间倒序(默认)/ 2 时间正序 */ + private String orderByTime = "1"; +} diff --git a/src/main/java/com/yunhai/homework/model/homework/HomeworkRequest.java b/src/main/java/com/yunhai/homework/model/homework/HomeworkRequest.java new file mode 100644 index 0000000..2cb560f --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/homework/HomeworkRequest.java @@ -0,0 +1,65 @@ +package com.yunhai.homework.model.homework; + +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import com.yunhai.homework.common.validation.ValidationGroups; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.time.LocalDateTime; + +/** + * 作业发布/编辑入参。发布人由当前登录用户上下文填充,不在此入参中传递。 + */ +@Data +public class HomeworkRequest { + + /** 作业标题 */ + @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "作业标题不得为空") + private String title; + + /** 作业内容 */ + private String content; + + /** 作业类型:1 普通 / 2 重要 */ + @NotNull(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "作业类型不得为空") + private Integer type = 1; + + /** 发布部门ID(原 DeptID,必填——对应原系统「发布部门不得为空」校验) */ + @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "发布部门不得为空") + private String deptId; + + /** 发布部门名称 */ + private String deptName; + + /** 是否置顶:0 否 / 1 是 */ + private Integer topmost = 0; + + /** 接收人列表(JSON 数组字符串,如 ["10012","10013"]——用户中心数字ID,或 "ALL" 表示全员) */ + private String receiveList; + + /** 附件文件ID列表(逗号分隔串,如 "1,2,3");旧名 chaoSend,已更名 fileIds(M6) */ + private String fileIds; + + /** 定时发送时间(为空表示立即发布) */ + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime timingSendTime; + + /** 短信发送标记:0 不发短信 / 1 发短信(旧名 topmost 实指发短信,已正名 smsFlag,M6) */ + private Integer smsFlag = 0; + + /** 转发源作业ID(非空时表示转发创建,对应旧 REST 壳 forward/{sourceId}) */ + private Integer sourceId; + + // ==================== 课堂作业 V1/V2 扩展(前端契约字段,兼容层同步写新列) ==================== + + /** 学科(字典值) */ + private String subject; + + /** 截止时间 */ + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime deadline; + + /** 提交方式:text/image/attach/none 组合 */ + private java.util.List submitType; +} diff --git a/src/main/java/com/yunhai/homework/model/homework/HomeworkStatisticsVo.java b/src/main/java/com/yunhai/homework/model/homework/HomeworkStatisticsVo.java new file mode 100644 index 0000000..c492f86 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/homework/HomeworkStatisticsVo.java @@ -0,0 +1,113 @@ +package com.yunhai.homework.model.homework; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 作业统计视图(对应原系统 GetStatisData / 按发布人统计)。 + */ +@Data +public class HomeworkStatisticsVo { + + /** 作业总数(全部已发布) */ + private long total; + + /** 已发布数 */ + private long publishedCount; + + /** 阅读记录总数(累计已读人次) */ + private long totalReadRecords; + + /** 当前用户未读数(已到点的已发布 - 当前用户已读) */ + private long unreadForCurrentUser; + + /** 按发布人统计 */ + private List byPublisher; + + /** 按月统计(已发布作业数) */ + private List byMonth; + + // ==================== 课程作业口径(前端 /stats 页消费) ==================== + + /** 我布置的作业数(当前登录人发布的全部作业,含草稿) */ + private long myAssignCount; + + /** 我的作业待批改总量(已交未批的当前版本行合计) */ + private long pendingGrade; + + /** 各作业平均提交率(%,仅统计需收集提交的作业;无样本时为 0) */ + private double avgSubmitRate; + + /** 按作业的提交与批改进度明细(草稿与仅查阅型不计) */ + private List byHomework = new ArrayList<>(); + + /** 已批阅提交的分数分布(四档) */ + private List scoreDist = new ArrayList<>(); + + /** 单作业进度条目(口径对齐前端 HomeworkProgress 契约) */ + @Data + public static class HomeworkStat { + private Integer id; + private String title; + private String subject; + /** 旧契约四态派生值:draft / collecting / closed / done */ + private String hwStatus; + private String hwStatusDesc; + /** 应交人数 */ + private long should; + /** 已读人数 */ + private long read; + /** 已交人数 */ + private long submitted; + /** 已批阅人数 */ + private long graded; + /** 待批改人数 */ + private long pendingGrade; + /** 未交人数 */ + private long unsubmitted; + /** 提交率(%) */ + private double submitRate; + /** 批阅率(%) */ + private double gradeRate; + } + + /** 分数分布档(key 供前端配色映射:excellent/good/pass/fail) */ + @Data + public static class ScoreBucket { + private String label; + private String key; + private long count; + + public ScoreBucket(String label, String key, long count) { + this.label = label; + this.key = key; + this.count = count; + } + } + + @Data + public static class PublisherStat { + private String publisherName; + private long count; + + public PublisherStat(String publisherName, long count) { + this.publisherName = publisherName; + this.count = count; + } + } + + @Data + public static class MonthStat { + private int year; + private int month; + private long count; + + public MonthStat(int year, int month, long count) { + this.year = year; + this.month = month; + this.count = count; + } + } +} diff --git a/src/main/java/com/yunhai/homework/model/homework/HomeworkUserListResult.java b/src/main/java/com/yunhai/homework/model/homework/HomeworkUserListResult.java new file mode 100644 index 0000000..c906e2e --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/homework/HomeworkUserListResult.java @@ -0,0 +1,34 @@ +package com.yunhai.homework.model.homework; + +import lombok.Data; + +import java.util.List; + +/** + * 用户端作业列表结果:在分页数据之外额外携带「当前用户未读总数」, + * 供前端展示未读角标(对应原系统 OtherData.unReadCount)。 + */ +@Data +public class HomeworkUserListResult { + + private int total; + private List content; + private int pageIndex; + private int pageSize; + private int totalPages; + private boolean hasNext; + + /** 当前用户未读作业总数 */ + private long unreadCount; + + public HomeworkUserListResult(List content, int total, int pageIndex, int pageSize, + int totalPages, boolean hasNext, long unreadCount) { + this.content = content; + this.total = total; + this.pageIndex = pageIndex; + this.pageSize = pageSize; + this.totalPages = totalPages; + this.hasNext = hasNext; + this.unreadCount = unreadCount; + } +} diff --git a/src/main/java/com/yunhai/homework/model/homework/HomeworkVo.java b/src/main/java/com/yunhai/homework/model/homework/HomeworkVo.java new file mode 100644 index 0000000..333d49e --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/homework/HomeworkVo.java @@ -0,0 +1,92 @@ +package com.yunhai.homework.model.homework; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 作业视图对象(列表/详情返回)。 + */ +@Data +public class HomeworkVo { + + private Integer id; + private String title; + private String content; + private Integer type; + private String typeDesc; + /** 发布人ID(用户中心数字ID) */ + private Integer publisherId; + private String publisherName; + private String deptId; + private String deptName; + private Integer status; + private String statusDesc; + private Integer topmost; + + /** 已读人数(基于阅读记录实时统计) */ + private Long readCount = 0L; + + /** 应读人数 */ + private Integer allowCount; + + /** 当前用户是否已读(用户端列表使用;管理端恒为 false 或不关注) */ + private boolean read = false; + + /** 是否撤回:0 否 / 1 是 */ + private Integer isPulled; + + /** 定时发送时间 */ + private LocalDateTime timingSendTime; + + /** 父作业ID(转发时关联原作业) */ + private Integer parentId; + + /** 短信发送标记 */ + private Integer smsFlag; + + /** 接收人列表(JSON) */ + private String receiveList; + + /** 附件ID串("1,2,3",列表页下载入口使用;对齐旧 .NET UpFile 字段) */ + private String UpFile; + + // ==================== 课堂作业 V1/V2 扩展(前端 types/homework.ts 契约) ==================== + + /** 学科(字典值) */ + private String subject; + + /** 截止时间(前端契约字段名 deadline) */ + private LocalDateTime deadline; + + /** 提交方式数组:text/image/attach/none */ + private java.util.List submitType; + + /** 作业流程状态——旧契约四态派生值(draft/collecting/closed/done),权威五态见 homework.hw_status */ + private String hwStatus; + + private String hwStatusDesc; + + /** + * 提交/批改进度(管理端列表页「提交进度 / 未读未交 / 批改」列使用)。 + * 草稿或仅查阅型为 null;列表场景由 HomeworkProgressCalculator 批量聚合回填。 + */ + private com.yunhai.homework.model.v2.ProgressVo progress; + + private String createBy; + private LocalDateTime createTime; + private String updateBy; + private LocalDateTime updateTime; + + /** + * 阅读明细视图(阅读列表查询用)。 + */ + @Data + public static class ReadDetail { + /** 读者ID(用户中心数字ID) */ + private Integer id; + private String name; + private boolean read; + private LocalDateTime readTime; + } +} diff --git a/src/main/java/com/yunhai/homework/model/homework/InitializeRedVo.java b/src/main/java/com/yunhai/homework/model/homework/InitializeRedVo.java new file mode 100644 index 0000000..15ece01 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/homework/InitializeRedVo.java @@ -0,0 +1,20 @@ +package com.yunhai.homework.model.homework; + +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/homework/model/homework/UpFileResultVo.java b/src/main/java/com/yunhai/homework/model/homework/UpFileResultVo.java new file mode 100644 index 0000000..8127480 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/homework/UpFileResultVo.java @@ -0,0 +1,30 @@ +package com.yunhai.homework.model.homework; + +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/homework/model/homework/UpFileVo.java b/src/main/java/com/yunhai/homework/model/homework/UpFileVo.java new file mode 100644 index 0000000..76dfa2f --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/homework/UpFileVo.java @@ -0,0 +1,31 @@ +package com.yunhai.homework.model.homework; + +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/homework/model/homework/UserModuleVo.java b/src/main/java/com/yunhai/homework/model/homework/UserModuleVo.java new file mode 100644 index 0000000..771eb3a --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/homework/UserModuleVo.java @@ -0,0 +1,122 @@ +package com.yunhai.homework.model.homework; + +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/homework/model/v2/AttachmentItem.java b/src/main/java/com/yunhai/homework/model/v2/AttachmentItem.java new file mode 100644 index 0000000..bedf7b5 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/AttachmentItem.java @@ -0,0 +1,25 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +/** + * 附件条目(作业附件与提交附件共用)。fileUrl 必传;fileId 为 file/Upload 产物时回传。 + */ +@Data +public class AttachmentItem { + + /** up_file.id */ + private Integer fileId; + + /** 文件访问 URL */ + private String fileUrl; + + /** 文件类型:image / file */ + private String fileType; + + /** 文件名 */ + private String fileName; + + /** 排序号 */ + private Integer sort = 0; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/ClassItem.java b/src/main/java/com/yunhai/homework/model/v2/ClassItem.java new file mode 100644 index 0000000..459ea80 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/ClassItem.java @@ -0,0 +1,18 @@ +package com.yunhai.homework.model.v2; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 班级条目(作业参与班级展示)。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ClassItem { + + private String classId; + + private String className; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/ClassTreeNode.java b/src/main/java/com/yunhai/homework/model/v2/ClassTreeNode.java new file mode 100644 index 0000000..c0f400b --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/ClassTreeNode.java @@ -0,0 +1,28 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 年级→班级树(发布作业选班 + 应交名单基数来源)。 + * 叶子节点 id = 班级 id(class_student 快照聚合)。 + */ +@Data +public class ClassTreeNode { + + /** 年级节点为 gradeName,班级叶子为 classId */ + private String id; + + /** 展示名 */ + private String name; + + /** 是否父节点(年级) */ + private Boolean isParent; + + /** 叶子节点:在班学生数 */ + private Integer studentCount; + + private List children = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/homework/model/v2/ExtendRequest.java b/src/main/java/com/yunhai/homework/model/v2/ExtendRequest.java new file mode 100644 index 0000000..f8c5dec --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/ExtendRequest.java @@ -0,0 +1,16 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.time.LocalDateTime; + +/** + * 延长截止请求(新截止时间必须晚于当前时间)。 + */ +@Data +public class ExtendRequest { + + @NotNull(message = "新截止时间不得为空") + private LocalDateTime deadlineAt; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/HomeworkStatsVo.java b/src/main/java/com/yunhai/homework/model/v2/HomeworkStatsVo.java new file mode 100644 index 0000000..1ac46f8 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/HomeworkStatsVo.java @@ -0,0 +1,36 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import java.util.List; + +/** + * 作业详情汇总统计卡片(PRD §18 页 4 stats 接口出参)。 + */ +@Data +public class HomeworkStatsVo { + + private Integer homeworkId; + + private String title; + + private String hwStatus; + + private String hwStatusDesc; + + /** 总进度 */ + private ProgressVo total; + + /** 分班进度 */ + private List byClass; + + @Data + public static class ByClass { + + private String classId; + + private String className; + + private ProgressVo progress; + } +} diff --git a/src/main/java/com/yunhai/homework/model/v2/HomeworkV2Request.java b/src/main/java/com/yunhai/homework/model/v2/HomeworkV2Request.java new file mode 100644 index 0000000..9464910 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/HomeworkV2Request.java @@ -0,0 +1,48 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 创建/编辑作业请求(PRD §18 页 1 表单)。 + * 创建一律落草稿(DRAFT_UNPUBLISHED),发布/定时走独立接口。 + */ +@Data +public class HomeworkV2Request { + + /** 作业标题(必填 ≤50 字) */ + @NotBlank(message = "作业标题不得为空") + @Size(max = 50, message = "作业标题不超过50字") + private String title; + + /** 学科(字典值) */ + private String subject; + + /** 作业说明(富文本) */ + private String description; + + /** 参与班级 id 列表 */ + private List classIds; + + /** 附件列表 */ + private List attachments; + + /** 截止时间(精确到分钟;空 = 仅查阅型) */ + private LocalDateTime deadlineAt; + + /** 提交方式:image / attach / text / none 的组合(PRD 图片/文件/两者) */ + private List submitType; + + /** 截止后是否允许逾期提交:0 否 / 1 是 */ + private Integer allowLate; + + /** 提醒设置:截止前 1 天提醒(0 关 / 1 开) */ + private Integer remind1d; + + /** 提醒设置:截止前 1 小时提醒(0 关 / 1 开) */ + private Integer remind1h; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/HomeworkV2Vo.java b/src/main/java/com/yunhai/homework/model/v2/HomeworkV2Vo.java new file mode 100644 index 0000000..8bf1b02 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/HomeworkV2Vo.java @@ -0,0 +1,73 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 作业视图对象 V2(草稿箱/已发布列表/详情共用)。 + * 列表场景 attachments/classes 可按需填充;progress 仅非草稿且非仅查阅型作业返回。 + */ +@Data +public class HomeworkV2Vo { + + private Integer id; + + private String title; + + /** 学科 */ + private String subject; + + /** 作业说明(富文本;列表场景可置空省流) */ + private String description; + + /** 作业状态(PRD 五态) */ + private String hwStatus; + + private String hwStatusDesc; + + /** 定时发布时间(复用 timing_send_time) */ + private LocalDateTime scheduledAt; + + /** 发布时间 */ + private LocalDateTime publishedAt; + + /** 截止时间 */ + private LocalDateTime deadlineAt; + + /** 归档时间 */ + private LocalDateTime archivedAt; + + /** 提交方式数组 */ + private List submitType; + + /** 截止后允许逾期提交:0/1 */ + private Integer allowLate; + + /** 提醒设置:截止前 1 天 */ + private Integer remind1d; + + /** 提醒设置:截止前 1 小时 */ + private Integer remind1h; + + /** 参与班级 */ + private List classes; + + /** 附件列表(详情场景填充) */ + private List attachments; + + /** 发布人ID(用户中心数字ID) */ + private Integer publisherId; + + /** 发布人姓名 */ + private String publisherName; + + /** 创建/更新时间(审计) */ + private LocalDateTime createTime; + + private LocalDateTime updateTime; + + /** 汇总进度(草稿或仅查阅型为 null) */ + private ProgressVo progress; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/NotificationPageResult.java b/src/main/java/com/yunhai/homework/model/v2/NotificationPageResult.java new file mode 100644 index 0000000..0303eb8 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/NotificationPageResult.java @@ -0,0 +1,22 @@ +package com.yunhai.homework.model.v2; + +import com.yunhai.homework.model.BasePageResult; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 通知分页结果(含当前用户未读数)。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class NotificationPageResult extends BasePageResult { + + /** 当前用户未读通知总数 */ + private long unreadCount; + + public NotificationPageResult(BasePageResult page, long unreadCount) { + super(page.getContent(), page.getTotal(), page.getPageIndex(), page.getPageSize(), + page.getTotalPages(), page.isHasNext()); + this.unreadCount = unreadCount; + } +} diff --git a/src/main/java/com/yunhai/homework/model/v2/NotificationQuery.java b/src/main/java/com/yunhai/homework/model/v2/NotificationQuery.java new file mode 100644 index 0000000..f8af4a7 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/NotificationQuery.java @@ -0,0 +1,19 @@ +package com.yunhai.homework.model.v2; + +import com.yunhai.homework.model.BasePageQuery; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 通知列表查询。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class NotificationQuery extends BasePageQuery { + + /** 类型过滤(NotificationTypeEnum code) */ + private String type; + + /** 已读过滤:0 未读 / 1 已读(空 = 全部) */ + private Integer isRead; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/NotificationVo.java b/src/main/java/com/yunhai/homework/model/v2/NotificationVo.java new file mode 100644 index 0000000..2a9b61a --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/NotificationVo.java @@ -0,0 +1,34 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 通知列表项。 + */ +@Data +public class NotificationVo { + + private Integer id; + + /** 通知类型 */ + private String type; + + private String typeDesc; + + private String title; + + private String content; + + private Integer homeworkId; + + private Integer relatedId; + + /** 是否已读:0/1 */ + private Integer isRead; + + private LocalDateTime readAt; + + private LocalDateTime createTime; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/ProgressVo.java b/src/main/java/com/yunhai/homework/model/v2/ProgressVo.java new file mode 100644 index 0000000..201a460 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/ProgressVo.java @@ -0,0 +1,44 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +/** + * 提交/批阅/阅读进度(PRD §18 页 3 汇总口径)。 + * should(应交)为分母;打回未重交计入已交。 + */ +@Data +public class ProgressVo { + + /** 应交人数(参与班级在班学生去重) */ + private int should; + + /** 已读人数 */ + private int read; + + /** 已交人数(含逾期/打回/已批阅) */ + private int submitted; + + /** 已批阅人数 */ + private int graded; + + /** 待批阅人数(submitted - graded) */ + private int pendingGrade; + + /** 未交人数 */ + private int unsubmitted; + + /** 未读人数 */ + private int unread; + + /** 未读且未交人数(高亮口径:should - 已读∪已交) */ + private int unreadUndone; + + /** 阅读率(百分数,一位小数) */ + private double readRate; + + /** 提交率(百分数,一位小数) */ + private double submitRate; + + /** 批阅率(已批阅 / 已交,百分数一位小数;submitted=0 时为 0) */ + private double gradeRate; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/RemindRequest.java b/src/main/java/com/yunhai/homework/model/v2/RemindRequest.java new file mode 100644 index 0000000..a97e0c1 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/RemindRequest.java @@ -0,0 +1,20 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import java.util.List; + +/** + * 教师手动提醒请求:type = NOT_READ / NOT_SUBMITTED,studentIds 为空时提醒全部未读/未交学生。 + */ +@Data +public class RemindRequest { + + /** 提醒类型:NOT_READ / NOT_SUBMITTED */ + @NotBlank(message = "提醒类型不得为空") + private String type; + + /** 定向学生ID(用户中心数字ID;空 = 全部未读/未交) */ + private List studentIds; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/ReturnRequest.java b/src/main/java/com/yunhai/homework/model/v2/ReturnRequest.java new file mode 100644 index 0000000..45d3f3e --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/ReturnRequest.java @@ -0,0 +1,15 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +/** + * 打回请求(PRD 页 6)。comment 为订正原因,必填。 + */ +@Data +public class ReturnRequest { + + @NotBlank(message = "打回须填写订正原因") + private String comment; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/ReviewRequest.java b/src/main/java/com/yunhai/homework/model/v2/ReviewRequest.java new file mode 100644 index 0000000..166b2e1 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/ReviewRequest.java @@ -0,0 +1,22 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 批阅保存请求(PRD 页 6)。score 与 comment 至少一项有值。 + */ +@Data +public class ReviewRequest { + + /** 评分 */ + private BigDecimal score; + + /** 评语 */ + private String comment; + + /** 圈划标注图片(P2,先收参) */ + private List markedImages; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/ScheduleRequest.java b/src/main/java/com/yunhai/homework/model/v2/ScheduleRequest.java new file mode 100644 index 0000000..894b433 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/ScheduleRequest.java @@ -0,0 +1,25 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import javax.validation.constraints.NotNull; +import java.time.LocalDateTime; + +/** + * 定时发布 / 取消定时请求。 + * cancel=true 时忽略 scheduledAt,作业回退草稿-未发布并清空定时时间。 + */ +@Data +public class ScheduleRequest { + + /** 定时发布时间(cancel=false 时必填且须晚于当前时间) */ + private LocalDateTime scheduledAt; + + /** 是否取消定时 */ + private Boolean cancel = false; + + @NotNull + public Boolean getCancel() { + return cancel; + } +} diff --git a/src/main/java/com/yunhai/homework/model/v2/StudentHomeworkDetailVo.java b/src/main/java/com/yunhai/homework/model/v2/StudentHomeworkDetailVo.java new file mode 100644 index 0000000..6372883 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/StudentHomeworkDetailVo.java @@ -0,0 +1,29 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 学生端作业详情(PRD §18 学生页 2)。进入详情后由前端停留 ≥3 秒再调 read 上报。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class StudentHomeworkDetailVo extends StudentHomeworkVo { + + /** 作业说明(富文本) */ + private String description; + + /** 作业附件 */ + private List attachments; + + /** 参与班级(学生所属班级视角) */ + private List classes; + + /** 我当前版本的提交附件(未提交为 null) */ + private List myAttachments; + + /** 我的文本内容(当前版本) */ + private String myText; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/StudentHomeworkVo.java b/src/main/java/com/yunhai/homework/model/v2/StudentHomeworkVo.java new file mode 100644 index 0000000..9d30037 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/StudentHomeworkVo.java @@ -0,0 +1,69 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 学生端作业列表项(PRD §18 学生页 1)。 + */ +@Data +public class StudentHomeworkVo { + + private Integer id; + + private String title; + + /** 学科 */ + private String subject; + + /** 任课教师(发布人姓名) */ + private String teacherName; + + /** 截止时间(前端做倒计时) */ + private LocalDateTime deadlineAt; + + /** 提交方式 */ + private java.util.List submitType; + + /** 作业状态 */ + private String hwStatus; + + private String hwStatusDesc; + + /** 截止后是否允许逾期补交:0 否 / 1 是(学生端据此决定是否渲染「补交」入口) */ + private Integer allowLate; + + /** 我的提交概要(未提交为 null) */ + private MySubmission my; + + @Data + public static class MySubmission { + + /** 当前版本提交行ID */ + private Integer submissionId; + + /** 提交状态(PRD 五态) */ + private String submitStatus; + + private String submitStatusDesc; + + /** 提交时间 */ + private LocalDateTime submittedAt; + + /** 是否逾期 */ + private Integer isLate; + + /** 版本号 */ + private Integer version; + + /** 批阅状态 */ + private String reviewStatus; + + /** 评分(已批阅才有) */ + private java.math.BigDecimal score; + + /** 评语 */ + private String comment; + } +} diff --git a/src/main/java/com/yunhai/homework/model/v2/StudentMatrixQuery.java b/src/main/java/com/yunhai/homework/model/v2/StudentMatrixQuery.java new file mode 100644 index 0000000..49e3144 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/StudentMatrixQuery.java @@ -0,0 +1,25 @@ +package com.yunhai.homework.model.v2; + +import com.yunhai.homework.model.BasePageQuery; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 学生矩阵查询(PRD §18 页 5)。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class StudentMatrixQuery extends BasePageQuery { + + /** 班级筛选(空 = 全部参与班级) */ + private String classId; + + /** 提交状态筛选:NOT_SUBMITTED/SUBMITTED/LATE_SUBMITTED/RETURNED/REVIEWED */ + private String submitStatus; + + /** 批阅状态筛选:PENDING/REVIEWED/RETURNED */ + private String reviewStatus; + + /** 只看待批阅(reviewStatus=PENDING 且已提交)快捷过滤 */ + private Boolean pendingGrade; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/StudentMatrixVo.java b/src/main/java/com/yunhai/homework/model/v2/StudentMatrixVo.java new file mode 100644 index 0000000..6257285 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/StudentMatrixVo.java @@ -0,0 +1,57 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 学生矩阵行(PRD §18 页 5)。未交学生也生成占位行(submissionId=0)。 + */ +@Data +public class StudentMatrixVo { + + /** 当前版本提交行ID(未交占位为 0) */ + private Integer submissionId; + + /** 学生ID(用户中心数字ID) */ + private Integer studentId; + + /** 姓名 */ + private String studentName; + + private String classId; + + private String className; + + /** 阅读状态:0 未读 / 1 已读 */ + private Integer isRead; + + /** 首次阅读时间 */ + private LocalDateTime firstReadAt; + + /** 最近阅读时间 */ + private LocalDateTime lastReadAt; + + /** 提交状态(PRD 五态;未交为 NOT_SUBMITTED) */ + private String submitStatus; + + /** 提交时间 */ + private LocalDateTime submittedAt; + + /** 是否逾期:0/1 */ + private Integer isLate; + + /** 批阅状态 */ + private String reviewStatus; + + /** 评分 */ + private BigDecimal score; + + /** 最新提交附件 URL 列表(缩略图) */ + private List thumbnails; + + /** 提交内容类型摘要:image=拍照/图片、attach=上传附件、text=文字作答(未交为空列表) */ + private List contentTypes; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/StudentResultVo.java b/src/main/java/com/yunhai/homework/model/v2/StudentResultVo.java new file mode 100644 index 0000000..f4d9cd1 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/StudentResultVo.java @@ -0,0 +1,22 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import java.util.List; + +/** + * 学生查看批阅结果(PRD §18 学生页 2 result 接口)。 + */ +@Data +public class StudentResultVo { + + private Integer homeworkId; + + private String homeworkTitle; + + /** 当前版本批阅结果(未批阅为 null) */ + private SubmissionVersionVo latest; + + /** 全部版本(含各版评语,新→旧) */ + private List versions; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/StudentSubmitRequest.java b/src/main/java/com/yunhai/homework/model/v2/StudentSubmitRequest.java new file mode 100644 index 0000000..1fa7545 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/StudentSubmitRequest.java @@ -0,0 +1,20 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import javax.validation.constraints.Size; +import java.util.List; + +/** + * 学生提交作业请求(PRD §18 学生页 3)。附件只接受 file/Upload 产物(fileId 或平台 URL),拒绝 dataURL。 + */ +@Data +public class StudentSubmitRequest { + + /** 文本内容(submitType 含 text 时有效) */ + @Size(max = 20000, message = "文本内容过长") + private String text; + + /** 附件列表(图片/文件) */ + private List attachments; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/SubjectDto.java b/src/main/java/com/yunhai/homework/model/v2/SubjectDto.java new file mode 100644 index 0000000..6c576d9 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/SubjectDto.java @@ -0,0 +1,16 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +/** + * 学科 DTO(布置过的学科 / 我的任教学科共用返回结构)。 + */ +@Data +public class SubjectDto { + + /** 学科编码(homework.subject 字典值 / bschclass SubID) */ + private String subId; + + /** 学科名称(本校学科字典解析;字典缺失时回退为编码) */ + private String subName; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/SubmissionActionRequest.java b/src/main/java/com/yunhai/homework/model/v2/SubmissionActionRequest.java new file mode 100644 index 0000000..cac00dc --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/SubmissionActionRequest.java @@ -0,0 +1,22 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +/** + * 撤回重交动作(PUT /student/homeworks/{id}/submissions/{submissionId})。 + * withdraw = 撤回(截止前,产生 NOT_SUBMITTED 新版本行);resubmit = 打回后重交(走提交语义)。 + */ +@Data +public class SubmissionActionRequest { + + @NotBlank(message = "action 不得为空") + private String action; + + /** resubmit 时的提交内容(等同 StudentSubmitRequest) */ + private String text; + + /** resubmit 时的附件 */ + private java.util.List attachments; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/SubmissionVersionVo.java b/src/main/java/com/yunhai/homework/model/v2/SubmissionVersionVo.java new file mode 100644 index 0000000..bff2972 --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/SubmissionVersionVo.java @@ -0,0 +1,63 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 提交版本条目(历史版本/批阅弹窗/学生结果页共用)。 + */ +@Data +public class SubmissionVersionVo { + + private Integer submissionId; + + private Integer homeworkId; + + /** 学生ID(用户中心数字ID) */ + private Integer studentId; + + private String studentName; + + /** 版本号 */ + private Integer version; + + /** 是否当前版本:0/1 */ + private Integer isLatest; + + /** 提交状态 */ + private String submitStatus; + + private String submitStatusDesc; + + /** 提交时间 */ + private LocalDateTime submittedAt; + + /** 是否逾期 */ + private Integer isLate; + + /** 文本内容 */ + private String text; + + /** 附件 */ + private List attachments; + + /** 评分 */ + private BigDecimal score; + + /** 评语 */ + private String comment; + + /** 批阅状态 */ + private String reviewStatus; + + private String reviewStatusDesc; + + /** 批阅人(展示姓名快照,缺失时回退ID) */ + private String reviewedBy; + + /** 批阅时间 */ + private LocalDateTime reviewedAt; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/TeacherHomeworkQuery.java b/src/main/java/com/yunhai/homework/model/v2/TeacherHomeworkQuery.java new file mode 100644 index 0000000..6d6991e --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/TeacherHomeworkQuery.java @@ -0,0 +1,19 @@ +package com.yunhai.homework.model.v2; + +import com.yunhai.homework.model.BasePageQuery; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 教师端作业列表查询(草稿箱/已发布列表共用)。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class TeacherHomeworkQuery extends BasePageQuery { + + /** 学科筛选 */ + private String subject; + + /** 作业状态筛选(已发布列表:PUBLISHED/CLOSED/ARCHIVED) */ + private String hwStatus; +} diff --git a/src/main/java/com/yunhai/homework/model/v2/TeachingClassVo.java b/src/main/java/com/yunhai/homework/model/v2/TeachingClassVo.java new file mode 100644 index 0000000..d9fd64e --- /dev/null +++ b/src/main/java/com/yunhai/homework/model/v2/TeachingClassVo.java @@ -0,0 +1,16 @@ +package com.yunhai.homework.model.v2; + +import lombok.Data; + +/** + * 任教班级 VO(按学科筛选任教班级返回结构)。 + */ +@Data +public class TeachingClassVo { + + /** 班级业务ID(bschclass sch_class.class_id) */ + private String classId; + + /** 班级名称(学校班级列表映射;缺失时回退为班级ID) */ + private String className; +} diff --git a/src/main/java/com/yunhai/homework/repository/ClassStudentRepository.java b/src/main/java/com/yunhai/homework/repository/ClassStudentRepository.java new file mode 100644 index 0000000..0581e29 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/ClassStudentRepository.java @@ -0,0 +1,31 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.ClassStudent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * 班级学生名册仓储。 + */ +public interface ClassStudentRepository extends JpaRepository { + + /** 按学生ID(用户中心数字ID)反查学生身份(学生端接口入口) */ + List findByStudentIdAndStatus(Integer studentId, Integer status); + + /** 班级在班学生 */ + List findByClassIdAndStatus(String classId, Integer status); + + /** 指定班级集合的全部在班学生(应交名单) */ + List findByClassIdInAndStatus(List classIds, Integer status); + + /** 指定学校的全部名册(同步任务全量刷新用) */ + List findBySchoolId(Integer schoolId); + + /** 年级→班级树:按学校去重班级(class_student 快照聚合),列序:classId, className, gradeName */ + @Query("select distinct c.classId, c.className, c.gradeName from ClassStudent c " + + "where c.schoolId = :schoolId and c.status = 1") + List findDistinctClasses(@Param("schoolId") Integer schoolId); +} diff --git a/src/main/java/com/yunhai/homework/repository/ContactsGroupRepository.java b/src/main/java/com/yunhai/homework/repository/ContactsGroupRepository.java new file mode 100644 index 0000000..4dc9ee4 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/ContactsGroupRepository.java @@ -0,0 +1,27 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.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(用户中心数字ID)查询联系组列表。 + */ + List findByCreatorId(Integer creatorId); + + /** + * 按创建人ID查询联系组列表(分页)。 + */ + Page findByCreatorId(Integer creatorId, Pageable pageable); + + /** + * 查询全部联系组(按创建时间降序)。 + */ + List findAllByOrderByCreateTimeDesc(); +} diff --git a/src/main/java/com/yunhai/homework/repository/ContactsGroupUserRepository.java b/src/main/java/com/yunhai/homework/repository/ContactsGroupUserRepository.java new file mode 100644 index 0000000..df5ab28 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/ContactsGroupUserRepository.java @@ -0,0 +1,42 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.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/homework/repository/HomeworkAttachmentRepository.java b/src/main/java/com/yunhai/homework/repository/HomeworkAttachmentRepository.java new file mode 100644 index 0000000..1e6af55 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/HomeworkAttachmentRepository.java @@ -0,0 +1,16 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.HomeworkAttachment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +/** + * 作业附件仓储。 + */ +public interface HomeworkAttachmentRepository extends JpaRepository { + + List findByHomeworkIdOrderBySortAscIdAsc(Integer homeworkId); + + void deleteByHomeworkId(Integer homeworkId); +} diff --git a/src/main/java/com/yunhai/homework/repository/HomeworkClassRepository.java b/src/main/java/com/yunhai/homework/repository/HomeworkClassRepository.java new file mode 100644 index 0000000..8f651f5 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/HomeworkClassRepository.java @@ -0,0 +1,26 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.HomeworkClass; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * 作业-班级关联仓储。 + */ +public interface HomeworkClassRepository extends JpaRepository { + + List findByHomeworkId(Integer homeworkId); + + void deleteByHomeworkId(Integer homeworkId); + + /** 指定班级的全部作业ID(学生端列表倒查用) */ + @Query("select c.homeworkId from HomeworkClass c where c.classId in :classIds") + List findHomeworkIdsByClassIds(@Param("classIds") List classIds); + + /** 批量查 N 个作业的班级关联(列表展示避免 N+1) */ + @Query("select c from HomeworkClass c where c.homeworkId in :homeworkIds") + List findByHomeworkIds(@Param("homeworkIds") List homeworkIds); +} diff --git a/src/main/java/com/yunhai/homework/repository/HomeworkReadRecordRepository.java b/src/main/java/com/yunhai/homework/repository/HomeworkReadRecordRepository.java new file mode 100644 index 0000000..6c16a81 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/HomeworkReadRecordRepository.java @@ -0,0 +1,23 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.HomeworkReadRecord; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +/** + * 学生阅读记录仓储。 + */ +public interface HomeworkReadRecordRepository extends JpaRepository { + + Optional findByHomeworkIdAndStudentId(Integer homeworkId, Integer studentId); + + List findByHomeworkId(Integer homeworkId); + + /** 批量查 N 个作业的已读记录(列表统计避免 N+1) */ + List findByHomeworkIdIn(Collection homeworkIds); + + void deleteByHomeworkId(Integer homeworkId); +} diff --git a/src/main/java/com/yunhai/homework/repository/HomeworkReadRepository.java b/src/main/java/com/yunhai/homework/repository/HomeworkReadRepository.java new file mode 100644 index 0000000..20dc83c --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/HomeworkReadRepository.java @@ -0,0 +1,52 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.HomeworkRead; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +/** + * 作业阅读记录仓储(懒标记模型)。 + */ +public interface HomeworkReadRepository extends JpaRepository { + + /** + * 某用户是否已读某作业。 + */ + boolean existsByHomeworkIdAndReaderId(Integer homeworkId, Integer readerId); + + /** + * 某作业的总已读人数。 + */ + long countByHomeworkId(Integer homeworkId); + + /** + * 删除某作业的全部阅读记录(作业删除时级联清理)。 + */ + void deleteByHomeworkId(Integer homeworkId); + + /** + * 某用户已读的作业总数(用于「我的未读」= 已发布总数 - 已读总数)。 + */ + long countByReaderId(Integer readerId); + + /** + * 查询某用户已读的作业ID列表(用于列表页批量判定未读)。 + */ + @Query("select r.homeworkId from HomeworkRead r where r.readerId = :readerId") + List findHomeworkIdsByReaderId(@Param("readerId") Integer readerId); + + /** + * 查询某用户对某作业的阅读记录(标记已读时幂等判断)。 + */ + Optional findByHomeworkIdAndReaderId(Integer homeworkId, Integer readerId); + + /** + * 查询某作业的全部阅读记录(按阅读时间倒序),用于阅读明细列表。 + */ + @Query("select r from HomeworkRead r where r.homeworkId = :homeworkId order by r.readTime desc") + List findByHomeworkIdOrderByReadTimeDesc(@Param("homeworkId") Integer homeworkId); +} diff --git a/src/main/java/com/yunhai/homework/repository/HomeworkReceiverRepository.java b/src/main/java/com/yunhai/homework/repository/HomeworkReceiverRepository.java new file mode 100644 index 0000000..402d455 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/HomeworkReceiverRepository.java @@ -0,0 +1,31 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.HomeworkReceiver; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * 作业接收人关联表仓储。 + */ +public interface HomeworkReceiverRepository extends JpaRepository { + + /** 按作业ID查询全部接收人 */ + List findByHomeworkId(Integer homeworkId); + + /** 按作业ID删除全部接收人 */ + void deleteByHomeworkId(Integer homeworkId); + + /** 按作业ID和接收类型查询接收人 */ + List findByHomeworkIdAndReceiveType(Integer homeworkId, Integer receiveType); + + /** + * 批量查 N 个作业的接收人,用于列表展示(避免 N+1)。 + * @param homeworkIds 作业ID列表 + * @return 接收人关联记录列表 + */ + @Query("select r from HomeworkReceiver r where r.homeworkId in :homeworkIds") + List findByHomeworkIds(@Param("homeworkIds") List homeworkIds); +} diff --git a/src/main/java/com/yunhai/homework/repository/HomeworkRepository.java b/src/main/java/com/yunhai/homework/repository/HomeworkRepository.java new file mode 100644 index 0000000..bf862b0 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/HomeworkRepository.java @@ -0,0 +1,139 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.Homework; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +/** + * 课程作业仓储。 + */ +public interface HomeworkRepository extends JpaRepository, + JpaSpecificationExecutor { + + /** + * 作业流程状态条件更新(CAS):仅当当前状态为 from 时更新为 to。 + * 状态机单点收敛用,天然幂等、并发安全(定时任务与接口共用)。 + * clearAutomatically:批量更新绕过一级缓存,必须清空以避免后续 findById 读到旧实体 + * 再被 save() 写回旧状态(同事务内必现,集成测试发现)。 + * + * @return 影响行数(0 = 状态已变化,调用方应放弃本次迁移) + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Homework h set h.hwStatus = :to where h.id = :id and h.hwStatus = :from") + int casHwStatus(@Param("id") Integer id, + @Param("from") String from, + @Param("to") String to); + + /** + * 定时发布任务扫描:定时待发布且已到点。 + */ + List findByHwStatusAndTimingSendTimeLessThanEqual(String hwStatus, LocalDateTime now); + + /** + * 到点关闭任务扫描:进行中且已过截止时间。 + */ + List findByHwStatusAndDeadlineAtBefore(String hwStatus, LocalDateTime now); + + /** + * 截止提醒任务扫描:进行中且截止时间落在指定窗口内(remind_1d/remind_1h 开关在服务侧逐单过滤)。 + */ + List findByHwStatusAndDeadlineAtBetween(String hwStatus, LocalDateTime start, LocalDateTime end); + + /** + * 按作业流程状态查询(兼容层/统计用)。 + */ + List findByHwStatus(String hwStatus); + + /** + * 当前登录人发布的全部作业(课程作业口径统计:我布置的作业 / 提交与批改进度明细)。 + */ + List findByPublisherId(Integer publisherId); + + /** + * 当前登录人布置过(已发布及之后状态)作业的学科去重清单(学科筛选用,字典序;草稿不计)。 + */ + @Query("select distinct h.subject from Homework h " + + "where h.publisherId = :publisherId and h.subject is not null and h.subject <> '' " + + "and h.hwStatus in :statuses order by h.subject") + List findAssignedSubjects(@Param("publisherId") Integer publisherId, + @Param("statuses") Collection statuses); + + /** + * 按状态计数(用于统计已发布总数)。 + */ + long countByStatus(Integer status); + + /** + * 已发布且已到点(定时发送未到点不计入)的作业数,用于用户端未读数统计。 + */ + @Query("select count(n) from Homework n where n.status = :status " + + "and (n.timingSendTime is null or n.timingSendTime <= :now)") + long countPublishedDue(@Param("status") Integer status, + @Param("now") LocalDateTime now); + + /** + * 查询指定状态、指定时间范围内的作业创建时间(用于按月统计,避免加载大字段)。 + */ + @Query("select n.createTime from Homework n " + + "where n.status = :status and n.createTime >= :start and n.createTime <= :end") + List findCreateTimesByStatusAndRange(@Param("status") Integer status, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** + * 查询指定状态、指定时间范围内的发布人姓名(用于按发布人统计)。 + */ + @Query("select n.publisherName from Homework n " + + "where n.status = :status and n.createTime >= :start and n.createTime <= :end") + List findPublisherNamesByStatusAndRange(@Param("status") Integer status, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** + * 按学校和状态计数(多租户隔离统计)。 + */ + long countBySchIdAndStatus(Integer schId, Integer status); + + /** + * 按学校计数(多租户隔离统计:全部状态)。 + */ + long countBySchId(Integer schId); + + /** + * 已发布且已到点(定时发送未到点不计入)且属于指定学校的作业数(用户端未读数统计,多租户隔离)。 + */ + @Query("select count(n) from Homework n where n.schId = :schId and n.status = :status " + + "and (n.timingSendTime is null or n.timingSendTime <= :now)") + long countPublishedDueBySchId(@Param("schId") Integer schId, + @Param("status") Integer status, + @Param("now") LocalDateTime now); + + /** + * 查询指定学校、指定状态、指定时间范围内的作业创建时间(按月统计,多租户隔离)。 + */ + @Query("select n.createTime from Homework n " + + "where n.schId = :schId and n.status = :status " + + "and n.createTime >= :start and n.createTime <= :end") + List findCreateTimesBySchIdStatusAndRange(@Param("schId") Integer schId, + @Param("status") Integer status, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** + * 查询指定学校、指定状态、指定时间范围内的发布人姓名(按发布人统计,多租户隔离)。 + */ + @Query("select n.publisherName from Homework n " + + "where n.schId = :schId and n.status = :status " + + "and n.createTime >= :start and n.createTime <= :end") + List findPublisherNamesBySchStatusAndRange(@Param("schId") Integer schId, + @Param("status") Integer status, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); +} diff --git a/src/main/java/com/yunhai/homework/repository/HomeworkSubmissionRepository.java b/src/main/java/com/yunhai/homework/repository/HomeworkSubmissionRepository.java new file mode 100644 index 0000000..547c5ea --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/HomeworkSubmissionRepository.java @@ -0,0 +1,48 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.HomeworkSubmission; +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.Collection; +import java.util.List; +import java.util.Optional; + +/** + * 作业提交仓储(多版本)。统计一律基于 is_latest=1 的当前版本行。 + */ +public interface HomeworkSubmissionRepository extends JpaRepository { + + /** 学生在指定作业的当前版本行 */ + Optional findByHomeworkIdAndStudentIdAndIsLatest(Integer homeworkId, + Integer studentId, + Integer isLatest); + + /** 学生在指定作业的全部版本(新→旧) */ + List findByHomeworkIdAndStudentIdOrderByVersionDesc(Integer homeworkId, Integer studentId); + + /** 指定作业的当前版本行(统计/矩阵用) */ + List findByHomeworkIdAndIsLatest(Integer homeworkId, Integer isLatest); + + /** 批量查 N 个作业的当前版本行(列表统计避免 N+1) */ + List findByHomeworkIdInAndIsLatest(Collection homeworkIds, Integer isLatest); + + /** 学生矩阵分页 */ + Page findByHomeworkIdAndIsLatest(Integer homeworkId, Integer isLatest, Pageable pageable); + + /** 学生当前版本最大版本号 */ + @Query("select max(s.version) from HomeworkSubmission s " + + "where s.homeworkId = :homeworkId and s.studentId = :studentId") + Integer findMaxVersion(@Param("homeworkId") Integer homeworkId, @Param("studentId") Integer studentId); + + void deleteByHomeworkId(Integer homeworkId); + + /** 附件等子表清理后按作业删除历史版本行 */ + @Modifying + @Query("delete from HomeworkSubmission s where s.homeworkId = :homeworkId") + int deleteAllByHomeworkId(@Param("homeworkId") Integer homeworkId); +} diff --git a/src/main/java/com/yunhai/homework/repository/HomeworkTemplateRepository.java b/src/main/java/com/yunhai/homework/repository/HomeworkTemplateRepository.java new file mode 100644 index 0000000..ecc75cb --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/HomeworkTemplateRepository.java @@ -0,0 +1,20 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.HomeworkTemplate; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface HomeworkTemplateRepository extends JpaRepository { + Page findByTypeId(Integer typeId, Pageable pageable); + + /** + * 查询全部模板(按引用次数降序),分页。 + */ + Page findAllByOrderByRefCountDesc(Pageable pageable); + + /** + * 按类型ID查询全部模板(不分页,用于检查类型是否被引用)。 + */ + long countByTypeId(Integer typeId); +} diff --git a/src/main/java/com/yunhai/homework/repository/NotificationRepository.java b/src/main/java/com/yunhai/homework/repository/NotificationRepository.java new file mode 100644 index 0000000..1066c10 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/NotificationRepository.java @@ -0,0 +1,49 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.Notification; +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; +import java.util.Optional; + +/** + * 站内通知仓储。 + */ +public interface NotificationRepository extends JpaRepository { + + Page findByReceiverId(Integer receiverId, Pageable pageable); + + Page findByReceiverIdAndType(Integer receiverId, String type, Pageable pageable); + + Page findByReceiverIdAndIsRead(Integer receiverId, Integer isRead, Pageable pageable); + + Page findByReceiverIdAndTypeAndIsRead(Integer receiverId, String type, + Integer isRead, Pageable pageable); + + long countByReceiverIdAndIsRead(Integer receiverId, Integer isRead); + + Optional findByIdAndReceiverId(Integer id, Integer receiverId); + + /** 截止提醒去重:同作业同类型同档位已发过即跳过(related_id 存档位:1=1天档 2=1小时档) */ + boolean existsByHomeworkIdAndTypeAndRelatedId(Integer homeworkId, String type, Integer relatedId); + + /** 截止提醒去重(不分档位) */ + boolean existsByHomeworkIdAndType(Integer homeworkId, String type); + + /** 全部已读(clearAutomatically 防同事务内缓存脏读) */ + @Modifying(clearAutomatically = true) + @Query("update Notification n set n.isRead = 1, n.readAt = :now " + + "where n.receiverId = :receiverId and n.isRead = 0") + int markAllRead(@Param("receiverId") Integer receiverId, @Param("now") java.time.LocalDateTime now); + + /** 按作业删除通知(作业删除级联) */ + void deleteByHomeworkId(Integer homeworkId); + + /** 指定作业的全部通知(清理/统计用) */ + List findByHomeworkId(Integer homeworkId); +} diff --git a/src/main/java/com/yunhai/homework/repository/SchClassTeachRepository.java b/src/main/java/com/yunhai/homework/repository/SchClassTeachRepository.java new file mode 100644 index 0000000..8e60f47 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/SchClassTeachRepository.java @@ -0,0 +1,22 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.SchClassTeach; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * 班级任教关系快照仓储。 + */ +public interface SchClassTeachRepository extends JpaRepository { + + /** 指定学校指定教师的全部有效任教记录 */ + List findBySchoolIdAndTeaIdAndStatus(Integer schoolId, Integer teaId, Integer status); + + /** 指定学校指定教师去重学科编码(字典序由调用方保证) */ + @Query("select distinct t.subId from SchClassTeach t " + + "where t.schoolId = :schoolId and t.teaId = :teaId and t.status = 1") + List findDistinctSubIds(@Param("schoolId") Integer schoolId, @Param("teaId") Integer teaId); +} diff --git a/src/main/java/com/yunhai/homework/repository/SmsModuleRepository.java b/src/main/java/com/yunhai/homework/repository/SmsModuleRepository.java new file mode 100644 index 0000000..18595bb --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/SmsModuleRepository.java @@ -0,0 +1,13 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.SmsModule; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface SmsModuleRepository extends JpaRepository { + List findAllByOrderByIdAsc(); + + /** 按模块标识查询(如 module_code="homework")。 */ + SmsModule findByModuleCode(String moduleCode); +} diff --git a/src/main/java/com/yunhai/homework/repository/SmsSchoolConfigRepository.java b/src/main/java/com/yunhai/homework/repository/SmsSchoolConfigRepository.java new file mode 100644 index 0000000..0d277a0 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/SmsSchoolConfigRepository.java @@ -0,0 +1,25 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.SmsSchoolConfig; +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 SmsSchoolConfigRepository extends JpaRepository { + Page findBySchoolNameContaining(String schoolName, Pageable pageable); + Page findAll(Pageable pageable); + List findBySchoolId(Integer schoolId); + List findBySchoolIdInAndModuleIdIn(List schoolIds, List moduleIds); + + /** + * 按学校ID和模块ID查询单条配置(判断是否已存在)。 + */ + SmsSchoolConfig findBySchoolIdAndModuleId(Integer schoolId, Integer moduleId); + + /** + * 按学校ID查询全部模块配置(用于"设置某校全部模块")。 + */ + List findBySchoolIdIn(List schoolIds); +} diff --git a/src/main/java/com/yunhai/homework/repository/SubmissionAttachmentRepository.java b/src/main/java/com/yunhai/homework/repository/SubmissionAttachmentRepository.java new file mode 100644 index 0000000..8848b14 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/SubmissionAttachmentRepository.java @@ -0,0 +1,22 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.SubmissionAttachment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; + +/** + * 提交附件仓储。 + */ +public interface SubmissionAttachmentRepository extends JpaRepository { + + List findBySubmissionIdOrderBySortAscIdAsc(Integer submissionId); + + /** 批量查 N 个提交行的附件(矩阵/版本列表避免 N+1) */ + List findBySubmissionIdIn(Collection submissionIds); + + List findByHomeworkId(Integer homeworkId); + + void deleteByHomeworkId(Integer homeworkId); +} diff --git a/src/main/java/com/yunhai/homework/repository/SysDictRepository.java b/src/main/java/com/yunhai/homework/repository/SysDictRepository.java new file mode 100644 index 0000000..c083840 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/SysDictRepository.java @@ -0,0 +1,15 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.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/homework/repository/TemplateTypeRepository.java b/src/main/java/com/yunhai/homework/repository/TemplateTypeRepository.java new file mode 100644 index 0000000..a3a0f12 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/TemplateTypeRepository.java @@ -0,0 +1,20 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.entity.TemplateType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TemplateTypeRepository extends JpaRepository { + List findAllByOrderBySortOrderAsc(); + + /** + * 按类型名称查询(重名校验)。 + */ + boolean existsByTypeName(String typeName); + + /** + * 按类型名称查询(重名校验,排除自身)。 + */ + boolean existsByTypeNameAndIdNot(String typeName, Integer id); +} diff --git a/src/main/java/com/yunhai/homework/repository/UpFileRepository.java b/src/main/java/com/yunhai/homework/repository/UpFileRepository.java new file mode 100644 index 0000000..421f142 --- /dev/null +++ b/src/main/java/com/yunhai/homework/repository/UpFileRepository.java @@ -0,0 +1,26 @@ +package com.yunhai.homework.repository; + +import com.yunhai.homework.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/homework/service/IClassService.java b/src/main/java/com/yunhai/homework/service/IClassService.java new file mode 100644 index 0000000..2346cd9 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/IClassService.java @@ -0,0 +1,116 @@ +package com.yunhai.homework.service; + +import com.yunhai.homework.model.SchTeaList; +import com.yunhai.homework.model.bsch.SchClassDto; +import com.yunhai.homework.model.bsch.SchGradeDto; +import com.yunhai.homework.model.bsch.SchInfoDto; +import com.yunhai.homework.model.bsch.SchStudentDto; +import com.yunhai.homework.model.bsch.SchSystemDto; +import com.yunhai.homework.model.bsch.SchTreeNodeDto; +import com.yunhai.homework.model.SchTeaList; +import com.yunhai.homework.model.bsch.*; + +import java.util.List; + +/** + * 班级管理(bschclass)开放接口客户端门面。 + *

基址取配置 {@code yh.open.bschclass}(/oapi/** 无需应用签名,独立 RestTemplate 直连)。 + * 推送范围树、圈人、身份快照统一经本门面取数,不散落 RestTemplate 调用(改造方案 2.0)。 + */ +public interface IClassService { + + /** + * 获取指定学校全部教师(含所属组 GroupIds)。 + * 对应 bschclass 开放接口:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}} + * + * @param schId 学校ID(即用户当前身份的 schId) + */ + List getSchTeaList(int schId); + + /** + * 用户中心学校ID → bschclass 学校信息(SchSerId / 学校名)。 + * 对应:GET {@code {bschclass}/oapi/SchInfo/GetUserSchFirst?UserSchID={schId}} + */ + SchInfoDto getSchFirst(int schId); + + /** + * 学校学段(学制)列表:小学部 / 初中部…。 + * 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenlist?SchSerID=} + */ + List getSchLenList(String schSerId); + + /** + * 学校年级列表(Id=gradeId、Name=gradeNickname、OwnerId=学段id)。 + * 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenLevellist?SchSerID=} + */ + List getSchGradeList(String schSerId); + + /** + * 学校全部在读班级(ClassID/ClassName/Levell=gradeId),ScopeTree 一次取数客户端组装。 + * 对应:GET {@code {bschclass}/oapi/VSchClass/GetVClassList?SchSerID=} + */ + List getSchClassList(String schSerId); + + /** + * 按校全部学生(圈人:全校范围展开)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetSchStulist?schserid=} + */ + List getSchStuList(String schSerId); + + /** + * 按年级直查学生(圈人:年级范围免逐班聚合;bschclass 侧补缺接口)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetGradeStulist?SchSerID=&GradeId=} + */ + List getGradeStuList(String schSerId, Integer gradeId); + + /** + * 按学段直查学生(圈人:学部范围一键展开;bschclass 侧补缺接口)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetSubStulist?SchSerID=&SchSystemId=} + */ + List getSubStuList(String schSerId, Integer schSystemId); + + /** + * 按班级批量查学生(圈人:班级范围一次多班)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetClaArrStulist?ClassIDs=1,2,3} + */ + List getClaArrStuList(List classIds); + + /** + * 按学生ID批量查(圈人:指定学员范围)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetStuIdslist?stuids=1,2,3} + */ + List getStuIdsList(List stuIds); + + /** + * 学校学生树:学校→学段→年级→班级→学生(单元素列表,根为学校节点)。 + * 节点契约:组节点 type=1、isParent=true、children 非 null;学生叶子 type=2、isParent=false、children=null。 + * 对应:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?SchSerID=} + */ + List getTeaTree(Integer schId, boolean toUser); + + List getStuTree(Integer schId,boolean toUser); + + /** + * 学校学科列表(含学科编码、名称、排序号)。 + * 对应:GET {@code {bschclass}/oapi/SubInfo/GetSchSublist?SchID=} + */ + List getSchSubList(String schSerId); + + /** + * 指定教师的全部任教(校验教师归属该校;SchID 过滤学校、UserTeaSerID 过滤教师)。 + * 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchTeachlist?SchID=&UserTeaSerID=} + */ + List getSchTeachList(String schSerId, Integer teaId); + + /** + * 指定班级的全部任教(班级维度:该班所有教师与学科)。 + * 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchClaTeachlist?ClassID=} + */ + List getSchClaTeachList(Integer classId); + + /** + * 学校全部任教(全校所有教师的班级任教关系)。 + * 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchTeachlist?SchID=} + */ + List getSchAllTeachList(String schSerId); +} diff --git a/src/main/java/com/yunhai/homework/service/IContactsService.java b/src/main/java/com/yunhai/homework/service/IContactsService.java new file mode 100644 index 0000000..0e1fdce --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/IContactsService.java @@ -0,0 +1,79 @@ +package com.yunhai.homework.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/homework/service/IDictService.java b/src/main/java/com/yunhai/homework/service/IDictService.java new file mode 100644 index 0000000..31ef515 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/IDictService.java @@ -0,0 +1,75 @@ +package com.yunhai.homework.service; + +import java.util.List; +import java.util.Map; + +/** + * 系统字典业务接口。 + * 对应前端 Base 组接口:GetSysDic/GetDictMangeList/AddDeptDic/UpdateDeptDic/DelDept/OrdbyDicIds/GetDeptSync/GetSchModuleIsSms。 + */ +public interface IDictService { + + /** + * 获取系统字典(下拉框用)。 + * C# GetSysDic: Type=1 部门类别, 其余类型略。 + * + * @param type 字典类型编号(1=部门类别) + * @return List<Map> 每项 {k, v} + */ + List> getSysDic(Integer type); + + /** + * 获取系统字典管理列表(含 SerID/CodeID/Name/Des/Enable)。 + * + * @param type 字典类型编号(0=部门类别) + * @return List<Map> + */ + List> getDictMangeList(String type); + + /** + * 添加发文单位。 + * + * @param name 单位名称 + */ + void addDeptDic(String name); + + /** + * 更新发文单位。 + * + * @param serID 记录ID + * @param name 名称 + * @param enable 启用状态 + */ + void updateDeptDic(Integer serID, String name, Integer enable); + + /** + * 删除发文单位(校验是否被作业引用)。 + * + * @param serID 记录ID + */ + void delDept(Integer serID); + + /** + * 发文单位排序。 + * + * @param ids 逗号分隔的 SerID 列表(按前端顺序) + */ + void ordbyDicIds(String ids); + + /** + * 本校是否开通短信模块(前端 dict/Sync,用于"本校是否开通短信"判断)。 + * + * @param schId 当前学校ID(取当前登录用户角色所属学校) + * @return 开通返回 true,未开通/未配置返回 false + */ + Boolean getSchModuleIsSms(Integer schId); + + /** + * 按发文部门字典 key(CodeID)解析部门名称;未命中返回 null。 + * 用于作业列表/详情展示 DeptName(对应 C# GetDeptName)。 + * + * @param deptId 作业 deptId(存的是 sys_dict.dict_key) + * @return 部门名称;无匹配时返回 null + */ + String resolveDeptName(String deptId); +} diff --git a/src/main/java/com/yunhai/homework/service/IFileService.java b/src/main/java/com/yunhai/homework/service/IFileService.java new file mode 100644 index 0000000..1b01bbf --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/IFileService.java @@ -0,0 +1,94 @@ +package com.yunhai.homework.service; + +import com.yunhai.homework.entity.UpFile; +import com.yunhai.homework.model.homework.UpFileVo; +import com.yunhai.homework.model.homework.UpFileResultVo; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 文件上传/下载/删除业务接口。 + */ +public interface IFileService { + /** + * 上传单个文件。 + * + * @param file 上传的文件 + * @param appType 应用类型(如 "homework") + * @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 主题类型(如 "homework") + * @param topicId 主题ID + * @param zipName 压缩包显示名 + * @param response HTTP 响应 + */ + void downloadZipByTopic(String topicType, Integer topicId, 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/homework/service/IHomeworkService.java b/src/main/java/com/yunhai/homework/service/IHomeworkService.java new file mode 100644 index 0000000..ed50167 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/IHomeworkService.java @@ -0,0 +1,142 @@ +package com.yunhai.homework.service; + +import com.yunhai.homework.entity.Homework; +import com.yunhai.homework.model.BasePageResult; +import com.yunhai.homework.model.homework.*; +import org.springframework.data.domain.Pageable; + +import javax.servlet.http.HttpServletResponse; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 课程作业业务接口。 + */ +public interface IHomeworkService { + + /** + * 发作业:以当前登录用户为发布人,状态置为「已发布」入库。 + * 若 request.timingSendTime 不为空且晚于当前时间,则作为定时发送, + * 未到点前用户端收件箱不可见(到点后由查询过滤自然可见)。 + * + * @return 新建作业 ID(用于后续回填附件 TopicID 等) + */ + Integer publish(HomeworkRequest request); + + /** + * 编辑作业:按 id 更新标题/内容/类型/部门/置顶,发布人与状态保持不变。 + */ + void edit(Integer id, HomeworkRequest request); + + /** + * 批量删除作业:删除主记录并级联清理阅读记录、接收人、附件关联。 + */ + void deleteBatch(List ids); + + /** + * 撤回作业:仅已发布且未撤回的作业可撤回,置 isPulled=1。 + */ + void pull(Integer id); + + /** + * 置顶/取消置顶。 + */ + void setTopmost(Integer id, Integer topmost); + + /** + * 转发作业:基于源作业创建新作业(已发布),parentId 关联源作业。 + */ + Integer forward(Integer sourceId, HomeworkRequest request); + + /** + * 用户端列表:仅展示已发布且已到点的作业,并标记当前用户是否已读,附未读总数。 + * 返回 HomeworkPageResult(扩展 unreadCount)。 + */ + HomeworkPageResult userList(HomeworkQuery query, Pageable pageable); + + /** + * 管理端列表:按条件查询全部作业(管理员查看全部)。 + */ + BasePageResult adminList(HomeworkQuery query, Pageable pageable); + + /** + * 作业详情:返回视图对象,并顺带为当前用户标记已读(打开即视为已读)。 + */ + HomeworkVo detail(Integer id); + + /** + * 编辑回显详情(不标记已读)。 + */ + HomeworkVo getDetail(Integer id); + + /** + * 标记已读:为当前用户写入(幂等)该作业的阅读记录。 + */ + void markRead(Integer id); + + /** + * 一键全部已读(批量化):当前用户所有未读的已发布作业批量插入阅读记录。 + * @return 已读条数 + */ + int initializeRed(); + + /** + * 追加接收人(事务化):并入接收人列表并重算 allowcount。 + */ + void receiveUsers(Integer homeworkId, String users); + + /** + * 上一篇/下一篇作业ID(SQL 相邻查询)。 + * @param serid 当前作业ID + * @param type 0 已发(当前用户发布的序列)/ 1 已收(阅读记录序列) + * @return {prevId, nextId} + */ + Map getNextPrev(Integer serid, String type); + + /** + * 获取某作业的阅读明细列表。 + */ + List getReadDetail(Integer homeworkId); + + /** + * 阅读情况分组(已读/未读)。 + */ + List> getReadDetailGrouped(Integer serid); + + /** + * 催收未读:枚举未读接收人,后续接入消息推送。 + */ + void expedite(Integer serid); + + /** + * 作业统计:总数/已发布、累计阅读人次、当前用户未读、按发布人、按月。 + */ + HomeworkStatisticsVo statistics(HomeworkQuery query, LocalDateTime start, LocalDateTime end); + + /** + * 作业详情视图(含附件、接收人信息),供 Controller 返回。 + */ + HomeworkDetailVo getDetailMap(Integer serid); + + /** + * 已发列表附件批量查询(消除 N+1)。 + */ + Map> batchFileIds(List homeworkIds); + + /** + * 查看作业详情视图(含附件、接收人)。 + * type=1 时先标记已读再返回,type=0 时直接返回不标记。 + */ + HomeworkDetailVo detailMap(Integer serid, Integer type); + + /** + * 一键全部已读并返回结果(count = 已读条数)。 + */ + InitializeRedVo initializeRedResult(); + + /** + * 批量下载作业全部附件(打包 zip):查作业标题后调文件域打包下载。 + */ + void downloadAllFiles(Integer dataid, HttpServletResponse response); +} diff --git a/src/main/java/com/yunhai/homework/service/ISmsService.java b/src/main/java/com/yunhai/homework/service/ISmsService.java new file mode 100644 index 0000000..09b9b5f --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/ISmsService.java @@ -0,0 +1,77 @@ +package com.yunhai.homework.service; + +import java.util.List; +import java.util.Map; + +/** + * 短信设置业务接口。 + * 对应前端 setSms 组接口:getmodule/getgroup/getschsms/getsch/setschsms/setschallsms/setschmodulesms。 + */ +public interface ISmsService { + + /** + * 获取短信模块列表。 + * C# 从 AuthConfig.AppKeyMap 读取。Java版从 sms_module 表读取。 + * + * @return List<Map> 每项 {ID, Name} + */ + List> getModuleList(); + + /** + * 获取学校父类组列表。 + * C# 从外部BLL获取学校组。Java版预留。 + * + * @return List<Map> 每项 {ID, Name} + */ + List> getSchParentGroup(); + + /** + * 分页获取学校短信开关列表。 + * + * @param pageSize 每页条数 + * @param pageIndex 页码 + * @param schName 学校名称(模糊搜索) + * @return Map: { list, total } + */ + Map getSchIsSmsList(Integer pageSize, Integer pageIndex, String schName); + + /** + * 获取学校列表。 + * C# 从外部BLL获取学校列表。Java版预留。 + * + * @return List<Map> 每项 {ID, Name} + */ + List> getSchList(); + + /** + * 设置单校单模块短信开关。 + * + * @param schSerID 学校ID + * @param moduleId 模块ID + * @param isSms 是否开通 + */ + void setSchSms(Integer schSerID, Integer moduleId, Boolean isSms); + + /** + * 设置某校全部模块短信开关。 + * + * @param schSerID 学校ID + * @param isSms 是否开通 + */ + void setSchSmsAll(Integer schSerID, Boolean isSms); + + /** + * 批量设置多校多模块短信开关。 + * + * @param schSerIDs 逗号分隔的学校ID + * @param modules 逗号分隔的模块ID + * @param isSms 是否开通 + */ + void setSchModuleSms(String schSerIDs, String modules, Boolean isSms); + + /** + * 从 JSON 字符串解析并设置单校单模块短信开关。 + * 解析 SchSerID/ModuID/IsSms 三个字段。 + */ + void setSchSmsFromJson(String itemsModuJson); +} diff --git a/src/main/java/com/yunhai/homework/service/ITemplateService.java b/src/main/java/com/yunhai/homework/service/ITemplateService.java new file mode 100644 index 0000000..5594401 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/ITemplateService.java @@ -0,0 +1,69 @@ +package com.yunhai.homework.service; + +import java.util.List; +import java.util.Map; + +/** + * 作业模板业务接口。 + * 对应前端 Temp 组接口:GetTypelist/EditType/DelType/GetTemplatelist/EditTemplate/DelTemplate/GetTemplateFist。 + */ +public interface ITemplateService { + + /** + * 获取模板类型列表(首项固定为"全部")。 + * + * @return List<Map> 每项 {TypeID, TypeName} + */ + List> getTypelist(); + + /** + * 新增/编辑模板类型。 + * + * @param typeid 类型ID(0=新增) + * @param typeName 类型名称 + */ + void editType(Integer typeid, String typeName); + + /** + * 删除模板类型(校验是否有模板引用此类型)。 + * + * @param typeid 类型ID + */ + void delType(Integer typeid); + + /** + * 分页获取模板列表。 + * + * @param pageSize 每页条数 + * @param pageIndex 页码 + * @param typeid 类型ID(0=全部) + * @return Map: { list, total } + */ + Map getTemplatelist(Integer pageSize, Integer pageIndex, Integer typeid); + + /** + * 新增/编辑模板。 + * + * @param serID 模板ID(null=新增) + * @param title 标题 + * @param content 内容 + * @param typeId 类型ID + * @param cover 封面文件ID + */ + void editTemplate(Integer serID, String title, String content, Integer typeId, Integer cover); + + /** + * 删除模板。 + * + * @param serID 模板ID + */ + void delTemplate(Integer serID); + + /** + * 获取单个模板详情。 + * + * @param serID 模板ID + * @return Map 模板详情 + */ + Map getTemplateFist(Integer serID); +} diff --git a/src/main/java/com/yunhai/homework/service/IUserCenterService.java b/src/main/java/com/yunhai/homework/service/IUserCenterService.java new file mode 100644 index 0000000..8698338 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/IUserCenterService.java @@ -0,0 +1,51 @@ +package com.yunhai.homework.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/homework/service/impl/ClassService.java b/src/main/java/com/yunhai/homework/service/impl/ClassService.java new file mode 100644 index 0000000..8be5e2a --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/impl/ClassService.java @@ -0,0 +1,217 @@ +package com.yunhai.homework.service.impl; + +import com.yunhai.homework.model.SchTeaList; +import com.yunhai.homework.model.bsch.SchClassDto; +import com.yunhai.homework.model.bsch.SchGradeDto; +import com.yunhai.homework.model.bsch.SchInfoDto; +import com.yunhai.homework.model.bsch.SchStudentDto; +import com.yunhai.homework.model.bsch.SchSystemDto; +import com.yunhai.homework.model.bsch.SchTreeNodeDto; +import com.yunhai.homework.service.IClassService; +import com.yunhai.homework.model.SchTeaList; +import com.yunhai.homework.model.bsch.*; +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)开放接口实现。 + * bschclass 为开放接口(无需应用签名),使用独立 RestTemplate 直连。 + *

推送范围树/圈人/身份快照统一经本门面(改造方案 2.0): + * 学段 GetSchLenlist、年级 GetSchLenLevellist、班级 GetVClassList、 + * 学生 GetSchStulist / GetGradeStulist(补缺) / GetSubStulist(补缺) / GetClaArrStulist / GetStuIdslist、 + * 学生树 SchGroup/GetStuTree。 + */ +@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] 请求班级管理接口失败, url={}", url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取学校信息失败"); + } + } + + @Override + public List getSchLenList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenlist?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学段列表", "schSerId=" + schSerId); + } + + @Override + public List getSchGradeList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenLevellist?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取年级列表", "schSerId=" + schSerId); + } + + @Override + public List getSchClassList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/VSchClass/GetVClassList?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取班级列表", "schSerId=" + schSerId); + } + + @Override + public List getSchStuList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/OAStu/GetSchStulist?schserid=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学校学生", "schSerId=" + schSerId); + } + + @Override + public List getGradeStuList(String schSerId, Integer gradeId) { + return exchangeList(bschClassHost + "/oapi/OAStu/GetGradeStulist?SchSerID=" + schSerId + "&GradeId=" + gradeId, + new ParameterizedTypeReference>() { + }, "获取年级学生", "schSerId=" + schSerId + ", gradeId=" + gradeId); + } + + @Override + public List getSubStuList(String schSerId, Integer schSystemId) { + return exchangeList(bschClassHost + "/oapi/OAStu/GetSubStulist?SchSerID=" + schSerId + "&SchSystemId=" + schSystemId, + new ParameterizedTypeReference>() { + }, "获取学段学生", "schSerId=" + schSerId + ", schSystemId=" + schSystemId); + } + + @Override + public List getClaArrStuList(List classIds) { + String csv = joinIds(classIds); + return exchangeList(bschClassHost + "/oapi/OAStu/GetClaArrStulist?ClassIDs=" + csv, + new ParameterizedTypeReference>() { + }, "获取班级学生", "classIds=" + csv); + } + + @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 getStuTree(Integer schId,boolean toUser) { + return exchangeList(bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId+"&toUser="+toUser, + new ParameterizedTypeReference>() { + }, "获取学生树", "schId=" + schId); + } + + @Override + public List getTeaTree(Integer schId,boolean toUser) { + return exchangeList(bschClassHost + "/oapi/SchGroup/GetTeaTree?schId=" + schId+"&toUser="+toUser, + new ParameterizedTypeReference>() { + }, "获取教师树", "schId=" + schId); + } + + @Override + public List getSchSubList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/SubInfo/GetSchSublist?SchID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学校学科", "schSerId=" + schSerId); + } + + @Override + public List getSchTeachList(String schSerId, Integer teaId) { + return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchTeachlist?SchID=" + schSerId + "&UserTeaSerID=" + teaId, + new ParameterizedTypeReference>() { + }, "获取教师任教", "schSerId=" + schSerId + ", teaId=" + teaId); + } + + @Override + public List getSchClaTeachList(Integer classId) { + return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchClaTeachlist?ClassID=" + classId, + new ParameterizedTypeReference>() { + }, "获取班级任教", "classId=" + classId); + } + + @Override + public List getSchAllTeachList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchTeachlist?SchID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学校全部任教", "schSerId=" + schSerId); + } + + + + + + // ============================ 通用调用 ============================ + + private List exchangeList(String url, ParameterizedTypeReference> type, + String action, String arg) { + requireHost(); + try { + // GET + Accept: application/json,保持与既有 GetSchteaList 调用一致 + 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/homework/service/impl/ClassStudentService.java b/src/main/java/com/yunhai/homework/service/impl/ClassStudentService.java new file mode 100644 index 0000000..0785269 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/impl/ClassStudentService.java @@ -0,0 +1,81 @@ +package com.yunhai.homework.service.impl; + +import com.yunhai.homework.entity.ClassStudent; +import com.yunhai.homework.repository.ClassStudentRepository; +import com.yunhai.homework.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 java.util.List; +import java.util.stream.Collectors; + +/** + * 班级学生名册服务:学生端身份映射 + 名册同步任务入口。 + *

+ * 身份口径:当前登录人用户中心数字ID(UserContext.getUserId(),即 SchStudent.UserStuSerID) + * ↔ class_student.student_id(status=1 在班)。名册不再存登录账号。 + * bschclass 名册开放接口未就绪期间(计划 §13-1),联调用种子数据 + * (sql/v3_seed.sql)填充本表;syncAll() 预留同步实现位。 + */ +@Service +public class ClassStudentService { + + private static final Logger log = LoggerFactory.getLogger(ClassStudentService.class); + + @Autowired + private ClassStudentRepository classStudentRepository; + + /** + * 当前登录用户ID对应的学生名册行(在班)。 + * + * @throws BizException 未登录或用户ID未匹配名册 + */ + public List resolveMe() { + Integer userId = UserContext.getUserId(); + if (userId == null) { + throw new BizException(ErrorCode.FORBIDDEN, "未获取到登录身份"); + } + List me = classStudentRepository.findByStudentIdAndStatus(userId, 1); + if (me.isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "当前用户未匹配到学生名册,请联系管理员确认班级名册"); + } + return me; + } + + /** + * 学生 id(多班级重复时取第一个;学籍号一人一号,正常唯一)。 + */ + public Integer resolveMyStudentId() { + return resolveMe().get(0).getStudentId(); + } + + /** + * 我所在的班级 id 集合。 + */ + public List resolveMyClassIds() { + return resolveMe().stream().map(ClassStudent::getClassId).distinct().collect(Collectors.toList()); + } + + /** + * 名册全量同步(每日 Job / 手动触发)。 + * TODO bschclass「按学校查年级班级树+学生名册」开放接口就绪后接入 + * (参照 ClassService.getSchTeaList 的直连模式);未就绪时仅记录日志跳过。 + */ + @Transactional + public int syncAll() { + log.warn("[syncAll] bschclass 名册开放接口未就绪,本轮跳过(联调请执行 sql/v3_seed.sql 种子数据)"); + return 0; + } + + /** + * 班级在班学生(运维排查/种子校验用)。 + */ + public List findByClassId(String classId) { + return classStudentRepository.findByClassIdAndStatus(classId, 1); + } +} diff --git a/src/main/java/com/yunhai/homework/service/impl/ContactsService.java b/src/main/java/com/yunhai/homework/service/impl/ContactsService.java new file mode 100644 index 0000000..80cbee6 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/impl/ContactsService.java @@ -0,0 +1,388 @@ +package com.yunhai.homework.service.impl; + +import com.yunhai.homework.entity.ContactsGroup; +import com.yunhai.homework.entity.ContactsGroupUser; +import com.yunhai.homework.repository.ContactsGroupRepository; +import com.yunhai.homework.repository.ContactsGroupUserRepository; +import com.yunhai.homework.service.IContactsService; +import com.yunhai.homework.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.client.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.data.domain.Page; +import org.springframework.data.domain.PageRequest; +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 userId = UserContext.getUserId(); + + // 查重:同创建人下同名同类型组不重复 + List existing = groupRepository.findByCreatorId(userId); + 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(userId); + groupRepository.save(group); + } + + // ============================ GetGroup ============================ + + @Override + public Map getGroup(Integer page, Integer count, Integer type) { + if (type == null) { + type = 1; + } + Integer userId = UserContext.getUserId(); + + // 查询当前用户创建的全部联系组 + List allGroups = groupRepository.findByCreatorId(userId); + + // 统计每组人数 + 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 userId = UserContext.getUserId(); + + // 查重(排除自身) + List existing = groupRepository.findByCreatorId(userId); + 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 uid : userIds) { + ContactsGroupUser member = new ContactsGroupUser(); + member.setGroupId(gid); + member.setUserId(uid); + // 姓名快照落库(对齐 PartyLearn memberEdit 保存 userName 的做法), + // 查询时 fillUserInfo 仅对缺失值兜底,避免组树/分页反复远程调用 + try { + BoaUser user = YschBaseOpenApi.getUserInfo(uid); + if (user != null) { + member.setUserName(user.getName()); + } + } catch (Exception e) { + log.warn("[setContactsTea] 查询用户信息失败, userId={}", uid, 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 的行为。 + * 查到的值会回写落库自愈,避免历史空数据每次读取都重复远程调用。 + */ + private void fillUserInfo(Map item, ContactsGroupUser m) { + String name = m.getUserName(); + if (m.getUserId() != null && !StringUtils.hasText(name)) { + BoaUser user = null; + try { + user = YschBaseOpenApi.getUserInfo(m.getUserId()); + } catch (Exception e) { + log.warn("[fillUserInfo] 查询用户中心用户失败, userId={}", m.getUserId(), e); + } + if (user == null) { + log.warn("[fillUserInfo] 用户中心未查到用户(历史脏数据?), userId={}", m.getUserId()); + } else if (StringUtils.hasText(user.getName())) { + name = user.getName(); + m.setUserName(name); + // 回写落库:下次读取直接命中快照 + try { + groupUserRepository.save(m); + } catch (Exception e) { + log.warn("[fillUserInfo] 回写用户信息失败, userId={}", m.getUserId(), e); + } + } + } + item.put("name", name != null ? name : ""); + } + + // ============================ GetGroupTree ============================ + + @Override + public List> getGroupTree(Integer type) { + if (type == null) { + type = 1; + } + Integer userId = UserContext.getUserId(); + + // 查询当前用户的联系组 + List groups = groupRepository.findByCreatorId(userId); + 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; + } +} diff --git a/src/main/java/com/yunhai/homework/service/impl/DictService.java b/src/main/java/com/yunhai/homework/service/impl/DictService.java new file mode 100644 index 0000000..23df1c6 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/impl/DictService.java @@ -0,0 +1,270 @@ +package com.yunhai.homework.service.impl; + +import com.yunhai.homework.entity.Homework; +import com.yunhai.homework.entity.SmsModule; +import com.yunhai.homework.entity.SmsSchoolConfig; +import com.yunhai.homework.entity.SysDict; +import com.yunhai.homework.repository.HomeworkRepository; +import com.yunhai.homework.repository.SmsModuleRepository; +import com.yunhai.homework.repository.SmsSchoolConfigRepository; +import com.yunhai.homework.repository.SysDictRepository; +import com.yunhai.homework.service.IDictService; +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.beans.factory.annotation.Value; +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# CommonController + GetSysDict.cs 中的字典管理逻辑。 + */ +@Service +public class DictService implements IDictService { + + private static final Logger log = LoggerFactory.getLogger(DictService.class); + + /** 字典类型常量:发文部门 */ + private static final String DICT_TYPE_DEPT = "dept"; + + @Autowired + private SysDictRepository sysDictRepository; + + @Autowired + private HomeworkRepository homeworkRepository; + + @Autowired + private SmsModuleRepository smsModuleRepository; + + @Autowired + private SmsSchoolConfigRepository smsSchoolConfigRepository; + + /** 本应用在 sms_module.module_code 中的标识(见 application.yml: sms.module-id)。 */ + @Value("${sms.module-id:homework}") + private String smsModuleId; + + /** 模块标识(缺省 homework)。 */ + private String smsModuleCode() { + return StringUtils.hasText(smsModuleId) ? smsModuleId : "homework"; + } + + // ============================ GetSysDic ============================ + + @Override + public List> getSysDic(Integer type) { + // C# 中 Type=1 为部门类别(DEPT),其余类型(文印、请假等)本系统暂不涉及 + String dictType = mapDictType(type); + if (dictType == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + List list = sysDictRepository.findByDictTypeAndEnableFlagOrderBySortOrderAsc(dictType, 1); + if (list.isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "尚未添加发文部门,请联系管理员添加!"); + } + // 按 sortOrder 升序返回 {k, v} + return list.stream() + .sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? Integer.MAX_VALUE : d.getSortOrder())) + .map(d -> { + Map item = new LinkedHashMap<>(); + item.put("k", d.getDictKey()); + item.put("v", d.getDictValue()); + return item; + }) + .collect(Collectors.toList()); + } + + // ============================ GetDictMangeList ============================ + + @Override + public List> getDictMangeList(String type) { + // type="dept" 表示部门类别(DEPT) + String dictType = "dept".equalsIgnoreCase(type) ? DICT_TYPE_DEPT : null; + if (dictType == null) { + // 非 DEPT 类型需超管权限,本系统暂只支持 DEPT + throw new BizException(ErrorCode.FORBIDDEN, "非超管不得操作!"); + } + List list = sysDictRepository.findByDictTypeOrderBySortOrderAsc(dictType); + return list.stream() + .sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? 999 : d.getSortOrder())) + .map(d -> { + Map item = new LinkedHashMap<>(); + item.put("SerID", d.getId()); + item.put("CodeID", d.getDictKey()); + item.put("Name", d.getDictValue()); + item.put("Des", d.getSortOrder() != null ? d.getSortOrder().toString() : ""); + item.put("Enable", d.getEnableFlag()); + return item; + }) + .collect(Collectors.toList()); + } + + // ============================ AddDeptDic ============================ + + @Override + @Transactional + public void addDeptDic(String name) { + if (!StringUtils.hasText(name)) { + throw new BizException(ErrorCode.PARAM_ERROR, "输入字符串格式不正确!"); + } + // 校验重名 + List existing = sysDictRepository.findByDictTypeOrderBySortOrderAsc(DICT_TYPE_DEPT); + boolean duplicate = existing.stream().anyMatch(d -> + DICT_TYPE_DEPT.equals(d.getDictType()) && name.equals(d.getDictValue()) && d.getEnableFlag() != 3); + if (duplicate) { + throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!"); + } + + // 计算 sortOrder = max + 1 + int maxSort = existing.stream() + .filter(d -> d.getEnableFlag() != 3) + .mapToInt(d -> d.getSortOrder() != null ? d.getSortOrder() : 0) + .max().orElse(0); + + SysDict dict = new SysDict(); + dict.setDictType(DICT_TYPE_DEPT); + dict.setDictValue(name); + dict.setSortOrder(maxSort + 1); + dict.setDictKey(DICT_TYPE_DEPT + (maxSort + 1)); + dict.setEnableFlag(1); + sysDictRepository.save(dict); + } + + // ============================ UpdateDeptDic ============================ + + @Override + @Transactional + public void updateDeptDic(Integer serID, String name, Integer enable) { + if (serID == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!"); + } + // 校验重名(排除自身) + List existing = sysDictRepository.findByDictTypeOrderBySortOrderAsc(DICT_TYPE_DEPT); + boolean duplicate = existing.stream().anyMatch(d -> + !d.getId().equals(serID) && name.equals(d.getDictValue()) && d.getEnableFlag() != 3); + if (duplicate) { + throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!"); + } + + SysDict dict = sysDictRepository.findById(serID) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在")); + if (StringUtils.hasText(name)) { + dict.setDictValue(name); + } + if (enable != null) { + dict.setEnableFlag(enable); + } + sysDictRepository.save(dict); + } + + // ============================ DelDept ============================ + + @Override + @Transactional + public void delDept(Integer serID) { + SysDict dict = sysDictRepository.findById(serID) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在")); + // 校验是否被作业引用(按 deptId = dictKey 匹配) + // C# 中检查 Notify_Message.DeptID == dict.CodeID + // Java 中 Homework.deptId 存的是 dictKey + List referenced = homeworkRepository.findAll((root, query, cb) -> + cb.equal(root.get("deptId"), dict.getDictKey())); + if (!referenced.isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "已引用该部门,无法删除!"); + } + sysDictRepository.delete(dict); + } + + // ============================ OrdbyDicIds ============================ + + @Override + @Transactional + public void ordbyDicIds(String ids) { + if (!StringUtils.hasText(ids)) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + List list = sysDictRepository.findByDictTypeOrderBySortOrderAsc(DICT_TYPE_DEPT); + if (list.isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "字典类型错误,未找到该字典类型"); + } + + // C# 逻辑:取当前最大 Sort 为起始序号,逆序遍历 ids 递减赋值 Des + int[] idArr = Arrays.stream(ids.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .mapToInt(Integer::parseInt) + .toArray(); + + int maxSort = list.stream() + .mapToInt(d -> d.getSortOrder() != null ? d.getSortOrder() : idArr.length) + .max().orElse(idArr.length); + + // lambda 中不能修改局部变量,用数组包装 + final int[] indexOrd = { maxSort }; + + // 逆序遍历,从最大序号递减 + for (int i = idArr.length - 1; i >= 0; i--) { + int id = idArr[i]; + sysDictRepository.findById(id).ifPresent(dict -> { + dict.setSortOrder(indexOrd[0]); + sysDictRepository.save(dict); + indexOrd[0]--; + }); + } + } + + // ============================ GetSchModuleIsSms ============================ + + @Override + public Boolean getSchModuleIsSms(Integer schId) { + // 本校是否开通短信:sms_module(module_code) + sms_school_config(school_id, module_id).is_sms。 + // 未登录/无学校/未配置一律视为未开通(前端据此隐藏短信相关入口)。 + if (schId == null) { + return false; + } + String moduleCode = smsModuleCode(); + if (!StringUtils.hasText(moduleCode)) { + return false; + } + SmsModule module = smsModuleRepository.findByModuleCode(moduleCode); + if (module == null) { + return false; + } + SmsSchoolConfig config = smsSchoolConfigRepository.findBySchoolIdAndModuleId(schId, module.getId()); + return config != null && Boolean.TRUE.equals(config.getIsSms()); + } + + // ============================ 辅助方法 ============================ + + /** + * 按发文部门字典 key 解析部门名称(对应 C# GetDeptName)。 + */ + @Override + public String resolveDeptName(String deptId) { + if (!StringUtils.hasText(deptId)) { + return null; + } + return sysDictRepository.findByDictTypeAndDictKey(DICT_TYPE_DEPT, deptId.trim()) + .map(SysDict::getDictValue) + .orElse(null); + } + + /** + * 将前端 Type 参数映射到字典类型字符串。 + * C# 中使用枚举,Java 版仅支持 DEPT(1)。 + */ + private String mapDictType(Integer type) { + if (type == null) return null; + switch (type) { + case 1: return DICT_TYPE_DEPT; + // 其余类型(文印/请假/物品等)本系统不涉及 + default: return null; + } + } +} diff --git a/src/main/java/com/yunhai/homework/service/impl/FileService.java b/src/main/java/com/yunhai/homework/service/impl/FileService.java new file mode 100644 index 0000000..6f1c57e --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/impl/FileService.java @@ -0,0 +1,326 @@ +package com.yunhai.homework.service.impl; + +import com.yunhai.homework.entity.UpFile; +import com.yunhai.homework.model.homework.UpFileVo; +import com.yunhai.homework.model.homework.UpFileResultVo; +import com.yunhai.homework.repository.UpFileRepository; +import com.yunhai.homework.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 = "homework"; + + // ============================ 上传 ============================ + + @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 + @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/homework/service/impl/HomeworkProgressCalculator.java b/src/main/java/com/yunhai/homework/service/impl/HomeworkProgressCalculator.java new file mode 100644 index 0000000..2e66e31 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/impl/HomeworkProgressCalculator.java @@ -0,0 +1,175 @@ +package com.yunhai.homework.service.impl; + +import com.yunhai.homework.entity.ClassStudent; +import com.yunhai.homework.entity.HomeworkReadRecord; +import com.yunhai.homework.entity.HomeworkSubmission; +import com.yunhai.homework.enums.SubmitStatusEnum; +import com.yunhai.homework.model.v2.ProgressVo; +import com.yunhai.homework.repository.ClassStudentRepository; +import com.yunhai.homework.repository.HomeworkReadRecordRepository; +import com.yunhai.homework.repository.HomeworkSubmissionRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 进度/名单聚合器(PRD §6.4 口径),教师列表、统计卡片、学生矩阵、提醒名单共用。 + *

+ * 单作业量级 ≤ 数百人,全部实时聚合不落冗余字段: + * should = 参与班级在班学生去重数;submitted = 当前版本 status ∈ 已交集合; + * graded = REVIEWED;unreadUndone = should −(已读 ∪ 已交)。 + */ +@Service +public class HomeworkProgressCalculator { + + /** + * 名册条目(去重前):矩阵与提醒名单需要 班级×学生 维度。 + */ + public static class RosterEntry { + public Integer studentId; + public String studentName; + public String classId; + public String className; + } + + /** + * 聚合结果:总进度 + 分班进度 + 全量名册(班级×学生)。 + */ + public static class Result { + public ProgressVo total; + public List byClass = new ArrayList<>(); + public List roster = new ArrayList<>(); + } + + /** + * 分班进度条目。 + */ + public static class ClassProgress { + public String classId; + public String className; + public ProgressVo progress; + } + + @Autowired + private ClassStudentRepository classStudentRepository; + + @Autowired + private HomeworkReadRecordRepository readRecordRepository; + + @Autowired + private HomeworkSubmissionRepository submissionRepository; + + /** + * 聚合指定作业的全部统计(仅查阅型/无名册时 should=0)。 + * + * @param classIds 参与班级 id(homework_class 解析结果) + */ + public Result compute(Integer homeworkId, List classIds) { + Result result = new Result(); + + // 1. 名册:班级×学生(应交基数) + List roster = classIds == null || classIds.isEmpty() + ? new ArrayList<>() + : classStudentRepository.findByClassIdInAndStatus(classIds, 1); + Set distinctStudents = new HashSet<>(); + for (ClassStudent cs : roster) { + RosterEntry entry = new RosterEntry(); + entry.studentId = cs.getStudentId(); + entry.studentName = cs.getStudentName(); + entry.classId = cs.getClassId(); + entry.className = cs.getClassName(); + result.roster.add(entry); + distinctStudents.add(cs.getStudentId()); + } + result.roster.sort(Comparator + .comparing((RosterEntry e) -> nullSafe(e.className)) + .thenComparing(e -> nullSafe(e.studentName))); + + // 2. 已读集合 + Set readSet = readRecordRepository.findByHomeworkId(homeworkId).stream() + .filter(r -> r.getIsRead() != null && r.getIsRead() == 1) + .map(HomeworkReadRecord::getStudentId) + .collect(Collectors.toSet()); + + // 3. 当前版本提交(按学生取最新行) + Map latest = submissionRepository + .findByHomeworkIdAndIsLatest(homeworkId, 1).stream() + .collect(Collectors.toMap(HomeworkSubmission::getStudentId, s -> s, (a, b) -> a)); + + // 4. 总进度(学生去重口径) + result.total = aggregate(distinctStudents, readSet, latest); + + // 5. 分班进度(班级维度不去重,矩阵口径) + Map> byClassEntries = result.roster.stream() + .collect(Collectors.groupingBy(e -> nullSafe(e.classId), LinkedHashMap::new, Collectors.toList())); + for (Map.Entry> e : byClassEntries.entrySet()) { + Set classStudentIds = e.getValue().stream().map(r -> r.studentId).collect(Collectors.toSet()); + ClassProgress cp = new ClassProgress(); + cp.classId = e.getKey(); + cp.className = e.getValue().get(0).className; + cp.progress = aggregate(classStudentIds, readSet, latest); + result.byClass.add(cp); + } + return result; + } + + private ProgressVo aggregate(Set students, Set readSet, + Map latest) { + ProgressVo vo = new ProgressVo(); + int should = students.size(); + int read = 0; + int submitted = 0; + int graded = 0; + Set readOrSubmitted = new HashSet<>(); + for (Integer studentId : students) { + boolean isRead = readSet.contains(studentId); + HomeworkSubmission sub = latest.get(studentId); + SubmitStatusEnum status = sub == null ? null : SubmitStatusEnum.of(sub.getStatus()); + boolean isSubmitted = status != null && status.countsAsSubmitted(); + if (isRead) { + read++; + readOrSubmitted.add(studentId); + } + if (isSubmitted) { + submitted++; + readOrSubmitted.add(studentId); + if (status == SubmitStatusEnum.REVIEWED) { + graded++; + } + } + } + vo.setShould(should); + vo.setRead(read); + vo.setSubmitted(submitted); + vo.setGraded(graded); + vo.setPendingGrade(submitted - graded); + vo.setUnsubmitted(should - submitted); + vo.setUnread(should - read); + vo.setUnreadUndone(should - readOrSubmitted.size()); + vo.setReadRate(rate(read, should)); + vo.setSubmitRate(rate(submitted, should)); + vo.setGradeRate(rate(graded, submitted)); + return vo; + } + + /** 百分数(一位小数) */ + public static double rate(int part, int total) { + if (total <= 0) { + return 0.0; + } + return Math.round(part * 1000.0 / total) / 10.0; + } + + private static String nullSafe(String s) { + return s == null ? "" : s; + } +} diff --git a/src/main/java/com/yunhai/homework/service/impl/HomeworkService.java b/src/main/java/com/yunhai/homework/service/impl/HomeworkService.java new file mode 100644 index 0000000..445bf82 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/impl/HomeworkService.java @@ -0,0 +1,1184 @@ +package com.yunhai.homework.service.impl; + +import com.yunhai.homework.entity.Homework; +import com.yunhai.homework.entity.HomeworkClass; +import com.yunhai.homework.entity.HomeworkRead; +import com.yunhai.homework.entity.HomeworkReceiver; +import com.yunhai.homework.entity.HomeworkSubmission; +import com.yunhai.homework.entity.UpFile; +import com.yunhai.homework.enums.HomeworkStatusEnum; +import com.yunhai.homework.enums.HomeworkTypeEnum; +import com.yunhai.homework.enums.HwStatusEnum; +import com.yunhai.homework.enums.ReviewStatusEnum; +import com.yunhai.homework.model.BasePageResult; +import com.yunhai.homework.model.homework.*; +import com.yunhai.homework.model.v2.ProgressVo; +import com.yunhai.homework.repository.HomeworkClassRepository; +import com.yunhai.homework.repository.HomeworkReadRepository; +import com.yunhai.homework.repository.HomeworkReceiverRepository; +import com.yunhai.homework.repository.HomeworkRepository; +import com.yunhai.homework.repository.HomeworkSubmissionRepository; +import com.yunhai.homework.repository.UpFileRepository; +import com.yunhai.homework.service.IFileService; +import com.yunhai.homework.service.IDictService; +import com.yunhai.homework.service.IHomeworkService; +import com.yunhai.homework.service.sms.SmsSender; +import com.yunhai.homework.utils.PageUtils; +import com.yunhai.homework.utils.SubmitTypes; +import com.yunhai.homework.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaUser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import javax.servlet.http.HttpServletResponse; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 课程作业业务实现。 + */ +@Service +public class HomeworkService implements IHomeworkService { + + private static final Logger log = LoggerFactory.getLogger(HomeworkService.class); + + @Autowired + private HomeworkRepository homeworkRepository; + + @Autowired + private HomeworkReadRepository homeworkReadRepository; + + @Autowired + private HomeworkClassRepository homeworkClassRepository; + + @Autowired + private HomeworkProgressCalculator homeworkProgressCalculator; + + @Autowired + private HomeworkSubmissionRepository submissionRepository; + + @Autowired + private HomeworkReceiverRepository homeworkReceiverRepository; + + @Autowired + private UpFileRepository upFileRepository; + + @Autowired + private SmsSender smsSender; + + @Autowired + private IFileService fileService; + + @Autowired + private IDictService dictService; + + private static final int PUBLISHED = HomeworkStatusEnum.PUBLISHED.getCode(); + + // ============================ 发作业 ============================ + + @Override + @Transactional + public Integer publish(HomeworkRequest request) { + Homework entity = new Homework(); + BeanUtils.copyProperties(request, entity); + entity.setId(null); + entity.setDeptName(resolveDeptName(request)); + normalizeReceiveList(entity, request.getReceiveList()); + // 无草稿态:一律置为「已发布」;定时发送保留 timingSendTime,由查询侧按「已到点」过滤可见性 + entity.setStatus(PUBLISHED); + entity.setPublisherId(UserContext.getUserId()); + entity.setPublisherName(UserContext.getUserName()); + entity.setSchId(UserContext.getSchId()); + entity.setIsPulled(0); + entity.setReadCount(0); + applyV2Compat(entity, request); + // 双轨兼容:notice 语义发布的同时置 PRD 五态,保证 V2 学生端可见 + entity.setHwStatus(HwStatusEnum.PUBLISHED.getCode()); + entity.setPublishedAt(LocalDateTime.now()); + homeworkRepository.save(entity); + // 写入接收人关联表(非全员时) + saveReceivers(entity); + // 绑定附件(前端上传后回传的 fileIds 逗号串) + fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); + // 立即发布且开启短信时预留短信发送(真实通道后续接入) + trySendSms(entity); + return entity.getId(); + } + + // ============================ 编辑 ============================ + + @Override + @Transactional + public void edit(Integer id, HomeworkRequest request) { + Objects.requireNonNull(id, "作业ID不得为空"); + Homework entity = homeworkRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "作业不存在: " + id)); + entity.setTitle(request.getTitle()); + entity.setContent(request.getContent()); + entity.setType(request.getType()); + entity.setDeptId(request.getDeptId()); + entity.setDeptName(resolveDeptName(request)); + entity.setTopmost(request.getTopmost()); + entity.setSmsFlag(request.getSmsFlag()); + if (request.getReceiveList() != null) { + normalizeReceiveList(entity, request.getReceiveList()); + // 更新接收人关联表 + homeworkReceiverRepository.deleteByHomeworkId(entity.getId()); + saveReceivers(entity); + } + if (request.getTimingSendTime() != null) { + entity.setTimingSendTime(request.getTimingSendTime()); + } + applyV2Compat(entity, request); + // 附件全量替换:先解绑旧附件,再绑定本次提交的列表(fileIds 为 null 时不改动附件) + if (request.getFileIds() != null) { + for (UpFile old : upFileRepository.findByTopicTypeAndTopicId("homework", entity.getId())) { + old.setTopicId(null); + upFileRepository.save(old); + } + fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); + } + homeworkRepository.save(entity); + } + + /** 解析 "1,2,3" 形式的附件ID串,忽略非法片段 */ + private List parseFileIds(String fileIds) { + if (!StringUtils.hasText(fileIds)) { + return Collections.emptyList(); + } + return Arrays.stream(fileIds.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(s -> { + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + return null; + } + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + // ============================ 删除(批量) ============================ + + @Override + @Transactional + public void deleteBatch(List ids) { + Objects.requireNonNull(ids, "作业ID列表不得为空"); + if (ids.isEmpty()) { + return; + } + for (Integer id : ids) { + if (!homeworkRepository.existsById(id)) { + continue; + } + homeworkReadRepository.deleteByHomeworkId(id); + homeworkReceiverRepository.deleteByHomeworkId(id); + upFileRepository.deleteByTopicTypeAndTopicId("homework", id); + homeworkRepository.deleteById(id); + } + } + + // ============================ 撤回 ============================ + + @Override + @Transactional + public void pull(Integer id) { + Objects.requireNonNull(id, "作业ID不得为空"); + Homework entity = homeworkRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "作业不存在: " + id)); + if (entity.getStatus() != PUBLISHED) { + throw new BizException(ErrorCode.FORBIDDEN, "仅已发布的作业可撤回"); + } + if (entity.getIsPulled() != null && entity.getIsPulled() == 1) { + throw new BizException(ErrorCode.FORBIDDEN, "作业已被撤回,请勿重复操作"); + } + entity.setIsPulled(1); + homeworkRepository.save(entity); + } + + // ============================ 置顶/取消置顶 ============================ + + @Override + @Transactional + public void setTopmost(Integer id, Integer topmost) { + Objects.requireNonNull(id, "作业ID不得为空"); + Homework entity = homeworkRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "作业不存在: " + id)); + entity.setTopmost(topmost == null ? 0 : topmost); + homeworkRepository.save(entity); + } + + // ============================ 转发 ============================ + + @Override + @Transactional + public Integer forward(Integer sourceId, HomeworkRequest request) { + Objects.requireNonNull(sourceId, "源作业ID不得为空"); + Homework source = homeworkRepository.findById(sourceId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "源作业不存在: " + sourceId)); + Homework entity = new Homework(); + BeanUtils.copyProperties(request, entity); + entity.setId(null); + entity.setDeptName(resolveDeptName(request)); + normalizeReceiveList(entity, request.getReceiveList()); + entity.setStatus(PUBLISHED); + entity.setPublisherId(UserContext.getUserId()); + entity.setPublisherName(UserContext.getUserName()); + entity.setParentId(sourceId); + entity.setIsPulled(0); + entity.setReadCount(0); + entity.setParentId(sourceId); + entity.setSchId(UserContext.getSchId()); + applyV2Compat(entity, request); + entity.setHwStatus(HwStatusEnum.PUBLISHED.getCode()); + entity.setPublishedAt(LocalDateTime.now()); + homeworkRepository.save(entity); + saveReceivers(entity); + trySendSms(entity); + return entity.getId(); + } + + // ============================ 用户端列表(含未读) ============================ + + @Override + public HomeworkPageResult userList(HomeworkQuery query, Pageable pageable) { + Integer userId = UserContext.getUserId(); + Set readIds = new HashSet<>(homeworkReadRepository.findHomeworkIdsByReaderId(userId)); + + Page page = homeworkRepository.findAll(userSpec(query, readIds), pageable); + + List content = page.getContent().stream() + .map(n -> toVo(n, readIds.contains(n.getId()))) + .collect(Collectors.toList()); + + long unreadCount = 0; + Integer schId = UserContext.getSchId(); + if (schId != null) { + unreadCount = homeworkRepository.countPublishedDueBySchId(schId, PUBLISHED, LocalDateTime.now()) + - homeworkReadRepository.countByReaderId(userId); + } else { + unreadCount = homeworkRepository.countPublishedDue(PUBLISHED, LocalDateTime.now()) + - homeworkReadRepository.countByReaderId(userId); + } + if (unreadCount < 0) { + unreadCount = 0; + } + + return new HomeworkPageResult<>(content, (int) page.getTotalElements(), + page.getNumber() + 1, page.getSize(), page.getTotalPages(), page.hasNext(), unreadCount); + } + + /** + * 用户端动态查询:仅已发布且已到点(定时发送未到点不可见);支持标题关键词、类型、部门、时间范围、未读/已读过滤。 + * 多租户:按当前用户学校过滤(schId 为空时不加过滤,兼容无学校归属用户)。 + */ + private Specification userSpec(HomeworkQuery query, Set readIds) { + return (Root root, CriteriaQuery cq, CriteriaBuilder cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("status"), PUBLISHED)); + predicates.add(cb.or(cb.isNull(root.get("timingSendTime")), + cb.lessThanOrEqualTo(root.get("timingSendTime"), LocalDateTime.now()))); + appendCommonPredicates(query, root, cb, predicates); + appendSchIdPredicate(root, cb, predicates); + + Integer readType = query.getReadType() == null ? 0 : query.getReadType(); + if (readType == 1 && !readIds.isEmpty()) { + predicates.add(cb.not(root.get("id").in(readIds))); + } else if (readType == 2) { + if (readIds.isEmpty()) { + predicates.add(cb.equal(root.get("id"), -1)); + } else { + predicates.add(root.get("id").in(readIds)); + } + } + return cq.where(predicates.toArray(new Predicate[0])).getRestriction(); + }; + } + + // ============================ 管理端列表(全部) ============================ + + @Override + public BasePageResult adminList(HomeworkQuery query, Pageable pageable) { + Page page = homeworkRepository.findAll(adminSpec(query), pageable); + List content = page.getContent().stream() + .map(n -> toVo(n, false)) + .collect(Collectors.toList()); + // 批量填充附件ID串("1,2,3"):列表页"附件下载"入口依赖 UpFile 字段 + if (!content.isEmpty()) { + Map> fileMap = batchFileIds( + content.stream().map(HomeworkVo::getId).collect(Collectors.toList())); + for (HomeworkVo vo : content) { + List fids = fileMap.getOrDefault(vo.getId(), Collections.emptyList()); + vo.setUpFile(fids.stream().map(String::valueOf).collect(Collectors.joining(","))); + } + fillProgress(content); + } + return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements())); + } + + /** + * 批量回填列表页提交/批改进度(「全部作业」页的 提交进度 / 未读未交 / 批改 列)。 + *

口径与 V2 一致:草稿两态与仅查阅型作业不计(progress 保持 null,前端显示「—」); + * 班级关联一次批量查出,避免逐行 N+1。 + *

草稿/仅查阅判定用 hwStatus(旧四态派生值)与 submitType 列表, + * 前者 draft 即草稿两态,后者由 SubmitTypes.collects 排除 none。 + */ + private void fillProgress(List content) { + List collectableIds = content.stream() + .filter(vo -> !"draft".equals(vo.getHwStatus())) + .filter(vo -> SubmitTypes.collects(vo.getSubmitType())) + .map(HomeworkVo::getId) + .collect(Collectors.toList()); + if (collectableIds.isEmpty()) { + return; + } + Map> classMap = homeworkClassRepository.findByHomeworkIds(collectableIds).stream() + .collect(Collectors.groupingBy(c -> c.getHomeworkId(), + Collectors.mapping(c -> c.getClassId(), Collectors.toList()))); + for (HomeworkVo vo : content) { + if (!collectableIds.contains(vo.getId())) { + continue; + } + List classIds = classMap.getOrDefault(vo.getId(), Collections.emptyList()); + ProgressVo progress = homeworkProgressCalculator.compute(vo.getId(), classIds).total; + vo.setProgress(progress); + } + } + + /** + * 管理端动态查询:按状态/标题/类型/部门/发布人/时间范围过滤。 + * 多租户:按当前用户学校过滤(schId 为空时不加过滤)。 + * scope="user" 时仅看当前用户发布的作业。 + */ + private Specification adminSpec(HomeworkQuery query) { + return (Root root, CriteriaQuery cq, CriteriaBuilder cb) -> { + List predicates = new ArrayList<>(); + if (query.getStatus() != null) { + predicates.add(cb.equal(root.get("status"), query.getStatus())); + } + appendCommonPredicates(query, root, cb, predicates); + // scope="user" → 只看自己发布的作业 + if ("user".equalsIgnoreCase(query.getScope())) { + Integer userId = UserContext.getUserId(); + if (userId != null) { + predicates.add(cb.equal(root.get("publisherId"), userId)); + } + } else if (query.getPublisherId() != null) { + predicates.add(cb.equal(root.get("publisherId"), query.getPublisherId())); + } + appendSchIdPredicate(root, cb, predicates); + return cq.where(predicates.toArray(new Predicate[0])).getRestriction(); + }; + } + + /** + * 多租户学校过滤:当前用户有学校身份时按 schId 过滤,无学校归属(区域组/根直属)不过滤。 + */ + private void appendSchIdPredicate(Root root, CriteriaBuilder cb, List predicates) { + Integer schId = UserContext.getSchId(); + if (schId != null) { + predicates.add(cb.equal(root.get("schId"), schId)); + } + } + + private void appendCommonPredicates(HomeworkQuery query, Root root, CriteriaBuilder cb, + List predicates) { + if (StringUtils.hasText(query.getKeyword())) { + predicates.add(cb.like(root.get("title"), "%" + query.getKeyword() + "%")); + } + if (query.getType() != null) { + predicates.add(cb.equal(root.get("type"), query.getType())); + } + if (StringUtils.hasText(query.getDeptId())) { + predicates.add(cb.equal(root.get("deptId"), query.getDeptId())); + } + // 前端传 yyyy-MM-dd 短日期:startTime 当天 00:00:00 起,endTime 补全到当天 23:59:59 + if (query.getStartTime() != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get("createTime"), query.getStartTime().atStartOfDay())); + } + if (query.getEndTime() != null) { + predicates.add(cb.lessThanOrEqualTo(root.get("createTime"), query.getEndTime().atTime(23, 59, 59))); + } + } + + // ============================ 详情(顺带标记已读) ============================ + + @Override + public HomeworkVo detail(Integer id) { + Homework entity = homeworkRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "作业不存在: " + id)); + Integer userId = UserContext.getUserId(); + boolean read = homeworkReadRepository.existsByHomeworkIdAndReaderId(id, userId); + HomeworkVo vo = toVo(entity, read); + if (!read) { + markRead(id); + vo.setRead(true); + } + return vo; + } + + // ============================ 编辑回显(不标记已读) ============================ + + @Override + public HomeworkVo getDetail(Integer id) { + Homework entity = homeworkRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "作业不存在: " + id)); + HomeworkVo vo = toVo(entity, false); + vo.setReadCount(homeworkReadRepository.countByHomeworkId(entity.getId())); + return vo; + } + + // ============================ 标记已读 ============================ + + @Override + @Transactional + public void markRead(Integer id) { + Objects.requireNonNull(id, "作业ID不得为空"); + Integer userId = UserContext.getUserId(); + if (homeworkReadRepository.existsByHomeworkIdAndReaderId(id, userId)) { + return; + } + HomeworkRead read = new HomeworkRead(); + read.setHomeworkId(id); + read.setReaderId(userId); + read.setReaderName(UserContext.getUserName()); + read.setReadTime(LocalDateTime.now()); + homeworkReadRepository.save(read); + } + + // ============================ 一键全部已读(批量化) ============================ + + @Override + @Transactional + public int initializeRed() { + Integer userId = UserContext.getUserId(); + List readIds = homeworkReadRepository.findHomeworkIdsByReaderId(userId); + Set readSet = new HashSet<>(readIds); + List published = homeworkRepository.findAll((root, query, cb) -> + cb.and( + cb.equal(root.get("status"), PUBLISHED), + cb.or(cb.isNull(root.get("timingSendTime")), + cb.lessThanOrEqualTo(root.get("timingSendTime"), LocalDateTime.now())) + )); + int count = 0; + for (Homework n : published) { + if (!readSet.contains(n.getId())) { + markRead(n.getId()); + count++; + } + } + return count; + } + + // ============================ 追加接收人(事务化) ============================ + + @Override + @Transactional + public void receiveUsers(Integer homeworkId, String users) { + if (!StringUtils.hasText(users)) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择追送用户!"); + } + Homework entity = homeworkRepository.findById(homeworkId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "作业不存在: " + homeworkId)); + if (entity.getReceiveList() != null && "ALL".equalsIgnoreCase(entity.getReceiveList().trim())) { + return; + } + // 合并旧接收人 + 新接收人 + Set merged = new LinkedHashSet<>(parseReceiveAccounts(entity.getReceiveList())); + for (String u : users.split(",")) { + String entry = u.trim(); + if (StringUtils.hasText(entry)) { + merged.add(entry); + } + } + entity.setReceiveList(toJsonAccounts(new ArrayList<>(merged))); + entity.setAllowCount(merged.size()); + homeworkRepository.save(entity); + // 更新接收人关联表 + homeworkReceiverRepository.deleteByHomeworkId(homeworkId); + saveReceivers(entity); + log.info("[receiveUsers] 作业 {} 追送用户, 现接收人 {} 人", homeworkId, merged.size()); + } + + // ============================ 上一篇/下一篇(SQL 相邻查询) ============================ + + @Override + public Map getNextPrev(Integer serid, String type) { + Map data = new LinkedHashMap<>(); + Homework current = homeworkRepository.findById(serid).orElse(null); + if (current == null) { + data.put("prevId", 0); + data.put("nextId", 0); + return data; + } + if ("0".equals(type)) { + // 已发序列:按当前用户发布的作业,按 createTime 排序 + List published = homeworkRepository.findAll( + (root, query, cb) -> cb.equal(root.get("publisherId"), UserContext.getUserId()), + Sort.by(Sort.Direction.DESC, "createTime")); + List ids = published.stream().map(Homework::getId).collect(Collectors.toList()); + int index = ids.indexOf(serid); + data.put("prevId", index > 0 ? ids.get(index - 1) : 0); + data.put("nextId", index >= 0 && index < ids.size() - 1 ? ids.get(index + 1) : 0); + } else { + // 已收序列:按阅读记录 + List ids = homeworkReadRepository.findHomeworkIdsByReaderId(UserContext.getUserId()); + Collections.reverse(ids); + int index = ids.indexOf(serid); + data.put("prevId", index > 0 ? ids.get(index - 1) : 0); + data.put("nextId", index >= 0 && index < ids.size() - 1 ? ids.get(index + 1) : 0); + } + return data; + } + + // ============================ 阅读明细 ============================ + + @Override + public List getReadDetail(Integer homeworkId) { + Objects.requireNonNull(homeworkId, "作业ID不得为空"); + List readList = homeworkReadRepository.findByHomeworkIdOrderByReadTimeDesc(homeworkId); + return readList.stream().map(r -> { + HomeworkVo.ReadDetail detail = new HomeworkVo.ReadDetail(); + detail.setId(r.getReaderId()); + detail.setName(r.getReaderName()); + detail.setRead(true); + detail.setReadTime(r.getReadTime()); + return detail; + }).collect(Collectors.toList()); + } + + // ============================ 阅读情况分组 ============================ + + /** + * 阅读情况按「学校组」分组(对齐旧 .NET OrgizeModify:组名 + 组内用户,绿已读/红未读)。 + *

与旧版差异:旧版接收人逐行落库(含状态),本实现已读=homework_read 明细、 + * 接收人=homework_receiver 明细(用户中心数字ID+姓名);用户姓名/所属组经用户中心按ID实时查询, + * 查不到或跨校的接收人归「其他」组。全员作业(receive_list=ALL)不落接收明细, + * 仅能展示已读人员。

+ */ + @Override + public List> getReadDetailGrouped(Integer serid) { + Homework homework = homeworkRepository.findById(serid) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "作业不存在: " + serid)); + List readDetails = getReadDetail(serid); + Set readIds = readDetails.stream() + .map(HomeworkVo.ReadDetail::getId) + .collect(Collectors.toSet()); + // 已读快照姓名:用户中心查不到时兜底展示 + Map readNameMap = new HashMap<>(); + for (HomeworkVo.ReadDetail d : readDetails) { + if (StringUtils.hasText(d.getName())) { + readNameMap.putIfAbsent(d.getId(), d.getName()); + } + } + + // 接收人:优先关联表明细(数字ID),缺失回退 receive_list JSON(历史数据,仅取数字条目) + List receivers = homeworkReceiverRepository.findByHomeworkId(serid).stream() + .map(HomeworkReceiver::getReceiverId) + .distinct() + .collect(Collectors.toList()); + if (receivers.isEmpty()) { + receivers = parseNumericIds(homework.getReceiveList()); + } + // 已读者不在接收明细中(如追送/数据修补),补充到末尾,保证已读情况完整 + for (Integer readerId : readIds) { + if (!receivers.contains(readerId)) { + receivers.add(readerId); + } + } + + // 逐接收人查用户中心(姓名+组织链) + Map userMap = new LinkedHashMap<>(); + for (Integer receiver : receivers) { + try { + BoaUser user = YschBaseOpenApi.getUserInfo(receiver); + if (user != null) { + userMap.put(receiver, user); + } + } catch (Exception e) { + log.warn("[readDetailGrouped] 查询用户中心失败, receiver={}", receiver, e); + } + } + + // 组名=用户组织链最近端(主组),跨校/查不到归「其他」(对齐旧版逻辑) + Map>> groupUsers = new LinkedHashMap<>(); + for (Integer receiver : receivers) { + BoaUser user = userMap.get(receiver); + boolean read = readIds.contains(receiver); + String groupName = resolveReadGroupName(user, homework.getSchId()); + String teaName = user != null && StringUtils.hasText(user.getName()) + ? user.getName() + : readNameMap.getOrDefault(receiver, String.valueOf(receiver)); + Map item = new LinkedHashMap<>(); + item.put("id", String.valueOf(receiver)); + item.put("teaname", teaName); + item.put("states", read ? "read" : "unread"); + groupUsers.computeIfAbsent(groupName, k -> new ArrayList<>()).add(item); + } + + List> groups = new ArrayList<>(); + for (Map.Entry>> entry : groupUsers.entrySet()) { + Map group = new LinkedHashMap<>(); + group.put("GroupName", entry.getKey()); + group.put("teaslist", entry.getValue()); + groups.add(group); + } + return groups; + } + + /** + * 阅读情况分组名:用户中心组织链最近端(主组)即所属组(如「英语组」); + * 直接挂学校根的展示学校名;跨校或用户中心查不到的归「其他」。 + */ + private String resolveReadGroupName(BoaUser user, Integer homeworkSchId) { + if (user == null) { + return "其他"; + } + if (homeworkSchId != null && user.getSchool() != null && user.getSchool().getId() != null + && !homeworkSchId.equals(user.getSchool().getId())) { + return "其他"; + } + List groups = user.getGroups(); + if (groups != null && !groups.isEmpty() && StringUtils.hasText(groups.get(0).getName())) { + return groups.get(0).getName(); + } + if (user.getSchool() != null && StringUtils.hasText(user.getSchool().getName())) { + return user.getSchool().getName(); + } + return "其他"; + } + + // ============================ 催收未读 ============================ + + @Override + public void expedite(Integer serid) { + Homework homework = homeworkRepository.findById(serid) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "作业不存在: " + serid)); + Set readIds = homeworkReadRepository.findByHomeworkIdOrderByReadTimeDesc(serid).stream() + .map(HomeworkRead::getReaderId) + .collect(Collectors.toSet()); + // 接收人:优先关联表明细(数字ID),缺失回退 receive_list JSON(历史数据,仅取数字条目) + List receivers = homeworkReceiverRepository.findByHomeworkId(serid).stream() + .map(HomeworkReceiver::getReceiverId) + .collect(Collectors.toList()); + if (receivers.isEmpty()) { + receivers = parseNumericIds(homework.getReceiveList()); + } + List unreadIds = receivers.stream() + .filter(a -> !readIds.contains(a)) + .collect(Collectors.toList()); + log.info("[expedite] 作业 {} 催收未读 {} 人: {}", serid, unreadIds.size(), unreadIds); + // TODO: 接入 yh-platform 消息推送后,对 unreadIds 发送催办消息 + } + + // ============================ 统计 ============================ + + @Override + public HomeworkStatisticsVo statistics(HomeworkQuery query, LocalDateTime start, LocalDateTime end) { + Integer userId = UserContext.getUserId(); + Integer schId = UserContext.getSchId(); + LocalDateTime s = (start == null) ? LocalDateTime.of(1970, 1, 1, 0, 0) : start; + LocalDateTime e = (end == null) ? LocalDateTime.now().plusDays(1) : end; + + // 多租户:统计总数按当前学校过滤(schId 为空时统计全校) + long total = schId != null ? homeworkRepository.countBySchId(schId) + : homeworkRepository.count(); + long publishedCount = schId != null ? homeworkRepository.countBySchIdAndStatus(schId, PUBLISHED) + : homeworkRepository.countByStatus(PUBLISHED); + long totalReadRecords = homeworkReadRepository.count(); + long unreadForCurrentUser = (schId != null ? homeworkRepository.countPublishedDueBySchId(schId, PUBLISHED, LocalDateTime.now()) + : homeworkRepository.countPublishedDue(PUBLISHED, LocalDateTime.now())) + - homeworkReadRepository.countByReaderId(userId); + if (unreadForCurrentUser < 0) { + unreadForCurrentUser = 0; + } + + List createTimes = schId != null + ? homeworkRepository.findCreateTimesBySchIdStatusAndRange(schId, PUBLISHED, s, e) + : homeworkRepository.findCreateTimesByStatusAndRange(PUBLISHED, s, e); + Map monthMap = new LinkedHashMap<>(); + for (LocalDateTime t : createTimes) { + String key = t.getYear() + "-" + String.format("%02d", t.getMonthValue()); + monthMap.merge(key, 1L, Long::sum); + } + List byMonth = monthMap.entrySet().stream() + .map(en -> { + String[] ym = en.getKey().split("-"); + return new HomeworkStatisticsVo.MonthStat( + Integer.parseInt(ym[0]), Integer.parseInt(ym[1]), en.getValue()); + }) + .collect(Collectors.toList()); + + List publishers = schId != null + ? homeworkRepository.findPublisherNamesBySchStatusAndRange(schId, PUBLISHED, s, e) + : homeworkRepository.findPublisherNamesByStatusAndRange(PUBLISHED, s, e); + Map pubMap = new LinkedHashMap<>(); + for (String p : publishers) { + pubMap.merge(p == null ? "未知" : p, 1L, Long::sum); + } + List byPublisher = pubMap.entrySet().stream() + .map(en -> new HomeworkStatisticsVo.PublisherStat(en.getKey(), en.getValue())) + .collect(Collectors.toList()); + + HomeworkStatisticsVo vo = new HomeworkStatisticsVo(); + vo.setTotal(total); + vo.setPublishedCount(publishedCount); + vo.setTotalReadRecords(totalReadRecords); + vo.setUnreadForCurrentUser(unreadForCurrentUser); + vo.setByPublisher(byPublisher); + vo.setByMonth(byMonth); + fillCourseworkStats(vo, userId); + return vo; + } + + /** + * 课程作业口径统计(/stats 页):我布置的作业数、待批改总量、平均提交率、 + * 单作业进度明细、已批阅分数分布。 + *

基数与 V2 {@link HomeworkProgressCalculator} 共用同一口径(应交=参与班级在班学生去重), + * 保证「统计」页与「我布置的」页数据一致。userId 为空(无登录态)时明细为空。 + */ + private void fillCourseworkStats(HomeworkStatisticsVo vo, Integer userId) { + if (userId == null) { + return; + } + List mine = homeworkRepository.findByPublisherId(userId); + vo.setMyAssignCount(mine.size()); + + // 仅「已发布 + 需收集提交」的作业进入进度明细(草稿/仅查阅型不产生提交数据) + List collected = mine.stream() + .filter(h -> !HwStatusEnum.of(h.getHwStatus()).isDraft()) + .filter(h -> SubmitTypes.collects(SubmitTypes.parse(h.getSubmitType()))) + .collect(Collectors.toList()); + if (collected.isEmpty()) { + return; + } + + List hwIds = collected.stream().map(Homework::getId).collect(Collectors.toList()); + Map> classMap = homeworkClassRepository.findByHomeworkIds(hwIds).stream() + .collect(Collectors.groupingBy(HomeworkClass::getHomeworkId, + Collectors.mapping(HomeworkClass::getClassId, Collectors.toList()))); + + long pendingTotal = 0; + double submitRateSum = 0; + int rateSamples = 0; + List details = new ArrayList<>(); + long excellent = 0, good = 0, pass = 0, fail = 0; + + for (Homework h : collected) { + List classIds = classMap.getOrDefault(h.getId(), Collections.emptyList()); + ProgressVo p = homeworkProgressCalculator.compute(h.getId(), classIds).total; + + HomeworkStatisticsVo.HomeworkStat d = new HomeworkStatisticsVo.HomeworkStat(); + d.setId(h.getId()); + d.setTitle(h.getTitle()); + d.setSubject(h.getSubject()); + String legacy = legacyHwStatus(h); + d.setHwStatus(legacy); + d.setHwStatusDesc(legacyHwStatusDesc(legacy)); + d.setShould(p.getShould()); + d.setRead(p.getRead()); + d.setSubmitted(p.getSubmitted()); + d.setGraded(p.getGraded()); + d.setPendingGrade(p.getPendingGrade()); + d.setUnsubmitted(p.getUnsubmitted()); + d.setSubmitRate(p.getSubmitRate()); + d.setGradeRate(p.getGradeRate()); + details.add(d); + + pendingTotal += p.getPendingGrade(); + if (p.getShould() > 0) { + submitRateSum += p.getSubmitRate(); + rateSamples++; + } + + // 分数分布:当前版本已批阅且有分数 + for (HomeworkSubmission s : submissionRepository.findByHomeworkIdAndIsLatest(h.getId(), 1)) { + if (!ReviewStatusEnum.REVIEWED.getCode().equals(s.getReviewStatus()) || s.getScore() == null) { + continue; + } + int score = s.getScore().intValue(); + if (score >= 90) { + excellent++; + } else if (score >= 80) { + good++; + } else if (score >= 60) { + pass++; + } else { + fail++; + } + } + } + + vo.setPendingGrade(pendingTotal); + vo.setAvgSubmitRate(rateSamples == 0 ? 0 : Math.round(submitRateSum / rateSamples * 10) / 10.0); + vo.setByHomework(details); + vo.setScoreDist(new ArrayList<>(Arrays.asList( + new HomeworkStatisticsVo.ScoreBucket("优秀(≥90)", "excellent", excellent), + new HomeworkStatisticsVo.ScoreBucket("良好(80-89)", "good", good), + new HomeworkStatisticsVo.ScoreBucket("及格(60-79)", "pass", pass), + new HomeworkStatisticsVo.ScoreBucket("不及格(<60)", "fail", fail)))); + } + + // ============================ 详情视图 VO(供 Controller 返回) ============================ + + @Override + public HomeworkDetailVo getDetailMap(Integer serid) { + HomeworkVo vo = getDetail(serid); + HomeworkDetailVo data = new HomeworkDetailVo(); + data.setSerid(serid); + data.setTitle(vo.getTitle()); + data.setContents(vo.getContent()); + data.setDeptId(vo.getDeptId()); + data.setDeptName(vo.getDeptName()); + data.setPublisherName(vo.getPublisherName()); + data.setEditDate(formatTime(vo.getCreateTime())); + data.setNumCount(vo.getAllowCount() != null ? vo.getAllowCount() : 0); + data.setReadNumCount(vo.getReadCount()); + data.setReceiveList(vo.getReceiveList()); + data.setDsfsTime(vo.getTimingSendTime() != null ? formatTime(vo.getTimingSendTime()) : null); + data.setIsDsfs(vo.getTimingSendTime() != null); + List files = upFileRepository.findByTopicTypeAndTopicId("homework", serid); + List fileList = files.stream().map(f -> { + HomeworkDetailVo.FileItem item = new HomeworkDetailVo.FileItem(); + item.setId(f.getId()); + item.setName(f.getFileTrueName()); + item.setSize(f.getFileSize()); + item.setFileid(f.getId()); + item.setFilename(f.getFileName()); + return item; + }).collect(Collectors.toList()); + data.setChaoSend(fileList); + String rl = vo.getReceiveList(); + if (rl == null || rl.trim().isEmpty() || "ALL".equalsIgnoreCase(rl.trim())) { + data.setTlist("all"); + data.setReceiveText("全部班级"); + } else { + data.setTlist(parseReceiveAccounts(rl)); + data.setReceiveText(receiveText(serid)); + } + return data; + } + + /** + * 接收对象可读文本:优先用 homework_class 的班级名快照("高一(1)班、高一(2)班")。 + * 快照缺失时退回 id 串,保证「接收班级」标签不显示空白。 + */ + private String receiveText(Integer homeworkId) { + List classes = homeworkClassRepository.findByHomeworkId(homeworkId); + if (classes.isEmpty()) { + return null; + } + String names = classes.stream() + .map(HomeworkClass::getClassName) + .filter(StringUtils::hasText) + .collect(Collectors.joining("、")); + if (StringUtils.hasText(names)) { + return names; + } + return classes.stream() + .map(HomeworkClass::getClassId) + .filter(StringUtils::hasText) + .collect(Collectors.joining("、")); + } + + // ============================ 已发列表附件批量查询 ============================ + + @Override + public Map> batchFileIds(List homeworkIds) { + if (homeworkIds == null || homeworkIds.isEmpty()) { + return Collections.emptyMap(); + } + Map> result = new HashMap<>(); + for (Integer id : homeworkIds) { + result.put(id, new ArrayList<>()); + } + List files = upFileRepository.findByTopicTypeAndTopicIdIn("homework", homeworkIds); + for (UpFile f : files) { + result.computeIfAbsent(f.getTopicId(), k -> new ArrayList<>()).add(f.getId()); + } + return result; + } + + // ============================ 详情视图 VO(type 控制标读) ============================ + + @Override + public HomeworkDetailVo detailMap(Integer serid, Integer type) { + if (type != null && type == 0) { + // 编辑模式:不标记已读 + return getDetailMap(serid); + } + // 查看模式:先标记已读再返回 + detail(serid); + return getDetailMap(serid); + } + + // ============================ 一键已读结果 ============================ + + @Override + @Transactional + public InitializeRedVo initializeRedResult() { + int count = initializeRed(); + return new InitializeRedVo(count); + } + + // ============================ 批量下载附件 ============================ + + @Override + public void downloadAllFiles(Integer dataid, HttpServletResponse response) { + HomeworkDetailVo detail = getDetailMap(dataid); + String title = detail.getTitle(); + fileService.downloadZipByTopic("homework", dataid, title != null ? title : "附件", response); + } + + // ============================ 转换 ============================ + + /** + * 双轨兼容(V3 计划 §7.4):旧接口入参中的 V2 扩展字段同步写 homework 新列。 + * hwStatus/publishedAt 由调用方按发布语义另行设置。 + */ + private void applyV2Compat(Homework entity, HomeworkRequest request) { + if (request.getSubject() != null) { + entity.setSubject(request.getSubject()); + } + if (request.getDeadline() != null) { + entity.setDeadlineAt(request.getDeadline().withSecond(0).withNano(0)); + } + if (request.getSubmitType() != null) { + entity.setSubmitType(SubmitTypes.toJson(SubmitTypes.normalize(request.getSubmitType()))); + } + } + + /** + * 旧契约 hwStatus 四态派生(V3 计划 §4.2 映射表):权威值为 homework.hw_status 五态; + * 存量行(hw_status 为空)按 notice 语义派生(定时未到点=draft,其余=collecting)。 + */ + private static String legacyHwStatus(Homework entity) { + String raw = entity.getHwStatus(); + if (raw == null || raw.isEmpty()) { + if (entity.getTimingSendTime() != null && entity.getTimingSendTime().isAfter(LocalDateTime.now())) { + return "draft"; + } + return "collecting"; + } + switch (HwStatusEnum.of(raw)) { + case DRAFT_UNPUBLISHED: + case DRAFT_SCHEDULED: + return "draft"; + case PUBLISHED: + return "collecting"; + case CLOSED: + return "closed"; + case ARCHIVED: + default: + return "done"; + } + } + + private static String legacyHwStatusDesc(String legacy) { + switch (legacy == null ? "" : legacy) { + case "draft": + return "草稿"; + case "closed": + return "已截止"; + case "done": + return "已完成"; + case "collecting": + default: + return "收集中"; + } + } + + private HomeworkVo toVo(Homework entity, boolean read) { + HomeworkVo vo = new HomeworkVo(); + BeanUtils.copyProperties(entity, vo); + vo.setTypeDesc(HomeworkTypeEnum.of(entity.getType()).getDesc()); + vo.setStatusDesc(HomeworkStatusEnum.of(entity.getStatus()).getDesc()); + // V2 扩展字段回填(hwStatus 输出旧四态派生值,submitType/deadline 手工映射) + vo.setDeadline(entity.getDeadlineAt()); + vo.setSubmitType(SubmitTypes.parse(entity.getSubmitType())); + String legacy = legacyHwStatus(entity); + vo.setHwStatus(legacy); + vo.setHwStatusDesc(legacyHwStatusDesc(legacy)); + // 存量数据 dept_name 为空时按 deptId 从字典兜底解析(对应 C# GetDeptName) + if (!StringUtils.hasText(vo.getDeptName())) { + vo.setDeptName(dictService.resolveDeptName(vo.getDeptId())); + } + vo.setRead(read); + vo.setReadCount(homeworkReadRepository.countByHomeworkId(entity.getId())); + return vo; + } + + /** + * 写入时解析部门名称:优先按 deptId 查字典,字典缺失时回退前端传值。 + */ + private String resolveDeptName(HomeworkRequest request) { + String resolved = dictService.resolveDeptName(request.getDeptId()); + return StringUtils.hasText(resolved) ? resolved : request.getDeptName(); + } + + // ============================ 短信(预留能力) ============================ + + private void trySendSms(Homework entity) { + if (entity.getSmsFlag() == null || entity.getSmsFlag() != 1) { + return; + } + if (!isImmediatePublish(entity)) { + return; + } + smsSender.sendHomeworkSms(entity.getId(), entity.getTitle(), + parseNumericIds(entity.getReceiveList())); + } + + private boolean isImmediatePublish(Homework entity) { + return entity.getTimingSendTime() == null + || !entity.getTimingSendTime().isAfter(LocalDateTime.now()); + } + + // ============================ 接收人工具方法 ============================ + + /** + * 接收人列表归一化:前端/存量可能传 "all"/"UserAll"/"ALL"(全员),统一存 "ALL"。 + * 其余视为 JSON 数组(用户ID或历史登录名条目)或逗号分隔串,原样保留。 + */ + private void normalizeReceiveList(Homework entity, String receiveList) { + if (receiveList == null) { + entity.setReceiveList(null); + return; + } + String rl = receiveList.trim(); + if ("all".equalsIgnoreCase(rl) || "UserAll".equalsIgnoreCase(rl) || "ALL".equalsIgnoreCase(rl)) { + entity.setReceiveList("ALL"); + return; + } + entity.setReceiveList(receiveList); + // 设置接收人数 + entity.setAllowCount(parseReceiveAccounts(receiveList).size()); + } + + /** + * 写入接收人关联表(非全员时)。条目经用户中心归一为「用户中心数字ID + 姓名」落库。 + */ + private void saveReceivers(Homework entity) { + String rl = entity.getReceiveList(); + if (rl == null || "ALL".equalsIgnoreCase(rl.trim())) { + return; + } + for (String entry : parseReceiveAccounts(rl)) { + HomeworkReceiver receiver = new HomeworkReceiver(); + receiver.setHomeworkId(entity.getId()); + receiver.setReceiveType(1); + fillReceiverIdentity(receiver, entry); + if (receiver.getReceiverId() == null) { + // 历史条目可能是登录名且用户中心查不到:无法落数字ID,跳过并留痕 + log.warn("[saveReceivers] 接收人条目无法解析为用户中心数字ID,已跳过, homeworkId={}, entry={}", + entity.getId(), entry); + continue; + } + homeworkReceiverRepository.save(receiver); + } + } + + /** + * 接收人条目归一:条目可能是用户中心数字ID或历史登录名,经用户中心换算为 + * 「数字ID + 姓名」;查不到但本身是数字时按原值落库(姓名留空,读取侧兜底)。 + */ + private void fillReceiverIdentity(HomeworkReceiver receiver, String entry) { + BoaUser user = null; + try { + user = YschBaseOpenApi.getUserInfo(entry); + if (user == null && entry.chars().allMatch(Character::isDigit)) { + user = YschBaseOpenApi.getUserInfo(Integer.parseInt(entry)); + } + } catch (Exception e) { + log.warn("[saveReceivers] 查询用户中心失败, receiver={}", entry, e); + } + if (user != null && user.getId() != null) { + receiver.setReceiverId(user.getId()); + receiver.setReceiverName(user.getName()); + } else if (entry.chars().allMatch(Character::isDigit)) { + receiver.setReceiverId(Integer.valueOf(entry)); + } else { + receiver.setReceiverId(null); + } + } + + /** + * 解析接收人列表为条目集合(用户ID或历史登录名):支持 "ALL"→空、JSON 数组、逗号分隔串。 + */ + private List parseReceiveAccounts(String receiveList) { + if (receiveList == null || receiveList.trim().isEmpty()) { + return Collections.emptyList(); + } + String rl = receiveList.trim(); + if ("ALL".equalsIgnoreCase(rl) || "UserAll".equalsIgnoreCase(rl) || "all".equalsIgnoreCase(rl)) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + if (rl.startsWith("[")) { + String inner = rl.substring(1, rl.length() - 1).trim(); + if (!inner.isEmpty()) { + for (String part : inner.split(",")) { + String entry = part.trim().replace("\"", "").replace("'", ""); + if (StringUtils.hasText(entry) && !result.contains(entry)) { + result.add(entry); + } + } + } + } else { + for (String part : rl.split(",")) { + String entry = part.trim(); + if (StringUtils.hasText(entry) && !result.contains(entry)) { + result.add(entry); + } + } + } + return result; + } + + /** + * 解析接收人列表并仅保留纯数字条目(用户中心数字ID)。 + * 历史登录名条目不属于任何数字ID空间,直接丢弃(迁移脚本已清洗存量)。 + */ + private List parseNumericIds(String receiveListJson) { + List result = new ArrayList<>(); + for (String entry : parseReceiveAccounts(receiveListJson)) { + if (entry.chars().allMatch(Character::isDigit)) { + result.add(Integer.valueOf(entry)); + } + } + return result; + } + + private String toJsonAccounts(List accounts) { + return accounts.stream() + .map(a -> "\"" + a.replace("\"", "\\\"") + "\"") + .collect(Collectors.joining(",", "[", "]")); + } + + private String formatTime(LocalDateTime time) { + if (time == null) return null; + return time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } +} diff --git a/src/main/java/com/yunhai/homework/service/impl/HomeworkStateService.java b/src/main/java/com/yunhai/homework/service/impl/HomeworkStateService.java new file mode 100644 index 0000000..9c75163 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/impl/HomeworkStateService.java @@ -0,0 +1,93 @@ +package com.yunhai.homework.service.impl; + +import com.yunhai.homework.entity.Homework; +import com.yunhai.homework.enums.HwStatusEnum; +import com.yunhai.homework.repository.HomeworkRepository; +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.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 作业状态机单点(PRD §17.2 五态流转)。 + *

+ * 一切 hw_status 迁移必须经 {@link #cas}(条件更新),定时任务与接口共用, + * 天然幂等且并发安全;迁移后的副作用(published_at、通知、归档时间)由调用方在本事务内补齐。 + */ +@Service +public class HomeworkStateService { + + @Autowired + private HomeworkRepository homeworkRepository; + + /** + * 条件状态迁移:仅当当前状态为 from 时置为 to。 + * + * @return true = 迁移成功;false = 状态已被并发操作改变(调用方应放弃后续副作用) + */ + @Transactional(propagation = Propagation.REQUIRED) + public boolean cas(Integer homeworkId, HwStatusEnum from, HwStatusEnum to) { + return homeworkRepository.casHwStatus(homeworkId, from.getCode(), to.getCode()) > 0; + } + + /** + * 发布(立即/定时到点共用):CAS 置 PUBLISHED 并写发布时间。 + * + * @throws BizException 状态已变化或作业不存在 + */ + @Transactional(propagation = Propagation.REQUIRED) + public Homework publish(Integer homeworkId, HwStatusEnum from) { + if (!cas(homeworkId, from, HwStatusEnum.PUBLISHED)) { + throw new BizException(ErrorCode.FORBIDDEN, "作业状态已变化,发布失败: " + homeworkId); + } + Homework homework = homeworkRepository.findById(homeworkId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "作业不存在: " + homeworkId)); + homework.setPublishedAt(java.time.LocalDateTime.now()); + // 定时发布成功后清空定时标记,避免重复触发 + homework.setTimingSendTime(null); + return homeworkRepository.save(homework); + } + + /** + * 定时发布失败回退:CAS 置回草稿-未发布并清空定时时间(在独立事务中调用,保证回退不被主事务连带回滚)。 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean rollbackFromSchedule(Integer homeworkId) { + boolean ok = cas(homeworkId, HwStatusEnum.DRAFT_SCHEDULED, HwStatusEnum.DRAFT_UNPUBLISHED); + if (ok) { + homeworkRepository.findById(homeworkId).ifPresent(h -> { + h.setTimingSendTime(null); + homeworkRepository.save(h); + }); + } + return ok; + } + + /** + * 归档:CAS 置 ARCHIVED 并写归档时间。 + */ + @Transactional(propagation = Propagation.REQUIRED) + public Homework archive(Integer homeworkId, HwStatusEnum from) { + if (!cas(homeworkId, from, HwStatusEnum.ARCHIVED)) { + throw new BizException(ErrorCode.FORBIDDEN, "作业状态已变化,归档失败: " + homeworkId); + } + Homework homework = homeworkRepository.findById(homeworkId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "作业不存在: " + homeworkId)); + homework.setArchivedAt(java.time.LocalDateTime.now()); + return homeworkRepository.save(homework); + } + + /** + * 加载作业并断言归属发布人(教师写操作统一入口校验)。 + */ + public Homework requireOwned(Integer homeworkId, Integer publisherId) { + Homework homework = homeworkRepository.findById(homeworkId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "作业不存在: " + homeworkId)); + if (publisherId == null || !publisherId.equals(homework.getPublisherId())) { + throw new BizException(ErrorCode.FORBIDDEN, "仅作业发布人可执行该操作"); + } + return homework; + } +} diff --git a/src/main/java/com/yunhai/homework/service/impl/NotificationService.java b/src/main/java/com/yunhai/homework/service/impl/NotificationService.java new file mode 100644 index 0000000..2e64d4d --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/impl/NotificationService.java @@ -0,0 +1,223 @@ +package com.yunhai.homework.service.impl; + +import com.yunhai.homework.entity.Homework; +import com.yunhai.homework.entity.Notification; +import com.yunhai.homework.enums.NotificationTypeEnum; +import com.yunhai.homework.model.BasePageResult; +import com.yunhai.homework.model.v2.NotificationPageResult; +import com.yunhai.homework.model.v2.NotificationQuery; +import com.yunhai.homework.model.v2.NotificationVo; +import com.yunhai.homework.repository.NotificationRepository; +import com.yunhai.homework.utils.PageUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +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.List; +import java.util.stream.Collectors; + +/** + * 站内通知中心(PRD 六类)。发送落 notification 表;短信等外部通道由调用方按需并行触发。 + */ +@Service +public class NotificationService { + + private static final Logger log = LoggerFactory.getLogger(NotificationService.class); + + private static final DateTimeFormatter MINUTE = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + public static final String ROLE_STUDENT = "student"; + public static final String ROLE_TEACHER = "teacher"; + + @Autowired + private NotificationRepository notificationRepository; + + // ============================ 发送 ============================ + + /** + * 发送单条通知(接收人 = 用户中心数字ID + 姓名快照)。 + */ + @Transactional + public void send(NotificationTypeEnum type, Integer homeworkId, Integer relatedId, + Integer receiverId, String receiverName, String receiverRole, String title, String content) { + if (receiverId == null) { + return; + } + Notification n = new Notification(); + n.setType(type.getCode()); + n.setHomeworkId(homeworkId); + n.setRelatedId(relatedId); + n.setReceiverId(receiverId); + n.setReceiverName(receiverName); + n.setReceiverRole(receiverRole); + n.setTitle(title); + n.setContent(content); + notificationRepository.save(n); + } + + /** + * 批量发送(同模板多人,接收人 = 学生ID)。 + */ + @Transactional + public int sendBatch(NotificationTypeEnum type, Integer homeworkId, Integer relatedId, + List receiverIds, String title, String content) { + if (receiverIds == null || receiverIds.isEmpty()) { + return 0; + } + for (Integer receiverId : receiverIds) { + send(type, homeworkId, relatedId, receiverId, null, ROLE_STUDENT, title, content); + } + log.info("[notify] type={}, homework={}, receivers={}", type.getCode(), homeworkId, receiverIds.size()); + return receiverIds.size(); + } + + /** + * 截止提醒去重:同作业同类型只发一轮(Job 幂等保障)。 + */ + public boolean alreadySent(Integer homeworkId, NotificationTypeEnum type) { + return notificationRepository.existsByHomeworkIdAndType(homeworkId, type.getCode()); + } + + /** + * 分档位去重(related_id 存档位:1=1 天档 2=1 小时档)——1 天与 1 小时两轮提醒互不抑制。 + */ + public boolean alreadySentSlot(Integer homeworkId, NotificationTypeEnum type, Integer slot) { + return notificationRepository.existsByHomeworkIdAndTypeAndRelatedId(homeworkId, type.getCode(), slot); + } + + // ============================ 文案模板(PRD §17.1 触发点矩阵) ============================ + + public static String publishedTitle(Homework h) { + return "作业发布:《" + h.getTitle() + "》"; + } + + public static String publishedContent(Homework h) { + String deadline = h.getDeadlineAt() == null ? "无截止时间" : h.getDeadlineAt().format(MINUTE); + return "老师发布了新作业,截止时间 " + deadline + ",请及时查看并完成。"; + } + + public static String deadlineReminderTitle(Homework h) { + return "截止提醒:《" + h.getTitle() + "》"; + } + + public static String deadlineReminderContent(Homework h) { + return "作业将于 " + h.getDeadlineAt().format(MINUTE) + " 截止,请尽快提交。"; + } + + public static String notReadTitle(Homework h) { + return "阅读提醒:《" + h.getTitle() + "》"; + } + + public static String notReadContent(Homework h) { + return "老师提醒你尽快查看作业内容。"; + } + + public static String notSubmittedTitle(Homework h) { + return "提交提醒:《" + h.getTitle() + "》"; + } + + public static String notSubmittedContent(Homework h) { + String deadline = h.getDeadlineAt() == null ? "尽快" : h.getDeadlineAt().format(MINUTE); + return "你还未提交作业,请于 " + deadline + " 前完成提交。"; + } + + public static String reviewDoneTitle(Homework h) { + return "批阅完成:《" + h.getTitle() + "》"; + } + + public static String reviewDoneContent(Homework h, String score, String comment) { + String detail = score == null ? "" : "得分 " + score + "。"; + return "老师已完成批阅," + detail + (StringUtils.hasText(comment) ? "评语:" + comment : ""); + } + + public static String returnedTitle(Homework h) { + return "作业打回:《" + h.getTitle() + "》"; + } + + public static String returnedContent(Homework h, String comment) { + return "你的提交被打回,请查看订正原因并重新提交。" + (StringUtils.hasText(comment) ? "原因:" + comment : ""); + } + + // ============================ 查询 / 已读 ============================ + + /** + * 当前用户通知分页(含未读数)。 + */ + @Transactional(readOnly = true) + public NotificationPageResult page(Integer receiverId, NotificationQuery query) { + Pageable pageable = PageRequest.of(Math.max(query.getPageIndex() - 1, 0), + Math.max(query.getPageSize(), 1), + Sort.by(Sort.Direction.DESC, "createTime")); + String type = NotificationTypeEnum.of(query.getType()) == null ? null : query.getType(); + Integer isRead = query.getIsRead(); + Page page; + if (type != null && isRead != null) { + page = notificationRepository.findByReceiverIdAndTypeAndIsRead(receiverId, type, isRead, pageable); + } else if (type != null) { + page = notificationRepository.findByReceiverIdAndType(receiverId, type, pageable); + } else if (isRead != null) { + page = notificationRepository.findByReceiverIdAndIsRead(receiverId, isRead, pageable); + } else { + page = notificationRepository.findByReceiverId(receiverId, pageable); + } + BasePageResult result = PageUtils.toResult(page.map(this::toVo)); + long unread = notificationRepository.countByReceiverIdAndIsRead(receiverId, 0); + return new NotificationPageResult(result, unread); + } + + /** + * 标记单条已读(仅本人通知)。 + */ + @Transactional + public void markRead(Integer id, Integer receiverId) { + notificationRepository.findByIdAndReceiverId(id, receiverId).ifPresent(n -> { + if (n.getIsRead() == null || n.getIsRead() == 0) { + n.setIsRead(1); + n.setReadAt(LocalDateTime.now()); + notificationRepository.save(n); + } + }); + } + + /** + * 全部已读。 + */ + @Transactional + public void markAllRead(Integer receiverId) { + notificationRepository.markAllRead(receiverId, LocalDateTime.now()); + } + + private NotificationVo toVo(Notification n) { + NotificationVo vo = new NotificationVo(); + vo.setId(n.getId()); + vo.setType(n.getType()); + NotificationTypeEnum typeEnum = NotificationTypeEnum.of(n.getType()); + vo.setTypeDesc(typeEnum == null ? n.getType() : typeEnum.getDesc()); + vo.setTitle(n.getTitle()); + vo.setContent(n.getContent()); + vo.setHomeworkId(n.getHomeworkId()); + vo.setRelatedId(n.getRelatedId()); + vo.setIsRead(n.getIsRead()); + vo.setReadAt(n.getReadAt()); + vo.setCreateTime(n.getCreateTime()); + return vo; + } + + /** + * 通知接收人列表去重(批量发送前调用)。 + */ + public static List distinct(List receiverIds) { + return receiverIds == null ? null : receiverIds.stream() + .filter(java.util.Objects::nonNull) + .distinct().collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/yunhai/homework/service/impl/SmsService.java b/src/main/java/com/yunhai/homework/service/impl/SmsService.java new file mode 100644 index 0000000..e2323de --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/impl/SmsService.java @@ -0,0 +1,284 @@ +package com.yunhai.homework.service.impl; + +import com.yunhai.homework.entity.SmsModule; +import com.yunhai.homework.entity.SmsSchoolConfig; +import com.yunhai.homework.repository.SmsModuleRepository; +import com.yunhai.homework.repository.SmsSchoolConfigRepository; +import com.yunhai.homework.service.ISmsService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +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# SchModuleSmsController + SchModuleSms.cs。 + * 注意:C# 原版依赖外部DLL(ISchIsSmsBLL/ISchoolBLL/ISchGroupBLL)获取学校和组数据, + * Java版改为从本地 sms_module + sms_school_config 表管理。 + */ +@Service +public class SmsService implements ISmsService { + + private static final Logger log = LoggerFactory.getLogger(SmsService.class); + + @Autowired + private SmsModuleRepository smsModuleRepository; + + @Autowired + private SmsSchoolConfigRepository smsSchoolConfigRepository; + + // ============================ getmodule ============================ + + @Override + public List> getModuleList() { + List modules = smsModuleRepository.findAllByOrderByIdAsc(); + return modules.stream().map(m -> { + Map item = new LinkedHashMap<>(); + item.put("ID", m.getId()); + item.put("Name", m.getModuleName()); + return item; + }).collect(Collectors.toList()); + } + + // ============================ getgroup ============================ + + @Override + public List> getSchParentGroup() { + // C# 从外部BLL获取学校组数据。Java版暂不接入用户中心API。 + // 返回空列表,前端可按需处理。 + log.info("[getSchParentGroup] 暂未接入用户中心API,返回空列表"); + return Collections.emptyList(); + } + + // ============================ getschsms ============================ + + @Override + public Map getSchIsSmsList(Integer pageSize, Integer pageIndex, String schName) { + if (pageSize == null || pageSize <= 0) pageSize = 10; + // 前端 PageIndex 从0开始(page-1),后端按0基处理 + if (pageIndex == null || pageIndex < 0) pageIndex = 0; + + // 查询学校配置(按学校名模糊搜索或全部) + Page page; + int offset = pageIndex; // 0基 + Sort sort = Sort.by(Sort.Direction.DESC, "schoolName"); + + if (StringUtils.hasText(schName)) { + page = smsSchoolConfigRepository.findBySchoolNameContaining(schName, + PageRequest.of(offset, pageSize, sort)); + } else { + page = smsSchoolConfigRepository.findAll(PageRequest.of(offset, pageSize, sort)); + } + + // 加载全部模块列表 + List modules = smsModuleRepository.findAllByOrderByIdAsc(); + + // 按 schoolId 分组配置 + Map> configBySchool = page.getContent().stream() + .collect(Collectors.groupingBy(SmsSchoolConfig::getSchoolId)); + + // 构建返回数据:每个学校一行,包含各模块的开关状态 + List> listData = page.getContent().stream() + .collect(Collectors.groupingBy(SmsSchoolConfig::getSchoolId)) + .entrySet().stream() + .map(entry -> { + Integer schoolId = entry.getKey(); + List configs = entry.getValue(); + String schoolName = configs.get(0).getSchoolName(); + + // 构建模块开关矩阵 + List> moduleList = new ArrayList<>(); + boolean isAll = true; + for (SmsModule module : modules) { + Map modu = new LinkedHashMap<>(); + SmsSchoolConfig config = configs.stream() + .filter(c -> module.getId().equals(c.getModuleId())) + .findFirst().orElse(null); + modu.put("SerID", config != null ? config.getId() : -1); + modu.put("ModuID", module.getId()); + modu.put("IsSms", config != null && Boolean.TRUE.equals(config.getIsSms())); + modu.put("SchSerID", schoolId); + moduleList.add(modu); + if (config == null || !Boolean.TRUE.equals(config.getIsSms())) { + isAll = false; + } + } + + Map schoolItem = new LinkedHashMap<>(); + schoolItem.put("SchSerID", schoolId); + schoolItem.put("SchName", schoolName); + schoolItem.put("IsAll", isAll); + schoolItem.put("itemsModu", moduleList); + return schoolItem; + }) + .collect(Collectors.toList()); + + // 如果没有配置数据,返回空列表(但保留total) + if (listData.isEmpty() && page.hasContent()) { + // 这种情况不应发生,但防御性处理 + listData = Collections.emptyList(); + } + + Map result = new LinkedHashMap<>(); + result.put("list", listData); + result.put("total", page.getTotalElements()); + return result; + } + + // ============================ getsch ============================ + + @Override + public List> getSchList() { + // C# 从外部BLL获取学校列表。Java版从 sms_school_config 表提取已配置学校。 + // 获取所有不重复的学校 + List allConfigs = smsSchoolConfigRepository.findAll(); + Map schoolMap = new LinkedHashMap<>(); + for (SmsSchoolConfig config : allConfigs) { + schoolMap.putIfAbsent(config.getSchoolId(), config.getSchoolName()); + } + + return schoolMap.entrySet().stream().map(entry -> { + Map item = new LinkedHashMap<>(); + item.put("ID", entry.getKey()); + item.put("Name", entry.getValue()); + return item; + }).collect(Collectors.toList()); + } + + // ============================ setschsms ============================ + + @Override + @Transactional + public void setSchSms(Integer schSerID, Integer moduleId, Boolean isSms) { + if (schSerID == null || moduleId == null) { + return; + } + SmsSchoolConfig config = smsSchoolConfigRepository.findBySchoolIdAndModuleId(schSerID, moduleId); + if (config == null) { + config = new SmsSchoolConfig(); + config.setSchoolId(schSerID); + config.setModuleId(moduleId); + // 学校名称从已有配置中获取 + List existing = smsSchoolConfigRepository.findBySchoolId(schSerID); + if (!existing.isEmpty()) { + config.setSchoolName(existing.get(0).getSchoolName()); + } + } + config.setIsSms(isSms != null ? isSms : false); + smsSchoolConfigRepository.save(config); + } + + // ============================ setschallsms ============================ + + @Override + @Transactional + public void setSchSmsAll(Integer schSerID, Boolean isSms) { + if (schSerID == null) { + return; + } + List modules = smsModuleRepository.findAllByOrderByIdAsc(); + boolean sms = Boolean.TRUE.equals(isSms); + + for (SmsModule module : modules) { + SmsSchoolConfig config = smsSchoolConfigRepository.findBySchoolIdAndModuleId(schSerID, module.getId()); + if (config == null) { + config = new SmsSchoolConfig(); + config.setSchoolId(schSerID); + config.setModuleId(module.getId()); + List existing = smsSchoolConfigRepository.findBySchoolId(schSerID); + if (!existing.isEmpty()) { + config.setSchoolName(existing.get(0).getSchoolName()); + } + } + config.setIsSms(sms); + smsSchoolConfigRepository.save(config); + } + } + + // ============================ setschmodulesms ============================ + + @Override + @Transactional + public void setSchModuleSms(String schSerIDs, String modules, Boolean isSms) { + if (!StringUtils.hasText(schSerIDs) || !StringUtils.hasText(modules)) { + return; + } + List schoolIds = Arrays.stream(schSerIDs.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Integer::parseInt) + .collect(Collectors.toList()); + List moduleIds = Arrays.stream(modules.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(Integer::parseInt) + .collect(Collectors.toList()); + boolean sms = Boolean.TRUE.equals(isSms); + + for (Integer schoolId : schoolIds) { + for (Integer moduleId : moduleIds) { + SmsSchoolConfig config = smsSchoolConfigRepository.findBySchoolIdAndModuleId(schoolId, moduleId); + if (config == null) { + config = new SmsSchoolConfig(); + config.setSchoolId(schoolId); + config.setModuleId(moduleId); + List existing = smsSchoolConfigRepository.findBySchoolId(schoolId); + if (!existing.isEmpty()) { + config.setSchoolName(existing.get(0).getSchoolName()); + } + } + config.setIsSms(sms); + smsSchoolConfigRepository.save(config); + } + } + } + + // ============================ setschsms (JSON 解析) ============================ + + @Override + @Transactional + public void setSchSmsFromJson(String itemsModuJson) { + Integer schSerID = extractJsonField(itemsModuJson, "SchSerID"); + Integer moduID = extractJsonField(itemsModuJson, "ModuID"); + Boolean isSms = extractJsonBool(itemsModuJson, "IsSms"); + setSchSms(schSerID, moduID, isSms); + } + + // ============================ 辅助方法 ============================ + + /** + * 从 JSON 字符串中提取整数字段。 + */ + private Integer extractJsonField(String json, String field) { + if (json == null) return null; + String pattern = "\"" + field + "\"\\s*:\\s*\"?(-?\\d+)\"?"; + java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern); + java.util.regex.Matcher m = p.matcher(json); + if (m.find()) { + return Integer.parseInt(m.group(1)); + } + return null; + } + + /** + * 从 JSON 字符串中提取布尔字段。 + */ + private Boolean extractJsonBool(String json, String field) { + if (json == null) return null; + String pattern = "\"" + field + "\"\\s*:\\s*(true|false)"; + java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern); + java.util.regex.Matcher m = p.matcher(json); + if (m.find()) { + return Boolean.parseBoolean(m.group(1)); + } + return null; + } +} diff --git a/src/main/java/com/yunhai/homework/service/impl/StudentHomeworkService.java b/src/main/java/com/yunhai/homework/service/impl/StudentHomeworkService.java new file mode 100644 index 0000000..7cf7753 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/impl/StudentHomeworkService.java @@ -0,0 +1,488 @@ +package com.yunhai.homework.service.impl; + +import com.yunhai.homework.entity.ClassStudent; +import com.yunhai.homework.entity.Homework; +import com.yunhai.homework.entity.HomeworkAttachment; +import com.yunhai.homework.entity.HomeworkClass; +import com.yunhai.homework.entity.HomeworkReadRecord; +import com.yunhai.homework.entity.HomeworkSubmission; +import com.yunhai.homework.entity.SubmissionAttachment; +import com.yunhai.homework.enums.HwStatusEnum; +import com.yunhai.homework.enums.ReviewStatusEnum; +import com.yunhai.homework.enums.SubmitStatusEnum; +import com.yunhai.homework.model.BasePageResult; +import com.yunhai.homework.model.v2.AttachmentItem; +import com.yunhai.homework.model.v2.ClassItem; +import com.yunhai.homework.model.v2.StudentHomeworkDetailVo; +import com.yunhai.homework.model.v2.StudentHomeworkVo; +import com.yunhai.homework.model.v2.StudentResultVo; +import com.yunhai.homework.model.v2.StudentSubmitRequest; +import com.yunhai.homework.model.v2.SubmissionActionRequest; +import com.yunhai.homework.model.v2.SubmissionVersionVo; +import com.yunhai.homework.repository.ClassStudentRepository; +import com.yunhai.homework.repository.HomeworkAttachmentRepository; +import com.yunhai.homework.repository.HomeworkClassRepository; +import com.yunhai.homework.repository.HomeworkReadRecordRepository; +import com.yunhai.homework.repository.HomeworkRepository; +import com.yunhai.homework.repository.HomeworkSubmissionRepository; +import com.yunhai.homework.repository.SubmissionAttachmentRepository; +import com.yunhai.homework.utils.SubmitTypes; +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.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 课堂作业 V2 学生端业务(PRD §18 学生页 1~3)。 + *

+ * 身份:登录平台用户ID ↔ class_student.student_id 名册(ClassStudentService);仅可见本人班级作业。 + * 提交多版本:提交/打回重交/撤回均新增行(version+1、is_latest 翻转),历史保留。 + */ +@Service +public class StudentHomeworkService { + + @Autowired + private HomeworkRepository homeworkRepository; + + @Autowired + private HomeworkClassRepository homeworkClassRepository; + + @Autowired + private HomeworkAttachmentRepository homeworkAttachmentRepository; + + @Autowired + private HomeworkReadRecordRepository readRecordRepository; + + @Autowired + private HomeworkSubmissionRepository submissionRepository; + + @Autowired + private SubmissionAttachmentRepository submissionAttachmentRepository; + + @Autowired + private ClassStudentRepository classStudentRepository; + + @Autowired + private ClassStudentService classStudentService; + + // ============================ 页 1:作业列表 ============================ + + /** + * 我的作业列表。tab:all / todo(未提交含打回)/ done(已交含批阅)/ closed(已截止)。 + */ + @Transactional(readOnly = true) + public BasePageResult list(String tab, com.yunhai.homework.model.BasePageQuery query) { + List me = classStudentService.resolveMe(); + List myClassIds = me.stream().map(ClassStudent::getClassId).distinct().collect(Collectors.toList()); + Integer myStudentId = me.get(0).getStudentId(); + + List homeworkIds = homeworkClassRepository.findHomeworkIdsByClassIds(myClassIds); + if (homeworkIds.isEmpty()) { + return new BasePageResult<>(new ArrayList<>(), 0, query.getPageIndex(), query.getPageSize(), 0, false); + } + List homeworks = homeworkRepository.findAllById(homeworkIds).stream() + .filter(h -> visibleStatus(HwStatusEnum.of(h.getHwStatus()))) + .collect(Collectors.toList()); + + Map myLatest = submissionRepository + .findByHomeworkIdInAndIsLatest(homeworkIds, 1).stream() + .filter(s -> myStudentId.equals(s.getStudentId())) + .collect(Collectors.toMap(HomeworkSubmission::getHomeworkId, s -> s, (a, b) -> a)); + + List rows = new ArrayList<>(); + for (Homework h : homeworks) { + StudentHomeworkVo vo = toVo(h, myLatest.get(h.getId())); + rows.add(vo); + } + rows = filterByTab(rows, tab); + + // 截止最近在前(无截止靠后),同截止按 id 倒序 + rows.sort(Comparator + .comparing((StudentHomeworkVo r) -> r.getDeadlineAt() == null ? LocalDateTime.MAX : r.getDeadlineAt()) + .thenComparing(Comparator.comparing(StudentHomeworkVo::getId).reversed())); + + int total = rows.size(); + int pageIndex = Math.max(query.getPageIndex(), 1); + int pageSize = Math.max(query.getPageSize(), 1); + int from = Math.min((pageIndex - 1) * pageSize, total); + int to = Math.min(from + pageSize, total); + return new BasePageResult<>(rows.subList(from, to), total, pageIndex, pageSize, + (total + pageSize - 1) / pageSize, to < total); + } + + private List filterByTab(List rows, String tab) { + String t = StringUtils.hasText(tab) ? tab : "all"; + if ("all".equals(t)) { + return rows; + } + return rows.stream().filter(r -> { + String status = r.getMy() == null ? SubmitStatusEnum.NOT_SUBMITTED.getCode() : r.getMy().getSubmitStatus(); + SubmitStatusEnum s = SubmitStatusEnum.of(status); + switch (t) { + case "todo": + return s == SubmitStatusEnum.NOT_SUBMITTED || s == SubmitStatusEnum.RETURNED; + case "done": + return s == SubmitStatusEnum.SUBMITTED || s == SubmitStatusEnum.LATE_SUBMITTED + || s == SubmitStatusEnum.REVIEWED; + case "closed": + return HwStatusEnum.CLOSED.getCode().equals(r.getHwStatus()) + || HwStatusEnum.ARCHIVED.getCode().equals(r.getHwStatus()); + default: + return true; + } + }).collect(Collectors.toList()); + } + + // ============================ 页 2:详情 / 阅读上报 / 结果 ============================ + + /** + * 作业详情(含作业附件与我的当前提交)。 + */ + @Transactional(readOnly = true) + public StudentHomeworkDetailVo detail(Integer id) { + Homework homework = requireVisible(id); + HomeworkSubmission latest = myLatest(homework.getId()); + + StudentHomeworkDetailVo vo = new StudentHomeworkDetailVo(); + copyBase(vo, homework, latest); + vo.setDescription(homework.getContent()); + vo.setAttachments(homeworkAttachmentRepository.findByHomeworkIdOrderBySortAscIdAsc(id).stream() + .map(this::toAttachmentItem).collect(Collectors.toList())); + vo.setClasses(homeworkClassRepository.findByHomeworkId(id).stream() + .map(c -> new ClassItem(c.getClassId(), c.getClassName())) + .collect(Collectors.toList())); + if (latest != null) { + vo.setMyText(latest.getText()); + vo.setMyAttachments(submissionAttachmentRepository + .findBySubmissionIdOrderBySortAscIdAsc(latest.getId()).stream() + .map(this::toAttachmentItem).collect(Collectors.toList())); + } + return vo; + } + + /** + * 阅读上报(前端停留 ≥3 秒后调用;幂等:首报写 first_read_at,重报只刷 last_read_at/count)。 + * 仅进行中/已截止作业产生阅读记录。 + */ + @Transactional + public void read(Integer id) { + Homework homework = requireVisible(id); + HwStatusEnum status = HwStatusEnum.of(homework.getHwStatus()); + if (status != HwStatusEnum.PUBLISHED && status != HwStatusEnum.CLOSED) { + return; + } + ClassStudent mine = classStudentService.resolveMe().get(0); + LocalDateTime now = LocalDateTime.now(); + HomeworkReadRecord record = readRecordRepository.findByHomeworkIdAndStudentId(id, mine.getStudentId()) + .orElse(null); + if (record == null) { + record = new HomeworkReadRecord(); + record.setHomeworkId(id); + record.setStudentId(mine.getStudentId()); + record.setStudentName(mine.getStudentName()); + record.setClassId(mine.getClassId()); + record.setIsRead(1); + record.setFirstReadAt(now); + record.setLastReadAt(now); + record.setReadCount(1); + } else { + record.setIsRead(1); + record.setLastReadAt(now); + record.setReadCount(record.getReadCount() == null ? 1 : record.getReadCount() + 1); + } + readRecordRepository.save(record); + } + + /** + * 批阅结果(当前版本 + 全部历史版本)。 + */ + @Transactional(readOnly = true) + public StudentResultVo result(Integer id) { + Homework homework = requireVisible(id); + ClassStudent mine = classStudentService.resolveMe().get(0); + List versions = + submissionRepository.findByHomeworkIdAndStudentIdOrderByVersionDesc(id, mine.getStudentId()); + StudentResultVo vo = new StudentResultVo(); + vo.setHomeworkId(id); + vo.setHomeworkTitle(homework.getTitle()); + vo.setVersions(versions.stream() + .map(s -> toVersionVo(s, submissionAttachmentRepository + .findBySubmissionIdOrderBySortAscIdAsc(s.getId()))) + .collect(Collectors.toList())); + vo.setLatest(versions.stream().filter(s -> s.getIsLatest() != null && s.getIsLatest() == 1) + .findFirst().map(s -> toVersionVo(s, submissionAttachmentRepository + .findBySubmissionIdOrderBySortAscIdAsc(s.getId()))) + .orElse(null)); + return vo; + } + + // ============================ 页 3:提交 / 撤回重交 ============================ + + /** + * 提交作业(截止前 SUBMITTED;截止后且允许逾期 LATE_SUBMITTED;否则拒绝)。 + * + * @return 新提交行 id + */ + @Transactional + public Integer submit(Integer homeworkId, StudentSubmitRequest request) { + Homework homework = requireVisible(homeworkId); + return doSubmit(homework, request.getText(), request.getAttachments()); + } + + /** + * 撤回 / 重交(PRD §18 学生页 3 撤回重交按钮)。 + * withdraw:截止前撤回,旧版本保留、新增 NOT_SUBMITTED 空行; + * resubmit:打回后重新提交(内容走 doSubmit 全量校验)。 + */ + @Transactional + public Integer action(Integer homeworkId, Integer submissionId, SubmissionActionRequest request) { + Homework homework = requireVisible(homeworkId); + HomeworkSubmission latest = myLatest(homeworkId); + if (latest == null || !latest.getId().equals(submissionId)) { + throw new BizException(ErrorCode.FORBIDDEN, "仅当前版本可执行该操作"); + } + String act = request.getAction(); + if ("withdraw".equals(act)) { + if (HwStatusEnum.of(homework.getHwStatus()) != HwStatusEnum.PUBLISHED || deadlinePassed(homework)) { + throw new BizException(ErrorCode.FORBIDDEN, "已截止作业不可撤回"); + } + latest.setIsLatest(0); + submissionRepository.save(latest); + HomeworkSubmission empty = new HomeworkSubmission(); + fillSnapshot(empty, homework); + empty.setVersion(nextVersion(homework.getId(), latest.getStudentId())); + empty.setIsLatest(1); + empty.setStatus(SubmitStatusEnum.NOT_SUBMITTED.getCode()); + empty.setIsLate(0); + empty.setReviewStatus(ReviewStatusEnum.PENDING.getCode()); + submissionRepository.save(empty); + return empty.getId(); + } + if ("resubmit".equals(act)) { + if (latest.getStatus() == null || !SubmitStatusEnum.RETURNED.getCode().equals(latest.getStatus())) { + throw new BizException(ErrorCode.FORBIDDEN, "仅被打回的提交可重交"); + } + return doSubmit(homework, request.getText(), request.getAttachments()); + } + throw new BizException(ErrorCode.FORBIDDEN, "action 仅支持 withdraw / resubmit"); + } + + // ============================ 提交内核 ============================ + + private Integer doSubmit(Homework homework, String text, List attachments) { + ClassStudent mine = classStudentService.resolveMe().get(0); + HwStatusEnum hwStatus = HwStatusEnum.of(homework.getHwStatus()); + if (hwStatus == HwStatusEnum.ARCHIVED) { + throw new BizException(ErrorCode.FORBIDDEN, "作业已归档,不可提交"); + } + List types = SubmitTypes.parse(homework.getSubmitType()); + if (!SubmitTypes.collects(types)) { + throw new BizException(ErrorCode.FORBIDDEN, "该作业为仅查阅类型,无需提交"); + } + boolean late = deadlinePassed(homework) || hwStatus == HwStatusEnum.CLOSED; + if (late && (homework.getAllowLate() == null || homework.getAllowLate() != 1)) { + throw new BizException(ErrorCode.FORBIDDEN, "作业已截止,如需补交请联系老师"); + } + boolean needAttachment = SubmitTypes.contains(types, SubmitTypes.IMAGE) + || SubmitTypes.contains(types, SubmitTypes.ATTACH); + List items = attachments == null ? new ArrayList<>() : attachments; + if (needAttachment && items.isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "请上传作业附件"); + } + if (!items.isEmpty() && !needAttachment) { + throw new BizException(ErrorCode.FORBIDDEN, "该作业仅接受文本提交,不接受附件"); + } + for (AttachmentItem item : items) { + if (item == null || (!StringUtils.hasText(item.getFileUrl()) && item.getFileId() == null)) { + throw new BizException(ErrorCode.FORBIDDEN, "附件信息不完整(须为上传产物)"); + } + if (item.getFileUrl() != null && item.getFileUrl().startsWith("data:")) { + throw new BizException(ErrorCode.FORBIDDEN, "不支持 dataURL 附件,请使用文件上传接口"); + } + } + + // 版本翻转:旧 latest 置 0,新行 version+1 + HomeworkSubmission previous = myLatest(homework.getId()); + if (previous != null) { + previous.setIsLatest(0); + submissionRepository.save(previous); + } + HomeworkSubmission submission = new HomeworkSubmission(); + fillSnapshot(submission, homework); + submission.setStudentId(mine.getStudentId()); + submission.setStudentName(mine.getStudentName()); + submission.setVersion(nextVersion(homework.getId(), mine.getStudentId())); + submission.setIsLatest(1); + submission.setStatus(late ? SubmitStatusEnum.LATE_SUBMITTED.getCode() : SubmitStatusEnum.SUBMITTED.getCode()); + submission.setIsLate(late ? 1 : 0); + submission.setSubmittedAt(LocalDateTime.now()); + submission.setText(SubmitTypes.contains(types, SubmitTypes.TEXT) ? text : null); + submission.setReviewStatus(ReviewStatusEnum.PENDING.getCode()); + submissionRepository.save(submission); + + int sort = 0; + for (AttachmentItem item : items) { + SubmissionAttachment sa = new SubmissionAttachment(); + sa.setSubmissionId(submission.getId()); + sa.setHomeworkId(homework.getId()); + sa.setStudentId(mine.getStudentId()); + sa.setFileId(item.getFileId()); + sa.setFileUrl(item.getFileUrl()); + sa.setFileType(item.getFileType()); + sa.setFileName(item.getFileName()); + sa.setSort(item.getSort() == null ? sort : item.getSort()); + submissionAttachmentRepository.save(sa); + sort++; + } + return submission.getId(); + } + + private Integer nextVersion(Integer homeworkId, Integer studentId) { + Integer max = submissionRepository.findMaxVersion(homeworkId, studentId); + return max == null ? 1 : max + 1; + } + + private void fillSnapshot(HomeworkSubmission submission, Homework homework) { + submission.setHomeworkId(homework.getId()); + ClassStudent mine = classStudentService.resolveMe().get(0); + submission.setClassId(mine.getClassId()); + submission.setClassName(mine.getClassName() == null ? mine.getClassId() : mine.getClassName()); + // studentId/studentName 由调用方覆盖(fillSnapshot 先行填充默认) + if (submission.getStudentId() == null) { + submission.setStudentId(mine.getStudentId()); + } + if (submission.getStudentName() == null) { + submission.setStudentName(mine.getStudentName()); + } + } + + // ============================ 校验 / 组装 ============================ + + /** + * 可见性校验:作业存在 + 状态可见 + 本人在参与班级(防 IDOR)。 + */ + private Homework requireVisible(Integer homeworkId) { + Homework homework = homeworkRepository.findById(homeworkId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "作业不存在: " + homeworkId)); + if (!visibleStatus(HwStatusEnum.of(homework.getHwStatus()))) { + throw new BizException(ErrorCode.FORBIDDEN, "作业不在可见范围"); + } + List myClassIds = classStudentService.resolveMyClassIds(); + boolean hit = homeworkClassRepository.findByHomeworkId(homeworkId).stream() + .map(HomeworkClass::getClassId) + .anyMatch(myClassIds::contains); + if (!hit) { + throw new BizException(ErrorCode.FORBIDDEN, "该作业不属于你所在班级"); + } + return homework; + } + + private static boolean visibleStatus(HwStatusEnum status) { + return status == HwStatusEnum.PUBLISHED || status == HwStatusEnum.CLOSED || status == HwStatusEnum.ARCHIVED; + } + + private boolean deadlinePassed(Homework homework) { + return homework.getDeadlineAt() != null && homework.getDeadlineAt().isBefore(LocalDateTime.now()); + } + + private HomeworkSubmission myLatest(Integer homeworkId) { + Integer studentId = classStudentService.resolveMyStudentId(); + return submissionRepository.findByHomeworkIdAndStudentIdAndIsLatest(homeworkId, studentId, 1) + .orElse(null); + } + + private StudentHomeworkVo toVo(Homework h, HomeworkSubmission latest) { + StudentHomeworkVo vo = new StudentHomeworkVo(); + copyBase(vo, h, latest); + return vo; + } + + private void copyBase(StudentHomeworkVo vo, Homework h, HomeworkSubmission latest) { + vo.setId(h.getId()); + vo.setTitle(h.getTitle()); + vo.setSubject(h.getSubject()); + vo.setTeacherName(h.getPublisherName()); + vo.setDeadlineAt(h.getDeadlineAt()); + vo.setSubmitType(SubmitTypes.parse(h.getSubmitType())); + vo.setHwStatus(h.getHwStatus()); + vo.setHwStatusDesc(HwStatusEnum.of(h.getHwStatus()).getDesc()); + vo.setAllowLate(h.getAllowLate() == null ? 0 : h.getAllowLate()); + if (latest != null) { + StudentHomeworkVo.MySubmission my = new StudentHomeworkVo.MySubmission(); + my.setSubmissionId(latest.getId()); + my.setSubmitStatus(latest.getStatus()); + my.setSubmitStatusDesc(SubmitStatusEnum.of(latest.getStatus()).getDesc()); + my.setSubmittedAt(latest.getSubmittedAt()); + my.setIsLate(latest.getIsLate()); + my.setVersion(latest.getVersion()); + my.setReviewStatus(latest.getReviewStatus()); + my.setScore(latest.getScore()); + my.setComment(latest.getComment()); + vo.setMy(my); + } + } + + private SubmissionVersionVo toVersionVo(HomeworkSubmission s, List attachments) { + SubmissionVersionVo vo = new SubmissionVersionVo(); + vo.setSubmissionId(s.getId()); + vo.setHomeworkId(s.getHomeworkId()); + vo.setStudentId(s.getStudentId()); + vo.setStudentName(s.getStudentName()); + vo.setVersion(s.getVersion()); + vo.setIsLatest(s.getIsLatest()); + vo.setSubmitStatus(s.getStatus()); + vo.setSubmitStatusDesc(SubmitStatusEnum.of(s.getStatus()).getDesc()); + vo.setSubmittedAt(s.getSubmittedAt()); + vo.setIsLate(s.getIsLate()); + vo.setText(s.getText()); + vo.setScore(s.getScore()); + vo.setComment(s.getComment()); + vo.setReviewStatus(s.getReviewStatus()); + vo.setReviewStatusDesc(ReviewStatusEnum.of(s.getReviewStatus()).getDesc()); + // 批阅人展示姓名快照,缺失时回退ID(与教师端展示口径一致) + vo.setReviewedBy(StringUtils.hasText(s.getReviewedByName()) ? s.getReviewedByName() + : s.getReviewedBy() == null ? null : String.valueOf(s.getReviewedBy())); + vo.setReviewedAt(s.getReviewedAt()); + vo.setAttachments(attachments.stream().map(a -> { + AttachmentItem item = new AttachmentItem(); + item.setFileId(a.getFileId()); + item.setFileUrl(a.getFileUrl()); + item.setFileType(a.getFileType()); + item.setFileName(a.getFileName()); + item.setSort(a.getSort()); + return item; + }).collect(Collectors.toList())); + return vo; + } + + private AttachmentItem toAttachmentItem(HomeworkAttachment a) { + AttachmentItem item = new AttachmentItem(); + item.setFileId(a.getFileId()); + item.setFileUrl(a.getFileUrl()); + item.setFileType(a.getFileType()); + item.setFileName(a.getFileName()); + item.setSort(a.getSort()); + return item; + } + + private AttachmentItem toAttachmentItem(SubmissionAttachment a) { + AttachmentItem item = new AttachmentItem(); + item.setFileId(a.getFileId()); + item.setFileUrl(a.getFileUrl()); + item.setFileType(a.getFileType()); + item.setFileName(a.getFileName()); + item.setSort(a.getSort()); + return item; + } +} diff --git a/src/main/java/com/yunhai/homework/service/impl/TeacherHomeworkV2Service.java b/src/main/java/com/yunhai/homework/service/impl/TeacherHomeworkV2Service.java new file mode 100644 index 0000000..035270f --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/impl/TeacherHomeworkV2Service.java @@ -0,0 +1,1172 @@ +package com.yunhai.homework.service.impl; + +import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.annotation.ExcelProperty; +import com.yunhai.homework.entity.ClassStudent; +import com.yunhai.homework.entity.Homework; +import com.yunhai.homework.entity.HomeworkAttachment; +import com.yunhai.homework.entity.HomeworkClass; +import com.yunhai.homework.entity.HomeworkReadRecord; +import com.yunhai.homework.entity.HomeworkSubmission; +import com.yunhai.homework.entity.SubmissionAttachment; +import com.yunhai.homework.enums.HwStatusEnum; +import com.yunhai.homework.enums.NotificationTypeEnum; +import com.yunhai.homework.enums.ReviewStatusEnum; +import com.yunhai.homework.enums.SubmitStatusEnum; +import com.yunhai.homework.model.BasePageResult; +import com.yunhai.homework.model.bsch.SchClassDto; +import com.yunhai.homework.model.bsch.SchInfoDto; +import com.yunhai.homework.model.bsch.SchSubjectDto; +import com.yunhai.homework.model.bsch.SchTeachDto; +import com.yunhai.homework.model.v2.AttachmentItem; +import com.yunhai.homework.model.v2.ClassItem; +import com.yunhai.homework.model.v2.ClassTreeNode; +import com.yunhai.homework.model.v2.ExtendRequest; +import com.yunhai.homework.model.v2.HomeworkStatsVo; +import com.yunhai.homework.model.v2.HomeworkV2Request; +import com.yunhai.homework.model.v2.HomeworkV2Vo; +import com.yunhai.homework.model.v2.RemindRequest; +import com.yunhai.homework.model.v2.ReturnRequest; +import com.yunhai.homework.model.v2.ReviewRequest; +import com.yunhai.homework.model.v2.ScheduleRequest; +import com.yunhai.homework.model.v2.StudentMatrixQuery; +import com.yunhai.homework.model.v2.StudentMatrixVo; +import com.yunhai.homework.model.v2.SubmissionVersionVo; +import com.yunhai.homework.model.v2.SubjectDto; +import com.yunhai.homework.model.v2.TeacherHomeworkQuery; +import com.yunhai.homework.model.v2.TeachingClassVo; +import com.yunhai.homework.repository.ClassStudentRepository; +import com.yunhai.homework.repository.HomeworkAttachmentRepository; +import com.yunhai.homework.repository.HomeworkClassRepository; +import com.yunhai.homework.repository.HomeworkReadRecordRepository; +import com.yunhai.homework.repository.HomeworkRepository; +import com.yunhai.homework.repository.HomeworkSubmissionRepository; +import com.yunhai.homework.repository.NotificationRepository; +import com.yunhai.homework.repository.SchClassTeachRepository; +import com.yunhai.homework.repository.SubmissionAttachmentRepository; +import com.yunhai.homework.service.IClassService; +import com.yunhai.homework.utils.PageUtils; +import com.yunhai.homework.utils.SubmitTypes; +import com.yunhai.homework.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.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import javax.persistence.criteria.Predicate; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 课堂作业 V2 教师端业务(PRD §18 教师页 1~6)。 + *

+ * 发布链路:创建/编辑一律落草稿,发布(立即/定时)经状态机 CAS 并推送 HOMEWORK_PUBLISHED; + * 统计聚合走 {@link HomeworkProgressCalculator};批阅/打回联动提交状态机并通知学生。 + */ +@Service +public class TeacherHomeworkV2Service { + + private static final Logger log = LoggerFactory.getLogger(TeacherHomeworkV2Service.class); + + @Autowired + private HomeworkRepository homeworkRepository; + + @Autowired + private HomeworkClassRepository homeworkClassRepository; + + @Autowired + private HomeworkAttachmentRepository homeworkAttachmentRepository; + + @Autowired + private HomeworkReadRecordRepository readRecordRepository; + + @Autowired + private HomeworkSubmissionRepository submissionRepository; + + @Autowired + private SubmissionAttachmentRepository submissionAttachmentRepository; + + @Autowired + private ClassStudentRepository classStudentRepository; + + @Autowired + private SchClassTeachRepository schClassTeachRepository; + + @Autowired + private NotificationRepository notificationRepository; + + @Autowired + private HomeworkStateService stateService; + + @Autowired + private NotificationService notificationService; + + @Autowired + private HomeworkProgressCalculator progressCalculator; + + @Autowired + private IClassService classService; + + // ============================ 页 1:创建 / 编辑 ============================ + + /** + * 创建作业:一律落草稿(DRAFT_UNPUBLISHED),发布/定时走独立接口。 + */ + @Transactional + public Integer create(HomeworkV2Request request) { + Homework homework = new Homework(); + applyRequest(homework, request); + homework.setHwStatus(HwStatusEnum.DRAFT_UNPUBLISHED.getCode()); + homework.setPublisherId(UserContext.getUserId()); + homework.setPublisherName(UserContext.getUserName()); + homework.setSchId(UserContext.getSchId()); + homework.setStatus(1); + homeworkRepository.save(homework); + saveChildren(homework, request.getClassIds(), request.getAttachments()); + return homework.getId(); + } + + /** + * 编辑作业(仅草稿两态)。 + */ + @Transactional + public void edit(Integer id, HomeworkV2Request request) { + Homework homework = stateService.requireOwned(id, UserContext.getUserId()); + requireDraft(homework); + applyRequest(homework, request); + homeworkRepository.save(homework); + // 子表全量替换(入参为 null 时不改动) + if (request.getClassIds() != null) { + homeworkClassRepository.deleteByHomeworkId(id); + saveClasses(homework, request.getClassIds()); + } + if (request.getAttachments() != null) { + homeworkAttachmentRepository.deleteByHomeworkId(id); + saveAttachments(homework, request.getAttachments()); + } + } + + /** + * 删除作业(仅草稿两态,级联子表;历史提交/阅读数据一并清理)。 + */ + @Transactional + public void delete(Integer id) { + Homework homework = stateService.requireOwned(id, UserContext.getUserId()); + requireDraft(homework); + homeworkClassRepository.deleteByHomeworkId(id); + homeworkAttachmentRepository.deleteByHomeworkId(id); + readRecordRepository.deleteByHomeworkId(id); + submissionAttachmentRepository.deleteByHomeworkId(id); + submissionRepository.deleteByHomeworkId(id); + notificationRepository.deleteByHomeworkId(id); + homeworkRepository.delete(homework); + } + + private void applyRequest(Homework homework, HomeworkV2Request request) { + homework.setTitle(request.getTitle()); + homework.setSubject(request.getSubject()); + homework.setContent(request.getDescription()); + // 截止时间精确到分钟 + homework.setDeadlineAt(truncateMinute(request.getDeadlineAt())); + List submitTypes = SubmitTypes.normalize(request.getSubmitType()); + homework.setSubmitType(SubmitTypes.toJson(submitTypes)); + homework.setAllowLate(request.getAllowLate() == null ? 0 : request.getAllowLate()); + homework.setRemind1d(request.getRemind1d() == null ? 1 : request.getRemind1d()); + homework.setRemind1h(request.getRemind1h() == null ? 1 : request.getRemind1h()); + if (request.getClassIds() != null && !request.getClassIds().isEmpty()) { + homework.setReceiveList(String.join(",", request.getClassIds())); + } + } + + private void saveChildren(Homework homework, List classIds, List attachments) { + saveClasses(homework, classIds); + saveAttachments(homework, attachments); + } + + private void saveClasses(Homework homework, List classIds) { + if (classIds == null || classIds.isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "请至少选择一个参与班级"); + } + List distinct = classIds.stream().filter(StringUtils::hasText).distinct().collect(Collectors.toList()); + for (String classId : distinct) { + HomeworkClass hc = new HomeworkClass(); + hc.setHomeworkId(homework.getId()); + hc.setClassId(classId); + hc.setClassName(resolveClassName(classId)); + homeworkClassRepository.save(hc); + } + } + + private void saveAttachments(Homework homework, List attachments) { + if (attachments == null) { + return; + } + int sort = 0; + for (AttachmentItem item : attachments) { + if (item == null || !StringUtils.hasText(item.getFileUrl())) { + continue; + } + HomeworkAttachment ha = new HomeworkAttachment(); + ha.setHomeworkId(homework.getId()); + ha.setFileId(item.getFileId()); + ha.setFileUrl(item.getFileUrl()); + ha.setFileType(item.getFileType()); + ha.setFileName(item.getFileName()); + ha.setSort(item.getSort() == null ? sort : item.getSort()); + homeworkAttachmentRepository.save(ha); + sort++; + } + } + + /** 班级名快照:优先取名册,无名册时回退班级 id */ + private String resolveClassName(String classId) { + return classStudentRepository.findByClassIdAndStatus(classId, 1).stream() + .map(ClassStudent::getClassName) + .filter(StringUtils::hasText) + .findFirst() + .orElse(classId); + } + + // ============================ 页 2:草稿箱 ============================ + + /** + * 草稿箱分页(草稿两态)。 + */ + @Transactional(readOnly = true) + public BasePageResult drafts(TeacherHomeworkQuery query, Sort sort) { + Page page = homeworkRepository.findAll( + ownedStatusSpec(UserContext.getUserId(), + Arrays.asList(HwStatusEnum.DRAFT_UNPUBLISHED.getCode(), HwStatusEnum.DRAFT_SCHEDULED.getCode()), + query), + PageUtils.toPageable(query, sort == null ? Sort.by(Sort.Direction.DESC, "updateTime") : sort)); + return PageUtils.toResult(page.map(h -> toVo(h, false, false))); + } + + // ============================ 页 3:已发布列表 ============================ + + /** + * 已发布列表分页(进行中/已截止/已归档,含聚合进度)。 + */ + @Transactional(readOnly = true) + public BasePageResult published(TeacherHomeworkQuery query, Sort sort) { + List statuses = StringUtils.hasText(query.getHwStatus()) + ? java.util.Collections.singletonList(query.getHwStatus()) + : Arrays.asList(HwStatusEnum.PUBLISHED.getCode(), HwStatusEnum.CLOSED.getCode(), + HwStatusEnum.ARCHIVED.getCode()); + Page page = homeworkRepository.findAll( + ownedStatusSpec(UserContext.getUserId(), statuses, query), + PageUtils.toPageable(query, sort == null ? Sort.by(Sort.Direction.DESC, "publishedAt") : sort)); + return PageUtils.toResult(page.map(h -> toVo(h, false, true))); + } + + private Specification ownedStatusSpec(Integer publisherId, List statuses, TeacherHomeworkQuery query) { + return (root, cq, cb) -> { + List ps = new ArrayList<>(); + ps.add(cb.equal(root.get("publisherId"), publisherId)); + ps.add(root.get("hwStatus").in(statuses)); + if (StringUtils.hasText(query.getKeyword())) { + ps.add(cb.like(root.get("title"), "%" + query.getKeyword() + "%")); + } + if (StringUtils.hasText(query.getSubject())) { + ps.add(cb.equal(root.get("subject"), query.getSubject())); + } + return cb.and(ps.toArray(new Predicate[0])); + }; + } + + // ============================ 页 1 操作:发布 / 定时 ============================ + + /** + * 立即发布:DRAFT_UNPUBLISHED → PUBLISHED,推送作业发布通知。 + */ + @Transactional + public void publish(Integer id) { + Homework homework = stateService.requireOwned(id, UserContext.getUserId()); + validateForPublish(homework); + publishInternal(id, HwStatusEnum.DRAFT_UNPUBLISHED); + } + + /** + * 发布内核(状态机 CAS + 发布时间 + 通知)。控制器路径与定时任务共用; + * 定时任务到点时以 from=DRAFT_SCHEDULED 调用(无登录上下文,归属校验由调用方决定)。 + */ + @Transactional + public void publishInternal(Integer id, HwStatusEnum from) { + stateService.publish(id, from); + notifyPublished(id); + } + + /** + * 待提交学生 id 集合(名册 − 已交),截止提醒任务用。 + */ + public List notSubmittedStudentIds(Integer homeworkId) { + Set submitted = submissionRepository.findByHomeworkIdAndIsLatest(homeworkId, 1).stream() + .filter(s -> SubmitStatusEnum.of(s.getStatus()).countsAsSubmitted()) + .map(HomeworkSubmission::getStudentId) + .collect(Collectors.toSet()); + return rosterStudentIds(homeworkId).stream() + .filter(sid -> !submitted.contains(sid)) + .collect(Collectors.toList()); + } + + /** + * 定时发布 / 取消定时。 + */ + @Transactional + public void schedule(Integer id, ScheduleRequest request) { + Homework homework = stateService.requireOwned(id, UserContext.getUserId()); + if (Boolean.TRUE.equals(request.getCancel())) { + requireDraft(homework); + if (!stateService.cas(id, HwStatusEnum.DRAFT_SCHEDULED, HwStatusEnum.DRAFT_UNPUBLISHED)) { + throw new BizException(ErrorCode.FORBIDDEN, "作业状态已变化,取消定时失败"); + } + homework.setTimingSendTime(null); + homeworkRepository.save(homework); + return; + } + if (!HwStatusEnum.of(homework.getHwStatus()).isDraft()) { + throw new BizException(ErrorCode.FORBIDDEN, "仅草稿状态可设置定时发布"); + } + if (request.getScheduledAt() == null || !request.getScheduledAt().isAfter(LocalDateTime.now())) { + throw new BizException(ErrorCode.FORBIDDEN, "定时发布时间必须晚于当前时间"); + } + validateForPublish(homework); + // 有截止时间时,定时点须早于截止时间 + if (homework.getDeadlineAt() != null && !request.getScheduledAt().isBefore(homework.getDeadlineAt())) { + throw new BizException(ErrorCode.FORBIDDEN, "定时发布时间必须早于截止时间"); + } + homework.setTimingSendTime(truncateMinute(request.getScheduledAt())); + homeworkRepository.save(homework); + stateService.cas(id, HwStatusEnum.of(homework.getHwStatus()), HwStatusEnum.DRAFT_SCHEDULED); + } + + private void validateForPublish(Homework homework) { + if (homework.getDeadlineAt() != null && !homework.getDeadlineAt().isAfter(LocalDateTime.now())) { + throw new BizException(ErrorCode.FORBIDDEN, "截止时间必须晚于当前时间"); + } + if (homeworkClassRepository.findByHomeworkId(homework.getId()).isEmpty()) { + throw new BizException(ErrorCode.FORBIDDEN, "请先选择参与班级"); + } + } + + /** + * 发布成功后向参与班级在班学生推送 HOMEWORK_PUBLISHED(发布事务内,失败不阻断发布)。 + */ + private void notifyPublished(Integer homeworkId) { + try { + Homework homework = homeworkRepository.findById(homeworkId).orElse(null); + if (homework == null) { + return; + } + List studentIds = rosterStudentIds(homeworkId); + notificationService.sendBatch(NotificationTypeEnum.HOMEWORK_PUBLISHED, homeworkId, null, + NotificationService.distinct(studentIds), + NotificationService.publishedTitle(homework), + NotificationService.publishedContent(homework)); + } catch (Exception e) { + log.warn("[notifyPublished] 发布通知推送失败 homeworkId={}", homeworkId, e); + } + } + + // ============================ 页 4:详情 / 统计 ============================ + + /** + * 作业详情(含附件/班级/进度)。 + */ + @Transactional(readOnly = true) + public HomeworkV2Vo detail(Integer id) { + Homework homework = stateService.requireOwned(id, UserContext.getUserId()); + return toVo(homework, true, !HwStatusEnum.of(homework.getHwStatus()).isDraft()); + } + + /** + * 汇总统计卡片(总进度 + 分班进度)。 + */ + @Transactional(readOnly = true) + public HomeworkStatsVo stats(Integer id) { + Homework homework = stateService.requireOwned(id, UserContext.getUserId()); + List classes = homeworkClassRepository.findByHomeworkId(id); + HomeworkProgressCalculator.Result result = + progressCalculator.compute(id, classIds(classes)); + HomeworkStatsVo vo = new HomeworkStatsVo(); + vo.setHomeworkId(id); + vo.setTitle(homework.getTitle()); + vo.setHwStatus(homework.getHwStatus()); + vo.setHwStatusDesc(HwStatusEnum.of(homework.getHwStatus()).getDesc()); + vo.setTotal(result.total); + vo.setByClass(result.byClass.stream().map(cp -> { + HomeworkStatsVo.ByClass item = new HomeworkStatsVo.ByClass(); + item.setClassId(cp.classId); + item.setClassName(cp.className); + item.setProgress(cp.progress); + return item; + }).collect(Collectors.toList())); + return vo; + } + + // ============================ 页 5:学生矩阵 ============================ + + /** + * 学生矩阵(班级×学生全量名册 + 阅读/提交/批阅状态),内存过滤分页。 + * 未交学生生成占位行(submissionId=0、NOT_SUBMITTED)。 + */ + @Transactional(readOnly = true) + public BasePageResult students(Integer id, StudentMatrixQuery query) { + Homework homework = stateService.requireOwned(id, UserContext.getUserId()); + List classes = homeworkClassRepository.findByHomeworkId(id); + List classIds = classIds(classes); + if (StringUtils.hasText(query.getClassId())) { + classIds = classIds.stream().filter(c -> c.equals(query.getClassId())).collect(Collectors.toList()); + } + + // 名册(班级×学生) + List roster = classIds.isEmpty() + ? new ArrayList<>() + : classStudentRepository.findByClassIdInAndStatus(classIds, 1); + Map readMap = readRecordRepository.findByHomeworkId(id).stream() + .collect(Collectors.toMap(HomeworkReadRecord::getStudentId, r -> r, (a, b) -> a)); + Map latestMap = submissionRepository.findByHomeworkIdAndIsLatest(id, 1).stream() + .collect(Collectors.toMap(HomeworkSubmission::getStudentId, s -> s, (a, b) -> a)); + Set subIds = latestMap.values().stream() + .map(HomeworkSubmission::getId).collect(Collectors.toSet()); + Map> attMap = submissionAttachmentRepository + .findBySubmissionIdIn(subIds).stream() + .collect(Collectors.groupingBy(SubmissionAttachment::getSubmissionId)); + + List rows = new ArrayList<>(); + for (ClassStudent cs : roster) { + StudentMatrixVo row = new StudentMatrixVo(); + row.setStudentId(cs.getStudentId()); + row.setStudentName(cs.getStudentName()); + row.setClassId(cs.getClassId()); + row.setClassName(cs.getClassName()); + HomeworkReadRecord read = readMap.get(cs.getStudentId()); + row.setIsRead(read != null && read.getIsRead() != null && read.getIsRead() == 1 ? 1 : 0); + row.setFirstReadAt(read == null ? null : read.getFirstReadAt()); + row.setLastReadAt(read == null ? null : read.getLastReadAt()); + HomeworkSubmission sub = latestMap.get(cs.getStudentId()); + if (sub == null) { + row.setSubmissionId(0); + row.setSubmitStatus(SubmitStatusEnum.NOT_SUBMITTED.getCode()); + row.setReviewStatus(ReviewStatusEnum.PENDING.getCode()); + } else { + row.setSubmissionId(sub.getId()); + row.setSubmitStatus(sub.getStatus()); + row.setSubmittedAt(sub.getSubmittedAt()); + row.setIsLate(sub.getIsLate()); + row.setReviewStatus(sub.getReviewStatus()); + row.setScore(sub.getScore()); + List atts = attMap.getOrDefault(sub.getId(), new ArrayList<>()); + row.setThumbnails(atts.stream() + .map(SubmissionAttachment::getFileUrl) + .collect(Collectors.toList())); + row.setContentTypes(submissionContentTypes(sub, atts)); + } + rows.add(row); + } + + // 过滤 + if (StringUtils.hasText(query.getSubmitStatus())) { + rows = rows.stream().filter(r -> query.getSubmitStatus().equals(r.getSubmitStatus())) + .collect(Collectors.toList()); + } + if (StringUtils.hasText(query.getReviewStatus())) { + rows = rows.stream().filter(r -> query.getReviewStatus().equals(r.getReviewStatus())) + .collect(Collectors.toList()); + } + if (Boolean.TRUE.equals(query.getPendingGrade())) { + rows = rows.stream().filter(r -> ReviewStatusEnum.PENDING.getCode().equals(r.getReviewStatus()) + && !SubmitStatusEnum.NOT_SUBMITTED.getCode().equals(r.getSubmitStatus())) + .collect(Collectors.toList()); + } + rows.sort(Comparator + .comparing((StudentMatrixVo r) -> nullSafe(r.getClassName())) + .thenComparing(r -> nullSafe(r.getStudentName()))); + + // 内存分页 + int total = rows.size(); + int pageIndex = Math.max(query.getPageIndex(), 1); + int pageSize = Math.max(query.getPageSize(), 1); + int from = Math.min((pageIndex - 1) * pageSize, total); + int to = Math.min(from + pageSize, total); + return new BasePageResult<>(rows.subList(from, to), total, pageIndex, pageSize, + (total + pageSize - 1) / pageSize, to < total); + } + + /** + * 提交内容类型摘要(批改中心按类型勾选筛选用): + * image=含拍照/图片附件(fileType=image)、attach=含其他附件、text=含文字作答。 + */ + private static List submissionContentTypes(HomeworkSubmission sub, List atts) { + List types = new ArrayList<>(3); + boolean hasImage = false; + boolean hasFile = false; + for (SubmissionAttachment a : atts) { + if ("image".equalsIgnoreCase(a.getFileType())) { + hasImage = true; + } else if (StringUtils.hasText(a.getFileType())) { + hasFile = true; + } + } + if (hasImage) { + types.add("image"); + } + if (hasFile) { + types.add("attach"); + } + if (StringUtils.hasText(sub.getText())) { + types.add("text"); + } + return types; + } + + // ============================ 页 5/6:批阅 / 打回 / 版本 ============================ + + /** + * 保存批阅:当前版本行 → REVIEWED,推送 REVIEW_DONE。 + */ + @Transactional + public void review(Integer homeworkId, Integer studentId, ReviewRequest request) { + Homework homework = requireOwnedReviewable(homeworkId); + HomeworkSubmission submission = requireLatestSubmission(homework, studentId); + if (request.getScore() == null && !StringUtils.hasText(request.getComment())) { + throw new BizException(ErrorCode.FORBIDDEN, "评分与评语至少填写一项"); + } + submission.setScore(request.getScore()); + submission.setComment(request.getComment()); + submission.setStatus(SubmitStatusEnum.REVIEWED.getCode()); + submission.setReviewStatus(ReviewStatusEnum.REVIEWED.getCode()); + submission.setReviewedBy(UserContext.getUserId()); + submission.setReviewedByName(UserContext.getUserName()); + submission.setReviewedAt(LocalDateTime.now()); + submissionRepository.save(submission); + notificationService.send(NotificationTypeEnum.REVIEW_DONE, homeworkId, submission.getId(), + studentId, submission.getStudentName(), NotificationService.ROLE_STUDENT, + NotificationService.reviewDoneTitle(homework), + NotificationService.reviewDoneContent(homework, + request.getScore() == null ? null : request.getScore().toPlainString(), + request.getComment())); + } + + /** + * 打回:当前版本行 → RETURNED,推送 RETURNED(学生可重新提交)。 + */ + @Transactional + public void returnSubmission(Integer homeworkId, Integer studentId, ReturnRequest request) { + Homework homework = requireOwnedReviewable(homeworkId); + HomeworkSubmission submission = requireLatestSubmission(homework, studentId); + if (SubmitStatusEnum.REVIEWED.getCode().equals(submission.getStatus())) { + throw new BizException(ErrorCode.FORBIDDEN, "已批阅的提交不可打回"); + } + submission.setComment(request.getComment()); + submission.setStatus(SubmitStatusEnum.RETURNED.getCode()); + submission.setReviewStatus(ReviewStatusEnum.RETURNED.getCode()); + submission.setReviewedBy(UserContext.getUserId()); + submission.setReviewedByName(UserContext.getUserName()); + submission.setReviewedAt(LocalDateTime.now()); + submissionRepository.save(submission); + notificationService.send(NotificationTypeEnum.RETURNED, homeworkId, submission.getId(), + studentId, submission.getStudentName(), NotificationService.ROLE_STUDENT, + NotificationService.returnedTitle(homework), + NotificationService.returnedContent(homework, request.getComment())); + } + + /** + * 历史提交版本(新→旧,含各版附件与批阅信息)。 + */ + @Transactional(readOnly = true) + public List versions(Integer homeworkId, Integer studentId) { + stateService.requireOwned(homeworkId, UserContext.getUserId()); + List rows = + submissionRepository.findByHomeworkIdAndStudentIdOrderByVersionDesc(homeworkId, studentId); + Map> attMap = submissionAttachmentRepository + .findBySubmissionIdIn(rows.stream().map(HomeworkSubmission::getId).collect(Collectors.toSet())) + .stream() + .collect(Collectors.groupingBy(SubmissionAttachment::getSubmissionId)); + return rows.stream().map(s -> toVersionVo(s, attMap.getOrDefault(s.getId(), new ArrayList<>()))) + .collect(Collectors.toList()); + } + + private Homework requireOwnedReviewable(Integer homeworkId) { + Homework homework = stateService.requireOwned(homeworkId, UserContext.getUserId()); + HwStatusEnum status = HwStatusEnum.of(homework.getHwStatus()); + if (status != HwStatusEnum.PUBLISHED && status != HwStatusEnum.CLOSED) { + throw new BizException(ErrorCode.FORBIDDEN, "当前作业状态不可批阅(仅进行中/已截止可批阅)"); + } + return homework; + } + + private HomeworkSubmission requireLatestSubmission(Homework homework, Integer studentId) { + HomeworkSubmission submission = submissionRepository + .findByHomeworkIdAndStudentIdAndIsLatest(homework.getId(), studentId, 1) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "该学生暂无有效提交")); + if (!SubmitStatusEnum.of(submission.getStatus()).countsAsSubmitted()) { + throw new BizException(ErrorCode.FORBIDDEN, "该学生尚未提交作业"); + } + return submission; + } + + private SubmissionVersionVo toVersionVo(HomeworkSubmission s, List attachments) { + SubmissionVersionVo vo = new SubmissionVersionVo(); + vo.setSubmissionId(s.getId()); + vo.setHomeworkId(s.getHomeworkId()); + vo.setStudentId(s.getStudentId()); + vo.setStudentName(s.getStudentName()); + vo.setVersion(s.getVersion()); + vo.setIsLatest(s.getIsLatest()); + vo.setSubmitStatus(s.getStatus()); + vo.setSubmitStatusDesc(SubmitStatusEnum.of(s.getStatus()).getDesc()); + vo.setSubmittedAt(s.getSubmittedAt()); + vo.setIsLate(s.getIsLate()); + vo.setText(s.getText()); + vo.setScore(s.getScore()); + vo.setComment(s.getComment()); + vo.setReviewStatus(s.getReviewStatus()); + vo.setReviewStatusDesc(ReviewStatusEnum.of(s.getReviewStatus()).getDesc()); + // 批阅人展示姓名快照,缺失时回退ID + vo.setReviewedBy(StringUtils.hasText(s.getReviewedByName()) ? s.getReviewedByName() + : s.getReviewedBy() == null ? null : String.valueOf(s.getReviewedBy())); + vo.setReviewedAt(s.getReviewedAt()); + vo.setAttachments(attachments.stream().map(a -> { + AttachmentItem item = new AttachmentItem(); + item.setFileId(a.getFileId()); + item.setFileUrl(a.getFileUrl()); + item.setFileType(a.getFileType()); + item.setFileName(a.getFileName()); + item.setSort(a.getSort()); + return item; + }).collect(Collectors.toList())); + return vo; + } + + // ============================ 页 3 操作:提醒 / 延长 / 复制 / 归档 / 导出 ============================ + + /** + * 手动提醒未读/未交。studentIds 为空时提醒全部未读/未交学生。 + * + * @return 实际提醒人数 + */ + @Transactional + public int remind(Integer homeworkId, RemindRequest request) { + Homework homework = stateService.requireOwned(homeworkId, UserContext.getUserId()); + if (HwStatusEnum.of(homework.getHwStatus()) != HwStatusEnum.PUBLISHED) { + throw new BizException(ErrorCode.FORBIDDEN, "仅进行中的作业可发起提醒"); + } + NotificationTypeEnum type = NotificationTypeEnum.of(request.getType()); + if (type != NotificationTypeEnum.NOT_READ && type != NotificationTypeEnum.NOT_SUBMITTED) { + throw new BizException(ErrorCode.FORBIDDEN, "提醒类型仅支持 NOT_READ / NOT_SUBMITTED"); + } + Set readSet = readRecordRepository.findByHomeworkId(homeworkId).stream() + .filter(r -> r.getIsRead() != null && r.getIsRead() == 1) + .map(HomeworkReadRecord::getStudentId) + .collect(Collectors.toSet()); + Set submittedSet = submissionRepository.findByHomeworkIdAndIsLatest(homeworkId, 1).stream() + .filter(s -> SubmitStatusEnum.of(s.getStatus()).countsAsSubmitted()) + .map(HomeworkSubmission::getStudentId) + .collect(Collectors.toSet()); + Set targets = new HashSet<>(); + for (Integer studentId : rosterStudentIds(homeworkId)) { + boolean hit = type == NotificationTypeEnum.NOT_READ + ? !readSet.contains(studentId) + : !submittedSet.contains(studentId); + if (hit) { + targets.add(studentId); + } + } + if (request.getStudentIds() != null && !request.getStudentIds().isEmpty()) { + targets.retainAll(request.getStudentIds()); + } + String title = type == NotificationTypeEnum.NOT_READ + ? NotificationService.notReadTitle(homework) : NotificationService.notSubmittedTitle(homework); + String content = type == NotificationTypeEnum.NOT_READ + ? NotificationService.notReadContent(homework) : NotificationService.notSubmittedContent(homework); + return notificationService.sendBatch(type, homeworkId, null, + NotificationService.distinct(new ArrayList<>(targets)), title, content); + } + + /** + * 延长截止:新截止须晚于当前时间与原截止,并通知学生截止变更。 + */ + @Transactional + public void extend(Integer homeworkId, ExtendRequest request) { + Homework homework = stateService.requireOwned(homeworkId, UserContext.getUserId()); + if (HwStatusEnum.of(homework.getHwStatus()) != HwStatusEnum.PUBLISHED) { + throw new BizException(ErrorCode.FORBIDDEN, "仅进行中的作业可延长截止"); + } + LocalDateTime newDeadline = truncateMinute(request.getDeadlineAt()); + if (!newDeadline.isAfter(LocalDateTime.now())) { + throw new BizException(ErrorCode.FORBIDDEN, "新截止时间必须晚于当前时间"); + } + if (homework.getDeadlineAt() != null && !newDeadline.isAfter(homework.getDeadlineAt())) { + throw new BizException(ErrorCode.FORBIDDEN, "新截止时间必须晚于原截止时间"); + } + homework.setDeadlineAt(newDeadline); + homeworkRepository.save(homework); + notificationService.sendBatch(NotificationTypeEnum.DEADLINE_CHANGED, homeworkId, null, + NotificationService.distinct(rosterStudentIds(homeworkId)), + "截止时间变更:《" + homework.getTitle() + "》", + "作业截止时间调整为 " + newDeadline + ",请留意。"); + } + + /** + * 复制为新草稿(不带提交/阅读数据)。 + */ + @Transactional + public Integer copy(Integer homeworkId) { + Homework source = stateService.requireOwned(homeworkId, UserContext.getUserId()); + Homework target = new Homework(); + target.setTitle(truncate(source.getTitle() + "(副本)", 50)); + target.setSchId(source.getSchId()); + target.setContent(source.getContent()); + target.setType(source.getType()); + target.setDeptId(source.getDeptId()); + target.setDeptName(source.getDeptName()); + target.setSubject(source.getSubject()); + target.setDeadlineAt(source.getDeadlineAt()); + target.setSubmitType(source.getSubmitType()); + target.setAllowLate(source.getAllowLate()); + target.setRemind1d(source.getRemind1d()); + target.setRemind1h(source.getRemind1h()); + target.setReceiveList(source.getReceiveList()); + target.setHwStatus(HwStatusEnum.DRAFT_UNPUBLISHED.getCode()); + target.setStatus(1); + target.setPublisherId(UserContext.getUserId()); + target.setPublisherName(UserContext.getUserName()); + homeworkRepository.save(target); + for (HomeworkClass hc : homeworkClassRepository.findByHomeworkId(homeworkId)) { + HomeworkClass nc = new HomeworkClass(); + nc.setHomeworkId(target.getId()); + nc.setClassId(hc.getClassId()); + nc.setClassName(hc.getClassName()); + homeworkClassRepository.save(nc); + } + for (HomeworkAttachment ha : homeworkAttachmentRepository.findByHomeworkIdOrderBySortAscIdAsc(homeworkId)) { + HomeworkAttachment na = new HomeworkAttachment(); + na.setHomeworkId(target.getId()); + na.setFileId(ha.getFileId()); + na.setFileUrl(ha.getFileUrl()); + na.setFileType(ha.getFileType()); + na.setFileName(ha.getFileName()); + na.setSort(ha.getSort()); + homeworkAttachmentRepository.save(na); + } + return target.getId(); + } + + /** + * 归档(进行中/已截止 → 已归档):只读,一切提醒任务跳过。 + */ + @Transactional + public void archive(Integer homeworkId) { + Homework homework = stateService.requireOwned(homeworkId, UserContext.getUserId()); + HwStatusEnum status = HwStatusEnum.of(homework.getHwStatus()); + if (status != HwStatusEnum.PUBLISHED && status != HwStatusEnum.CLOSED) { + throw new BizException(ErrorCode.FORBIDDEN, "仅进行中/已截止的作业可归档"); + } + stateService.archive(homeworkId, status); + } + + /** + * 导出学生矩阵 Excel(easyexcel,列对齐 PRD 页 5)。 + */ + public void export(Integer homeworkId, HttpServletResponse response) throws IOException { + Homework homework = stateService.requireOwned(homeworkId, UserContext.getUserId()); + StudentMatrixQuery all = new StudentMatrixQuery(); + all.setPageIndex(1); + all.setPageSize(Integer.MAX_VALUE); + BasePageResult matrix = students(homeworkId, all); + List data = matrix.getContent().stream().map(r -> { + MatrixExportRow row = new MatrixExportRow(); + row.className = r.getClassName(); + row.studentId = r.getStudentId() == null ? "" : String.valueOf(r.getStudentId()); + row.studentName = r.getStudentName(); + row.readStatus = r.getIsRead() != null && r.getIsRead() == 1 ? "已读" : "未读"; + row.firstReadAt = r.getFirstReadAt() == null ? "" : r.getFirstReadAt().toString(); + row.lastReadAt = r.getLastReadAt() == null ? "" : r.getLastReadAt().toString(); + row.submitStatus = SubmitStatusEnum.of(r.getSubmitStatus()).getDesc(); + row.submittedAt = r.getSubmittedAt() == null ? "" : r.getSubmittedAt().toString(); + row.late = r.getIsLate() != null && r.getIsLate() == 1 ? "是" : "否"; + row.reviewStatus = ReviewStatusEnum.of(r.getReviewStatus()).getDesc(); + row.score = r.getScore() == null ? "" : r.getScore().toPlainString(); + return row; + }).collect(Collectors.toList()); + + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + String fileName = URLEncoder.encode(homework.getTitle() + "-学生矩阵", "UTF-8"); + response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); + EasyExcel.write(response.getOutputStream(), MatrixExportRow.class) + .sheet("学生矩阵") + .doWrite(data); + } + + /** + * 导出行定义(easyexcel 注解列)。 + */ + public static class MatrixExportRow { + @ExcelProperty("班级") + public String className; + @ExcelProperty("学号") + public String studentId; + @ExcelProperty("姓名") + public String studentName; + @ExcelProperty("阅读状态") + public String readStatus; + @ExcelProperty("首次阅读时间") + public String firstReadAt; + @ExcelProperty("最近阅读时间") + public String lastReadAt; + @ExcelProperty("提交状态") + public String submitStatus; + @ExcelProperty("提交时间") + public String submittedAt; + @ExcelProperty("是否逾期") + public String late; + @ExcelProperty("批阅状态") + public String reviewStatus; + @ExcelProperty("评分") + public String score; + } + + // ============================ 班级树 / 名册 ============================ + + /** + * 年级→班级树(发布选班;来自名册快照聚合)。 + */ + @Transactional(readOnly = true) + public List classTree() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + return new ArrayList<>(); + } + List rows = classStudentRepository.findDistinctClasses(schId); + Map> byGrade = rows.stream() + .collect(Collectors.groupingBy(r -> r[2] == null || !StringUtils.hasText(r[2].toString()) + ? "未分组" : r[2].toString(), java.util.LinkedHashMap::new, Collectors.toList())); + List tree = new ArrayList<>(); + for (Map.Entry> entry : byGrade.entrySet()) { + ClassTreeNode grade = new ClassTreeNode(); + grade.setId(entry.getKey()); + grade.setName(entry.getKey()); + grade.setIsParent(true); + for (Object[] r : entry.getValue()) { + String classId = String.valueOf(r[0]); + ClassTreeNode leaf = new ClassTreeNode(); + leaf.setId(classId); + leaf.setName(r[1] == null || !StringUtils.hasText(r[1].toString()) ? classId : r[1].toString()); + leaf.setIsParent(false); + leaf.setStudentCount(classStudentRepository.findByClassIdAndStatus(classId, 1).size()); + grade.getChildren().add(leaf); + } + tree.add(grade); + } + return tree; + } + + // ============================ 学科(我布置的 / 我任教的) ============================ + + /** + * 我布置过的学科:本人已发布及之后状态作业的学科(草稿不计)∪ 我的任教学科,合并去重, + * 名称经本校学科字典解析,字典序输出。 + */ + @Transactional(readOnly = true) + public List assignedSubjects() { + Integer userId = UserContext.getUserId(); + if (userId == null) { + return new ArrayList<>(); + } + Set codes = new LinkedHashSet<>(); + codes.addAll(homeworkRepository.findAssignedSubjects(userId, publishedStatuses())); + try { + codes.addAll(teachingSubjectCodes()); + } catch (BizException e) { + log.warn("[assignedSubjects] 任教学科拉取失败, 仅返回布置学科: {}", e.getMessage()); + } + return toSubjectDtos(codes, subjectDictionary()); + } + + /** + * 我的任教学科:bschclass 任教关系(sch_class_teach)当前教师去重学科, + * 名称经本校学科字典解析。 + */ + public List teachingSubjects() { + return toSubjectDtos(teachingSubjectCodes(), subjectDictionary()); + } + + /** + * 我的任教去重学科编码:bschclass 任教关系(sch_class_teach)当前教师。 + * 登录身份缺学校/教师信息时返回空集合;远端不可用时降级读本地任教快照 + * (sch_class_teach,同 class_student 口径),快照也无记录则保持原语义向上抛出。 + */ + private Set teachingSubjectCodes() { + Set codes = new LinkedHashSet<>(); + try { + for (SchTeachDto teach : currentTeachList()) { + if (StringUtils.hasText(teach.getSubId())) { + codes.add(teach.getSubId()); + } + } + } catch (BizException e) { + codes.addAll(localTeachingSubIds()); + if (codes.isEmpty()) { + throw e; + } + log.warn("[teachingSubjectCodes] bschclass 任教拉取失败, 降级本地任教快照: {}", e.getMessage()); + } + return codes; + } + + /** + * 当前教师在本地任教快照(sch_class_teach)中的去重学科编码; + * 缺学校/教师身份时返回空集合。 + */ + private Set localTeachingSubIds() { + Integer schId = UserContext.getSchId(); + Integer userId = UserContext.getUserId(); + if (schId == null || userId == null) { + return Collections.emptySet(); + } + return new LinkedHashSet<>(schClassTeachRepository.findDistinctSubIds(schId, userId)); + } + + /** + * 按学科筛选我的任教班级:任教关系当前教师该学科的班级去重, + * 班级名称取学校班级列表(bschclass GetVClassList)映射并按其排序输出, + * 班级列表拉取失败时降级为班级ID兜底,不影响清单本身。 + */ + public List teachingClasses(String subId) { + if (!StringUtils.hasText(subId)) { + throw new BizException(ErrorCode.PARAM_ERROR, "学科编码(subId)不得为空"); + } + String schSerId = currentSchSerId(); + Integer userId = UserContext.getUserId(); + if (schSerId == null || userId == null) { + return new ArrayList<>(); + } + Set classIds = new LinkedHashSet<>(); + for (SchTeachDto teach : classService.getSchTeachList(schSerId, userId)) { + if (subId.equals(teach.getSubId()) && teach.getClassId() != null) { + classIds.add(teach.getClassId()); + } + } + if (classIds.isEmpty()) { + return new ArrayList<>(); + } + List result = new ArrayList<>(); + try { + for (SchClassDto schClass : classService.getSchClassList(schSerId)) { + Integer classId = schClass.getClassId(); + if (classId != null && classIds.remove(classId)) { + result.add(teachingClassVo(classId, schClass.getClassName())); + } + } + } catch (BizException e) { + log.warn("[teachingClasses] 学校班级列表拉取失败, 降级为班级ID展示: {}", e.getMessage()); + } + for (Integer classId : classIds) { + result.add(teachingClassVo(classId, null)); + } + return result; + } + + private static TeachingClassVo teachingClassVo(Integer classId, String className) { + TeachingClassVo vo = new TeachingClassVo(); + vo.setClassId(String.valueOf(classId)); + vo.setClassName(StringUtils.hasText(className) ? className : String.valueOf(classId)); + return vo; + } + + /** + * 当前登录人学校的 bschclass GUID(无学校身份或上游未映射时返回 null)。 + */ + private String currentSchSerId() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + return null; + } + SchInfoDto sch = classService.getSchFirst(schId); + return sch == null ? null : sch.getSchSerId(); + } + + /** + * 当前教师在 bschclass 的任教关系明细(sch_class_teach)。 + * 登录身份缺学校/教师信息时返回空列表;远端不可用时抛 BizException,由调用方决定是否降级。 + */ + private List currentTeachList() { + String schSerId = currentSchSerId(); + Integer userId = UserContext.getUserId(); + if (schSerId == null || userId == null) { + return Collections.emptyList(); + } + return classService.getSchTeachList(schSerId, userId); + } + + /** + * 已发布及之后(进行中/已截止/已归档)的作业流程状态集合(布置学科口径)。 + */ + private static List publishedStatuses() { + return Arrays.asList(HwStatusEnum.PUBLISHED.getCode(), HwStatusEnum.CLOSED.getCode(), + HwStatusEnum.ARCHIVED.getCode()); + } + + /** + * 本校学科字典(bschclass SubInfo,当前登录人学校)。远端不可用或无学校身份时降级为空列表, + * 仅影响名称解析(学科名称回退为编码),不阻断学科清单本身。 + */ + private List subjectDictionary() { + try { + Integer schId = UserContext.getSchId(); + if (schId == null) { + return Collections.emptyList(); + } + SchInfoDto sch = classService.getSchFirst(schId); + if (sch == null || !StringUtils.hasText(sch.getSchSerId())) { + return Collections.emptyList(); + } + return classService.getSchSubList(sch.getSchSerId()); + } catch (BizException e) { + log.warn("[subjectDictionary] 本校学科字典拉取失败, 降级为编码展示: {}", e.getMessage()); + return Collections.emptyList(); + } + } + + /** + * 学科编码集合 → 共用 DTO:subId=编码,subName=字典名称(缺失时回退编码)。 + * 排序先按字典顺序(SubInfo 排序号),字典外编码按传入顺序追加在后。 + */ + private List toSubjectDtos(Collection codes, List dictionary) { + if (codes == null || codes.isEmpty()) { + return new ArrayList<>(); + } + Set pending = new LinkedHashSet<>(); + for (String code : codes) { + if (StringUtils.hasText(code)) { + pending.add(code); + } + } + List result = new ArrayList<>(); + for (SchSubjectDto subject : dictionary) { + String code = subject.getSubId(); + if (code != null && pending.remove(code)) { + result.add(subjectDto(code, subject.getSubName())); + } + } + for (String code : pending) { + result.add(subjectDto(code, null)); + } + return result; + } + + private static SubjectDto subjectDto(String code, String name) { + SubjectDto dto = new SubjectDto(); + dto.setSubId(code); + dto.setSubName(StringUtils.hasText(name) ? name : code); + return dto; + } + + // ============================ VO 组装 ============================ + + private HomeworkV2Vo toVo(Homework h, boolean withAttachments, boolean withProgress) { + HomeworkV2Vo vo = new HomeworkV2Vo(); + vo.setId(h.getId()); + vo.setTitle(h.getTitle()); + vo.setSubject(h.getSubject()); + vo.setDescription(withAttachments ? h.getContent() : null); + vo.setHwStatus(h.getHwStatus()); + vo.setHwStatusDesc(HwStatusEnum.of(h.getHwStatus()).getDesc()); + vo.setScheduledAt(h.getTimingSendTime()); + vo.setPublishedAt(h.getPublishedAt()); + vo.setDeadlineAt(h.getDeadlineAt()); + vo.setArchivedAt(h.getArchivedAt()); + vo.setSubmitType(SubmitTypes.parse(h.getSubmitType())); + vo.setAllowLate(h.getAllowLate()); + vo.setRemind1d(h.getRemind1d()); + vo.setRemind1h(h.getRemind1h()); + vo.setClasses(homeworkClassRepository.findByHomeworkId(h.getId()).stream() + .map(c -> new ClassItem(c.getClassId(), c.getClassName())) + .collect(Collectors.toList())); + if (withAttachments) { + vo.setAttachments(homeworkAttachmentRepository.findByHomeworkIdOrderBySortAscIdAsc(h.getId()).stream() + .map(a -> { + AttachmentItem item = new AttachmentItem(); + item.setFileId(a.getFileId()); + item.setFileUrl(a.getFileUrl()); + item.setFileType(a.getFileType()); + item.setFileName(a.getFileName()); + item.setSort(a.getSort()); + return item; + }).collect(Collectors.toList())); + } + vo.setPublisherId(h.getPublisherId()); + vo.setPublisherName(h.getPublisherName()); + vo.setCreateTime(h.getCreateTime()); + vo.setUpdateTime(h.getUpdateTime()); + if (withProgress && !HwStatusEnum.of(h.getHwStatus()).isDraft()) { + vo.setProgress(progressCalculator.compute(h.getId(), classIds(homeworkClassRepository.findByHomeworkId(h.getId()))).total); + } + return vo; + } + + // ============================ 名册辅助 ============================ + + /** 参与班级在班学生 id 集合 */ + private List rosterStudentIds(Integer homeworkId) { + return roster(homeworkId).stream().map(ClassStudent::getStudentId).collect(Collectors.toList()); + } + + private List roster(Integer homeworkId) { + List classIds = classIds(homeworkClassRepository.findByHomeworkId(homeworkId)); + if (classIds.isEmpty()) { + return new ArrayList<>(); + } + return classStudentRepository.findByClassIdInAndStatus(classIds, 1); + } + + private static List classIds(List classes) { + return classes.stream().map(HomeworkClass::getClassId).collect(Collectors.toList()); + } + + private static void requireDraft(Homework homework) { + if (!HwStatusEnum.of(homework.getHwStatus()).isDraft()) { + throw new BizException(ErrorCode.FORBIDDEN, "仅草稿状态的作业可执行该操作"); + } + } + + private static LocalDateTime truncateMinute(LocalDateTime time) { + return time == null ? null : time.withSecond(0).withNano(0); + } + + private static String truncate(String s, int max) { + if (s == null) { + return null; + } + return s.length() <= max ? s : s.substring(0, max); + } + + private static String nullSafe(String s) { + return s == null ? "" : s; + } +} diff --git a/src/main/java/com/yunhai/homework/service/impl/TemplateService.java b/src/main/java/com/yunhai/homework/service/impl/TemplateService.java new file mode 100644 index 0000000..460acca --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/impl/TemplateService.java @@ -0,0 +1,264 @@ +package com.yunhai.homework.service.impl; + +import com.yunhai.homework.entity.HomeworkTemplate; +import com.yunhai.homework.entity.TemplateType; +import com.yunhai.homework.entity.UpFile; +import com.yunhai.homework.repository.HomeworkTemplateRepository; +import com.yunhai.homework.repository.TemplateTypeRepository; +import com.yunhai.homework.repository.UpFileRepository; +import com.yunhai.homework.service.ITemplateService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +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# TemplateController + Template.cs。 + */ +@Service +public class TemplateService implements ITemplateService { + + @Autowired + private TemplateTypeRepository templateTypeRepository; + + @Autowired + private HomeworkTemplateRepository homeworkTemplateRepository; + + @Autowired + private UpFileRepository upFileRepository; + + // ============================ GetTypelist ============================ + + @Override + public List> getTypelist() { + List types = templateTypeRepository.findAllByOrderBySortOrderAsc(); + List> result = new ArrayList<>(); + + // 首项固定为"全部" + Map allItem = new LinkedHashMap<>(); + allItem.put("TypeID", 0); + allItem.put("TypeName", "全部"); + result.add(allItem); + + for (TemplateType t : types) { + Map item = new LinkedHashMap<>(); + item.put("TypeID", t.getId()); + item.put("TypeName", t.getTypeName()); + result.add(item); + } + return result; + } + + // ============================ EditType ============================ + + @Override + @Transactional + public void editType(Integer typeid, String typeName) { + if (!StringUtils.hasText(typeName)) { + throw new BizException(ErrorCode.PARAM_ERROR, "类型名称不能为空!"); + } + // 重名校验 + boolean exists; + if (typeid == null || typeid == 0) { + exists = templateTypeRepository.existsByTypeName(typeName); + } else { + exists = templateTypeRepository.existsByTypeNameAndIdNot(typeName, typeid); + } + if (exists) { + throw new BizException(ErrorCode.FORBIDDEN, "类型名称已存在!"); + } + + if (typeid == null || typeid == 0) { + // 新增 + TemplateType type = new TemplateType(); + type.setTypeName(typeName); + // sortOrder = max + 1 + List all = templateTypeRepository.findAllByOrderBySortOrderAsc(); + int maxSort = all.stream() + .mapToInt(t -> t.getSortOrder() != null ? t.getSortOrder() : 0) + .max().orElse(0); + type.setSortOrder(maxSort + 1); + templateTypeRepository.save(type); + } else { + // 编辑 + TemplateType type = templateTypeRepository.findById(typeid) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "类型不存在!")); + type.setTypeName(typeName); + templateTypeRepository.save(type); + } + } + + // ============================ DelType ============================ + + @Override + @Transactional + public void delType(Integer typeid) { + if (typeid == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "类型不存在!"); + } + TemplateType type = templateTypeRepository.findById(typeid) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "类型不存在!")); + // 校验是否有模板引用此类型 + long refCount = homeworkTemplateRepository.countByTypeId(typeid); + if (refCount > 0) { + throw new BizException(ErrorCode.FORBIDDEN, "类型存在模板!"); + } + templateTypeRepository.delete(type); + } + + // ============================ GetTemplatelist ============================ + + @Override + public Map getTemplatelist(Integer pageSize, Integer pageIndex, Integer typeid) { + if (pageSize == null || pageSize <= 0) pageSize = 10; + if (pageIndex == null || pageIndex <= 0) pageIndex = 1; + if (typeid == null) typeid = 0; + + Page page; + int offset = pageIndex - 1; + + if (typeid > 0) { + page = homeworkTemplateRepository.findByTypeId(typeid, + PageRequest.of(offset, pageSize, Sort.by(Sort.Direction.DESC, "refCount"))); + } else { + page = homeworkTemplateRepository.findAllByOrderByRefCountDesc(PageRequest.of(offset, pageSize)); + } + + // 加载类型名映射 + List allTypes = templateTypeRepository.findAllByOrderBySortOrderAsc(); + Map typeNameMap = allTypes.stream() + .collect(Collectors.toMap(TemplateType::getId, TemplateType::getTypeName, (a, b) -> a)); + + // 加载封面图 + List coverIds = page.getContent().stream() + .map(HomeworkTemplate::getCover) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + Map coverUrlMap = new HashMap<>(); + if (!coverIds.isEmpty()) { + for (Integer fid : coverIds) { + upFileRepository.findById(fid).ifPresent(f -> coverUrlMap.put(fid, f.getFileName())); + } + } + + List> listData = page.getContent().stream().map(t -> { + Map item = new LinkedHashMap<>(); + item.put("SerID", t.getId()); + item.put("Title", t.getTitle()); + item.put("TypeName", typeNameMap.getOrDefault(t.getTypeId(), "")); + item.put("Contents", t.getContent()); + item.put("ContentsFilter", filterHtml(t.getContent())); + item.put("Num", t.getRefCount() != null ? t.getRefCount() : 0); + item.put("imgurl", t.getCover() != null ? coverUrlMap.get(t.getCover()) : null); + return item; + }).collect(Collectors.toList()); + + Map result = new LinkedHashMap<>(); + result.put("list", listData); + result.put("total", page.getTotalElements()); + return result; + } + + // ============================ EditTemplate ============================ + + @Override + @Transactional + public void editTemplate(Integer serID, String title, String content, Integer typeId, Integer cover) { + if (!StringUtils.hasText(title)) { + throw new BizException(ErrorCode.PARAM_ERROR, "模板标题不能为空!"); + } + if (!StringUtils.hasText(content)) { + throw new BizException(ErrorCode.PARAM_ERROR, "模板内容不能为空!"); + } + + if (serID == null || serID == 0) { + // 新增 + HomeworkTemplate template = new HomeworkTemplate(); + template.setTitle(title); + template.setContent(content); + template.setTypeId(typeId); + template.setCover(cover); + template.setRefCount(0); + homeworkTemplateRepository.save(template); + } else { + // 编辑 + HomeworkTemplate template = homeworkTemplateRepository.findById(serID) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "模板不存在!")); + template.setTitle(title); + template.setContent(content); + template.setTypeId(typeId); + template.setCover(cover); + homeworkTemplateRepository.save(template); + } + } + + // ============================ DelTemplate ============================ + + @Override + @Transactional + public void delTemplate(Integer serID) { + if (serID == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + homeworkTemplateRepository.findById(serID).ifPresent(homeworkTemplateRepository::delete); + } + + // ============================ GetTemplateFist ============================ + + @Override + public Map getTemplateFist(Integer serID) { + if (serID == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + HomeworkTemplate t = homeworkTemplateRepository.findById(serID) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "模板不存在!")); + + // 加载类型名 + String typeName = ""; + if (t.getTypeId() != null) { + Optional type = templateTypeRepository.findById(t.getTypeId()); + if (type.isPresent()) { + typeName = type.get().getTypeName(); + } + } + + // 加载封面图 + String imgurl = null; + if (t.getCover() != null) { + Optional file = upFileRepository.findById(t.getCover()); + if (file.isPresent()) { + imgurl = file.get().getFileName(); + } + } + + Map result = new LinkedHashMap<>(); + result.put("SerID", t.getId()); + result.put("Title", t.getTitle()); + result.put("Type", t.getTypeId()); + result.put("TypeName", typeName); + result.put("Contents", t.getContent()); + result.put("Num", t.getRefCount() != null ? t.getRefCount() : 0); + result.put("imgurl", imgurl); + return result; + } + + // ============================ 辅助方法 ============================ + + /** + * 过滤HTML标签,返回纯文本(对应 C# WebHelper.FilterHtml)。 + */ + private String filterHtml(String html) { + if (html == null) return ""; + return html.replaceAll("<[^>]+>", "").trim(); + } +} diff --git a/src/main/java/com/yunhai/homework/service/impl/UserCenterService.java b/src/main/java/com/yunhai/homework/service/impl/UserCenterService.java new file mode 100644 index 0000000..33c6c40 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/impl/UserCenterService.java @@ -0,0 +1,268 @@ +package com.yunhai.homework.service.impl; + +import com.yunhai.homework.model.SchTeaList; +import com.yunhai.homework.service.IClassService; +import com.yunhai.homework.service.IUserCenterService; +import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.apistd.BizException; +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); + } + + /** + * 将扁平模块列表组装为树(对应原 HomeworkController.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/homework/service/sms/DefaultSmsSender.java b/src/main/java/com/yunhai/homework/service/sms/DefaultSmsSender.java new file mode 100644 index 0000000..241ce15 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/sms/DefaultSmsSender.java @@ -0,0 +1,22 @@ +package com.yunhai.homework.service.sms; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 短信发送默认空实现:仅记录日志,真实短信通道接入前的占位。 + */ +@Component +public class DefaultSmsSender implements SmsSender { + + private static final Logger log = LoggerFactory.getLogger(DefaultSmsSender.class); + + @Override + public void sendHomeworkSms(Integer homeworkId, String title, List receiverIds) { + log.info("[SmsSender] 预留短信能力:作业 {} 标题「{}」,接收人 {} 人(暂未接入真实短信通道)", + homeworkId, title, receiverIds == null ? 0 : receiverIds.size()); + } +} diff --git a/src/main/java/com/yunhai/homework/service/sms/SmsSender.java b/src/main/java/com/yunhai/homework/service/sms/SmsSender.java new file mode 100644 index 0000000..c3c3b59 --- /dev/null +++ b/src/main/java/com/yunhai/homework/service/sms/SmsSender.java @@ -0,0 +1,22 @@ +package com.yunhai.homework.service.sms; + +import java.util.List; + +/** + * 作业短信发送抽象接口。 + *

+ * 预留未来短信能力:发作业且开启短信(smsFlag=1)时调用; + * 当前仅提供默认空实现(记录日志),真实短信通道后续接入。 + *

+ */ +public interface SmsSender { + + /** + * 发送作业短信。 + * + * @param homeworkId 作业ID + * @param title 作业标题 + * @param receiverIds 接收人用户ID列表(用户中心数字ID;空表示全员作业) + */ + void sendHomeworkSms(Integer homeworkId, String title, List receiverIds); +} diff --git a/src/main/java/com/yunhai/homework/utils/PageUtils.java b/src/main/java/com/yunhai/homework/utils/PageUtils.java new file mode 100644 index 0000000..cfcc468 --- /dev/null +++ b/src/main/java/com/yunhai/homework/utils/PageUtils.java @@ -0,0 +1,37 @@ +package com.yunhai.homework.utils; + +import com.yunhai.homework.model.BasePageQuery; +import com.yunhai.homework.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/homework/utils/SubmitTypes.java b/src/main/java/com/yunhai/homework/utils/SubmitTypes.java new file mode 100644 index 0000000..470cf9f --- /dev/null +++ b/src/main/java/com/yunhai/homework/utils/SubmitTypes.java @@ -0,0 +1,82 @@ +package com.yunhai.homework.utils; + +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 提交方式(homework.submit_type)存取辅助。 + * 落库格式为 JSON 数组文本(如 ["image","attach"]),此处手工编解码避免实体层引入序列化器。 + * 取值:text / image / attach / none;PRD 的 图片/文件/两者 分别映射 + * ["image"] / ["attach"] / ["image","attach"]。 + */ +public class SubmitTypes { + + public static final String TEXT = "text"; + public static final String IMAGE = "image"; + public static final String ATTACH = "attach"; + public static final String NONE = "none"; + + private SubmitTypes() { + } + + /** 编码为落库文本;空列表返回 null */ + public static String toJson(List types) { + if (types == null || types.isEmpty()) { + return null; + } + String body = types.stream() + .filter(StringUtils::hasText) + .map(String::trim) + .map(t -> "\"" + t.toLowerCase() + "\"") + .collect(Collectors.joining(",")); + return StringUtils.hasText(body) ? "[" + body + "]" : null; + } + + /** 解析落库文本;空/非法返回空列表 */ + public static List parse(String json) { + List result = new ArrayList<>(); + if (!StringUtils.hasText(json)) { + return result; + } + String body = json.trim(); + if (body.startsWith("[")) { + body = body.substring(1); + } + if (body.endsWith("]")) { + body = body.substring(0, body.length() - 1); + } + Arrays.stream(body.split(",")) + .map(s -> s.replace("\"", "").trim()) + .filter(StringUtils::hasText) + .forEach(result::add); + return result; + } + + public static boolean contains(List types, String target) { + return types != null && types.stream().anyMatch(t -> t.equalsIgnoreCase(target)); + } + + /** 是否收集提交(none 表示仅查阅不收集) */ + public static boolean collects(List types) { + return types != null && !types.isEmpty() && !contains(types, NONE); + } + + /** 规范化入参:小写、去未知值、保序去重;空入参返回默认(图片+文件) */ + public static List normalize(List types) { + if (types == null) { + return new ArrayList<>(Arrays.asList(IMAGE, ATTACH)); + } + List result = types.stream() + .filter(StringUtils::hasText) + .map(String::trim) + .map(String::toLowerCase) + .filter(t -> t.equals(TEXT) || t.equals(IMAGE) || t.equals(ATTACH) || t.equals(NONE)) + .distinct() + .collect(Collectors.toList()); + return result.isEmpty() ? new ArrayList<>(Arrays.asList(IMAGE, ATTACH)) : result; + } +} diff --git a/src/main/java/com/yunhai/homework/utils/UserContext.java b/src/main/java/com/yunhai/homework/utils/UserContext.java new file mode 100644 index 0000000..47cca09 --- /dev/null +++ b/src/main/java/com/yunhai/homework/utils/UserContext.java @@ -0,0 +1,251 @@ +package com.yunhai.homework.utils; + +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import com.yunhai.platform.auth.spring.CurrentBoaUser; +import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.Value; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.StringUtils; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 当前登录人上下文(ThreadLocal)。结构对齐 UserclassService 同名工具: + * 人员标识统一为用户中心数字ID({@code Integer},{@code BoaUser.id},与 bschclass 的 + * {@code SchTeacher.teaId} / {@code SchStudent.stuId} 同一 ID 空间),姓名为真实姓名, + * 学校ID取当前激活身份({@code BoaUser.currentRole.schId})。 + *

+ * 三个核心取值(userId/userName/schId)在首次访问时从平台登录态 + * ({@link UserContextHolder} + {@link CurrentBoaUser})惰性解析并在本请求内缓存; + * 线程复用清理由 {@code UserContextConfig} 注册的拦截器在 afterCompletion 统一执行。 + */ +public class UserContext { + + private static final Logger log = LoggerFactory.getLogger(UserContext.class); + + private static final ThreadLocal USER_ID = new ThreadLocal<>(); + private static final ThreadLocal USER_NAME = new ThreadLocal<>(); + private static final ThreadLocal ROLE_ID = new ThreadLocal<>(); + private static final ThreadLocal SCH_ID = new ThreadLocal<>(); + private static final ThreadLocal IP = new ThreadLocal<>(); + private static final ThreadLocal TYPE = new ThreadLocal<>(); + + /** 平台 BoaUser 供应器(Spring 启动时注入,见 UserContextConfig) */ + private static CurrentBoaUser currentBoaUser; + + /** 账号→真实姓名缓存:姓名属低频变更数据,进程内缓存避免同账号反复外呼用户中心 */ + private static final Map ACCOUNT_NAME_CACHE = new ConcurrentHashMap<>(); + + /** 注入平台 BoaUser 供应器(启动期调用一次) */ + public static void init(CurrentBoaUser boaUserSupplier) { + currentBoaUser = boaUserSupplier; + } + + public static void setUserId(Integer userId) { + USER_ID.set(userId); + } + + /** + * 当前登录人ID(用户中心数字ID,即教师 teaId / 学生 stuId 同域标识)。 + * {@code LoginUser.userId} 为数字时直取;非数字(平台默认 DefaultLoginUserProvider + * 会把登录名放进 userId)时回退用户中心按登录名反查。无登录态或解析不到为 null。 + */ + public static Integer getUserId() { + Integer userId = USER_ID.get(); + if (userId == null) { + userId = resolveUserId(); + if (userId != null) { + USER_ID.set(userId); + } + } + return userId; + } + + public static void setUserName(String userName) { + USER_NAME.set(userName); + } + + /** + * 当前登录人姓名:会话 displayName 有效且不等于登录名时直取; + * 缺失或等于登录名(平台默认 Provider 的历史行为)时回退用户中心真实姓名,解析失败保持登录名。 + */ + public static String getUserName() { + String userName = USER_NAME.get(); + if (userName == null) { + userName = resolveUserName(); + if (userName != null) { + USER_NAME.set(userName); + } + } + return userName; + } + + public static void setRoleId(Integer roleId) { + ROLE_ID.set(roleId); + } + + public static Integer getRoleId() { + return ROLE_ID.get(); + } + + public static void setSchId(Integer schId) { + SCH_ID.set(schId); + } + + /** 当前登录人学校ID(多租户隔离:发布时填充,查询时过滤),无身份时为 null。 */ + public static Integer getSchId() { + Integer schId = SCH_ID.get(); + if (schId == null) { + BoaUser boaUser = currentBoaUser == null ? null : currentBoaUser.get(); + if (boaUser != null && boaUser.getCurrentRole() != null) { + schId = boaUser.getCurrentRole().getSchId(); + if (schId != null) { + SCH_ID.set(schId); + } + } + } + return schId; + } + + public static void setIp(String ip) { + IP.set(ip); + } + + public static String getIp() { + return IP.get(); + } + + public static void setType(Integer type) { + TYPE.set(type); + } + + public static Integer getType() { + return TYPE.get(); + } + + @Value + public static class UserInfo { + Integer userId; + String userName; + Integer roleId; + Integer schId; + String ip; + Integer type; + } + + public static UserInfo getCurrentUser() { + return new UserInfo( + getUserId(), + getUserName(), + ROLE_ID.get(), + getSchId(), + IP.get(), + TYPE.get() + ); + } + + public static void setUserInfo(UserInfo info) { + if (info == null) return; // 或抛异常 + USER_ID.set(info.getUserId()); + USER_NAME.set(info.getUserName()); + ROLE_ID.set(info.getRoleId()); + SCH_ID.set(info.getSchId()); + IP.set(info.getIp()); + TYPE.set(info.getType()); + } + + public static Runnable wrap(Runnable task) { + UserInfo info = getCurrentUser(); + return () -> { + try { + setUserInfo(info); + task.run(); + } finally { + clear(); + } + }; + } + + /** 清理线程上下文(请求结束后必须调用,避免线程复用串号) */ + public static void clear() { + USER_ID.remove(); + USER_NAME.remove(); + ROLE_ID.remove(); + SCH_ID.remove(); + IP.remove(); + TYPE.remove(); + } + + private static Integer resolveUserId() { + LoginUser user = UserContextHolder.get(); + if (user == null || !StringUtils.hasText(user.getUserId())) { + return null; + } + String raw = user.getUserId().trim(); + try { + return Integer.valueOf(raw); + } catch (NumberFormatException ignored) { + // 平台默认 Provider 把登录名放进 userId(非数字):回退用户中心按账号反查数字ID + } + BoaUser boa = userCenterByAccount(raw); + return boa == null ? null : boa.getId(); + } + + private static String resolveUserName() { + LoginUser user = UserContextHolder.get(); + if (user == null) { + return null; + } + String loginName = user.getLoginName(); + String display = user.getDisplayName(); + if (!StringUtils.hasText(display) || display.equals(loginName)) { + return cachedUserName(loginName); + } + return display; + } + + /** 按登录账号取用户中心资料;数字型账号(历史落库的教师ID形态)改按ID查询 */ + private static BoaUser userCenterByAccount(String account) { + try { + BoaUser boa = YschBaseOpenApi.getUserInfo(account); + if ((boa == null || !StringUtils.hasText(boa.getName())) && isNumericId(account)) { + boa = YschBaseOpenApi.getUserInfo(Integer.valueOf(account)); + } + return boa; + } catch (Exception e) { + log.warn("[UserContext] 用户中心按账号反查失败, account={}", account); + return null; + } + } + + /** 按账号取用户中心真实姓名,查不到回退账号本身(结果含回退值一并缓存) */ + private static String cachedUserName(String account) { + if (!StringUtils.hasText(account)) { + return account; + } + if (ACCOUNT_NAME_CACHE.size() > 2000) { + ACCOUNT_NAME_CACHE.clear(); + } + return ACCOUNT_NAME_CACHE.computeIfAbsent(account, acc -> { + BoaUser boa = userCenterByAccount(acc); + return boa != null && StringUtils.hasText(boa.getName()) ? boa.getName() : acc; + }); + } + + /** 值是否为纯数字(用于区分「教师/学生ID」与「登录账号」两种形态) */ + private static boolean isNumericId(String value) { + if (!StringUtils.hasText(value)) { + return false; + } + for (int i = 0; i < value.length(); i++) { + if (!Character.isDigit(value.charAt(i))) { + return false; + } + } + return true; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..c59c52f --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,77 @@ +server: + port: 8102 + +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/class_homework?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: homework + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + env-config: + source: + # 前端运行时配置(JSON 字符串),线上由 Nacos/projman 维护;空则前端走同域推导兜底。 + # 端点 /api/env-config 由平台 yh-env-config-spring-boot-starter 提供(免登录 SSO 旁路)。 + front: "" + sso: + server-url-prefix: http://sso.yunhaixingtong.com + client-host-url: http://localhost:8102 + allow-headers: all + allow-origins: all + url-pattern: /api/* + mobi-api-pattern: /mobiapi/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用) + app-key: 100007 + app-secret: 01cdedfae2ad4724a752beffe4443e84 + 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..6c0b864 --- /dev/null +++ b/src/main/resources/bootstrap.yml @@ -0,0 +1,33 @@ +spring: + application: + name: classhomework + 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},即 classhomework.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