From 9e83208f35eea932bf7d6a37db52826dd8a5f50a Mon Sep 17 00:00:00 2001 From: wanghu Date: Wed, 21 Oct 2026 15:00:26 +0800 Subject: [PATCH] commit --- .gitignore | 5 + META-INF/MANIFEST.MF | 13 + docker/application-prod.yml | 52 + docker/run-app.sh | 9 + pom.xml | 211 +++ .../AcaClassAssignApplication.java | 20 + .../aspect/ControllerUserContextAspect.java | 154 ++ .../aspect/SysOperationLogAspect.java | 223 +++ .../common/listener/BaseAuditListener.java | 47 + .../common/validation/ValidationGroups.java | 10 + .../yunhai/acaclassassign/config/Config.java | 11 + .../acaclassassign/config/JacksonConfig.java | 35 + .../acaclassassign/config/SwaggerConfig.java | 22 + .../api/AssignProjectController.java | 113 ++ .../controller/api/AssignRunController.java | 99 + .../api/AssignStudentController.java | 181 ++ .../controller/api/BaseApiController.java | 147 ++ .../controller/api/LogController.java | 32 + .../acaclassassign/entity/AssignClass.java | 53 + .../acaclassassign/entity/AssignProject.java | 51 + .../acaclassassign/entity/AssignPush.java | 60 + .../acaclassassign/entity/AssignResult.java | 69 + .../acaclassassign/entity/AssignRule.java | 66 + .../acaclassassign/entity/AssignStudent.java | 96 + .../acaclassassign/entity/BaseEntity.java | 49 + .../entity/SysOperationLog.java | 84 + .../yunhai/acaclassassign/enums/UserType.java | 20 + .../acaclassassign/model/BasePageQuery.java | 14 + .../acaclassassign/model/BasePageResult.java | 33 + .../acaclassassign/model/SchTeaList.java | 106 ++ .../acaclassassign/model/UserSysRole.java | 29 + .../model/assign/AdjustRequest.java | 24 + .../model/assign/AdjustResultVo.java | 19 + .../model/assign/AssignSubmitCheckVo.java | 52 + .../model/assign/BalanceVo.java | 33 + .../model/assign/BandReportVo.java | 44 + .../acaclassassign/model/assign/ChartsVo.java | 49 + .../model/assign/ClassCardVo.java | 61 + .../model/assign/ClassItemVo.java | 37 + .../model/assign/ClassReportVo.java | 35 + .../model/assign/ConstraintVo.java | 22 + .../model/assign/ImportPasteRequest.java | 25 + .../model/assign/ImportResultVo.java | 40 + .../model/assign/MetricItemVo.java | 44 + .../model/assign/PresetBatchRequest.java | 24 + .../model/assign/PresetBatchResultVo.java | 17 + .../model/assign/ProjectInfoVo.java | 46 + .../model/assign/ProjectStatsVo.java | 22 + .../model/assign/PushStatusVo.java | 68 + .../acaclassassign/model/assign/RuleVo.java | 32 + .../model/assign/RunResultVo.java | 92 + .../model/assign/SaveClassesRequest.java | 21 + .../model/assign/SaveRulesRequest.java | 18 + .../model/assign/SourceAnalysisVo.java | 37 + .../model/assign/StudentPageRequest.java | 26 + .../model/assign/StudentPageVo.java | 33 + .../model/assign/StudentStatsVo.java | 48 + .../model/assign/StudentUpdateRequest.java | 18 + .../model/assign/StudentVo.java | 55 + .../model/base/SetRoleRequest.java | 12 + .../model/base/SetRoleResult.java | 15 + .../model/bsch/ClassTreeNodeVo.java | 29 + .../model/bsch/NowYearTermVo.java | 50 + .../acaclassassign/model/bsch/SubjectVo.java | 19 + .../model/bsch/SyncClassModels.java | 67 + .../model/bsch/YearTermItemVo.java | 19 + .../repository/AssignClassRepository.java | 20 + .../repository/AssignProjectRepository.java | 16 + .../repository/AssignPushRepository.java | 15 + .../repository/AssignResultRepository.java | 18 + .../repository/AssignRuleRepository.java | 18 + .../repository/AssignStudentRepository.java | 20 + .../repository/SysOperationLogRepository.java | 12 + .../service/IAssignProjectService.java | 52 + .../service/IAssignRunService.java | 42 + .../service/IAssignStudentService.java | 100 + .../acaclassassign/service/IClassService.java | 104 ++ .../service/IPushClassService.java | 21 + .../service/ISysOperationLogService.java | 17 + .../service/IUserCenterService.java | 51 + .../service/assign/AssignAlgorithm.java | 1634 +++++++++++++++++ .../service/assign/SameSound.java | 54 + .../service/assign/SampleDataGenerator.java | 53 + .../service/assign/StudentParser.java | 504 +++++ .../service/impl/AssignProjectService.java | 579 ++++++ .../service/impl/AssignRunService.java | 1375 ++++++++++++++ .../service/impl/AssignStudentService.java | 1116 +++++++++++ .../service/impl/ClassService.java | 723 ++++++++ .../service/impl/PushClassService.java | 468 +++++ .../service/impl/SysOperationLogService.java | 73 + .../service/impl/UserCenterService.java | 268 +++ .../yunhai/acaclassassign/utils/CsvUtil.java | 65 + .../yunhai/acaclassassign/utils/GzipUtil.java | 27 + .../yunhai/acaclassassign/utils/IpUtils.java | 44 + .../acaclassassign/utils/PageUtils.java | 37 + .../acaclassassign/utils/PinyinInitials.java | 74 + .../acaclassassign/utils/UserContext.java | 140 ++ src/main/resources/application.yml | 82 + src/main/resources/bootstrap.yml | 33 + src/main/resources/logback-spring.xml | 35 + .../resources/sql/acaclassassign_schema.sql | 126 ++ src/main/resources/sql/sys_operation_log.sql | 31 + .../service/assign/AssignAlgorithmTest.java | 359 ++++ .../assign/SampleDataGeneratorTest.java | 38 + .../service/assign/StudentExportListTest.java | 174 ++ .../service/assign/StudentPageGradeTest.java | 85 + .../service/assign/StudentParserTest.java | 96 + .../service/assign/StudentStatsTest.java | 114 ++ .../service/impl/AssignRunRerunTest.java | 110 ++ .../service/impl/ClassExportImportTest.java | 239 +++ .../impl/GenStudentAccountFlowTest.java | 192 ++ .../service/impl/GenStudentNoFlowTest.java | 198 ++ .../impl/GenStudentPasswordFlowTest.java | 117 ++ .../service/impl/ImportPasteFlowTest.java | 62 + .../service/impl/PushClassAliasTest.java | 52 + .../service/impl/SampleTextFlowTest.java | 75 + .../utils/PinyinInitialsTest.java | 33 + 117 files changed, 13358 insertions(+) create mode 100644 .gitignore create mode 100644 META-INF/MANIFEST.MF 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/acaclassassign/AcaClassAssignApplication.java create mode 100644 src/main/java/com/yunhai/acaclassassign/aspect/ControllerUserContextAspect.java create mode 100644 src/main/java/com/yunhai/acaclassassign/aspect/SysOperationLogAspect.java create mode 100644 src/main/java/com/yunhai/acaclassassign/common/listener/BaseAuditListener.java create mode 100644 src/main/java/com/yunhai/acaclassassign/common/validation/ValidationGroups.java create mode 100644 src/main/java/com/yunhai/acaclassassign/config/Config.java create mode 100644 src/main/java/com/yunhai/acaclassassign/config/JacksonConfig.java create mode 100644 src/main/java/com/yunhai/acaclassassign/config/SwaggerConfig.java create mode 100644 src/main/java/com/yunhai/acaclassassign/controller/api/AssignProjectController.java create mode 100644 src/main/java/com/yunhai/acaclassassign/controller/api/AssignRunController.java create mode 100644 src/main/java/com/yunhai/acaclassassign/controller/api/AssignStudentController.java create mode 100644 src/main/java/com/yunhai/acaclassassign/controller/api/BaseApiController.java create mode 100644 src/main/java/com/yunhai/acaclassassign/controller/api/LogController.java create mode 100644 src/main/java/com/yunhai/acaclassassign/entity/AssignClass.java create mode 100644 src/main/java/com/yunhai/acaclassassign/entity/AssignProject.java create mode 100644 src/main/java/com/yunhai/acaclassassign/entity/AssignPush.java create mode 100644 src/main/java/com/yunhai/acaclassassign/entity/AssignResult.java create mode 100644 src/main/java/com/yunhai/acaclassassign/entity/AssignRule.java create mode 100644 src/main/java/com/yunhai/acaclassassign/entity/AssignStudent.java create mode 100644 src/main/java/com/yunhai/acaclassassign/entity/BaseEntity.java create mode 100644 src/main/java/com/yunhai/acaclassassign/entity/SysOperationLog.java create mode 100644 src/main/java/com/yunhai/acaclassassign/enums/UserType.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/BasePageQuery.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/BasePageResult.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/SchTeaList.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/UserSysRole.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/AdjustRequest.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/AdjustResultVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/AssignSubmitCheckVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/BalanceVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/BandReportVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/ChartsVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/ClassCardVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/ClassItemVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/ClassReportVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/ConstraintVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/ImportPasteRequest.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/ImportResultVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/MetricItemVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/PresetBatchRequest.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/PresetBatchResultVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/ProjectInfoVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/ProjectStatsVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/PushStatusVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/RuleVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/RunResultVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/SaveClassesRequest.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/SaveRulesRequest.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/SourceAnalysisVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/StudentPageRequest.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/StudentPageVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/StudentStatsVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/StudentUpdateRequest.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/assign/StudentVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/base/SetRoleRequest.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/base/SetRoleResult.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/bsch/ClassTreeNodeVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/bsch/NowYearTermVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/bsch/SubjectVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/bsch/SyncClassModels.java create mode 100644 src/main/java/com/yunhai/acaclassassign/model/bsch/YearTermItemVo.java create mode 100644 src/main/java/com/yunhai/acaclassassign/repository/AssignClassRepository.java create mode 100644 src/main/java/com/yunhai/acaclassassign/repository/AssignProjectRepository.java create mode 100644 src/main/java/com/yunhai/acaclassassign/repository/AssignPushRepository.java create mode 100644 src/main/java/com/yunhai/acaclassassign/repository/AssignResultRepository.java create mode 100644 src/main/java/com/yunhai/acaclassassign/repository/AssignRuleRepository.java create mode 100644 src/main/java/com/yunhai/acaclassassign/repository/AssignStudentRepository.java create mode 100644 src/main/java/com/yunhai/acaclassassign/repository/SysOperationLogRepository.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/IAssignProjectService.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/IAssignRunService.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/IAssignStudentService.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/IClassService.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/IPushClassService.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/ISysOperationLogService.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/IUserCenterService.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/assign/AssignAlgorithm.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/assign/SameSound.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/assign/SampleDataGenerator.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/assign/StudentParser.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/impl/AssignProjectService.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/impl/AssignRunService.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/impl/AssignStudentService.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/impl/ClassService.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/impl/PushClassService.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/impl/SysOperationLogService.java create mode 100644 src/main/java/com/yunhai/acaclassassign/service/impl/UserCenterService.java create mode 100644 src/main/java/com/yunhai/acaclassassign/utils/CsvUtil.java create mode 100644 src/main/java/com/yunhai/acaclassassign/utils/GzipUtil.java create mode 100644 src/main/java/com/yunhai/acaclassassign/utils/IpUtils.java create mode 100644 src/main/java/com/yunhai/acaclassassign/utils/PageUtils.java create mode 100644 src/main/java/com/yunhai/acaclassassign/utils/PinyinInitials.java create mode 100644 src/main/java/com/yunhai/acaclassassign/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 create mode 100644 src/main/resources/sql/acaclassassign_schema.sql create mode 100644 src/main/resources/sql/sys_operation_log.sql create mode 100644 src/test/java/com/yunhai/acaclassassign/service/assign/AssignAlgorithmTest.java create mode 100644 src/test/java/com/yunhai/acaclassassign/service/assign/SampleDataGeneratorTest.java create mode 100644 src/test/java/com/yunhai/acaclassassign/service/assign/StudentExportListTest.java create mode 100644 src/test/java/com/yunhai/acaclassassign/service/assign/StudentPageGradeTest.java create mode 100644 src/test/java/com/yunhai/acaclassassign/service/assign/StudentParserTest.java create mode 100644 src/test/java/com/yunhai/acaclassassign/service/assign/StudentStatsTest.java create mode 100644 src/test/java/com/yunhai/acaclassassign/service/impl/AssignRunRerunTest.java create mode 100644 src/test/java/com/yunhai/acaclassassign/service/impl/ClassExportImportTest.java create mode 100644 src/test/java/com/yunhai/acaclassassign/service/impl/GenStudentAccountFlowTest.java create mode 100644 src/test/java/com/yunhai/acaclassassign/service/impl/GenStudentNoFlowTest.java create mode 100644 src/test/java/com/yunhai/acaclassassign/service/impl/GenStudentPasswordFlowTest.java create mode 100644 src/test/java/com/yunhai/acaclassassign/service/impl/ImportPasteFlowTest.java create mode 100644 src/test/java/com/yunhai/acaclassassign/service/impl/PushClassAliasTest.java create mode 100644 src/test/java/com/yunhai/acaclassassign/service/impl/SampleTextFlowTest.java create mode 100644 src/test/java/com/yunhai/acaclassassign/utils/PinyinInitialsTest.java 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/META-INF/MANIFEST.MF b/META-INF/MANIFEST.MF new file mode 100644 index 0000000..07db349 --- /dev/null +++ b/META-INF/MANIFEST.MF @@ -0,0 +1,13 @@ +Manifest-Version: 1.0 +Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx +Implementation-Title: AcaClassAssign +Implementation-Version: 0.0.1 +Spring-Boot-Layers-Index: BOOT-INF/layers.idx +Start-Class: com.yunhai.acaclassassign.AcaClassAssignApplication +Spring-Boot-Classes: BOOT-INF/classes/ +Spring-Boot-Lib: BOOT-INF/lib/ +Build-Jdk-Spec: 1.8 +Spring-Boot-Version: 2.7.18 +Created-By: Maven JAR Plugin 3.2.2 +Main-Class: org.springframework.boot.loader.JarLauncher + diff --git a/docker/application-prod.yml b/docker/application-prod.yml new file mode 100644 index 0000000..4f977e7 --- /dev/null +++ b/docker/application-prod.yml @@ -0,0 +1,52 @@ +# AcaClassAssign 生产配置(线上以 nacos 的 acaclassassign.yaml 为准,本文件是 nacos 不可达时的兜底) +# 真正上线请在 Nacos 新建 dataId=acaclassassign.yaml、group=OA_APP 的配置覆盖以下项 +# (尤其 datasource 的库名/账号、yh.sso.client-host-url)。 +server: + port: 8070 + servlet: + # 对外经 caddy 暴露为 http://base.yunhaixingtong.com/acaclassassign + context-path: /acaclassassign + +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;库名为 bsch_acaclassassign(需先在 mysql8 上建库) + url: jdbc:mysql://mysql8:3306/bsch_acaclassassign?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: 'CVf^x$sW*b6CGHxh' + driver-class-name: com.mysql.cj.jdbc.Driver + min-idle: 10 + max-active: 30 + jpa: + database: MySQL + show-sql: false + hibernate: + ddl-auto: update + database-platform: org.hibernate.dialect.MySQL5InnoDBDialect + properties: + hibernate.order_by.default_null_ordering: last + open-in-view: true + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + sso: + server-url-prefix: http://sso.yunhaixingtong.com + client-host-url: http://base.yunhaixingtong.com/acaclassassign + 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..88f623e --- /dev/null +++ b/docker/run-app.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# AcaClassAssign 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml) +# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到 +# 镜像内烤好的 application-prod.yml 兜底默认。 +set -e + +echo "[run-app] starting AcaClassAssign (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..." +exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/app.jar \ + --spring.profiles.active=prod diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..b83a40b --- /dev/null +++ b/pom.xml @@ -0,0 +1,211 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + acaclassassign + 0.0.1 + AcaClassAssign + 分班管理子应用(新生入学分班) + + 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 + + + + + org.springframework.boot + spring-boot-starter-aop + + + + + 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 + + + + + org.apache.poi + poi + 4.1.2 + + + + + + + + acaclassassign + + + org.apache.maven.plugins + maven-compiler-plugin + + + false + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.acaclassassign.AcaClassAssignApplication + + + + + repackage + + + + + + + + + + + nexus-public + bsch/nexus public + http://nexus.yunhaixingtong.com/repository/maven-public/ + true + true + + + + + + ci + + C:/aca-build/acaclassassign + + + + + diff --git a/src/main/java/com/yunhai/acaclassassign/AcaClassAssignApplication.java b/src/main/java/com/yunhai/acaclassassign/AcaClassAssignApplication.java new file mode 100644 index 0000000..4cc5ca1 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/AcaClassAssignApplication.java @@ -0,0 +1,20 @@ +package com.yunhai.acaclassassign; + +import com.alibaba.fastjson.parser.ParserConfig; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@SpringBootApplication(scanBasePackages = "com.yunhai.acaclassassign") +@EnableTransactionManagement +@EnableAsync +public class AcaClassAssignApplication { + + public static void main(String[] args) { + // 开启 fastjson 安全模式 + ParserConfig.getGlobalInstance().setSafeMode(true); + SpringApplication.run(AcaClassAssignApplication.class, args); + } + +} diff --git a/src/main/java/com/yunhai/acaclassassign/aspect/ControllerUserContextAspect.java b/src/main/java/com/yunhai/acaclassassign/aspect/ControllerUserContextAspect.java new file mode 100644 index 0000000..bbd0deb --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/aspect/ControllerUserContextAspect.java @@ -0,0 +1,154 @@ +package com.yunhai.acaclassassign.aspect; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.acaclassassign.enums.UserType; +import com.yunhai.acaclassassign.model.UserSysRole; +import com.yunhai.acaclassassign.utils.IpUtils; +import com.yunhai.acaclassassign.utils.UserContext; +import com.yunhai.platform.auth.BoaUserContext; +import com.yunhai.platform.utils.model.BoaUser; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +/** + * 当前用户上下文填充切面(移植自 UserclassService ControllerUserContextAspect)。 + *

@Order(1) 先于审计日志切面(Order 3)执行,保证日志能取到 UserContext。 + * 取用户优先级:方法参数 BoaUser → BoaUserContext 静态持有者 → request attribute; + * 角色切换信息从 currentrole(Header/Query,URL 编码 JSON)解析。

+ */ +@Aspect +@Component +@Order(1) +public class ControllerUserContextAspect { + + private static final Logger log = LoggerFactory.getLogger(ControllerUserContextAspect.class); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 外部认证框架把解析好的 BoaUser 放进 request attribute 的候选 key */ + private static final List BOA_USER_ATTR_KEYS = Arrays.asList( + "boaUser", "boauser", "currentUser", "user", "authUser", "loginUser" + ); + + @Around("execution(* com.yunhai.acaclassassign.controller..*.*(..))") + public Object setUserContext(ProceedingJoinPoint joinPoint) throws Throwable { + Object[] args = joinPoint.getArgs(); + BoaUser boaUser = null; + UserSysRole userSysRole = null; + + for (Object arg : args) { + if (arg instanceof BoaUser) { + boaUser = (BoaUser) arg; + } else if (arg instanceof UserSysRole) { + userSysRole = (UserSysRole) arg; + } + } + + if (boaUser == null) { + try { + boaUser = BoaUserContext.get(); + } catch (Throwable ignore) { + boaUser = null; + } + } + HttpServletRequest request = getCurrentHttpRequest(); + if (request != null) { + if (boaUser == null) { + boaUser = resolveBoaUserFromRequest(request); + } + if (userSysRole == null) { + userSysRole = resolveUserSysRoleFromRequest(request); + } + } + + populateUserContext(boaUser, userSysRole); + + try { + return joinPoint.proceed(); + } finally { + UserContext.clear(); + log.debug("UserContext cleared for {}", joinPoint.getSignature().toShortString()); + } + } + + /** 从 currentrole(URL 编码,Header 或 Query 参数)解析 UserSysRole */ + private UserSysRole resolveUserSysRoleFromRequest(HttpServletRequest request) { + String raw = request.getHeader("currentrole"); + boolean fromQuery = false; + if (raw == null) { + raw = request.getParameter("currentrole"); + fromQuery = true; + } + if (raw == null) { + return null; + } + try { + String decoded = fromQuery ? raw : URLDecoder.decode(raw, StandardCharsets.UTF_8.name()); + return MAPPER.readValue(decoded, UserSysRole.class); + } catch (Exception e) { + log.warn("解析 currentrole 失败: {}", e.getMessage()); + return null; + } + } + + /** 从 request attribute(外部框架注入的 BoaUser)回退解析 */ + private BoaUser resolveBoaUserFromRequest(HttpServletRequest request) { + for (String key : BOA_USER_ATTR_KEYS) { + Object attr = request.getAttribute(key); + if (attr instanceof BoaUser) { + return (BoaUser) attr; + } + } + return null; + } + + private HttpServletRequest getCurrentHttpRequest() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attributes != null ? attributes.getRequest() : null; + } + + private void populateUserContext(BoaUser boaUser, UserSysRole userSysRole) { + if (boaUser != null) { + UserContext.setUserId(boaUser.getId()); + UserContext.setUserName(boaUser.getName() != null ? boaUser.getName() : "unknown"); + Integer schId = boaUser.getSchool() == null ? null : boaUser.getSchool().getId(); + if (schId == null && userSysRole != null) { + schId = userSysRole.getSchId(); + } + UserContext.setSchId(schId); + } else { + UserContext.setUserName("anonymous"); + if (userSysRole != null && userSysRole.getSchId() != null) { + UserContext.setSchId(userSysRole.getSchId()); + } + log.warn("No BoaUser found, using anonymous"); + } + + if (userSysRole != null) { + UserContext.setRoleId(userSysRole.getRoleId()); + UserContext.setChildId(userSysRole.getChildId()); + } + + UserContext.setType(boaUser != null ? boaUser.getType() : null); + + // 超级管理员(TYPE=1)不绑定具体学校,schId 统一置 0 + if (UserType.SUPER_ADMINISTRATOR.getId().equals(UserContext.getType())) { + UserContext.setSchId(0); + } + + UserContext.setIp(IpUtils.getClientIp()); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/aspect/SysOperationLogAspect.java b/src/main/java/com/yunhai/acaclassassign/aspect/SysOperationLogAspect.java new file mode 100644 index 0000000..860fc55 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/aspect/SysOperationLogAspect.java @@ -0,0 +1,223 @@ +package com.yunhai.acaclassassign.aspect; + +import com.alibaba.fastjson.JSON; +import com.yunhai.acaclassassign.entity.SysOperationLog; +import com.yunhai.acaclassassign.service.ISysOperationLogService; +import com.yunhai.acaclassassign.utils.GzipUtil; +import com.yunhai.acaclassassign.utils.IpUtils; +import com.yunhai.acaclassassign.utils.UserContext; +import com.yunhai.platform.apistd.ApiResponse; +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.exception.IErrorCode; +import com.yunhai.platform.utils.http.ApiResult; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 系统审计日志切面(移植自 UserclassService SysOperationLogAspect)。 + *

全量拦截 controller 包所有方法,不依赖注解;排除日志查询控制器自身(防递归); + * 用户信息来自 {@link ControllerUserContextAspect}(Order 1)填充的 UserContext; + * 响应体 GZIP 压缩落库,落库走 @Async 不阻塞请求。

+ */ +@Aspect +@Component +@Order(3) +public class SysOperationLogAspect { + + private static final Logger log = LoggerFactory.getLogger(SysOperationLogAspect.class); + + /** 排除的控制器(日志查询自身,防递归刷屏) */ + private static final List EXCLUDE_CONTROLLERS = java.util.Arrays.asList( + "com.yunhai.acaclassassign.controller.api.LogController" + ); + private static final int DEFAULT_MAX_RESPONSE_SIZE = 10240; + private static final int MAX_ERROR_MSG = 8000; + private static final int MAX_PARAMS = 2000; + + @Autowired + private ISysOperationLogService sysOperationLogService; + + @Around("execution(* com.yunhai.acaclassassign.controller..*.*(..))") + public Object around(ProceedingJoinPoint point) throws Throwable { + String declType = point.getSignature().getDeclaringTypeName(); + if (EXCLUDE_CONTROLLERS.contains(declType)) { + return point.proceed(); + } + + HttpServletRequest request = getRequest(); + String httpMethod = request != null ? request.getMethod() : ""; + + long start = System.currentTimeMillis(); + SysOperationLog entity = buildEntity(point, request, httpMethod); + + Object result; + try { + result = point.proceed(); + entity.setStatusCode(resolveSuccessCode(result)); + fillResponse(entity, result, DEFAULT_MAX_RESPONSE_SIZE); + return result; + } catch (Throwable e) { + entity.setStatusCode(resolveErrorCode(e)); + entity.setErrorMsg(buildErrorMsg(e)); + throw e; + } finally { + entity.setCostTime(System.currentTimeMillis() - start); + sysOperationLogService.saveAsync(entity); + } + } + + private SysOperationLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request, String httpMethod) { + SysOperationLog e = new SysOperationLog(); + e.setCreateTime(LocalDateTime.now()); + + // 用户信息(ControllerUserContextAspect Order 1 已填充) + e.setUserId(UserContext.getUserId()); + e.setUserName(StringUtils.hasText(UserContext.getUserName()) ? UserContext.getUserName() : "anonymous"); + e.setSchId(UserContext.getSchId()); + e.setRequestIp(StringUtils.hasText(UserContext.getIp()) ? UserContext.getIp() : IpUtils.getClientIp()); + + String uri = request != null ? request.getRequestURI() : ""; + String methodName = point.getSignature().getName(); + e.setRequestUri(uri); + e.setRequestMethod(httpMethod); + e.setClassMethod(point.getTarget().getClass().getName() + "." + methodName); + e.setLogLevel(resolveLevel(httpMethod)); + e.setOperationType(resolveOperationType(httpMethod, methodName)); + if (request != null) { + e.setUserAgent(request.getHeader("User-Agent")); + } + e.setRequestParams(truncate(buildRequestParams(point, request), MAX_PARAMS)); + return e; + } + + private String buildRequestParams(ProceedingJoinPoint point, HttpServletRequest request) { + try { + if (request != null && "GET".equalsIgnoreCase(request.getMethod())) { + String qs = request.getQueryString(); + if (StringUtils.hasText(qs)) { + return qs; + } + } + Object[] args = point.getArgs(); + if (args == null || args.length == 0) { + return ""; + } + List filtered = new ArrayList<>(); + for (Object arg : args) { + if (arg == null) { + continue; + } + if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse + || arg instanceof HttpSession || arg instanceof MultipartFile + || arg instanceof MultipartFile[]) { + continue; + } + // BoaUser 等平台对象跳过序列化(无 toString 意义且可能循环引用) + if (arg.getClass().getName().startsWith("com.yunhai.platform.")) { + continue; + } + filtered.add(arg); + } + return filtered.isEmpty() ? "" : JSON.toJSONString(filtered); + } catch (Exception ex) { + return ""; + } + } + + private void fillResponse(SysOperationLog e, Object result, int max) { + if (result == null) { + return; + } + try { + String resp = JSON.toJSONString(result); + if (resp.length() > max) { + resp = resp.substring(0, max) + "...[截断]"; + } + e.setResponseData(GzipUtil.compress(resp)); + } catch (Exception ex) { + log.warn("审计日志响应序列化失败, uri={}", e.getRequestUri()); + } + } + + /** 成功路径:Controller 返回 ApiResponse/ApiResult 时取其 code,其余视为 SUCCESS(0) */ + private int resolveSuccessCode(Object result) { + if (result instanceof ApiResponse) { + return ((ApiResponse) result).getCode(); + } + if (result instanceof ApiResult) { + return ((ApiResult) result).getCode(); + } + return ErrorCode.SUCCESS.getCode(); + } + + /** 异常路径:从业务异常提取错误码,兜底 INTERNAL_SERVER_ERROR */ + private int resolveErrorCode(Throwable e) { + if (e instanceof DException) { + IErrorCode ec = ((DException) e).getCode(); + if (ec != null) { + return ec.getCode(); + } + } + return ErrorCode.INTERNAL_SERVER_ERROR.getCode(); + } + + private HttpServletRequest getRequest() { + ServletRequestAttributes a = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return a != null ? a.getRequest() : null; + } + + private String buildErrorMsg(Throwable e) { + String msg = e.getMessage(); + String head = (msg == null || msg.trim().isEmpty()) ? e.getClass().getName() : msg; + return truncate(head + System.lineSeparator() + ExceptionUtils.getStackTrace(e), MAX_ERROR_MSG); + } + + private String truncate(String s, int max) { + return s != null && s.length() > max ? s.substring(0, max) : s; + } + + /** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */ + private int resolveLevel(String httpMethod) { + if ("DELETE".equalsIgnoreCase(httpMethod)) { + return 1; + } + if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod)) { + return 2; + } + return 3; + } + + private String resolveOperationType(String httpMethod, String methodName) { + String lower = methodName == null ? "" : methodName.toLowerCase(); + if ("DELETE".equalsIgnoreCase(httpMethod) || lower.startsWith("delete") || lower.startsWith("del")) { + return "DELETE"; + } + if (lower.startsWith("create") || lower.startsWith("add")) { + return "CREATE"; + } + if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod) + || lower.startsWith("edit") || lower.startsWith("update") || lower.startsWith("set")) { + return "UPDATE"; + } + return "QUERY"; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/common/listener/BaseAuditListener.java b/src/main/java/com/yunhai/acaclassassign/common/listener/BaseAuditListener.java new file mode 100644 index 0000000..2e9c470 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/common/listener/BaseAuditListener.java @@ -0,0 +1,47 @@ +package com.yunhai.acaclassassign.common.listener; + +import com.yunhai.acaclassassign.entity.BaseEntity; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import org.springframework.stereotype.Component; + +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import java.time.LocalDateTime; + +/** + * JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(平台当前登录人)与时间戳。 + * 对应 {@link BaseEntity} 的 createBy / updateBy / createTime / updateTime 字段。 + * 当前登录人取自基础平台 {@link UserContextHolder}。 + */ +@Component +public class BaseAuditListener { + + @PrePersist + public void prePersist(BaseEntity entity) { + LocalDateTime now = LocalDateTime.now(); + entity.setCreateTime(now); + entity.setUpdateTime(now); + String account = currentAccount(); + if (account != null) { + if (entity.getCreateBy() == null) { + entity.setCreateBy(account); + } + entity.setUpdateBy(account); + } + } + + @PreUpdate + public void preUpdate(BaseEntity entity) { + entity.setUpdateTime(LocalDateTime.now()); + String account = currentAccount(); + if (account != null) { + entity.setUpdateBy(account); + } + } + + private String currentAccount() { + LoginUser user = UserContextHolder.get(); + return user == null ? null : user.getLoginName(); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/common/validation/ValidationGroups.java b/src/main/java/com/yunhai/acaclassassign/common/validation/ValidationGroups.java new file mode 100644 index 0000000..c9237c9 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/common/validation/ValidationGroups.java @@ -0,0 +1,10 @@ +package com.yunhai.acaclassassign.common.validation; + +/** + * JSR-303 分组校验标记接口(对齐 UserBase 规范)。 + * Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。 + */ +public interface ValidationGroups { + interface Create {} + interface Update {} +} diff --git a/src/main/java/com/yunhai/acaclassassign/config/Config.java b/src/main/java/com/yunhai/acaclassassign/config/Config.java new file mode 100644 index 0000000..5f358fd --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/config/Config.java @@ -0,0 +1,11 @@ +package com.yunhai.acaclassassign.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/acaclassassign/config/JacksonConfig.java b/src/main/java/com/yunhai/acaclassassign/config/JacksonConfig.java new file mode 100644 index 0000000..cff8233 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/config/JacksonConfig.java @@ -0,0 +1,35 @@ +package com.yunhai.acaclassassign.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/acaclassassign/config/SwaggerConfig.java b/src/main/java/com/yunhai/acaclassassign/config/SwaggerConfig.java new file mode 100644 index 0000000..7bfd9a8 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/config/SwaggerConfig.java @@ -0,0 +1,22 @@ +package com.yunhai.acaclassassign.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 接口文档(springdoc-openapi,替代旧 Springfox)。 + */ +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI noticeOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("分班管理接口文档") + .description("通知公告子应用:发通知、列表、删除、编辑、管理员查看、统计(springdoc-openapi)") + .version("1.0")); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/controller/api/AssignProjectController.java b/src/main/java/com/yunhai/acaclassassign/controller/api/AssignProjectController.java new file mode 100644 index 0000000..dc1f188 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/controller/api/AssignProjectController.java @@ -0,0 +1,113 @@ +package com.yunhai.acaclassassign.controller.api; + +import com.yunhai.acaclassassign.config.Config; +import com.yunhai.acaclassassign.model.assign.AssignSubmitCheckVo; +import com.yunhai.acaclassassign.model.assign.ClassItemVo; +import com.yunhai.acaclassassign.model.assign.ProjectInfoVo; +import com.yunhai.acaclassassign.model.assign.PushStatusVo; +import com.yunhai.acaclassassign.model.assign.RuleVo; +import com.yunhai.acaclassassign.model.assign.SaveClassesRequest; +import com.yunhai.acaclassassign.model.assign.SaveRulesRequest; +import com.yunhai.acaclassassign.model.bsch.YearTermItemVo; +import com.yunhai.acaclassassign.service.IAssignProjectService; +import com.yunhai.acaclassassign.service.IPushClassService; +import com.yunhai.platform.utils.model.BoaUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +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.List; + +/** + * 分班项目接口(/{platform}/assign/project)。 + *

工作台信息、班级设置、规则、提交锁定。返回裸对象由 ApiResponseAdvice 包装。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/assign/project/") +public class AssignProjectController { + + @Autowired + private IAssignProjectService projectService; + + @Autowired + private IPushClassService pushClassService; + + /** 工作台信息(项目不存在自动创建) */ + @GetMapping("Info") + public ProjectInfoVo info(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + BoaUser authUserWithDept) { + return projectService.info(stage, year, term == null ? 1 : term); + } + + /** 当前学年学期 */ + @GetMapping("NowYearTerm") + public ProjectInfoVo.CurrentYear nowYearTerm() { + return projectService.nowYearTerm(); + } + + /** 学年下拉列表(班级管理学年学期接口,按学年倒序;不可达时为空列表由前端回退) */ + @GetMapping("YearList") + public List yearList() { + return projectService.yearList(); + } + + /** 保存班级设置 */ + @PostMapping("SaveClasses") + public List saveClasses(@RequestBody SaveClassesRequest request) { + return projectService.saveClasses(request); + } + + /** 保存分班规则 */ + @PostMapping("SaveRules") + public RuleVo saveRules(@RequestBody SaveRulesRequest request) { + return projectService.saveRules(request); + } + + /** 提交前检查清单 */ + @GetMapping("SubmitCheck") + public AssignSubmitCheckVo submitCheck(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term) { + return projectService.submitCheck(stage, year, term == null ? 1 : term); + } + + /** 提交锁定(force=人数差超限时人工确认) */ + @PostMapping("Submit") + public void submit(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + @RequestParam(defaultValue = "false") boolean force) { + projectService.submit(stage, year, term == null ? 1 : term, force); + } + + /** 重置全部(删除项目全部数据) */ + @PostMapping("Reset") + public void reset(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term) { + projectService.reset(stage, year, term == null ? 1 : term); + } + + /** 推送分班结果到班级管理(retry=true 只重推失败学生;仅提交后可推,推送中拒绝并发) */ + @PostMapping("Push") + public PushStatusVo push(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + @RequestParam(defaultValue = "false") boolean retry) { + return pushClassService.push(projectService.require(stage, year, term == null ? 1 : term), retry); + } + + /** 推送状态查询(状态/成功失败计数/失败明细) */ + @GetMapping("PushStatus") + public PushStatusVo pushStatus(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term) { + return pushClassService.status(projectService.require(stage, year, term == null ? 1 : term)); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/controller/api/AssignRunController.java b/src/main/java/com/yunhai/acaclassassign/controller/api/AssignRunController.java new file mode 100644 index 0000000..d338604 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/controller/api/AssignRunController.java @@ -0,0 +1,99 @@ +package com.yunhai.acaclassassign.controller.api; + +import com.yunhai.acaclassassign.config.Config; +import com.yunhai.acaclassassign.model.assign.AdjustRequest; +import com.yunhai.acaclassassign.model.assign.AdjustResultVo; +import com.yunhai.acaclassassign.model.assign.BandReportVo; +import com.yunhai.acaclassassign.model.assign.ClassReportVo; +import com.yunhai.acaclassassign.model.assign.RunResultVo; +import com.yunhai.acaclassassign.service.IAssignRunService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +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.List; + +import javax.servlet.http.HttpServletResponse; + +/** + * 分班执行与结果接口(/{platform}/assign/run)。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/assign/run/") +public class AssignRunController { + + @Autowired + private IAssignRunService runService; + + /** 执行均衡分班 */ + @PostMapping("Run") + public RunResultVo run(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term) { + return runService.run(stage, year, term); + } + + /** 结果页数据 */ + @GetMapping("Result") + public RunResultVo result(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term) { + return runService.result(stage, year, term); + } + + /** 手动调班(约束冲突未强制时返回 conflicts) */ + @PostMapping("Adjust") + public AdjustResultVo adjust(@RequestBody AdjustRequest request) { + return runService.adjust(request); + } + + /** 随机匹配班主任 */ + @PostMapping("MatchTeachers") + public List matchTeachers(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term) { + return runService.matchTeachers(stage, year, term); + } + + /** 班级均分报表(高中/初中) */ + @GetMapping("ClassReport") + public List classReport(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term) { + return runService.classReport(stage, year, term); + } + + /** 分数段报表 */ + @GetMapping("BandReport") + public BandReportVo bandReport(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + @RequestParam(defaultValue = "总分") String subject, + @RequestParam(required = false) Double min, + @RequestParam(required = false) Double max, + @RequestParam(required = false) Double step) { + return runService.bandReport(stage, year, term, subject, min, max, step); + } + + /** 分班明细 CSV 导出 */ + @GetMapping("ExportDetail") + public void exportDetail(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + HttpServletResponse response) throws Exception { + runService.exportDetail(stage, year, term, response); + } + + /** 班级统计 CSV 导出 */ + @GetMapping("ExportClassStats") + public void exportClassStats(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + HttpServletResponse response) throws Exception { + runService.exportClassStats(stage, year, term, response); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/controller/api/AssignStudentController.java b/src/main/java/com/yunhai/acaclassassign/controller/api/AssignStudentController.java new file mode 100644 index 0000000..1b3d3b0 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/controller/api/AssignStudentController.java @@ -0,0 +1,181 @@ +package com.yunhai.acaclassassign.controller.api; + +import com.yunhai.acaclassassign.config.Config; +import com.yunhai.acaclassassign.model.assign.ImportPasteRequest; +import com.yunhai.acaclassassign.model.assign.ImportResultVo; +import com.yunhai.acaclassassign.model.assign.PresetBatchRequest; +import com.yunhai.acaclassassign.model.assign.PresetBatchResultVo; +import com.yunhai.acaclassassign.model.assign.SourceAnalysisVo; +import com.yunhai.acaclassassign.model.assign.StudentPageVo; +import com.yunhai.acaclassassign.model.assign.StudentStatsVo; +import com.yunhai.acaclassassign.model.assign.StudentUpdateRequest; +import com.yunhai.acaclassassign.service.IAssignStudentService; +import com.yunhai.platform.utils.model.BoaUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +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 org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletResponse; + +/** + * 分班学生接口(/{platform}/assign/student)。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/assign/student/") +public class AssignStudentController { + + @Autowired + private IAssignStudentService studentService; + + /** 粘贴文本导入 */ + @PostMapping("ImportPaste") + public ImportResultVo importPaste(@RequestBody ImportPasteRequest request) { + return studentService.importPaste(request); + } + + /** 文件导入(csv/txt/tsv) */ + @PostMapping("ImportFile") + public ImportResultVo importFile(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + @RequestParam(defaultValue = "replace") String mode, + @RequestParam(required = false) Boolean hasHeader, + @RequestParam("file") MultipartFile file) { + return studentService.importFile(stage, year, term, mode, hasHeader, file); + } + + /** 示例名单文本(服务端生成,前端填入粘贴框,确认后走 ImportPaste 入库) */ + @GetMapping("SampleText") + public Map sampleText(@RequestParam Integer stage) { + return studentService.sampleText(stage); + } + + /** 名单分页(附识别出的单科/是否/数值列名清单) */ + @GetMapping("Page") + public StudentPageVo page(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + @RequestParam(required = false) Integer classId, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "1") Integer pageIndex, + @RequestParam(defaultValue = "50") Integer pageSize) { + return studentService.page(stage, year, term, classId, keyword, + pageIndex == null ? 1 : pageIndex, pageSize == null ? 50 : pageSize); + } + + /** 学生名单 CSV 下载(与 Page 同筛选口径,导出全部匹配行) */ + @GetMapping("ExportList") + public void exportList(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + @RequestParam(required = false) Integer classId, + @RequestParam(required = false) String keyword, + HttpServletResponse response) throws Exception { + studentService.exportList(stage, year, term, classId, keyword, response); + } + + /** 名单简单统计(全量:人数/性别/总分三值/均衡字段/毕业学校所数) */ + @GetMapping("Stats") + public StudentStatsVo stats(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term) { + return studentService.stats(stage, year, term); + } + + /** 单元格编辑保存 */ + @PostMapping("Update") + public void update(@RequestBody StudentUpdateRequest request) { + studentService.update(request); + } + + /** 删除学生 */ + @PostMapping("Delete") + public void delete(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + @RequestParam Integer id) { + studentService.delete(stage, year, term, id); + } + + /** 清空名单 */ + @PostMapping("Clear") + public void clear(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term) { + studentService.clear(stage, year, term); + } + + /** 按姓名批量预设班级 */ + @PostMapping("PresetBatch") + public PresetBatchResultVo presetBatch(@RequestBody PresetBatchRequest request) { + return studentService.presetBatch(request); + } + + /** 生源分析(高中/初中) */ + @GetMapping("SourceAnalysis") + public SourceAnalysisVo sourceAnalysis(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + @RequestParam(required = false) List cuts) { + return studentService.sourceAnalysis(stage, year, term, cuts); + } + + /** 生源分析交叉表 CSV 下载 */ + @GetMapping("ExportSource") + public void exportSource(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + @RequestParam(required = false) List cuts, + HttpServletResponse response) throws Exception { + studentService.exportSource(stage, year, term, cuts, response); + } + + /** 导入模板 CSV 下载 */ + @GetMapping("Template") + public void template(@RequestParam Integer stage, HttpServletResponse response) throws Exception { + studentService.template(stage, response); + } + + /** 班级导入数据 .xls 下载(班级管理「班级导入」模板:年级|班级|班级别名|班主任|副班主任 + 导入规则页) */ + @GetMapping("ExportClassImport") + public void exportClassImport(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + HttpServletResponse response) throws Exception { + studentService.exportClassImport(stage, year, term, response); + } + + /** 按成绩/序号生成学号(有总分=成绩排名;无总分=序号。scope=class 班内2位 01、02…;scope=grade 学年+4位 如 20250001;覆盖已有学号) */ + @PostMapping("GenNo") + public Map genNo(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + @RequestParam(defaultValue = "class") String scope) { + return studentService.genStudentNo(stage, year, term, scope); + } + + /** 按成绩/序号生成账号(学校名首字母小写+s+学年+序号段:class=班序2位+班内序2位;grade=年级序4位;覆盖已有账号) */ + @PostMapping("GenAccount") + public Map genAccount(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term, + @RequestParam(defaultValue = "class") String scope) { + return studentService.genStudentAccount(stage, year, term, scope); + } + + /** 生成密码=账号+@(仅已分班且有账号的学生,无账号跳过;覆盖已有密码) */ + @PostMapping("GenPassword") + public Map genPassword(@RequestParam Integer stage, + @RequestParam(required = false) String year, + @RequestParam(required = false) Integer term) { + return studentService.genStudentPassword(stage, year, term); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/controller/api/BaseApiController.java b/src/main/java/com/yunhai/acaclassassign/controller/api/BaseApiController.java new file mode 100644 index 0000000..569e603 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/controller/api/BaseApiController.java @@ -0,0 +1,147 @@ +package com.yunhai.acaclassassign.controller.api; + +import com.yunhai.acaclassassign.config.Config; +import com.yunhai.acaclassassign.model.UserSysRole; +import com.yunhai.acaclassassign.model.bsch.ClassTreeNodeVo; +import com.yunhai.acaclassassign.model.base.SetRoleRequest; +import com.yunhai.acaclassassign.model.base.SetRoleResult; +import com.yunhai.acaclassassign.service.IClassService; +import com.yunhai.acaclassassign.service.IUserCenterService; +import com.yunhai.acaclassassign.utils.UserContext; +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.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +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.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 平台代理域接口(base/)。 + *

承载:GetUserInfo / GetUserMod / GetSchoolAll / GetClassTree / SetRole / Logout。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/base/") +public class BaseApiController { + + private static final Logger log = LoggerFactory.getLogger(BaseApiController.class); + + /** 家长角色ID(UserRole.PATRIARCH_USER),用于 GetUserInfo 的 role 判定 */ + private static final Integer ROLE_PARENT = 4; + + @Autowired + private IUserCenterService userCenterService; + + @Autowired + private IClassService classService; + + // ============================ GetUserInfo ============================ + + /** + * 获取当前登录用户信息。 + * 返回 {id, name, role: teacher|parent, profilePicture, school: {id, name}, currentRole} + */ + @GetMapping("GetUserInfo") + public BoaUser getUserInfo(BoaUser authUserWithDept) { + return authUserWithDept; + } + + + // ============================ GetUserMod ============================ + + /** + * 获取当前用户已授权的模块列表(左侧菜单树)。 + */ + @GetMapping("GetUserMod") + public List> getUserMod(BoaUser authUserWithDept) { + Integer schId = schIdOf(authUserWithDept); + return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId); + } + + // ============================ GetSchoolAll ============================ + + /** + * 获取当前用户学校组及教师(树形结构,选人数据源)。 + */ + @GetMapping("GetSchoolAll") + public List> getSchoolAll(BoaUser authUserWithDept) { + return userCenterService.getSchoolAllTree(schIdOf(authUserWithDept)); + } + + // ============================ GetClassTree ============================ + + /** + * 年级→班级树(叶子=班级,附学生数)。数据来自班级服务学生树(5 分钟缓存)。 + */ + @GetMapping("GetClassTree") + public List getClassTree(BoaUser authUserWithDept) { + return classService.getClassTree(schIdOf(authUserWithDept)); + } + + // ============================ SetRole ============================ + + /** + * 角色切换(teacher / parent)。会话身份由平台 CAS 管理,此处回显确认。 + */ + @PostMapping("SetRole") + public SetRoleResult setRole(@RequestBody SetRoleRequest request) { + String role = request != null && "parent".equalsIgnoreCase(request.getAs()) ? "parent" : "teacher"; + return new SetRoleResult(role); + } + + // ============================ Logout ============================ + + /** + * 退出登录。认证由平台网关处理,此处仅返回成功。 + */ + @PostMapping("Logout") + public void logout() { + LoginUser user = UserContextHolder.get(); + log.info("[logout] 用户退出登录: {}", user == null ? null : user.getLoginName()); + } + + // ============================ 内部方法 ============================ + + private Integer schIdOf(BoaUser user) { + if (user.getSchool() != null) { + return user.getSchool().getId(); + } + return UserContext.getSchId(); + } + + private UserSysRole currentRole(BoaUser user) { + UserSysRole role = new UserSysRole(); + if (user.getSchool() != null) { + role.setSchId(user.getSchool().getId()); + } + role.setUserId(user.getId()); + if (user.getRoleIds() != null && !user.getRoleIds().isEmpty()) { + role.setRoleId(user.getRoleIds().get(0)); + } + // childId 由 ControllerUserContextAspect 填充进 UserContext + Integer childId = UserContext.getChildId(); + role.setChildId(childId == null ? 0 : childId); + return role; + } + + /** 家长判定:角色=家长(4) 或带有效 childId */ + private boolean isParent(UserSysRole role) { + if (role == null) { + return false; + } + if (ROLE_PARENT.equals(role.getRoleId())) { + return true; + } + return role.getChildId() != null && role.getChildId() > 0; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/controller/api/LogController.java b/src/main/java/com/yunhai/acaclassassign/controller/api/LogController.java new file mode 100644 index 0000000..081b6df --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/controller/api/LogController.java @@ -0,0 +1,32 @@ +package com.yunhai.acaclassassign.controller.api; + +import com.yunhai.acaclassassign.config.Config; +import com.yunhai.acaclassassign.entity.SysOperationLog; +import com.yunhai.acaclassassign.model.BasePageQuery; +import com.yunhai.acaclassassign.model.BasePageResult; +import com.yunhai.acaclassassign.service.ISysOperationLogService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 审计日志查询接口(log/)。 + *

本控制器被 SysOperationLogAspect 排除,避免日志查询自身再产生日志(防递归)。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/log/") +public class LogController { + + @Autowired + private ISysOperationLogService logService; + + /** 分页查询审计日志(responseData 不返回) */ + @GetMapping("Page") + public BasePageResult page(BasePageQuery query, + @RequestParam(value = "operationType", required = false) String operationType, + @RequestParam(value = "statusCode", required = false) Integer statusCode) { + return logService.page(query, operationType, statusCode); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/entity/AssignClass.java b/src/main/java/com/yunhai/acaclassassign/entity/AssignClass.java new file mode 100644 index 0000000..9ae62f6 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/entity/AssignClass.java @@ -0,0 +1,53 @@ +package com.yunhai.acaclassassign.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; + +/** + * 分班班级设置:项目下的班级清单(序号/班名/班主任/目标人数/A班标记)。 + *

班主任只保存平台教师 id(teacherId),教师候选来自基础平台 GetSchoolAll; + * teacherName 为保存时刻的展示冗余,不建任何账号字段。

+ */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "aca_assign_class", indexes = { + @Index(name = "idx_ac_project", columnList = "project_id"), + @Index(name = "idx_ac_teacher", columnList = "teacher_id")}) +public class AssignClass extends BaseEntity { + + /** 所属分班项目 */ + @Column(name = "project_id", nullable = false) + private Integer projectId; + + /** 班级序号(1 起,决定 A/B 分层时 A 班的先后) */ + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + /** 班级名称,默认 {n}班,可自定义 */ + @Column(name = "name", nullable = false, length = 32) + private String name; + + /** 班主任平台教师id(可空=未指定);不存教师账号 */ + @Column(name = "teacher_id") + private Integer teacherId; + + /** 班主任姓名展示冗余(保存时刻快照,来源于平台教师列表) */ + @Column(name = "teacher_name", length = 64) + private String teacherName; + + /** 目标人数(空=自动均分;手动值优先,未填班均分剩余人数) */ + @Column(name = "target_count") + private Integer targetCount; + + /** 是否A班(高中 layer 分层:取列表前 abClassCount 个班) */ + @Column(name = "ab_flag", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") + private Boolean abFlag; +} diff --git a/src/main/java/com/yunhai/acaclassassign/entity/AssignProject.java b/src/main/java/com/yunhai/acaclassassign/entity/AssignProject.java new file mode 100644 index 0000000..aafd8e4 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/entity/AssignProject.java @@ -0,0 +1,51 @@ +package com.yunhai.acaclassassign.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 分班项目:每校 × 学段 × 学年 × 学期一条,承载提交锁定状态。 + *

提交后该学年学期锁定为只读(不可改数据/规则/重新分班),撤销只能重置全部。

+ */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "aca_assign_project", + indexes = @Index(name = "idx_ap_sch_stage", columnList = "sch_id,stage"), + uniqueConstraints = @UniqueConstraint(name = "uk_ap_sch_stage_year_term", + columnNames = {"sch_id", "stage", "year", "term"})) +public class AssignProject extends BaseEntity { + + /** 学校ID(多校隔离;超管为 0) */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 学段:2小学 3初中 4高中 */ + @Column(name = "stage", nullable = false, columnDefinition = "TINYINT") + private Integer stage; + + /** 学年起始年,如 2025 表示 2025-2026 学年 */ + @Column(name = "year", nullable = false, length = 16) + private String year; + + /** 学期:1第一学期 2第二学期 */ + @Column(name = "term", nullable = false, columnDefinition = "TINYINT") + private Integer term; + + /** 状态:0草稿 1已提交(提交后锁定) */ + @Column(name = "status", nullable = false, columnDefinition = "TINYINT DEFAULT 0") + private Integer status; + + /** 提交时间 */ + @Column(name = "submit_time") + private LocalDateTime submitTime; +} diff --git a/src/main/java/com/yunhai/acaclassassign/entity/AssignPush.java b/src/main/java/com/yunhai/acaclassassign/entity/AssignPush.java new file mode 100644 index 0000000..82efd10 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/entity/AssignPush.java @@ -0,0 +1,60 @@ +package com.yunhai.acaclassassign.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 分班结果推送记录:每项目一条(提交分班结果 → 推送班级管理)。 + *

推送与提交解耦:推送异常不回滚提交锁定,只影响本表状态; + * 状态机 0未推送 → 4推送中 → 1已推送 / 2部分失败 / 3失败(推送中拒绝重复 Push)。 + * 重推幂等依赖被推方按 code/account 查重,本表只收敛状态与失败明细。

+ */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "aca_assign_push", + uniqueConstraints = @UniqueConstraint(name = "uk_apush_project", columnNames = "project_id")) +public class AssignPush extends BaseEntity { + + public static final int STATUS_NONE = 0; + + public static final int STATUS_OK = 1; + + public static final int STATUS_PARTIAL = 2; + + public static final int STATUS_FAIL = 3; + + public static final int STATUS_PUSHING = 4; + + /** 所属分班项目(唯一) */ + @Column(name = "project_id", nullable = false) + private Integer projectId; + + /** 状态:0未推送 1已推送 2部分失败 3失败 4推送中 */ + @Column(name = "status", nullable = false, columnDefinition = "TINYINT DEFAULT 0") + private Integer status; + + /** 最近一次推送完成时间 */ + @Column(name = "push_time") + private LocalDateTime pushTime; + + /** 本地班级id → 平台班级id(班级组id)映射 JSON:{"12":9901,...} */ + @Column(name = "class_map_json", columnDefinition = "TEXT") + private String classMapJson; + + /** 未成功学生明细 JSON:[{"studentId":1,"name":"张三","reason":"..."}](跨多次推送合并收敛) */ + @Column(name = "fail_json", columnDefinition = "TEXT") + private String failJson; + + /** 摘要信息(成功 N 班 M 人 / 失败原因) */ + @Column(name = "msg", length = 512) + private String msg; +} diff --git a/src/main/java/com/yunhai/acaclassassign/entity/AssignResult.java b/src/main/java/com/yunhai/acaclassassign/entity/AssignResult.java new file mode 100644 index 0000000..6bffe06 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/entity/AssignResult.java @@ -0,0 +1,69 @@ +package com.yunhai.acaclassassign.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 分班结果:每项目保留最新一次运行(重新分班覆盖)。 + *

学生落位写回 aca_assign_student.class_id/level;本表存指标、日志与分层元信息。

+ */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "aca_assign_result", + uniqueConstraints = @UniqueConstraint(name = "uk_arl_project", columnNames = "project_id")) +public class AssignResult extends BaseEntity { + + /** 所属分班项目 */ + @Column(name = "project_id", nullable = false) + private Integer projectId; + + /** 分班时间 */ + @Column(name = "assign_time", nullable = false) + private LocalDateTime assignTime; + + /** 局部搜索迭代次数 */ + @Column(name = "iterations") + private Integer iterations; + + /** 算法耗时 ms */ + @Column(name = "cost_ms") + private Long costMs; + + /** 综合均衡度 0–100 */ + @Column(name = "score", precision = 5, scale = 2) + private BigDecimal score; + + /** 是否含手动调整 */ + @Column(name = "adjusted", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") + private Boolean adjusted; + + /** 指标卡 + 均衡明细 JSON(items/score/countSpread/avgSpread/genderDev/abInfo 等) */ + @Column(name = "metrics_json", columnDefinition = "TEXT") + private String metricsJson; + + /** 算法/校验/手动调班/班主任匹配日志文本(每行一条) */ + @Column(name = "log_text", columnDefinition = "TEXT") + private String logText; + + /** 警告 JSON:["...","..."] */ + @Column(name = "warn_json", columnDefinition = "TEXT") + private String warnJson; + + /** 分数段切点 JSON:{"cuts":[...],"labels":[...]} */ + @Column(name = "band_cuts_json", columnDefinition = "TEXT") + private String bandCutsJson; + + /** A/B 分层元信息 JSON:{"enabled":true,"mode":"layer","aClassIds":[...],"groups":{...}} */ + @Column(name = "groups_json", columnDefinition = "TEXT") + private String groupsJson; +} diff --git a/src/main/java/com/yunhai/acaclassassign/entity/AssignRule.java b/src/main/java/com/yunhai/acaclassassign/entity/AssignRule.java new file mode 100644 index 0000000..e385888 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/entity/AssignRule.java @@ -0,0 +1,66 @@ +package com.yunhai.acaclassassign.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 分班规则:每项目一条(首次保存时创建)。 + *

均衡维度勾选与权重、约束开关、分数段方案、高中 A/B 分层设置。

+ */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "aca_assign_rule", + uniqueConstraints = @UniqueConstraint(name = "uk_ar_project", columnNames = "project_id")) +public class AssignRule extends BaseEntity { + + /** 所属分班项目 */ + @Column(name = "project_id", nullable = false) + private Integer projectId; + + /** + * 均衡维度 JSON:{"total":true,"gender":true,"bands":true, + * "subjects":["语文","数学"],"nums":["视力分"],"tags":["特长生"]} + * (人数维度恒开,不入库;小学版成绩类恒关) + */ + @Column(name = "balance_json", nullable = false, columnDefinition = "TEXT") + private String balanceJson; + + /** + * 约束规则 JSON(4 项硬约束开关,默认全开): + * {"sameName":true,"sameSound":true,"diffClass":true,"sameClass":true} + */ + @Column(name = "constraint_json", nullable = false, columnDefinition = "TEXT") + private String constraintJson; + + /** 分数段模式:count 按段数 / step 按分数间隔(小学版空) */ + @Column(name = "band_mode", length = 8) + private String bandMode; + + /** 分数段段数(3/4/5,上限 20) */ + @Column(name = "band_count") + private Integer bandCount; + + /** 分数段间隔(1–200 分/段) */ + @Column(name = "band_step") + private Integer bandStep; + + /** 是否启用 A/B 分层(仅高中) */ + @Column(name = "ab_enabled", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") + private Boolean abEnabled; + + /** 分层模式:layer 按总分分层编班 / within 均衡编班+班内分层 */ + @Column(name = "ab_mode", length = 8) + private String abMode; + + /** A 班(实验班)数量:1 ≤ K < 班级数 */ + @Column(name = "ab_class_count") + private Integer abClassCount; +} diff --git a/src/main/java/com/yunhai/acaclassassign/entity/AssignStudent.java b/src/main/java/com/yunhai/acaclassassign/entity/AssignStudent.java new file mode 100644 index 0000000..809acfd --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/entity/AssignStudent.java @@ -0,0 +1,96 @@ +package com.yunhai.acaclassassign.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; +import java.math.BigDecimal; + +/** + * 分班学生:导入名单的数据行。学号/账号为标识文本列(仅展示/导出,不参与均衡), + * 不存联系方式,也不与平台账号做关联。 + *

单科成绩、是否型/数值型自定义字段为动态结构,以 JSON 文本列存储; + * 分班结果写回 classId(与 level A/B 层)。

+ */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "aca_assign_student", indexes = { + @Index(name = "idx_as_project", columnList = "project_id"), + @Index(name = "idx_as_project_class", columnList = "project_id,class_id"), + @Index(name = "idx_as_name", columnList = "name")}) +public class AssignStudent extends BaseEntity { + + /** 所属分班项目 */ + @Column(name = "project_id", nullable = false) + private Integer projectId; + + /** 姓名(必填;同名/同音约束、拼音排序依据) */ + @Column(name = "name", nullable = false, length = 64) + private String name; + + /** 性别:1男 2女 0未知 */ + @Column(name = "gender", nullable = false, columnDefinition = "TINYINT DEFAULT 0") + private Integer gender; + + /** 总分(小学版恒空) */ + @Column(name = "total_score", precision = 6, scale = 2) + private BigDecimal totalScore; + + /** 单科成绩 JSON:{"语文":95.5,"数学":88,...} */ + @Column(name = "scores_json", columnDefinition = "TEXT") + private String scoresJson; + + /** 是否型自定义字段 JSON:{"特长生":true,"住宿":false,...} */ + @Column(name = "tags_json", columnDefinition = "TEXT") + private String tagsJson; + + /** 数值型自定义字段 JSON:{"视力分":4.8,...} */ + @Column(name = "nums_json", columnDefinition = "TEXT") + private String numsJson; + + /** 学号(标识文本列,仅展示/导出,不参与均衡) */ + @Column(name = "student_no", length = 64) + private String studentNo; + + /** 账号(标识文本列,仅展示/导出,不参与均衡,不与平台账号关联) */ + @Column(name = "account", length = 64) + private String account; + + /** 密码(标识文本列,生成=账号+@;仅展示/导出/推送初始密码,不参与均衡) */ + @Column(name = "password", length = 64) + private String password; + + /** 毕业学校(仅展示/导出/生源分析,不参与均衡) */ + @Column(name = "graduate_school", length = 128) + private String graduateSchool; + + /** 备注 */ + @Column(name = "remark", length = 255) + private String remark; + + /** 预设班级原文(如 1 / 1班 / 同班:张三 / 不同班:李四) */ + @Column(name = "preset_raw", length = 255) + private String presetRaw; + + /** 预设解析结果 JSON:[{"type":"class|same|diff","value":"1","target":"张三"}] */ + @Column(name = "preset_json", columnDefinition = "TEXT") + private String presetJson; + + /** 分班结果:所在班级id(aca_assign_class.id) */ + @Column(name = "class_id") + private Integer classId; + + /** 分层结果:A/B(高中版用;其他学段空) */ + @Column(name = "level", length = 4) + private String level; + + /** 导入行号(告警定位) */ + @Column(name = "src_row") + private Integer srcRow; +} diff --git a/src/main/java/com/yunhai/acaclassassign/entity/BaseEntity.java b/src/main/java/com/yunhai/acaclassassign/entity/BaseEntity.java new file mode 100644 index 0000000..6c15ed3 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/entity/BaseEntity.java @@ -0,0 +1,49 @@ +package com.yunhai.acaclassassign.entity; + +import com.yunhai.acaclassassign.common.listener.BaseAuditListener; +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.*; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link BaseAuditListener} 自动填充)。 + */ +@MappedSuperclass +@Getter +@Setter +@EntityListeners(BaseAuditListener.class) +public abstract class BaseEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + /** + * 创建人登录账号 + */ + @Column(name = "create_by", updatable = false) + private String createBy; + + /** + * 创建时间 + */ + @Column(name = "create_time", updatable = false) + private LocalDateTime createTime; + + /** + * 更新人登录账号 + */ + @Column(name = "update_by") + private String updateBy; + + /** + * 更新时间 + */ + @Column(name = "update_time") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/yunhai/acaclassassign/entity/SysOperationLog.java b/src/main/java/com/yunhai/acaclassassign/entity/SysOperationLog.java new file mode 100644 index 0000000..4f75ff2 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/entity/SysOperationLog.java @@ -0,0 +1,84 @@ +package com.yunhai.acaclassassign.entity; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 系统审计日志实体(移植自 UserclassService SysOperationLog)。 + * 全量记录 controller 请求:用户、URI、参数、响应(GZIP)、耗时、异常。 + * 索引由 resources/sql/sys_operation_log.sql 创建,本实体不含 @Table(indexes), + * 避免 ddl-auto=update 启动时重复建索引告警。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "sys_operation_log") +public class SysOperationLog implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */ + @Column(name = "log_level", nullable = false, columnDefinition = "TINYINT") + private Integer logLevel; + + @Column(name = "operation_type", nullable = false, length = 16) + private String operationType; + + @Column(name = "user_id") + private Integer userId; + + @Column(name = "user_name", length = 64) + private String userName; + + @Column(name = "sch_id") + private Integer schId; + + @Column(name = "request_ip", nullable = false, length = 64) + private String requestIp; + + @Column(name = "request_uri", nullable = false, length = 512) + private String requestUri; + + @Column(name = "request_method", nullable = false, length = 16) + private String requestMethod; + + @Column(name = "class_method", nullable = false, length = 512) + private String classMethod; + + @Column(name = "request_params", columnDefinition = "TEXT") + private String requestParams; + + /** GZIP 压缩后的响应 JSON(不参与 JSON 序列化) */ + @Column(name = "response_data", columnDefinition = "MEDIUMBLOB") + private byte[] responseData; + + @Column(name = "status_code", nullable = false) + private Integer statusCode; + + /** 耗时 ms */ + @Column(name = "cost_time", nullable = false) + private Long costTime; + + @Column(name = "user_agent", length = 512) + private String userAgent; + + @Column(name = "error_msg", columnDefinition = "TEXT") + private String errorMsg; + + @CreationTimestamp + @Column(name = "create_time", nullable = false, updatable = false, columnDefinition = "DATETIME(3)") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/yunhai/acaclassassign/enums/UserType.java b/src/main/java/com/yunhai/acaclassassign/enums/UserType.java new file mode 100644 index 0000000..cb5ad11 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/enums/UserType.java @@ -0,0 +1,20 @@ +package com.yunhai.acaclassassign.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 用户类型(与基础平台 BoaUser.type 对齐)。 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public enum UserType { + SUPER_ADMINISTRATOR(1, "超级管理员"), + SCHOOL_ADMIN(2, "学校管理员"), + REGULAR_USER(3, "普通用户"); + + private Integer id; + private String name; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/BasePageQuery.java b/src/main/java/com/yunhai/acaclassassign/model/BasePageQuery.java new file mode 100644 index 0000000..d3fb549 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/BasePageQuery.java @@ -0,0 +1,14 @@ +package com.yunhai.acaclassassign.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/acaclassassign/model/BasePageResult.java b/src/main/java/com/yunhai/acaclassassign/model/BasePageResult.java new file mode 100644 index 0000000..aa42044 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/BasePageResult.java @@ -0,0 +1,33 @@ +package com.yunhai.acaclassassign.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/acaclassassign/model/SchTeaList.java b/src/main/java/com/yunhai/acaclassassign/model/SchTeaList.java new file mode 100644 index 0000000..59285f2 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/SchTeaList.java @@ -0,0 +1,106 @@ +package com.yunhai.acaclassassign.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/acaclassassign/model/UserSysRole.java b/src/main/java/com/yunhai/acaclassassign/model/UserSysRole.java new file mode 100644 index 0000000..a443977 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/UserSysRole.java @@ -0,0 +1,29 @@ +package com.yunhai.acaclassassign.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 用户系统角色上下文(来自请求头 currentrole,URL 编码 JSON:{childId, roleId, schId, userId})。 + * 家长端 childId 即当前孩子(学生)ID,0 代表主账号操作。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class UserSysRole { + + /** 子账号/孩子ID,0=主账号 */ + private Integer childId; + + /** 角色ID */ + private Integer roleId; + + /** 学校ID(数据隔离关键字段) */ + private Integer schId; + + /** 用户ID(部分平台版本携带) */ + private Integer userId; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/AdjustRequest.java b/src/main/java/com/yunhai/acaclassassign/model/assign/AdjustRequest.java new file mode 100644 index 0000000..9ac3cae --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/AdjustRequest.java @@ -0,0 +1,24 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +/** + * 手动调班请求。 + */ +@Data +public class AdjustRequest { + + private Integer stage; + + private String year; + + private Integer term; + + private Integer studentId; + + /** 目标班级id */ + private Integer toClassId; + + /** 约束冲突时是否人工确认强行执行(审计记录) */ + private Boolean force; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/AdjustResultVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/AdjustResultVo.java new file mode 100644 index 0000000..b73de8c --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/AdjustResultVo.java @@ -0,0 +1,19 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 手动调班结果:冲突未强制时返回 conflicts,applied=true 表示已执行。 + */ +@Data +public class AdjustResultVo { + + /** 是否已执行 */ + private boolean applied; + + /** 约束冲突提示(applied=false 时返回,供前端人工确认) */ + private List conflicts = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/AssignSubmitCheckVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/AssignSubmitCheckVo.java new file mode 100644 index 0000000..aa3c2f3 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/AssignSubmitCheckVo.java @@ -0,0 +1,52 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 数据提交页:摘要 + 提交前检查清单。 + */ +@Data +public class AssignSubmitCheckVo { + + private Integer status; + + private LocalDateTime submitTime; + + private String year; + + private Integer term; + + private int classCount; + + private long studentCount; + + /** 班主任已指定数 */ + private int teacherSetCount; + + /** 是否已完成分班 */ + private boolean assigned; + + /** 人数极差 */ + private int countSpread; + + /** 校验警告条数 */ + private int warnCount; + + /** 检查清单:{name, level(ok/warn/bad), msg} */ + private List checks = new ArrayList<>(); + + @Data + public static class CheckItem { + + private String name; + + /** ok / warn / bad */ + private String level; + + private String msg; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/BalanceVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/BalanceVo.java new file mode 100644 index 0000000..1b3af92 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/BalanceVo.java @@ -0,0 +1,33 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.util.List; + +/** + * 均衡维度勾选(人数维度恒开不入库)。 + */ +@Data +public class BalanceVo { + + /** 总分均分(小学版恒 false) */ + private Boolean total; + + /** 三科平均分(语文+数学+英语;三科齐全时可用) */ + private Boolean core3; + + /** 男女生比例 */ + private Boolean gender; + + /** 各分数段人数 */ + private Boolean bands; + + /** 参与均衡的单科列表 */ + private List subjects; + + /** 参与均衡的数值型字段列表 */ + private List nums; + + /** 参与均衡的是否型字段列表 */ + private List tags; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/BandReportVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/BandReportVo.java new file mode 100644 index 0000000..8827a61 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/BandReportVo.java @@ -0,0 +1,44 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 分数段报表(学科 × 区间人数,全部 + 各班)。 + */ +@Data +public class BandReportVo { + + /** 学科名("总分" 或单科名) */ + private String subject; + + private double minScore; + + private double maxScore; + + private double step; + + /** 区间标签(左闭右开) */ + private List labels = new ArrayList<>(); + + /** 每段一行:全部 + 各班人数 */ + private List rows = new ArrayList<>(); + + private long total; + + private double maxScoreSeen; + + private double minScoreSeen; + + @Data + public static class BandRow { + + /** 区间标签 */ + private String label; + + /** 各班人数,key=班名;含 "全部" */ + private List counts = new ArrayList<>(); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/ChartsVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/ChartsVo.java new file mode 100644 index 0000000..a221fd2 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/ChartsVo.java @@ -0,0 +1,49 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 结果页图表数据(前端 SVG 渲染)。 + */ +@Data +public class ChartsVo { + + /** 各班平均分(无成绩学段为人数) */ + private List avgBars = new ArrayList<>(); + + /** 各班性别结构(堆叠条形) */ + private List genderBars = new ArrayList<>(); + + /** 各班分数段分布 */ + private List bandStacks = new ArrayList<>(); + + @Data + public static class Bar { + + private String name; + + private double value; + } + + @Data + public static class GenderBar { + + private String name; + + private int male; + + private int female; + } + + @Data + public static class BandStack { + + private String name; + + /** 与 bandCuts.labels 对齐的各段人数 */ + private List counts = new ArrayList<>(); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/ClassCardVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/ClassCardVo.java new file mode 100644 index 0000000..ef99629 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/ClassCardVo.java @@ -0,0 +1,61 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 班级卡片(结果页):统计 + 可展开名单。 + */ +@Data +public class ClassCardVo { + + private Integer classId; + + private String name; + + private Integer sortOrder; + + private String teacherName; + + /** A/B(高中 layer:A=实验班;within:无) */ + private String abGroup; + + private int count; + + private int male; + + private int female; + + /** 总分均分(无成绩学段为空) */ + private BigDecimal avg; + + private BigDecimal max; + + private BigDecimal min; + + /** 单科均分 {科目:均值} */ + private Map subjectAvg = new LinkedHashMap<>(); + + /** 数值字段均分 */ + private Map numAvg = new LinkedHashMap<>(); + + /** 是否型字段人数 {字段:人数} */ + private Map tagCount = new LinkedHashMap<>(); + + /** 分数段人数(与 bandCuts 对齐) */ + private List bandCounts = new ArrayList<>(); + + /** A/B 层人数 {A:n,B:m}(within 模式) */ + private Map levelCounts = new LinkedHashMap<>(); + + /** 前若干名(mini 柱状图,按总分降序,最多12) */ + private List topScores = new ArrayList<>(); + + /** 全班名单 */ + private List students = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/ClassItemVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/ClassItemVo.java new file mode 100644 index 0000000..ae2c8f2 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/ClassItemVo.java @@ -0,0 +1,37 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +/** + * 班级设置项 / 保存请求行。 + */ +@Data +public class ClassItemVo { + + /** 班级id(保存时已有班级携带;无 id=新增) */ + private Integer id; + + /** 序号(1 起) */ + private Integer sortOrder; + + /** 班级名称 */ + private String name; + + /** 班主任平台教师id(可空;不存账号) */ + private Integer teacherId; + + /** 班主任姓名(展示冗余,服务端按平台教师列表回填) */ + private String teacherName; + + /** 目标人数(空=自动均分) */ + private Integer targetCount; + + /** 是否A班(高中分层) */ + private Boolean abFlag; + + /** 已预设学生数(只读,服务端计算) */ + private Integer presetCount; + + /** 剩余 = 目标人数 - 已预设(只读) */ + private Integer remain; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/ClassReportVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/ClassReportVo.java new file mode 100644 index 0000000..cd9fe43 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/ClassReportVo.java @@ -0,0 +1,35 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 班级均分报表行(高中/初中)。 + */ +@Data +public class ClassReportVo { + + private Integer classId; + + private String name; + + private String teacherName; + + private int count; + + private int male; + + private int female; + + /** 各科均分 */ + private Map subjectAvg = new LinkedHashMap<>(); + + /** 三科总分均分(语数英齐才时,参考项) */ + private BigDecimal core3Avg; + + /** 总分均分 */ + private BigDecimal totalAvg; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/ConstraintVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/ConstraintVo.java new file mode 100644 index 0000000..2cab8b5 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/ConstraintVo.java @@ -0,0 +1,22 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +/** + * 约束规则开关(硬约束,默认全开;换人优化与手动调班时强制校验)。 + */ +@Data +public class ConstraintVo { + + /** 同名学生分到不同班 */ + private Boolean sameName; + + /** 姓名读音相近分到不同班 */ + private Boolean sameSound; + + /** 预设「不同班」的学生叉开 */ + private Boolean diffClass; + + /** 预设「同班」的学生捆绑 */ + private Boolean sameClass; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/ImportPasteRequest.java b/src/main/java/com/yunhai/acaclassassign/model/assign/ImportPasteRequest.java new file mode 100644 index 0000000..e6635e6 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/ImportPasteRequest.java @@ -0,0 +1,25 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +/** + * 粘贴文本导入请求。 + */ +@Data +public class ImportPasteRequest { + + private Integer stage; + + private String year; + + private Integer term; + + /** 粘贴的原始文本(Tab/逗号/空格分隔) */ + private String text; + + /** 首行是否表头 */ + private Boolean hasHeader; + + /** replace 替换全部 / append 追加 */ + private String mode; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/ImportResultVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/ImportResultVo.java new file mode 100644 index 0000000..7cc2820 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/ImportResultVo.java @@ -0,0 +1,40 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 导入结果(统计条 + 告警)。 + */ +@Data +public class ImportResultVo { + + /** 本次导入入库人数 */ + private int inserted; + + /** 项目现有总人数 */ + private long total; + + /** 告警(列识别、数据异常) */ + private List warn = new ArrayList<>(); + + /** 男生数 */ + private long male; + + /** 女生数 */ + private long female; + + /** 识别到的单科列 */ + private List subjects = new ArrayList<>(); + + /** 是否型字段 */ + private List tags = new ArrayList<>(); + + /** 数值型字段 */ + private List nums = new ArrayList<>(); + + /** 毕业学校数 */ + private int schools; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/MetricItemVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/MetricItemVo.java new file mode 100644 index 0000000..a00b734 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/MetricItemVo.java @@ -0,0 +1,44 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 均衡明细行:维度 / 最低班 / 最高班 / 极差 / 相对偏差 / 判定 / 权重。 + */ +@Data +public class MetricItemVo { + + /** 维度名(人数/总分均分/男女生比例/语文/视力分均值/特长生人数...) */ + private String dimension; + + /** 类型:count 人数型 / avg 均值型 */ + private String type; + + /** 权重 */ + private double weight; + + /** 最低班名 */ + private String minClass; + + private double minValue; + + /** 最高班名 */ + private String maxClass; + + private double maxValue; + + /** 极差 */ + private double spread; + + /** 相对偏差(极差/均值,%) */ + private double relDev; + + /** 判定:优/良/关注/偏差大/样本过小/信息项 */ + private String grade; + + /** 各班数值(与 classCards 顺序对齐) */ + private List perClass = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/PresetBatchRequest.java b/src/main/java/com/yunhai/acaclassassign/model/assign/PresetBatchRequest.java new file mode 100644 index 0000000..45997af --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/PresetBatchRequest.java @@ -0,0 +1,24 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.util.List; + +/** + * 按姓名批量预设班级请求。 + */ +@Data +public class PresetBatchRequest { + + private Integer stage; + + private String year; + + private Integer term; + + /** 学生姓名(多个,分隔符已由前端拆分) */ + private List names; + + /** 目标班级id */ + private Integer classId; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/PresetBatchResultVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/PresetBatchResultVo.java new file mode 100644 index 0000000..e5eb26c --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/PresetBatchResultVo.java @@ -0,0 +1,17 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 批量预设结果:未匹配姓名回填提示。 + */ +@Data +public class PresetBatchResultVo { + + private int updated; + + private List unmatched = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/ProjectInfoVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/ProjectInfoVo.java new file mode 100644 index 0000000..1f704f9 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/ProjectInfoVo.java @@ -0,0 +1,46 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 工作台信息汇总(步骤1/2 页面加载入口)。 + */ +@Data +public class ProjectInfoVo { + + private Integer projectId; + + private Integer stage; + + private String year; + + private Integer term; + + /** 0草稿 1已提交(锁定) */ + private Integer status; + + private LocalDateTime submitTime; + + private List classes; + + private RuleVo rule; + + private ProjectStatsVo stats; + + /** 平台当前学年学期(默认选中项) */ + private String currentYear; + + private Integer currentTerm; + + /** 当前学年学期(NowYearTerm 接口返回) */ + @Data + public static class CurrentYear { + + private String year; + + private Integer term; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/ProjectStatsVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/ProjectStatsVo.java new file mode 100644 index 0000000..c8f3864 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/ProjectStatsVo.java @@ -0,0 +1,22 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +/** + * 项目统计汇总(名单导入后实时统计)。 + */ +@Data +public class ProjectStatsVo { + + private long studentCount; + + private long maleCount; + + private long femaleCount; + + /** 已落位班级的学生数 */ + private long assignedCount; + + /** 毕业学校数(高中/初中) */ + private long schoolCount; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/PushStatusVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/PushStatusVo.java new file mode 100644 index 0000000..e3cf2fc --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/PushStatusVo.java @@ -0,0 +1,68 @@ +package com.yunhai.acaclassassign.model.assign; + +import com.yunhai.acaclassassign.entity.AssignPush; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 推送状态(GET /api/assign/project/PushStatus)。 + */ +@Getter +@Setter +public class PushStatusVo { + + /** 0未推送 1已推送 2部分失败 3失败 4推送中 */ + private Integer status; + + private String statusText; + + private LocalDateTime pushTime; + + private String msg; + + /** 班级映射数(已建立本地班→平台班映射的班级数) */ + private Integer classCount; + + /** 已成功学生数(分班结果中已推送成功的人数) */ + private Integer okCount; + + /** 未成功学生数 */ + private Integer failCount; + + /** 未成功明细 */ + private List fails = new ArrayList<>(); + + @Getter + @Setter + public static class FailItem { + + /** 本地学生id(aca_assign_student.id) */ + private Integer studentId; + + private String name; + + private String reason; + } + + public static String textOf(Integer status) { + if (status == null) { + return "未推送"; + } + switch (status) { + case AssignPush.STATUS_OK: + return "已推送"; + case AssignPush.STATUS_PARTIAL: + return "部分失败"; + case AssignPush.STATUS_FAIL: + return "推送失败"; + case AssignPush.STATUS_PUSHING: + return "推送中"; + default: + return "未推送"; + } + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/RuleVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/RuleVo.java new file mode 100644 index 0000000..5de2e5a --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/RuleVo.java @@ -0,0 +1,32 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +/** + * 分班规则(读写共用)。 + */ +@Data +public class RuleVo { + + private BalanceVo balance; + + private ConstraintVo constraint; + + /** 分数段模式:count 按段数 / step 按分数间隔(小学版空) */ + private String bandMode; + + /** 段数 3/4/5(上限 20) */ + private Integer bandCount; + + /** 分数间隔 1-200 */ + private Integer bandStep; + + /** 启用 A/B 分层(仅高中) */ + private Boolean abEnabled; + + /** layer 按总分分层编班 / within 均衡编班+班内分层 */ + private String abMode; + + /** A 班数量:1 ≤ K < 班级数 */ + private Integer abClassCount; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/RunResultVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/RunResultVo.java new file mode 100644 index 0000000..aa4da1d --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/RunResultVo.java @@ -0,0 +1,92 @@ +package com.yunhai.acaclassassign.model.assign; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 分班结果页数据(指标卡/图表/均衡明细/日志/班级卡片)。 + */ +@Data +public class RunResultVo { + + /** 项目状态:0草稿 1已提交 */ + private Integer status; + + private LocalDateTime assignTime; + + private Integer iterations; + + private Long costMs; + + /** 综合均衡度 0-100 */ + private BigDecimal score; + + /** 是否含手动调整 */ + private Boolean adjusted; + + /** 参与分班人数 */ + private long total; + + /** 人数极差 */ + private int countSpread; + + /** 均分极差(小学=0) */ + private BigDecimal avgSpread; + + /** 男女比例最大偏差%(相对全校平均) */ + private double genderDev; + + /** A/B 分层信息(高中):aCount/bCount/aAvg/bAvg/layerDiff */ + private AbInfo abInfo; + + /** 均衡明细 */ + private List metrics = new ArrayList<>(); + + /** 加权相对偏差(%) */ + private double weightedDev; + + /** 警告 */ + private List warns = new ArrayList<>(); + + /** 日志(算法/校验/手动调班/班主任匹配) */ + private List logs = new ArrayList<>(); + + /** 分数段切点标签 */ + private List bandLabels = new ArrayList<>(); + + private ChartsVo charts = new ChartsVo(); + + private List classCards = new ArrayList<>(); + + @Data + public static class AbInfo { + + private Boolean enabled; + + private String mode; + + /** Jackson 会把 getAClassCount() 推导成 aclassCount,须显式指定与前端一致的键名 */ + @JsonProperty("aClassCount") + private int aClassCount; + + @JsonProperty("aCount") + private int aCount; + + @JsonProperty("aAvg") + private BigDecimal aAvg; + + @JsonProperty("bCount") + private int bCount; + + @JsonProperty("bAvg") + private BigDecimal bAvg; + + /** 层间均分差 */ + private BigDecimal layerDiff; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/SaveClassesRequest.java b/src/main/java/com/yunhai/acaclassassign/model/assign/SaveClassesRequest.java new file mode 100644 index 0000000..55071ae --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/SaveClassesRequest.java @@ -0,0 +1,21 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.util.List; + +/** + * 保存班级设置请求。 + */ +@Data +public class SaveClassesRequest { + + private Integer stage; + + private String year; + + private Integer term; + + /** 全量班级清单(以本列表为准重建:删/改/增) */ + private List classes; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/SaveRulesRequest.java b/src/main/java/com/yunhai/acaclassassign/model/assign/SaveRulesRequest.java new file mode 100644 index 0000000..b0e6025 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/SaveRulesRequest.java @@ -0,0 +1,18 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +/** + * 保存分班规则请求。 + */ +@Data +public class SaveRulesRequest { + + private Integer stage; + + private String year; + + private Integer term; + + private RuleVo rule; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/SourceAnalysisVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/SourceAnalysisVo.java new file mode 100644 index 0000000..f603bc8 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/SourceAnalysisVo.java @@ -0,0 +1,37 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.util.List; + +/** + * 生源分析:毕业学校 × 分数档交叉表(高中/初中;仅结构分析,不影响分班)。 + */ +@Data +public class SourceAnalysisVo { + + /** 分数线(升序) */ + private List cuts; + + /** 分数档标签(左闭右开) */ + private List labels; + + /** 每校一行:counts 与 labels 对齐 */ + private List rows; + + /** 各档合计 */ + private List sums; + + /** 录取总人数 */ + private long total; + + @Data + public static class SchoolRow { + + private String school; + + private List counts; + + private long total; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/StudentPageRequest.java b/src/main/java/com/yunhai/acaclassassign/model/assign/StudentPageRequest.java new file mode 100644 index 0000000..04a8693 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/StudentPageRequest.java @@ -0,0 +1,26 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +/** + * 学生名单查询(GET 参数绑定)。 + */ +@Data +public class StudentPageRequest { + + private Integer stage; + + private String year; + + private Integer term; + + /** 全部=null/0;否则班级id */ + private Integer classId; + + /** 姓名关键字 */ + private String keyword; + + private Integer pageIndex = 1; + + private Integer pageSize = 50; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/StudentPageVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/StudentPageVo.java new file mode 100644 index 0000000..87fd474 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/StudentPageVo.java @@ -0,0 +1,33 @@ +package com.yunhai.acaclassassign.model.assign; + +import com.yunhai.acaclassassign.model.BasePageResult; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 学生名单分页响应:在通用分页外壳上补充识别出的列名清单, + * 前端据此渲染单科 / 是否型 / 数值型自定义列(刷新后无需重新导入)。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class StudentPageVo extends BasePageResult { + + /** 单科列名(按导入出现序,如 语文、数学、英语、物理、化学) */ + private List subjects; + + /** 是否型列名(按导入出现序,如 特长生、住宿) */ + private List tags; + + /** 数值型列名(按导入出现序,如 视力分) */ + private List nums; + + /** 年级名称(项目全体学生同一入学年级;班级管理字典名,不可得回退 K12 标准新生年级名) */ + private String gradeName; + + public StudentPageVo(BasePageResult page) { + super(page.getContent(), page.getTotal(), page.getPageIndex(), + page.getPageSize(), page.getTotalPages(), page.isHasNext()); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/StudentStatsVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/StudentStatsVo.java new file mode 100644 index 0000000..c65c351 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/StudentStatsVo.java @@ -0,0 +1,48 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 名单简单统计(全量学生,不分页):人数 / 性别 / 总分三值 / 均衡字段统计 / 毕业学校所数。 + */ +@Data +public class StudentStatsVo { + + private long total; + + private long male; + + private long female; + + /** 总分均分(无成绩数据时为 null) */ + private Double avgScore; + + private Double maxScore; + + private Double minScore; + + /** 均衡字段统计(是否型计人数、数值型计班均值,按首次出现顺序) */ + private List fields = new ArrayList<>(); + + /** 非空毕业学校去重数 */ + private int schoolCount; + + @Data + public static class FieldStat { + + /** 字段名 */ + private String name; + + /** tag=是否型(count 有效) / num=数值型(avg 有效) */ + private String kind; + + /** 是否型:取真人数 */ + private Long count; + + /** 数值型:全员均值(2 位小数) */ + private Double avg; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/StudentUpdateRequest.java b/src/main/java/com/yunhai/acaclassassign/model/assign/StudentUpdateRequest.java new file mode 100644 index 0000000..ef774bc --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/StudentUpdateRequest.java @@ -0,0 +1,18 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +/** + * 单元格编辑保存请求。 + */ +@Data +public class StudentUpdateRequest { + + private Integer stage; + + private String year; + + private Integer term; + + private StudentVo student; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/assign/StudentVo.java b/src/main/java/com/yunhai/acaclassassign/model/assign/StudentVo.java new file mode 100644 index 0000000..51a3ffe --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/assign/StudentVo.java @@ -0,0 +1,55 @@ +package com.yunhai.acaclassassign.model.assign; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * 学生行(列表/编辑/结果名单共用)。 + */ +@Data +public class StudentVo { + + private Integer id; + + private String name; + + /** 1男 2女 0未知 */ + private Integer gender; + + private BigDecimal totalScore; + + /** 单科成绩 {"语文":95.5} */ + private Map scores; + + /** 是否型字段 {"特长生":true} */ + private Map tags; + + /** 数值型字段 {"视力分":4.8} */ + private Map nums; + + private String graduateSchool; + + /** 学号(标识文本列,仅展示/导出) */ + private String studentNo; + + /** 账号(标识文本列,仅展示/导出) */ + private String account; + + /** 密码(标识文本列,生成=账号+@,仅展示/导出/推送初始密码) */ + private String password; + + private String remark; + + private String presetRaw; + + private Integer classId; + + private String className; + + /** A/B 层(高中) */ + private String level; + + private Integer srcRow; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/base/SetRoleRequest.java b/src/main/java/com/yunhai/acaclassassign/model/base/SetRoleRequest.java new file mode 100644 index 0000000..ba1b8ca --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/base/SetRoleRequest.java @@ -0,0 +1,12 @@ +package com.yunhai.acaclassassign.model.base; + +import lombok.Data; + +/** + * 角色切换入参(对齐前端 baseApi.setRole)。 + */ +@Data +public class SetRoleRequest { + /** "teacher" 或 "parent" */ + private String as; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/base/SetRoleResult.java b/src/main/java/com/yunhai/acaclassassign/model/base/SetRoleResult.java new file mode 100644 index 0000000..9bc057e --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/base/SetRoleResult.java @@ -0,0 +1,15 @@ +package com.yunhai.acaclassassign.model.base; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 角色切换结果。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SetRoleResult { + private String role; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/bsch/ClassTreeNodeVo.java b/src/main/java/com/yunhai/acaclassassign/model/bsch/ClassTreeNodeVo.java new file mode 100644 index 0000000..4f39a8f --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/bsch/ClassTreeNodeVo.java @@ -0,0 +1,29 @@ +package com.yunhai.acaclassassign.model.bsch; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * 年级→班级树节点(对齐前端 ClassTreeNode;叶子=班级,附学生数)。 + */ +@Data +public class ClassTreeNodeVo { + private Integer id; + private String name; + /** 前端固定字段名 isParent */ + @JsonProperty("isParent") + private boolean parent; + /** 叶子(班级)附学生数 */ + private Integer studentCount; + private List children; + + public void setParent(boolean parent) { + this.parent = parent; + } + + public boolean isParent() { + return parent; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/bsch/NowYearTermVo.java b/src/main/java/com/yunhai/acaclassassign/model/bsch/NowYearTermVo.java new file mode 100644 index 0000000..650dc05 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/bsch/NowYearTermVo.java @@ -0,0 +1,50 @@ +package com.yunhai.acaclassassign.model.bsch; + +import lombok.Data; + +/** + * 当前学年学期(班级服务 GET /oapi/sch/getNowYearTerm 返回)。 + * 日期字段为源服务序列化的 yyyy-MM-dd 字符串。 + */ +@Data +public class NowYearTermVo { + + /** 学年学期记录ID(base_year_term.id) */ + private Integer id; + + /** 学年(如 2026) */ + private Integer year; + + /** 当前学期(1第一学期 2第二学期),按当前日期落在学期区间判定 */ + private Integer term; + + /** 学年起始日期 */ + private String yearStartDate; + + /** 学年结束日期 */ + private String yearEndDate; + + /** 第一学期开始日期 */ + private String firstTermStartDate; + + /** 第一学期结束日期 */ + private String firstTermEndDate; + + /** 第二学期开始日期 */ + private String secondTermStartDate; + + /** 第二学期结束日期 */ + private String secondTermEndDate; + + /** 升级日期(上学年→本学年的切换日) */ + private String upgradeDate; + + /** 当前学期开始日期(按 term 取一/二学期开始日期) */ + private String termStartDate; + + /** 当前学期结束日期(按 term 取一/二学期结束日期) */ + private String termEndDate; + + /** 是否当前学年学期,恒为 true */ + private Boolean nowYear; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/bsch/SubjectVo.java b/src/main/java/com/yunhai/acaclassassign/model/bsch/SubjectVo.java new file mode 100644 index 0000000..b992680 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/bsch/SubjectVo.java @@ -0,0 +1,19 @@ +package com.yunhai.acaclassassign.model.bsch; + +import lombok.Data; + +/** + * 学校学科(班级服务 sch_dic_subject 派生)。 + */ +@Data +public class SubjectVo { + + /** 学科ID(sch_dic_subject.id) */ + private Integer id; + + /** 学科编码 */ + private String code; + + /** 学科名称 */ + private String name; +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/bsch/SyncClassModels.java b/src/main/java/com/yunhai/acaclassassign/model/bsch/SyncClassModels.java new file mode 100644 index 0000000..54bf5c0 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/bsch/SyncClassModels.java @@ -0,0 +1,67 @@ +package com.yunhai.acaclassassign.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.List; + +/** + * 班级管理 /oapi/class 推送接口出参(反序列化用)。 + * 与 UserclassService 的 SyncClassResultVO / SyncStudentResultVO 字段对齐。 + */ +public class SyncClassModels { + + /** POST /oapi/class/AddClass 出参:本地班号→平台 classId 映射 */ + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class SyncClassResult { + + private Integer gradeId; + + private Integer gradeGroupId; + + private List classes; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ClassItem { + + /** 班号(=分班侧 sort_order) */ + private Integer sort; + + /** 平台班级id(班级组id) */ + private Integer classId; + + private String code; + + private String name; + + /** true=本次新建;false=按 code 查重复用(幂等重推) */ + private Boolean created; + } + + /** POST /oapi/class/AddClassStudent 出参:逐生成败 */ + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class SyncStudentResult { + + private List results; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class StudentItem { + + private String name; + + private String account; + + private Boolean ok; + + private Integer stuId; + + /** 失败原因 / 幂等命中说明 */ + private String reason; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/model/bsch/YearTermItemVo.java b/src/main/java/com/yunhai/acaclassassign/model/bsch/YearTermItemVo.java new file mode 100644 index 0000000..8cec924 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/model/bsch/YearTermItemVo.java @@ -0,0 +1,19 @@ +package com.yunhai.acaclassassign.model.bsch; + +import lombok.Data; + +/** + * 学年选项(班级服务 GET /oapi/sch/getYearTermList 返回项,按学年倒序)。 + */ +@Data +public class YearTermItemVo { + + /** 学年学期记录ID(base_year_term.id) */ + private Integer id; + + /** 学年(起始年,如 2026) */ + private Integer year; + + /** 是否当前学年 */ + private Boolean nowYear; +} diff --git a/src/main/java/com/yunhai/acaclassassign/repository/AssignClassRepository.java b/src/main/java/com/yunhai/acaclassassign/repository/AssignClassRepository.java new file mode 100644 index 0000000..e9f8555 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/repository/AssignClassRepository.java @@ -0,0 +1,20 @@ +package com.yunhai.acaclassassign.repository; + +import com.yunhai.acaclassassign.entity.AssignClass; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AssignClassRepository extends JpaRepository { + + + List findByProjectIdOrderBySortOrderAsc(Integer projectId); + + long countByProjectId(Integer projectId); + + void deleteByProjectId(Integer projectId); + + + +} diff --git a/src/main/java/com/yunhai/acaclassassign/repository/AssignProjectRepository.java b/src/main/java/com/yunhai/acaclassassign/repository/AssignProjectRepository.java new file mode 100644 index 0000000..6d2d929 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/repository/AssignProjectRepository.java @@ -0,0 +1,16 @@ +package com.yunhai.acaclassassign.repository; + +import com.yunhai.acaclassassign.entity.AssignProject; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AssignProjectRepository extends JpaRepository { + + Optional findBySchIdAndStageAndYearAndTerm(Integer schId, Integer stage, String year, Integer term); + + + + +} diff --git a/src/main/java/com/yunhai/acaclassassign/repository/AssignPushRepository.java b/src/main/java/com/yunhai/acaclassassign/repository/AssignPushRepository.java new file mode 100644 index 0000000..2ae4cff --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/repository/AssignPushRepository.java @@ -0,0 +1,15 @@ +package com.yunhai.acaclassassign.repository; + +import com.yunhai.acaclassassign.entity.AssignPush; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface AssignPushRepository extends JpaRepository { + + /** 每项目唯一一条推送记录 */ + Optional findByProjectId(Integer projectId); + + /** 项目重置时一并清除推送记录(远端数据不随本地重置回收) */ + void deleteByProjectId(Integer projectId); +} diff --git a/src/main/java/com/yunhai/acaclassassign/repository/AssignResultRepository.java b/src/main/java/com/yunhai/acaclassassign/repository/AssignResultRepository.java new file mode 100644 index 0000000..f083317 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/repository/AssignResultRepository.java @@ -0,0 +1,18 @@ +package com.yunhai.acaclassassign.repository; + +import com.yunhai.acaclassassign.entity.AssignResult; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AssignResultRepository extends JpaRepository { + + + + + + Optional findByProjectId(Integer projectId); + + void deleteByProjectId(Integer projectId); +} diff --git a/src/main/java/com/yunhai/acaclassassign/repository/AssignRuleRepository.java b/src/main/java/com/yunhai/acaclassassign/repository/AssignRuleRepository.java new file mode 100644 index 0000000..e207225 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/repository/AssignRuleRepository.java @@ -0,0 +1,18 @@ +package com.yunhai.acaclassassign.repository; + +import com.yunhai.acaclassassign.entity.AssignRule; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AssignRuleRepository extends JpaRepository { + + + + + Optional findByProjectId(Integer projectId); + + void deleteByProjectId(Integer projectId); + +} diff --git a/src/main/java/com/yunhai/acaclassassign/repository/AssignStudentRepository.java b/src/main/java/com/yunhai/acaclassassign/repository/AssignStudentRepository.java new file mode 100644 index 0000000..89d388b --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/repository/AssignStudentRepository.java @@ -0,0 +1,20 @@ +package com.yunhai.acaclassassign.repository; + +import com.yunhai.acaclassassign.entity.AssignStudent; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AssignStudentRepository extends JpaRepository { + + + + List findByProjectId(Integer projectId); + + long countByProjectId(Integer projectId); + + void deleteByProjectId(Integer projectId); + + +} diff --git a/src/main/java/com/yunhai/acaclassassign/repository/SysOperationLogRepository.java b/src/main/java/com/yunhai/acaclassassign/repository/SysOperationLogRepository.java new file mode 100644 index 0000000..05b64fb --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/repository/SysOperationLogRepository.java @@ -0,0 +1,12 @@ +package com.yunhai.acaclassassign.repository; + +import com.yunhai.acaclassassign.entity.SysOperationLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +/** + * 系统审计日志仓储。 + */ +public interface SysOperationLogRepository + extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/IAssignProjectService.java b/src/main/java/com/yunhai/acaclassassign/service/IAssignProjectService.java new file mode 100644 index 0000000..64bbfff --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/IAssignProjectService.java @@ -0,0 +1,52 @@ +package com.yunhai.acaclassassign.service; + +import com.yunhai.acaclassassign.entity.AssignProject; +import com.yunhai.acaclassassign.model.assign.AssignSubmitCheckVo; +import com.yunhai.acaclassassign.model.assign.ClassItemVo; +import com.yunhai.acaclassassign.model.assign.ProjectInfoVo; +import com.yunhai.acaclassassign.model.assign.RuleVo; +import com.yunhai.acaclassassign.model.assign.SaveClassesRequest; +import com.yunhai.acaclassassign.model.assign.SaveRulesRequest; +import com.yunhai.acaclassassign.model.bsch.YearTermItemVo; + +import java.util.List; + +/** + * 分班项目:学年学期维度的工作台信息、班级设置、规则、提交锁定。 + *

schId 统一取 {@code UserContext.getSchId()}(多校隔离)。

+ */ +public interface IAssignProjectService { + + /** 工作台信息(项目不存在则自动创建草稿) */ + ProjectInfoVo info(int stage, String year, int term); + + /** 当前学年学期(平台接口优先,失败本地推断) */ + ProjectInfoVo.CurrentYear nowYearTerm(); + + /** 学年下拉列表(班级管理学年学期接口,按学年倒序;不可达返回空列表由前端回退) */ + List yearList(); + + /** 保存班级设置(全量替换;班主任只存平台 teacherId) */ + List saveClasses(SaveClassesRequest request); + + /** 保存分班规则 */ + RuleVo saveRules(SaveRulesRequest request); + + /** 提交前检查清单 */ + AssignSubmitCheckVo submitCheck(int stage, String year, int term); + + /** 提交锁定(force=人数差>1 时人工确认) */ + void submit(int stage, String year, int term, boolean force); + + /** 重置全部(删除项目全部数据;已提交也可重置,审计见操作日志) */ + void reset(int stage, String year, int term); + + /** 取项目(无则建) */ + AssignProject getOrCreate(int stage, String year, int term); + + /** 取项目(无则抛业务异常) */ + AssignProject require(int stage, String year, int term); + + /** 已提交锁定校验(已提交抛业务异常) */ + void checkNotSubmitted(AssignProject project); +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/IAssignRunService.java b/src/main/java/com/yunhai/acaclassassign/service/IAssignRunService.java new file mode 100644 index 0000000..47f86c7 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/IAssignRunService.java @@ -0,0 +1,42 @@ +package com.yunhai.acaclassassign.service; + +import com.yunhai.acaclassassign.model.assign.AdjustRequest; +import com.yunhai.acaclassassign.model.assign.AdjustResultVo; +import com.yunhai.acaclassassign.model.assign.BandReportVo; +import com.yunhai.acaclassassign.model.assign.ClassReportVo; +import com.yunhai.acaclassassign.model.assign.RunResultVo; + +import java.util.List; + +import javax.servlet.http.HttpServletResponse; + +/** + * 分班执行与结果:算法运行、结果读取、手动调班、随机班主任、统计报表、导出。 + */ +public interface IAssignRunService { + + /** 执行均衡分班(覆盖旧结果) */ + RunResultVo run(int stage, String year, Integer term); + + /** 结果页数据(含重新统计的班级卡片) */ + RunResultVo result(int stage, String year, Integer term); + + /** 手动调班(硬约束校验;force 人工确认强行执行并审计) */ + AdjustResultVo adjust(AdjustRequest request); + + /** 随机匹配班主任(已填教师 id 洗牌重排) */ + List matchTeachers(int stage, String year, Integer term); + + /** 班级均分报表(高中/初中) */ + List classReport(int stage, String year, Integer term); + + /** 分数段报表 */ + BandReportVo bandReport(int stage, String year, Integer term, + String subject, Double min, Double max, Double step); + + /** 分班明细 CSV 导出 */ + void exportDetail(int stage, String year, Integer term, HttpServletResponse response) throws Exception; + + /** 班级统计 CSV 导出 */ + void exportClassStats(int stage, String year, Integer term, HttpServletResponse response) throws Exception; +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/IAssignStudentService.java b/src/main/java/com/yunhai/acaclassassign/service/IAssignStudentService.java new file mode 100644 index 0000000..7ff7165 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/IAssignStudentService.java @@ -0,0 +1,100 @@ +package com.yunhai.acaclassassign.service; + +import com.yunhai.acaclassassign.model.assign.ImportPasteRequest; +import com.yunhai.acaclassassign.model.assign.ImportResultVo; +import com.yunhai.acaclassassign.model.assign.PresetBatchRequest; +import com.yunhai.acaclassassign.model.assign.PresetBatchResultVo; +import com.yunhai.acaclassassign.model.assign.SourceAnalysisVo; +import com.yunhai.acaclassassign.model.assign.StudentPageVo; +import com.yunhai.acaclassassign.model.assign.StudentStatsVo; +import com.yunhai.acaclassassign.model.assign.StudentUpdateRequest; +import com.yunhai.acaclassassign.model.assign.StudentVo; + +import java.util.List; + +import javax.servlet.http.HttpServletResponse; + +/** + * 分班学生:导入解析、名单维护、批量预设、生源分析、模板下载。 + */ +public interface IAssignStudentService { + + /** 粘贴文本导入 */ + ImportResultVo importPaste(ImportPasteRequest request); + + /** 文件导入(csv/txt/tsv,UTF-8) */ + ImportResultVo importFile(int stage, String year, Integer term, String mode, Boolean hasHeader, + org.springframework.web.multipart.MultipartFile file); + + /** 示例名单文本(服务端生成,返回 {text},由前端填入粘贴框、确认后走导入) */ + java.util.Map sampleText(int stage); + + /** 名单分页(班级/关键字筛选,班级+姓名拼音序;附全量识别出的列名清单) */ + StudentPageVo page(int stage, String year, Integer term, Integer classId, + String keyword, int pageIndex, int pageSize); + + /** 学生名单 CSV 下载(与 page 同筛选:班级/关键字,导出全部匹配行) */ + void exportList(int stage, String year, Integer term, Integer classId, String keyword, + HttpServletResponse response) throws Exception; + + /** 名单简单统计(全量:人数/性别/总分三值/均衡字段/毕业学校所数) */ + StudentStatsVo stats(int stage, String year, Integer term); + + /** 单元格编辑保存 */ + void update(StudentUpdateRequest request); + + /** 删除单个学生 */ + void delete(int stage, String year, Integer term, Integer id); + + /** 清空名单 */ + void clear(int stage, String year, Integer term); + + /** 按姓名批量预设班级(同名全部设置),返回未匹配姓名 */ + PresetBatchResultVo presetBatch(PresetBatchRequest request); + + /** 生源分析:毕业学校 × 分数档交叉表(小学不支持) */ + SourceAnalysisVo sourceAnalysis(int stage, String year, Integer term, List cuts); + + /** 生源分析交叉表 CSV 下载(与页面 SourceAnalysis 同口径) */ + void exportSource(int stage, String year, Integer term, List cuts, + HttpServletResponse response) throws Exception; + + /** 导入模板 CSV 下载 */ + void template(int stage, HttpServletResponse response) throws Exception; + + /** + * 班级导入数据 .xls 下载(班级管理「班级导入」模板格式):数据页 = 标题「班级导入」+ 表头 + * 年级|班级|班级别名|班主任|副班主任 + 每班一行(班级={序号}班,语义班名进别名,口径同推送); + * 附「导入规则」页(年级候选/班级格式/教师名册)。年级列经班级管理接口按 学校+学年+学段 + * 查学校年级字典真实名称,不可得回退 K12 标准新生年级名(小一/初一/高一)。 + */ + void exportClassImport(int stage, String year, Integer term, + HttpServletResponse response) throws Exception; + + /** + * 按成绩/序号生成学号(覆盖已有学号;仅已分班学生参与编号,未分班学生学号不动)。 + * 项目内有总分=成绩模式:scope=class 各班按总分降序=班内名次2位(01、02、03…); + * scope=grade 全年级按总分降序=学年+名次4位(如 20250001)。 + * 无任何总分=序号模式:排序退化为班级序+姓名拼音(class 为班内姓名拼音序)。 + * 返回 {scope, mode: score|seq, count}。 + */ + java.util.Map genStudentNo(int stage, String year, Integer term, String scope); + + /** + * 按成绩/序号生成账号(覆盖已有账号;仅已分班学生参与编号,未分班学生账号不动)。 + * 格式:学校名称拼音首字母(小写)+s+学年+序号段——scope=class 为 班级序号2位+班内名次/序号2位(如 dngjxxs20260101); + * scope=grade 为 年级名次/序号4位(如 dngjxxs20260001)。有总分按成绩排名,无总分按序号(同 genStudentNo 的序号模式)。 + * 返回 {scope, mode: score|seq, count}。 + */ + java.util.Map genStudentAccount(int stage, String year, Integer term, String scope); + + /** + * 生成密码=账号+@(覆盖已有密码)。仅已分班且有账号的学生参与,无账号的跳过并计数; + * 全部无账号报错提示先生成账号。返回 {count, skipped}。 + */ + java.util.Map genStudentPassword(int stage, String year, Integer term); + + /** 实体 → VO */ + StudentVo toVo(com.yunhai.acaclassassign.entity.AssignStudent entity, + java.util.Map classNameById); +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/IClassService.java b/src/main/java/com/yunhai/acaclassassign/service/IClassService.java new file mode 100644 index 0000000..ac0cb42 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/IClassService.java @@ -0,0 +1,104 @@ +package com.yunhai.acaclassassign.service; + +import com.yunhai.acaclassassign.model.SchTeaList; +import com.yunhai.acaclassassign.model.bsch.ClassTreeNodeVo; +import com.yunhai.acaclassassign.model.bsch.NowYearTermVo; +import com.yunhai.acaclassassign.model.bsch.SubjectVo; +import com.yunhai.acaclassassign.model.bsch.YearTermItemVo; + +import java.util.List; +import java.util.Map; + +/** + * 班级管理(bschclass)开放接口客户端。 + * 基址取配置 {@code yh.open.bschclass}。 + *
    + *
  • 教师:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}}
  • + *
  • 学生树:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?schId={schId}&toUser=true} + * (学校→学段→年级→班级→学生)
  • + *
  • 学科:GET {@code {bschclass}/oapi/sch/getSchSubjectList?schId={schId}}
  • + *
  • 当前学年学期:GET {@code {bschclass}/oapi/sch/getNowYearTerm}
  • + *
  • 学年学期列表:GET {@code {bschclass}/oapi/sch/getYearTermList}(按学年倒序,当前学年带 nowYear 标记)
  • + *
+ */ +public interface IClassService { + + /** + * 获取当前学年学期。 + * 返回当前学年 year、当前学期 term(1第一学期 2第二学期)及学年/学期起止日期。 + */ + NowYearTermVo getNowYearTerm(); + + /** + * 获取全部学年列表(带 5 分钟内存缓存,按学年倒序)。 + * 外部接口不可达时返回空列表(调用方降级,不阻断分班业务)。 + */ + List getYearTermList(); + + /** + * 获取指定学校全部教师(含所属组 GroupIds)。 + */ + List getSchTeaList(int schId); + + /** + * 获取学校学科列表(带 5 分钟内存缓存)。 + * 外部接口不可达时返回空列表(调用方降级,不阻断分班业务)。 + */ + List getSubjectList(int schId); + + /** + * 获取学校学生树原始节点(带 5 分钟内存缓存)。 + * 节点字段:id/name/pid/type(1组 2学生)/isParent/children。 + * 外部接口不可达时返回 null(调用方降级处理,不阻断业务)。 + */ + List> getStuTree(int schId); + + /** + * 教师任教列表(bschclass GET /oapi/sch/getTeacherTeachList,带 5 分钟内存缓存)。 + * 项字段:id/schId/classId(班级组ID)/className/gradeId/gradeName/teaId/teaName/subId/subName。 + * 外部接口不可达时返回空列表(调用方降级,不阻断业务)。 + */ + List> getTeacherTeachList(int schId, int teaId); + + /** + * 学校班级列表(bschclass GET /oapi/sch/getSchClassList,不含毕业班,带 5 分钟内存缓存)。 + * 项字段:id/classId(班级组ID)/name/gradeId/gradeName/firstTeacherId/firstTeacherName/ + * secondTeacherId/secondTeacherName/graduate 等。 + * 外部接口不可达时返回空列表(调用方降级,不阻断业务)。 + */ + List> getSchClassList(int schId); + + /** + * 年级名称(bschclass GET /oapi/sch/getGradeName,按学校+入学年份+学段查学校年级字典别称, + * 带 5 分钟内存缓存)。stage 为班级管理学段口径(1小学/2初中/3高中)。 + * 接口不可达 / 学制或年级无匹配时返回 null(调用方降级,不阻断业务)。 + */ + String getGradeName(int schId, int year, int stage); + + /** + * 年级→班级树(叶子=班级,附 studentCount),由学生树派生。 + * 外部接口不可达时返回空列表。 + */ + List getClassTree(int schId); + + /** 班级学生数;班级不存在或数据不可得返回 0 */ + int studentCountOf(int schId, Integer classId); + + /** 班级名称;未知返回 null */ + String classNameOf(int schId, Integer classId); + + /** 年级名(按班级反查其父年级节点名);未知返回 null */ + String gradeNameOf(int schId, Integer classId); + + /** + * 学生定位:按学生ID在学生树中找到所在班级。 + * 返回 {studentId, studentName, classId, className};找不到或不可得返回 null。 + */ + Map resolveStudent(int schId, Integer studentId); + + /** + * 班级→学生名单(roster):{studentId, studentName, classId, className} 列表。 + * classIds 为空集合时返回空列表。 + */ + List> studentsOfClasses(int schId, List classIds); +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/IPushClassService.java b/src/main/java/com/yunhai/acaclassassign/service/IPushClassService.java new file mode 100644 index 0000000..ab1dc1a --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/IPushClassService.java @@ -0,0 +1,21 @@ +package com.yunhai.acaclassassign.service; + +import com.yunhai.acaclassassign.entity.AssignProject; +import com.yunhai.acaclassassign.model.assign.PushStatusVo; + +/** + * 分班结果推送班级管理(userclass /oapi/class)服务。 + *

链路:AddClass(同步组,返回 本地班→平台classId 映射)→ AddClassStudent(用户中心建号+sch_student 落库)。 + * 推送与提交解耦:推送异常不影响提交锁定状态。

+ */ +public interface IPushClassService { + + /** + * 推送分班结果(整体:全部班级 + 全部已分班学生)。 + * retry=true 时只重推 fail_json 中的失败学生(AddClass 幂等仍会整体调用)。 + */ + PushStatusVo push(AssignProject project, boolean retry); + + /** 查询推送状态(无记录返回未推送状态,不抛错) */ + PushStatusVo status(AssignProject project); +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/ISysOperationLogService.java b/src/main/java/com/yunhai/acaclassassign/service/ISysOperationLogService.java new file mode 100644 index 0000000..09eed7c --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/ISysOperationLogService.java @@ -0,0 +1,17 @@ +package com.yunhai.acaclassassign.service; + +import com.yunhai.acaclassassign.entity.SysOperationLog; +import com.yunhai.acaclassassign.model.BasePageQuery; +import com.yunhai.acaclassassign.model.BasePageResult; + +/** + * 系统审计日志业务接口:异步落库 + 分页查询。 + */ +public interface ISysOperationLogService { + + /** 异步保存(失败仅记应用日志,不影响业务请求) */ + void saveAsync(SysOperationLog entity); + + /** 分页查询(responseData 不返回) */ + BasePageResult page(BasePageQuery query, String operationType, Integer statusCode); +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/IUserCenterService.java b/src/main/java/com/yunhai/acaclassassign/service/IUserCenterService.java new file mode 100644 index 0000000..104d7c0 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/IUserCenterService.java @@ -0,0 +1,51 @@ +package com.yunhai.acaclassassign.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/acaclassassign/service/assign/AssignAlgorithm.java b/src/main/java/com/yunhai/acaclassassign/service/assign/AssignAlgorithm.java new file mode 100644 index 0000000..7037d3b --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/assign/AssignAlgorithm.java @@ -0,0 +1,1634 @@ +package com.yunhai.acaclassassign.service.assign; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 均衡分班算法(服务端移植原型内核 v2.0)。 + *

流程:捆绑并查集 → 预设固定落位 →(高中 layer)A/B 分层切池 → 蛇形容量分配 → + * 约束感知局部搜索(固定种子 + 扰动重启) →(within)班内分层 → 指标计算。

+ *

硬约束:同名不同班 / 同音不同班 / 「不同班」叉开 / 「同班」捆绑;预设固定班级锁定。

+ */ +public class AssignAlgorithm { + + /** 固定随机种子(与原型一致,保证结果可复现) */ + private static final long SEED = 20261016L; + + // ==================== 输入模型 ==================== + + /** 算法用学生(服务层从实体转换) */ + public static class AStudent { + + public int idx; + + public Integer id; + + public String name; + + public String soundKey; + + /** 1男 2女 0未知 */ + public int gender; + + public double total; + + /** 分数段下标(未启用 = -1) */ + public int bandIdx = -1; + + /** 预设固定班级序号(1 起;空=不固定) */ + public Integer fixedSort; + + /** 捆绑单元号 */ + public int unit = -1; + + /** A/B 层(within 模式输出) */ + public String level = ""; + + /** 单科成绩(服务层从 JSON 解析) */ + public final Map scores = new LinkedHashMap<>(); + + /** 是否型字段 */ + public final Map tags = new LinkedHashMap<>(); + + /** 数值型字段 */ + public final Map nums = new LinkedHashMap<>(); + } + + /** 算法用班级 */ + public static class AClass { + + public int idx; + + public Integer id; + + public int sortOrder; + + public String name; + + /** -1 = 自动均分 */ + public int target = -1; + + /** 是否 A 班(实验班,layer 模式取前 K 个) */ + public boolean abClass; + } + + /** 均衡维度配置 */ + public static class Balance { + + public boolean total = true; + + /** 三科平均分(语数英) */ + public boolean core3; + + public boolean gender = true; + + public boolean bands; + + public final Set subjects = new HashSet<>(); + + public final Set nums = new HashSet<>(); + + public final Set tags = new HashSet<>(); + } + + /** 约束开关(默认全开) */ + public static class Constraint { + + public boolean sameName = true; + + public boolean sameSound = true; + + public boolean diffClass = true; + + public boolean sameClass = true; + } + + /** 分数段方案(升序切点 + 标签) */ + public static class BandPlan { + + public double[] cuts = new double[0]; + + public String[] labels = new String[0]; + } + + /** 「不同班」互斥姓名对 / 「同班」捆绑姓名对 */ + public static class NamePair { + + public String a; + + public String b; + + public NamePair(String a, String b) { + this.a = a; + this.b = b; + } + } + + /** 算法入参 */ + public static class Config { + + public int stage; + + public boolean hasScore; + + public List students = new ArrayList<>(); + + public List classes = new ArrayList<>(); + + public Balance balance = new Balance(); + + public Constraint constraint = new Constraint(); + + public BandPlan bands = new BandPlan(); + + public boolean abEnabled; + + /** layer 按总分分层编班 / within 均衡编班+班内分层 */ + public String abMode = "layer"; + + public int abClassCount = 1; + + /** 「同班」捆绑姓名对 */ + public final List samePairs = new ArrayList<>(); + + /** 「不同班」互斥姓名对 */ + public final List diffPairs = new ArrayList<>(); + } + + // ==================== 输出模型 ==================== + + public static class MetricItem { + + public String dimension; + + /** count / avg */ + public String type; + + public double weight; + + public String minClass; + + public double minValue; + + public String maxClass; + + public double maxValue; + + public double spread; + + /** 相对偏差(%) */ + public double relDev; + + /** 优/良/关注/偏差大/样本过小 */ + public String grade; + + public double[] perClass; + } + + public static class MetricsOut { + + public final List items = new ArrayList<>(); + + /** 加权相对偏差(分数) */ + public double weightedDev; + + /** 综合均衡度 0-100 */ + public double score; + + public int countSpread; + + public double avgSpread; + + /** 男女比例最大偏差%(相对全校平均) */ + public double genderDev; + } + + public static class Result { + + /** 每个学生落位班级下标 */ + public int[] classIdx; + + /** 每个学生 A/B 层(无分层为空串) */ + public String[] level; + + public MetricsOut metrics = new MetricsOut(); + + public final List logs = new ArrayList<>(); + + public final List warns = new ArrayList<>(); + + public int iterations; + + public int accepted; + + public long costMs; + + public double costBefore; + + public double costAfter; + + /** A 班班级下标列表(layer 模式) */ + public final List aClassIdx = new ArrayList<>(); + } + + // ==================== 维度 ==================== + + private static final int SRC_SIZE = -1; + + private static final int SRC_MALE = -2; + + private static final int SRC_BAND = -3; + + private static final int SRC_TAG = -4; + + private static final int SRC_TOTAL = -5; + + private static final int SRC_NUM = -6; + + /** 三科平均分(语文+数学+英语)/3 */ + private static final int SRC_CORE3 = -7; + + /** 三科平均分口径:语文 / 数学 / 英语 */ + public static final String[] CORE3_SUBJECTS = {"语文", "数学", "英语"}; + + private static class DimSpec { + + final String name; + + final String type; + + final double weight; + + final int source; + + final int sub; + + DimSpec(String name, String type, double weight, int source, int sub) { + this.name = name; + this.type = type; + this.weight = weight; + this.source = source; + this.sub = sub; + } + } + + /** 确定性随机数(mulberry32,与原型同族) */ + static class Mulberry32 { + + private long state; + + Mulberry32(long seed) { + this.state = seed; + } + + int nextInt(int bound) { + state += 0x6D2B79F5L; + long t = state; + t = (t ^ (t >>> 15)) * (t | 1L); + t ^= (t + (t ^ (t >>> 7)) * (t | 61L)); + final int v = (int) ((t ^ (t >>> 14)) & 0x7FFFFFFFL); + return bound <= 0 ? 0 : v % bound; + } + } + + // ==================== 运行状态 ==================== + + private final Config cfg; + + private final int nClass; + + private final int nStu; + + private final int[] stuClass; + + private final int[] capacity; + + private final int[] size; + + /** layer 模式 A 池班级标记 */ + private final boolean[] isAPool; + + private final List dims = new ArrayList<>(); + + private double[][] sums; + + /** 初始分配按各层的维度均值控制累计偏差。 */ + private double[][] targetRates; + + private int genderDim = -1; + + private final List> units = new ArrayList<>(); + + private final boolean[] unitFixed; + + private final int[] unitClass; + + /** 学生下标 → 互斥学生下标集合 */ + private final Map> diffPartners = new HashMap<>(); + + private final List> nameCount = new ArrayList<>(); + + private final List> soundCount = new ArrayList<>(); + + private double[][] subjVal; + + private double[][] numVal; + + private boolean[][] tagVal; + + /** 三科平均分(按学生下标) */ + private double[] core3Val; + + /** layer 模式下按 A/B 层分组统计(每组班级下标);非分层为 null */ + private int[][] groupSlices; + + /** 全部班级下标 */ + private int[] classIdxAll; + + private List subjectNames; + + private List numNames; + + private List tagNames; + + private final Mulberry32 rnd = new Mulberry32(SEED); + + public AssignAlgorithm(Config cfg) { + this.cfg = cfg; + this.nClass = cfg.classes.size(); + this.nStu = cfg.students.size(); + this.stuClass = new int[Math.max(nStu, 1)]; + java.util.Arrays.fill(stuClass, -1); + this.capacity = new int[Math.max(nClass, 1)]; + this.size = new int[Math.max(nClass, 1)]; + this.isAPool = new boolean[Math.max(nClass, 1)]; + this.unitFixed = new boolean[Math.max(nStu, 1)]; + this.unitClass = new int[Math.max(nStu, 1)]; + this.classIdxAll = new int[Math.max(nClass, 1)]; + for (int c = 0; c < nClass; c++) { + classIdxAll[c] = c; + } + } + + // ==================== 主流程 ==================== + + public Result run() { + final long t0 = System.currentTimeMillis(); + final Result out = new Result(); + out.classIdx = new int[Math.max(nStu, 1)]; + out.level = new String[Math.max(nStu, 1)]; + + out.logs.add(String.format("[算法] 参与分班 %d 人 / %d 班(学段 %d,成绩:%s)", + nStu, nClass, cfg.stage, cfg.hasScore ? "启用" : "不使用")); + + buildDerivedData(); + buildUnits(); + buildDiffPairs(); + computeCapacity(); + out.logs.add(String.format("[算法] 班级容量:%s", capSummary())); + + final boolean layerMode = cfg.abEnabled && "layer".equals(cfg.abMode) && cfg.hasScore; + if (layerMode) { + splitLayers(out); + } + + buildDimsAndAggregates(); + snakeAssign(out, layerMode); + repairConstraints(out); + out.costBefore = cost(); + localSearch(out); + refine(out); + out.costAfter = cost(); + + if (layerMode) { + markLevelsByPool(); + } + if (cfg.abEnabled && "within".equals(cfg.abMode) && cfg.hasScore) { + withinLayer(out); + } + + for (int s = 0; s < nStu; s++) { + out.classIdx[s] = stuClass[s]; + out.level[s] = cfg.students.get(s).level; + } + out.metrics = computeMetrics(); + verifyConstraints(out); + + out.costMs = System.currentTimeMillis() - t0; + out.logs.add(String.format("[算法] 完成:迭代 %d 次,接受交换 %d 次,不均衡度 %.4f → %.4f,耗时 %dms", + out.iterations, out.accepted, out.costBefore, out.costAfter, out.costMs)); + return out; + } + + // ==================== 预处理 ==================== + + private void buildDerivedData() { + subjectNames = new ArrayList<>(cfg.balance.subjects); + numNames = new ArrayList<>(cfg.balance.nums); + tagNames = new ArrayList<>(cfg.balance.tags); + Collections.sort(subjectNames); + Collections.sort(numNames); + Collections.sort(tagNames); + subjVal = new double[subjectNames.size()][Math.max(nStu, 1)]; + numVal = new double[numNames.size()][Math.max(nStu, 1)]; + tagVal = new boolean[tagNames.size()][Math.max(nStu, 1)]; + core3Val = new double[Math.max(nStu, 1)]; + for (AssignAlgorithm.AStudent st : cfg.students) { + int si = 0; + for (String sub : subjectNames) { + final Double v = st.scores.get(sub); + subjVal[si++][st.idx] = v == null ? 0 : v; + } + int ni = 0; + for (String f : numNames) { + final Double v = st.nums.get(f); + numVal[ni++][st.idx] = v == null ? 0 : v; + } + int ti = 0; + for (String f : tagNames) { + tagVal[ti++][st.idx] = Boolean.TRUE.equals(st.tags.get(f)); + } + // 三科平均分:三科齐全才计,缺任一科按 0(与单科缺失口径一致) + final Double c3c = st.scores.get(CORE3_SUBJECTS[0]); + final Double c3m = st.scores.get(CORE3_SUBJECTS[1]); + final Double c3e = st.scores.get(CORE3_SUBJECTS[2]); + core3Val[st.idx] = c3c == null || c3m == null || c3e == null ? 0 : (c3c + c3m + c3e) / 3; + if (cfg.hasScore && cfg.balance.bands && cfg.bands.cuts.length > 0) { + st.bandIdx = bandIndexOf(st.total, cfg.bands.cuts); + } + } + } + + public static int bandIndexOf(double total, double[] cuts) { + int i = 0; + while (i < cuts.length && total >= cuts[i]) { + i++; + } + return i; + } + + private void buildUnits() { + final int[] parent = new int[Math.max(nStu, 1)]; + for (int i = 0; i < nStu; i++) { + parent[i] = i; + } + if (cfg.constraint.sameClass) { + final Map> byName = byName(); + for (NamePair p : cfg.samePairs) { + for (int a : byName.getOrDefault(p.a, Collections.emptyList())) { + for (int b : byName.getOrDefault(p.b, Collections.emptyList())) { + if (a != b) { + union(parent, a, b); + } + } + } + } + } + final Map> grouped = new LinkedHashMap<>(); + for (int i = 0; i < nStu; i++) { + grouped.computeIfAbsent(find(parent, i), k -> new ArrayList<>()).add(i); + } + int uid = 0; + for (List members : grouped.values()) { + for (int m : members) { + cfg.students.get(m).unit = uid; + } + units.add(members); + uid++; + } + unitClassInit(); + } + + private void unitClassInit() { + for (int u = 0; u < units.size(); u++) { + unitClass[u] = -1; + } + } + + private void buildDiffPairs() { + if (!cfg.constraint.diffClass) { + return; + } + final Map> byName = byName(); + for (NamePair p : cfg.diffPairs) { + final List as = byName.getOrDefault(p.a, Collections.emptyList()); + final List bs = byName.getOrDefault(p.b, Collections.emptyList()); + for (int a : as) { + for (int b : bs) { + if (a == b) { + continue; + } + diffPartners.computeIfAbsent(a, k -> new HashSet<>()).add(b); + diffPartners.computeIfAbsent(b, k -> new HashSet<>()).add(a); + } + } + } + } + + private Map> byName() { + final Map> m = new HashMap<>(); + for (AssignAlgorithm.AStudent st : cfg.students) { + m.computeIfAbsent(st.name, k -> new ArrayList<>()).add(st.idx); + } + return m; + } + + private void union(int[] parent, int a, int b) { + parent[find(parent, a)] = find(parent, b); + } + + private int find(int[] parent, int x) { + while (parent[x] != x) { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + return x; + } + + /** 容量:手动目标优先;合计不符按比例最大余数法折算,保证总数相等 */ + private void computeCapacity() { + boolean anyManual = false; + int manual = 0; + for (AssignAlgorithm.AClass c : cfg.classes) { + if (c.target > 0) { + anyManual = true; + manual += c.target; + } + } + if (!anyManual) { + final int base = nStu / nClass; + final int rem = nStu % nClass; + for (int c = 0; c < nClass; c++) { + capacity[c] = base + (c < rem ? 1 : 0); + } + return; + } + if (manual == nStu) { + for (int c = 0; c < nClass; c++) { + capacity[c] = Math.max(cfg.classes.get(c).target, 1); + } + return; + } + final double scale = nStu * 1.0 / manual; + final double[] exact = new double[nClass]; + int assigned = 0; + for (int c = 0; c < nClass; c++) { + final int t = cfg.classes.get(c).target > 0 ? cfg.classes.get(c).target : 1; + exact[c] = t * scale; + capacity[c] = Math.max((int) Math.floor(exact[c]), 1); + assigned += capacity[c]; + } + final Integer[] order = new Integer[nClass]; + for (int c = 0; c < nClass; c++) { + order[c] = c; + } + java.util.Arrays.sort(order, (a, b) -> Double.compare( + exact[b] - Math.floor(exact[b]), exact[a] - Math.floor(exact[a]))); + int i = 0; + int remain = nStu - assigned; + while (remain > 0) { + capacity[order[i % nClass]]++; + remain--; + i++; + } + while (remain < 0) { + final int c = order[i % nClass]; + if (capacity[c] > 1) { + capacity[c]--; + remain++; + } + i++; + } + } + + private String capSummary() { + final StringBuilder sb = new StringBuilder(); + for (int c = 0; c < nClass; c++) { + if (c > 0) { + sb.append('/'); + } + sb.append(cfg.classes.get(c).name).append(':').append(capacity[c]); + } + return sb.toString(); + } + + // ==================== A/B 分层(layer) ==================== + + private void splitLayers(Result out) { + final int k = Math.min(Math.max(cfg.abClassCount, 1), Math.max(nClass - 1, 1)); + final List aSlots = new ArrayList<>(); + final List bSlots = new ArrayList<>(); + for (int c = 0; c < nClass; c++) { + cfg.classes.get(c).abClass = c < k; + isAPool[c] = c < k; + if (c < k) { + aSlots.add(c); + out.aClassIdx.add(c); + } else { + bSlots.add(c); + } + } + groupSlices = bSlots.isEmpty() ? null : new int[][]{toIntArray(aSlots), toIntArray(bSlots)}; + + final List order = new ArrayList<>(); + for (int u = 0; u < units.size(); u++) { + order.add(u); + } + order.sort((a, b) -> Double.compare(unitMaxTotal(b), unitMaxTotal(a))); + + int aCap = 0; + for (int c = 0; c < k; c++) { + aCap += capacity[c]; + } + // 预设固定班级的单元按目标班所在层归池:否则按分数切池后再强行塞回固定班, + // 会造成 A 池、B 池人数与两层实际座位数不符(班级人数 55/54 vs 49) + final Set forced = new HashSet<>(); + final Map keyA = new HashMap<>(); + int aUsed = 0; + int forcedInPeople = 0; + int forcedOutPeople = 0; + for (int u : order) { + final Integer fs = fixedSortOf(u); + if (fs == null) { + continue; + } + forced.add(u); + if (fs - 1 < k) { + unitClass[u] = 0; + aUsed += units.get(u).size(); + forcedInPeople += units.get(u).size(); + for (String key : poolKeys(u)) { + keyA.merge(key, 1, Integer::sum); + } + } else { + unitClass[u] = 1; + forcedOutPeople += units.get(u).size(); + } + } + // 其余单元按总分从高到低补满 A 池;同名学生/同音姓名在 A 层最多 K 个单元, + // 否则层内必然出现同班,后续局部搜索无法挽回 + int keySkip = 0; + for (int u : order) { + if (forced.contains(u)) { + continue; + } + final int sz = units.get(u).size(); + if (aUsed + sz <= aCap) { + final Set keys = poolKeys(u); + boolean blocked = false; + for (String key : keys) { + if (keyA.getOrDefault(key, 0) >= k) { + blocked = true; + break; + } + } + if (!blocked) { + unitClass[u] = 0; + aUsed += sz; + for (String key : keys) { + keyA.merge(key, 1, Integer::sum); + } + continue; + } + keySkip++; + } + unitClass[u] = 1; + } + out.logs.add(String.format("[分层] 实验班 %d 个(容量 %d):A 池 %d 人(预设 %d 人)/ B 池 %d 人(预设 %d 人)", + k, aCap, aUsed, forcedInPeople, nStu - aUsed, forcedOutPeople)); + if (keySkip > 0) { + out.logs.add("[分层] " + keySkip + " 个单元因同名学生 / 同音姓名在 A 层名额已满,改入 B 层"); + } + if (aUsed > aCap) { + out.warns.add(String.format("A 层人数(%d)超出均衡目标(%d),原因是预设班级优先,可能导致 A 班人数偏多", + aUsed, aCap)); + } + } + + /** 单元预设固定班级序号(多成员取最小;越界视为未固定) */ + private Integer fixedSortOf(int u) { + Integer out = null; + for (int s : units.get(u)) { + final Integer fs = cfg.students.get(s).fixedSort; + if (fs != null && fs >= 1 && fs <= nClass && (out == null || fs < out)) { + out = fs; + } + } + return out; + } + + /** 分层名额占用键:同名(约束开启)与同音(约束开启) */ + private Set poolKeys(int u) { + final Set keys = new HashSet<>(); + for (int s : units.get(u)) { + final AStudent st = cfg.students.get(s); + if (cfg.constraint.sameName) { + keys.add("N:" + st.name); + } + if (cfg.constraint.sameSound) { + keys.add("S:" + st.soundKey); + } + } + return keys; + } + + private static int[] toIntArray(List list) { + final int[] arr = new int[list.size()]; + for (int i = 0; i < arr.length; i++) { + arr[i] = list.get(i); + } + return arr; + } + + /** layer 模式:按所在班级的 A/B 池标记回填学生层级 */ + private void markLevelsByPool() { + for (int s = 0; s < nStu; s++) { + cfg.students.get(s).level = isAPool[stuClass[s]] ? "A" : "B"; + } + } + + private double unitMaxTotal(int u) { + if (!cfg.hasScore) { + return -u; + } + double max = Double.NEGATIVE_INFINITY; + for (int s : units.get(u)) { + max = Math.max(max, cfg.students.get(s).total); + } + return max; + } + + // ==================== 维度与聚合 ==================== + + private void buildDimsAndAggregates() { + dims.clear(); + dims.add(new DimSpec("班级人数", "count", 3, SRC_SIZE, 0)); + if (cfg.hasScore && cfg.balance.total) { + dims.add(new DimSpec("总分均分", "avg", 4, SRC_TOTAL, 0)); + } + if (cfg.hasScore && cfg.balance.core3) { + dims.add(new DimSpec("三科平均分", "avg", 3, SRC_CORE3, 0)); + } + if (cfg.balance.gender) { + genderDim = dims.size(); + dims.add(new DimSpec("男生人数", "count", 3, SRC_MALE, 0)); + } + for (int i = 0; i < subjectNames.size(); i++) { + dims.add(new DimSpec(subjectNames.get(i) + "均分", "avg", 2, i, i)); + } + if (cfg.hasScore && cfg.balance.bands) { + for (int b = 0; b < cfg.bands.labels.length; b++) { + dims.add(new DimSpec(cfg.bands.labels[b] + "人数", "count", 1.5, SRC_BAND, b)); + } + } + for (int i = 0; i < numNames.size(); i++) { + dims.add(new DimSpec(numNames.get(i) + "均值", "avg", 2, SRC_NUM, i)); + } + for (int i = 0; i < tagNames.size(); i++) { + dims.add(new DimSpec(tagNames.get(i) + "人数", "count", 2, SRC_TAG, i)); + } + sums = new double[dims.size()][Math.max(nClass, 1)]; + targetRates = new double[dims.size()][Math.max(nClass, 1)]; + final double[][] poolSums = new double[dims.size()][2]; + final int[] poolSizes = new int[2]; + for (int u = 0; u < units.size(); u++) { + final int pool = groupSlices == null ? 0 : unitClass[u]; + poolSizes[pool] += units.get(u).size(); + for (int s : units.get(u)) { + for (int d = 0; d < dims.size(); d++) { + poolSums[d][pool] += stuDimValue(d, s); + } + } + } + for (int c = 0; c < nClass; c++) { + nameCount.add(new HashMap<>()); + soundCount.add(new HashMap<>()); + final int pool = groupSlices == null || isAPool[c] ? 0 : 1; + for (int d = 0; d < dims.size(); d++) { + targetRates[d][c] = poolSums[d][pool] / Math.max(poolSizes[pool], 1); + } + } + } + + private double stuDimValue(int d, int s) { + final DimSpec dim = dims.get(d); + switch (dim.source) { + case SRC_SIZE: + return 1; + case SRC_MALE: + return cfg.students.get(s).gender == 1 ? 1 : 0; + case SRC_BAND: + return cfg.students.get(s).bandIdx == dim.sub ? 1 : 0; + case SRC_TAG: + return tagVal[dim.sub][s] ? 1 : 0; + case SRC_TOTAL: + return cfg.students.get(s).total; + case SRC_CORE3: + return core3Val[s]; + case SRC_NUM: + return numVal[dim.sub][s]; + default: + return subjVal[dim.sub][s]; + } + } + + // ==================== 蛇形分配 ==================== + + private void snakeAssign(Result out, boolean layerMode) { + // 预设固定落位(捆绑组按组内最小序号;冲突告警) + int fixedCount = 0; + for (int u = 0; u < units.size(); u++) { + Integer fixedSort = null; + boolean conflict = false; + for (int s : units.get(u)) { + final Integer fs = cfg.students.get(s).fixedSort; + if (fs != null) { + if (fixedSort != null && !fixedSort.equals(fs)) { + conflict = true; + } + if (fixedSort == null || fs < fixedSort) { + fixedSort = fs; + } + } + } + if (fixedSort != null && fixedSort >= 1 && fixedSort <= nClass) { + final int c = fixedSort - 1; + if (layerMode && unitClass[u] == 1 && isAPool[c]) { + out.warns.add(String.format("[提示] 捆绑组预设 %s 与 B 层冲突,已按预设放入 %s", + fixedSort + "班", cfg.classes.get(c).name)); + } + place(u, c); + unitFixed[u] = true; + fixedCount += units.get(u).size(); + if (conflict) { + out.warns.add("[提示] 捆绑组成员预设了不同班级,已按序号较小者处理"); + } + } + } + if (fixedCount > 0) { + out.logs.add("[算法] 预设固定班级落位 " + fixedCount + " 人(锁定不参与优化)"); + } + + // 待分配单元:成绩版按单元最高分降序、捆绑组优先;小学按名单顺序 + final List bundles = new ArrayList<>(); + final List singles = new ArrayList<>(); + for (int u = 0; u < units.size(); u++) { + if (unitFixed[u]) { + continue; + } + if (units.get(u).size() > 1) { + bundles.add(u); + } else { + singles.add(u); + } + } + if (cfg.hasScore) { + bundles.sort((a, b) -> Double.compare(unitMaxTotal(b), unitMaxTotal(a))); + singles.sort((a, b) -> Double.compare(unitMaxTotal(b), unitMaxTotal(a))); + } + for (int u : bundles) { + place(u, pickClass(u, layerMode)); + } + for (int u : singles) { + place(u, pickClass(u, layerMode)); + } + out.logs.add(String.format("[算法] 蛇形容量分配完成,各班人数 %s", sizeSummary())); + } + + /** 容量优先,同容量选择落位后各已选维度累计偏差最小的班级,优先满足硬约束。 */ + private int pickClass(int u, boolean layerMode) { + final int sz = units.get(u).size(); + final boolean wantA = layerMode && unitClass[u] == 0; + int best = -1; + int bestFree = Integer.MIN_VALUE; + double bestKey = Double.MAX_VALUE; + // 无法满足约束时仍完成落位,交给修复和最终校验报告冲突。 + for (int pass = 0; pass < 2 && best < 0; pass++) { + for (int c = 0; c < nClass; c++) { + if (layerMode && isAPool[c] != wantA) { + continue; + } + final int free = capacity[c] - size[c]; + if (free < sz || (pass == 0 && !unitFits(u, c))) { + continue; + } + if (free > bestFree) { + best = c; + bestFree = free; + bestKey = placementCost(u, c); + } else if (free == bestFree) { + final double key = placementCost(u, c); + if (key < bestKey) { + best = c; + bestKey = key; + } + } + } + } + if (best < 0) { + for (int c = 0; c < nClass; c++) { + if (layerMode && isAPool[c] != wantA) { + continue; + } + if (capacity[c] - size[c] > bestFree) { + best = c; + bestFree = capacity[c] - size[c]; + } + } + } + return Math.max(best, 0); + } + + private boolean unitFits(int u, int c) { + for (int s : units.get(u)) { + if (!fits(s, c, Collections.emptyList())) { + return false; + } + } + return true; + } + + private double placementCost(int u, int c) { + double cost = 0; + for (int d = 0; d < dims.size(); d++) { + final double rate = targetRates[d][c]; + if (dims.get(d).source == SRC_SIZE || Math.abs(rate) < 1e-9) { + continue; + } + double added = 0; + for (int s : units.get(u)) { + added += stuDimValue(d, s); + } + final double before = (sums[d][c] - rate * size[c]) / Math.abs(rate); + final double after = before + (added - rate * units.get(u).size()) / Math.abs(rate); + cost += dims.get(d).weight * (after * after - before * before); + } + return cost; + } + + private String sizeSummary() { + final StringBuilder sb = new StringBuilder(); + for (int c = 0; c < nClass; c++) { + if (c > 0) { + sb.append('/'); + } + sb.append(size[c]); + } + return sb.toString(); + } + + private void place(int u, int c) { + for (int s : units.get(u)) { + stuClass[s] = c; + size[c]++; + final AStudent st = cfg.students.get(s); + nameCount.get(c).merge(st.name, 1, Integer::sum); + soundCount.get(c).merge(st.soundKey, 1, Integer::sum); + for (int d = 0; d < dims.size(); d++) { + sums[d][c] += stuDimValue(d, s); + } + } + unitClass[u] = c; + } + + private void addStu(int s, int c, int sign) { + size[c] += sign; + final AStudent st = cfg.students.get(s); + nameCount.get(c).merge(st.name, sign, Integer::sum); + soundCount.get(c).merge(st.soundKey, sign, Integer::sum); + for (int d = 0; d < dims.size(); d++) { + sums[d][c] += sign * stuDimValue(d, s); + } + } + + // ==================== 局部搜索 ==================== + + /** 蛇形分配后的约束修复:把落位违反同名/同音/叉开的可动单元换出去(无视代价) */ + private void repairConstraints(Result out) { + int repaired = 0; + for (int pass = 0; pass < units.size(); pass++) { + boolean any = false; + for (int u = 0; u < units.size(); u++) { + if (unitFixed[u] || !unitViolates(u)) { + continue; + } + boolean done = false; + for (int v = 0; v < units.size() && !done; v++) { + if (v == u || unitFixed[v] + || units.get(v).size() != units.get(u).size()) { + continue; + } + if (canSwap(u, v)) { + doSwap(u, v); + repaired++; + any = true; + done = true; + } + } + } + if (!any) { + break; + } + } + if (repaired > 0) { + out.logs.add("[校验] 初始约束修复:调出违规单元 " + repaired + " 个"); + } + } + + /** 单元当前班级内是否违反同名/同音/互斥(忽略与本单元内部成员的冲突) */ + private boolean unitViolates(int u) { + final int c = unitClass[u]; + for (int s : units.get(u)) { + final AStudent st = cfg.students.get(s); + if (cfg.constraint.sameName + && nameCount.get(c).getOrDefault(st.name, 0) + - countIn(units.get(u), st.name, true) > 0) { + return true; + } + if (cfg.constraint.sameSound + && soundCount.get(c).getOrDefault(st.soundKey, 0) + - countIn(units.get(u), st.soundKey, false) > 0) { + return true; + } + if (cfg.constraint.diffClass) { + final Set ps = diffPartners.get(s); + if (ps != null) { + for (int p : ps) { + if (stuClass[p] == c && !units.get(u).contains(p)) { + return true; + } + } + } + } + } + return false; + } + + private double cost() { + double total = 0; + for (int d = 0; d < dims.size(); d++) { + final SpreadStat s = spreadStat(d); + if (s == null || s.noData || s.smallSample) { + continue; + } + total += dims.get(d).weight * s.rel; + } + return total; + } + + /** 先降低与展示指标一致的极差代价;极差相同时,用所有班的方差打破平台。 */ + private static class SearchScore { + final double spread; + final double variance; + + SearchScore(double spread, double variance) { + this.spread = spread; + this.variance = variance; + } + + boolean betterThan(SearchScore other) { + return spread < other.spread - 1e-9 + || (Math.abs(spread - other.spread) <= 1e-9 && variance < other.variance - 1e-9); + } + } + + private SearchScore searchScore() { + double variance = 0; + final int[][] groups = groupSlices == null ? new int[][]{classIdxAll} : groupSlices; + for (int d = 0; d < dims.size(); d++) { + for (int[] group : groups) { + if (group.length < 2) { + continue; + } + final SpreadStat stat = spreadStat(d, group, group.length); + if (stat.noData || stat.smallSample) { + continue; + } + for (int c : group) { + final double delta = (valueOf(d, c) - stat.mean) / Math.abs(stat.mean); + variance += dims.get(d).weight * delta * delta / group.length; + } + } + } + return new SearchScore(cost(), variance); + } + + /** 从单元落位重建全部聚合,恢复扰动前保存的最优方案。 */ + private void restoreAssignment(int[] assignment) { + java.util.Arrays.fill(size, 0); + for (double[] row : sums) { + java.util.Arrays.fill(row, 0); + } + for (int c = 0; c < nClass; c++) { + nameCount.get(c).clear(); + soundCount.get(c).clear(); + } + for (int u = 0; u < units.size(); u++) { + place(u, assignment[u]); + } + } + + private static class SpreadStat { + + final int minC; + + final int maxC; + + final double min; + + final double max; + + final double mean; + + /** 原始极差 */ + final double spread; + + /** 人数型扣除 ±1 人离散后的相对偏差 */ + final double rel; + + /** 人数型维度平均每班不足 2 人,无均衡空间 */ + final boolean smallSample; + + final boolean noData; + + SpreadStat(int minC, int maxC, double min, double max, double mean, double spread, + double rel, boolean smallSample, boolean noData) { + this.minC = minC; + this.maxC = maxC; + this.min = min; + this.max = max; + this.mean = mean; + this.spread = spread; + this.rel = rel; + this.smallSample = smallSample; + this.noData = noData; + } + } + + /** 维度统计:layer 模式取组内最差(先相对偏差、再绝对极差),避免跨层差值污染均衡度 */ + private SpreadStat spreadStat(int d) { + if (groupSlices == null) { + return spreadStat(d, classIdxAll, nClass); + } + SpreadStat worst = null; + for (int[] g : groupSlices) { + if (g.length == 0) { + continue; + } + final SpreadStat s = spreadStat(d, g, g.length); + if (worst == null || s.rel > worst.rel + 1e-12 + || (Math.abs(s.rel - worst.rel) <= 1e-12 && s.spread > worst.spread)) { + worst = s; + } + } + return worst == null ? spreadStat(d, classIdxAll, nClass) : worst; + } + + private SpreadStat spreadStat(int d, int[] classes, int len) { + final boolean count = "count".equals(dims.get(d).type); + double min = Double.MAX_VALUE; + double max = -Double.MAX_VALUE; + double sum = 0; + int minC = classes[0]; + int maxC = classes[0]; + for (int i = 0; i < len; i++) { + final int c = classes[i]; + final double v = valueOf(d, c); + if (v < min) { + min = v; + minC = c; + } + if (v > max) { + max = v; + maxC = c; + } + sum += v; + } + final double mean = sum / len; + final double rawSpread = max - min; + final double excess = count ? Math.max(0, rawSpread - 1) : rawSpread; + final boolean noData = Math.abs(mean) < 1e-9 || (!count && mean < 2); + final double rel = noData || Math.abs(mean) < 1e-9 ? 0 : excess / Math.abs(mean); + final boolean small = count && Math.abs(mean) < 2; + return new SpreadStat(minC, maxC, min, max, mean, rawSpread, rel, small, noData); + } + + /** 指标卡口径的组内最大极差(分层时取各组内最大值) */ + private double maxGroupSpread(int d) { + if (groupSlices == null) { + return spreadStat(d, classIdxAll, nClass).spread; + } + double best = 0; + for (int[] g : groupSlices) { + if (g.length > 0) { + best = Math.max(best, spreadStat(d, g, g.length).spread); + } + } + return best; + } + + private double valueOf(int d, int c) { + final DimSpec dim = dims.get(d); + if ("avg".equals(dim.type)) { + return size[c] == 0 ? 0 : sums[d][c] / size[c]; + } + return sums[d][c]; + } + + private void localSearch(Result out) { + final List movable = new ArrayList<>(); + for (int u = 0; u < units.size(); u++) { + if (!unitFixed[u]) { + movable.add(u); + } + } + if (movable.size() < 2 || nClass < 2) { + out.logs.add("[局部搜索] 可动单元或班级不足,无交换空间"); + return; + } + final int maxIter = Math.max(8000, Math.min(60000, movable.size() * 200)); + int accepted = 0; + int lastImprove = 0; + SearchScore current = searchScore(); + SearchScore best = current; + final int[] bestAssignment = unitClass.clone(); + for (int it = 0; it < maxIter; it++) { + out.iterations = it + 1; + if (it - lastImprove >= 5000 && movable.size() > 30 && (it - lastImprove) % 5000 == 0) { + restoreAssignment(bestAssignment); + perturb(movable, out); + current = searchScore(); + lastImprove = it; + continue; + } + final int ua = movable.get(rnd.nextInt(movable.size())); + final int ub = movable.get(rnd.nextInt(movable.size())); + if (ua == ub || unitClass[ua] == unitClass[ub] + || units.get(ua).size() != units.get(ub).size()) { + continue; + } + if (!canSwap(ua, ub)) { + continue; + } + doSwap(ua, ub); + final SearchScore after = searchScore(); + if (after.betterThan(current)) { + current = after; + accepted++; + lastImprove = it; + if (after.betterThan(best)) { + best = after; + System.arraycopy(unitClass, 0, bestAssignment, 0, unitClass.length); + } + } else { + doSwap(ub, ua); + } + } + restoreAssignment(bestAssignment); + out.accepted = accepted; + out.logs.add(String.format("[局部搜索] 迭代上限 %d,接受交换 %d 次,保留历史最优方案", maxIter, accepted)); + } + + /** + * 定向精修:随机搜索命中「少数极端单元」的概率极低(如 A 层仅 4 名中等生, + * 随机抽中对应客体的概率约万分之一)。这里按当前代价最高的维度,取该维度最高班与 + * 最低班的极端单元做精确比选,逐个接受最优交换,直到无改进。 + */ + private void refine(Result out) { + final double eps = 1e-9; + int accepted = 0; + final int maxRounds = Math.max(50, Math.min(600, units.size())); + for (int round = 0; round < maxRounds; round++) { + final List order = new ArrayList<>(); + for (int d = 0; d < dims.size(); d++) { + final SpreadStat s = spreadStat(d); + if (s == null || s.noData || s.smallSample) { + continue; + } + if ("count".equals(dims.get(d).type) && s.spread <= 1) { + continue; + } + if (dims.get(d).weight * s.rel > eps) { + order.add(d); + } + } + order.sort((a, b) -> Double.compare( + dims.get(b).weight * spreadStat(b).rel, dims.get(a).weight * spreadStat(a).rel)); + boolean improved = false; + for (int worstDim : order) { + if (tryRefineDim(worstDim)) { + accepted++; + improved = true; + break; + } + } + if (!improved) { + break; + } + } + if (accepted > 0) { + out.accepted += accepted; + out.logs.add("[局部搜索] 定向精修接受交换 " + accepted + " 次"); + } + } + + /** 针对单个维度,分别在各层最高班与最低班中找最优交换。 */ + private boolean tryRefineDim(int d) { + int bestUa = -1; + int bestUb = -1; + SearchScore bestCost = searchScore(); + final int[][] groups = groupSlices == null ? new int[][]{classIdxAll} : groupSlices; + for (int[] group : groups) { + if (group.length < 2) { + continue; + } + final SpreadStat stat = spreadStat(d, group, group.length); + final List hi = extremeUnits(d, stat.maxC, true); + final List lo = extremeUnits(d, stat.minC, false); + for (int ua : hi) { + for (int ub : lo) { + if (ua == ub || units.get(ua).size() != units.get(ub).size() + || unitClass[ua] == unitClass[ub] || !canSwap(ua, ub)) { + continue; + } + doSwap(ua, ub); + final SearchScore after = searchScore(); + doSwap(ub, ua); + if (after.betterThan(bestCost)) { + bestCost = after; + bestUa = ua; + bestUb = ub; + } + } + } + } + if (bestUa < 0) { + return false; + } + doSwap(bestUa, bestUb); + return true; + } + + /** 小班穷举;大班等距取样,覆盖极端和中间值,保留微调所需的相近单元。 */ + private List extremeUnits(int d, int c, boolean high) { + final List list = new ArrayList<>(); + for (int u = 0; u < units.size(); u++) { + if (!unitFixed[u] && unitClass[u] == c) { + list.add(u); + } + } + list.sort((a, b) -> { + final double va = unitDimAvg(d, a); + final double vb = unitDimAvg(d, b); + final int cmp = high ? Double.compare(vb, va) : Double.compare(va, vb); + return cmp != 0 ? cmp : Integer.compare(a, b); + }); + if (list.size() <= 48) { + return list; + } + final List sampled = new ArrayList<>(); + for (int i = 0; i < 48; i++) { + sampled.add(list.get(i * (list.size() - 1) / 47)); + } + return sampled; + } + + private double unitDimAvg(int d, int u) { + double sum = 0; + for (int s : units.get(u)) { + sum += stuDimValue(d, s); + } + return sum / Math.max(units.get(u).size(), 1); + } + + /** 扰动:随机交换约 6% 可动单元(无视代价),跳出局部最优 */ + private void perturb(List movable, Result out) { + int n = Math.max(1, (int) (movable.size() * 0.06)); + while (n-- > 0) { + final int ua = movable.get(rnd.nextInt(movable.size())); + final int ub = movable.get(rnd.nextInt(movable.size())); + if (ua == ub || unitClass[ua] == unitClass[ub] + || units.get(ua).size() != units.get(ub).size() || !canSwap(ua, ub)) { + continue; + } + doSwap(ua, ub); + } + out.logs.add("[局部搜索] 扰动重启,跳出局部最优"); + } + + private boolean canSwap(int ua, int ub) { + final int ca = unitClass[ua]; + final int cb = unitClass[ub]; + if (hasPoolSplit() && isAPool[ca] != isAPool[cb]) { + return false; // layer 模式不得跨 A/B 层调换 + } + for (int s : units.get(ua)) { + if (!fits(s, cb, units.get(ub))) { + return false; + } + } + for (int s : units.get(ub)) { + if (!fits(s, ca, units.get(ua))) { + return false; + } + } + return true; + } + + private boolean hasPoolSplit() { + for (int c = 0; c < nClass; c++) { + if (isAPool[c]) { + return true; + } + } + return false; + } + + /** 学生 s 进入班 c(leaving 单元成员将离开该班)是否违反同名/同音/互斥 */ + private boolean fits(int s, int c, List leaving) { + final AStudent st = cfg.students.get(s); + if (cfg.constraint.sameName) { + final Integer cnt = nameCount.get(c).get(st.name); + if (cnt != null && cnt - countIn(leaving, st.name, true) > 0) { + return false; + } + } + if (cfg.constraint.sameSound) { + final Integer cnt = soundCount.get(c).get(st.soundKey); + if (cnt != null && cnt - countIn(leaving, st.soundKey, false) > 0) { + return false; + } + } + if (cfg.constraint.diffClass) { + final Set ps = diffPartners.get(s); + if (ps != null) { + for (int p : ps) { + if (stuClass[p] == c && !leaving.contains(p)) { + return false; + } + } + } + } + return true; + } + + private int countIn(List leaving, String key, boolean byName) { + int n = 0; + for (int s : leaving) { + final AStudent st = cfg.students.get(s); + if (byName ? st.name.equals(key) : st.soundKey.equals(key)) { + n++; + } + } + return n; + } + + private void doSwap(int ua, int ub) { + final int ca = unitClass[ua]; + final int cb = unitClass[ub]; + for (int s : units.get(ua)) { + addStu(s, ca, -1); + addStu(s, cb, 1); + stuClass[s] = cb; + } + for (int s : units.get(ub)) { + addStu(s, cb, -1); + addStu(s, ca, 1); + stuClass[s] = ca; + } + unitClass[ua] = cb; + unitClass[ub] = ca; + } + + // ==================== within 班内分层 ==================== + + private void withinLayer(Result out) { + int aCount = 0; + for (int c = 0; c < nClass; c++) { + final List members = new ArrayList<>(); + for (int s = 0; s < nStu; s++) { + if (stuClass[s] == c) { + members.add(s); + } + } + members.sort((x, y) -> Double.compare(cfg.students.get(y).total, cfg.students.get(x).total)); + final int half = (members.size() + 1) / 2; + for (int i = 0; i < members.size(); i++) { + cfg.students.get(members.get(i)).level = i < half ? "A" : "B"; + } + aCount += half; + } + out.logs.add(String.format("[分层] 班内分层完成:A层 %d 人 / B层 %d 人(各班入学总分前 50%%)", + aCount, nStu - aCount)); + } + + // ==================== 指标 ==================== + + private MetricsOut computeMetrics() { + final MetricsOut m = new MetricsOut(); + double weighted = 0; + double weightSum = 0; + for (int d = 0; d < dims.size(); d++) { + final DimSpec dim = dims.get(d); + final MetricItem item = new MetricItem(); + item.dimension = dim.name; + item.type = dim.type; + item.weight = dim.weight; + item.perClass = new double[nClass]; + for (int c = 0; c < nClass; c++) { + item.perClass[c] = round2(valueOf(d, c)); + } + final SpreadStat s = spreadStat(d); + item.minClass = cfg.classes.get(s.minC).name; + item.maxClass = cfg.classes.get(s.maxC).name; + item.minValue = round2(s.min); + item.maxValue = round2(s.max); + item.spread = round2(s.spread); + if (s.noData || s.smallSample) { + item.grade = "样本过小"; + item.relDev = 0; + } else { + item.relDev = round2(s.rel * 100); + final double relp = s.rel * 100; + item.grade = relp <= 0.5 ? "优" : relp <= 2 ? "良" : relp <= 5 ? "关注" : "偏差大"; + weighted += dim.weight * s.rel; + weightSum += dim.weight; + if ("count".equals(dim.type) && "班级人数".equals(dim.name)) { + m.countSpread = (int) Math.round(maxGroupSpread(d)); + } + if ("avg".equals(dim.type) && "总分均分".equals(dim.name)) { + m.avgSpread = maxGroupSpread(d); + } + } + m.items.add(item); + } + m.weightedDev = weightSum > 0 ? weighted / weightSum : 0; + m.score = Math.max(0, Math.min(100, 100 * (1 - m.weightedDev * 1.6))); + + int maleAll = 0; + for (int s = 0; s < nStu; s++) { + if (cfg.students.get(s).gender == 1) { + maleAll++; + } + } + final double globalRatio = nStu == 0 ? 0 : maleAll * 1.0 / nStu; + double dev = 0; + if (cfg.balance.gender) { + for (int c = 0; c < nClass; c++) { + if (size[c] == 0) { + continue; + } + final double male = genderDim >= 0 ? sums[genderDim][c] : 0; + dev = Math.max(dev, Math.abs(male / size[c] - globalRatio)); + } + } + m.genderDev = round2(dev * 100); + return m; + } + + /** 硬约束校验(最终结果全量复查,结论写入日志/警告) */ + private void verifyConstraints(Result out) { + int violations = 0; + for (int c = 0; c < nClass; c++) { + for (Map.Entry e : nameCount.get(c).entrySet()) { + if (cfg.constraint.sameName && e.getValue() > 1) { + violations++; + out.warns.add(String.format("[校验] %s 存在同名学生:%s ×%d", + cfg.classes.get(c).name, e.getKey(), e.getValue())); + } + } + for (Map.Entry e : soundCount.get(c).entrySet()) { + if (cfg.constraint.sameSound && e.getValue() > 1) { + violations++; + out.warns.add(String.format("[校验] %s 存在同音学生(音:%s)×%d", + cfg.classes.get(c).name, e.getKey(), e.getValue())); + } + } + } + if (cfg.constraint.diffClass) { + for (Map.Entry> e : diffPartners.entrySet()) { + for (int p : e.getValue()) { + if (p > e.getKey() && stuClass[p] == stuClass[e.getKey()]) { + violations++; + out.warns.add(String.format("[校验] 预设「不同班」的 %s 与 %s 落入同班 %s", + cfg.students.get(e.getKey()).name, cfg.students.get(p).name, + cfg.classes.get(stuClass[e.getKey()]).name)); + } + } + } + } + out.logs.add(violations == 0 + ? "[校验] 硬约束全部满足:同名/同音不同班、叉开约束、捆绑完整" + : String.format("[校验] 发现 %d 处约束提示,详见警告", violations)); + } + + private static double round2(double v) { + return Math.round(v * 100.0) / 100.0; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/assign/SameSound.java b/src/main/java/com/yunhai/acaclassassign/service/assign/SameSound.java new file mode 100644 index 0000000..74979b6 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/assign/SameSound.java @@ -0,0 +1,54 @@ +package com.yunhai.acaclassassign.service.assign; + +import java.util.HashMap; +import java.util.Map; + +/** + * 姓名同音判定:内置常用同音字组(姓氏 + 名字常用字)。 + *

同组内任意字视为同音。soundKey 将姓名逐字映射为组代表字/原字后拼接。

+ */ +public final class SameSound { + + private SameSound() { + } + + /** 同音字组(移植自分班原型 45 组常用同音字表) */ + private static final String[] GROUPS = { + "张章彰樟", "李理莉鲤里", "王旺望", "刘留流榴", "陈晨尘辰橙", + "杨扬洋阳羊", "黄煌凰皇", "赵兆照罩", "吴梧吾无芜", "徐绪储", + "孙损笋", "马玛码", "朱珠株珠诸", "胡湖糊蝴狐", "郭锅国帼", + "何合河荷和", "高膏糕羔", "林淋琳霖麟", "罗锣萝箩络", "郑正证政挣", + "梁粱良凉", "谢卸蟹泄", "宋送诵颂", "唐糖塘堂棠", "许序絮绪", + "韩含寒涵涵函", "冯逢缝凤", "邓灯登等", "曹槽草漕", "彭澎蓬鹏棚", + "曾增憎", "肖削消宵萧潇霄", "田填甜佃", "董懂冬", "袁元园原源圆员", + "蔡菜采彩", "蒋桨讲奖", "余鱼渔愉于娱俞豫", "杜肚度渡镀", "叶页夜业", + "程成城诚呈橙乘", "苏酥俗", "魏位未卫味畏", "吕铝侣旅履", "丁钉叮盯叮宁" + }; + + private static final Map CHAR_TO_REP = new HashMap<>(); + + static { + for (String group : GROUPS) { + final char rep = group.charAt(0); + for (char c : group.toCharArray()) { + CHAR_TO_REP.putIfAbsent(c, rep); + } + } + } + + /** + * 姓名同音键:逐字映射同音组代表字后拼接。 + */ + public static String soundKey(String name) { + if (name == null) { + return ""; + } + final String trimmed = name.trim(); + final StringBuilder sb = new StringBuilder(trimmed.length()); + for (int i = 0; i < trimmed.length(); i++) { + final Character rep = CHAR_TO_REP.get(trimmed.charAt(i)); + sb.append(rep == null ? trimmed.charAt(i) : rep.charValue()); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/assign/SampleDataGenerator.java b/src/main/java/com/yunhai/acaclassassign/service/assign/SampleDataGenerator.java new file mode 100644 index 0000000..ea92b78 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/assign/SampleDataGenerator.java @@ -0,0 +1,53 @@ +package com.yunhai.acaclassassign.service.assign; + +/** + * 示例名单生成:服务端生成与导入模板同口径的演示数据文本。 + * + * 2026-10-21 按用户反馈定稿:「载入示例数据」只把文本填进前端粘贴框,由用户确认后 + * 再走 ImportPaste 通道入库(中途曾改为服务端直接落库,因用户要可预览/可编辑而回退); + * 数据内容与历史前端示例保持一致(小学 60 人 / 中学 306 人)。 + */ +public final class SampleDataGenerator { + + private SampleDataGenerator() { + } + + /** 按学段生成示例名单文本(首行为表头,Tab 分隔,口径同导入模板) */ + public static String text(int stage) { + final StringBuilder sb = new StringBuilder(); + if (stage == 2) { + sb.append("姓名\t账号\t学号\t性别\t备注\t预设班级\n"); + for (int i = 1; i <= 60; i++) { + sb.append(String.format("学生%03d\tstu%04d\t2026%04d\t%s\t%s\t%s\n", + i, i, i, + i % 2 == 1 ? "男" : "女", + i % 17 == 0 ? "双胞胎" : "", + i % 29 == 0 ? "1" : "")); + } + return sb.toString(); + } + sb.append("姓名\t账号\t学号\t性别\t总分\t语文\t数学\t英语\t特长生\t住宿\t毕业学校\t备注\t预设班级\n"); + for (int i = 1; i <= 300; i++) { + final int chinese = 70 + (i % 30); + final int math = 65 + (i % 35); + final int english = 75 + (i % 25); + sb.append(String.format("学生%03d\tstu%04d\t2026%04d\t%s\t%d\t%d\t%d\t%d\t%s\t%s\t%s\t%s\t%s\n", + i, i, i, + i % 2 == 1 ? "男" : "女", + chinese + math + english, chinese, math, english, + i % 11 == 0 ? "是" : "否", + i % 4 == 0 ? "是" : "否", + "毕业学校" + ((i % 6) + 1), + i % 23 == 0 ? "同名" : "", + i % 37 == 0 ? "1" : "")); + } + // 约束演示行:同名、双胞胎同班、矛盾学生不同班 + sb.append("张三\tstu0901\t2026901\t男\t240\t80\t80\t80\t否\t否\t毕业学校1\t\t\n"); + sb.append("张三\tstu0902\t2026902\t女\t235\t78\t79\t78\t否\t是\t毕业学校2\t\t\n"); + sb.append("李大\tstu0903\t2026903\t男\t250\t85\t85\t80\t否\t否\t毕业学校1\t双胞胎\t同班:李二\n"); + sb.append("李二\tstu0904\t2026904\t男\t248\t84\t84\t80\t否\t否\t毕业学校3\t双胞胎\t\n"); + sb.append("赵六\tstu0905\t2026905\t男\t230\t76\t77\t77\t否\t否\t毕业学校4\t\t不同班:钱七\n"); + sb.append("钱七\tstu0906\t2026906\t男\t228\t75\t76\t77\t否\t否\t毕业学校5\t\t\n"); + return sb.toString(); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/assign/StudentParser.java b/src/main/java/com/yunhai/acaclassassign/service/assign/StudentParser.java new file mode 100644 index 0000000..6287f59 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/assign/StudentParser.java @@ -0,0 +1,504 @@ +package com.yunhai.acaclassassign.service.assign; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * 学生名单解析器(粘贴文本 / csv·txt·tsv 文件共用)。 + *

移植原型列识别规则:表头关键词识别、性别归一化、是否/数值型字段推断、预设班级解析。

+ */ +public final class StudentParser { + + private StudentParser() { + } + + /** 单科列名正则 */ + private static final Pattern SUBJECT = Pattern.compile( + ".*(?:语文|数学|英语|物理|化学|生物|政治|历史|地理|科学|道法|道德|思想品德|音乐|美术|体育|信息技术|信息|综合).*"); + /** 毕业学校列名 */ + private static final Pattern SCHOOL = Pattern.compile( + ".*(?:毕业学校|毕业小学|毕业院校|原学校|就读学校|来源学校|毕业中学).*"); + /** 学号列名 */ + private static final Pattern STU_NO = Pattern.compile(".*(学号|学籍号|考号|准考证).*"); + /** 账号列名 */ + private static final Pattern ACCOUNT = Pattern.compile(".*(账号|账户).*"); + /** 备注列名 */ + private static final Pattern REMARK = Pattern.compile(".*(备注|说明).*"); + /** 预设班级列名 */ + private static final Pattern PRESET = Pattern.compile(".*(?:预设|指定班级|意向|固定班级|要求).*"); + /** 姓名列名 */ + private static final Pattern NAME = Pattern.compile(".*(姓名|名字).*"); + /** 性别列名 */ + private static final Pattern GENDER = Pattern.compile(".*(性别|sex).*"); + /** 总分列名 */ + private static final Pattern TOTAL = Pattern.compile(".*(总分|总成绩|入学总分|合计).*"); + /** 忽略列名(已有分班结果列等) */ + private static final Pattern IGNORE = Pattern.compile("^(序号|编号|#|班级|行政班|分班结果)$"); + + /** 预设解析结果 */ + public static class Preset { + + /** class=固定到某班 / same=同班捆绑 / diff=不同班叉开 */ + public String type; + + /** type=class 时的班级序号(1 起) */ + public Integer classSort; + + /** type=same/diff 时的对方姓名 */ + public String target; + + public Preset(String type, Integer classSort, String target) { + this.type = type; + this.classSort = classSort; + this.target = target; + } + } + + /** 解析后的行 */ + public static class ParsedRow { + + public String name; + + /** 1男 2女 0未知 */ + public int gender; + + public Double total; + + public final Map scores = new LinkedHashMap<>(); + + public final Map tags = new LinkedHashMap<>(); + + public final Map nums = new LinkedHashMap<>(); + + public String school; + + public String studentNo; + + public String account; + + public String remark; + + public String presetRaw; + + public Preset preset; + + public int srcRow; + } + + /** 解析结果 */ + public static class ParseResult { + + public final List rows = new ArrayList<>(); + + public final List warn = new ArrayList<>(); + + /** 识别到的列(保持顺序) */ + public final List subjects = new ArrayList<>(); + + public final List tags = new ArrayList<>(); + + public final List nums = new ArrayList<>(); + + public boolean hasScore; + } + + /** + * 解析粘贴文本/文件内容。 + * + * @param stage 2小学 3初中 4高中(决定无表头时的默认列序与成绩开关) + * @param hasHeader null=自动检测 + */ + public static ParseResult parse(String text, int stage, Boolean hasHeader) { + final ParseResult result = new ParseResult(); + final boolean primary = stage == 2; + final List> grid = splitGrid(text); + if (grid.isEmpty()) { + result.warn.add("未解析到有效数据行"); + return result; + } + + // 表头检测:显式指定优先,否则首行出现任一已知关键词即视为表头 + boolean header = false; + List head = null; + final List first = grid.get(0); + if (hasHeader != null) { + header = hasHeader; + } else { + for (String cell : first) { + if (NAME.matcher(cell).matches() || GENDER.matcher(cell).matches() + || TOTAL.matcher(cell).matches() || SUBJECT.matcher(cell).matches() + || SCHOOL.matcher(cell).matches() || REMARK.matcher(cell).matches() + || STU_NO.matcher(cell).matches() || ACCOUNT.matcher(cell).matches()) { + header = true; + break; + } + } + } + int start = 0; + if (header) { + head = first; + start = 1; + } + + // 列分类 + final int cols = head != null ? head.size() : first.size(); + final int[] kind = new int[cols]; // 0忽略 1姓名 2性别 3总分 4单科 5学校 6备注 7预设 8自定义 9学号 10账号 + final String[] customName = new String[cols]; + Arrays.fill(kind, -1); + for (int c = 0; c < cols; c++) { + final String h = head != null && c < head.size() ? safe(head.get(c)) : ""; + kind[c] = classify(h, c); + customName[c] = h; + } + if (head == null) { + // 无表头默认列序:成绩版 姓名/性别/总分/语文/数学/英语 + 自定义字段N;小学版 姓名/性别/备注/预设班级 + if (!primary) { + final String[] defaults = {"姓名", "性别", "总分", "语文", "数学", "英语"}; + for (int c = 0; c < cols; c++) { + if (c < defaults.length) { + kind[c] = classify(defaults[c], c); + customName[c] = defaults[c]; + } else { + kind[c] = 8; + customName[c] = "自定义字段" + c; + } + } + } else { + final String[] defaults = {"姓名", "性别", "备注", "预设班级"}; + for (int c = 0; c < cols; c++) { + if (c < defaults.length) { + kind[c] = classify(defaults[c], c); + customName[c] = defaults[c]; + } else { + kind[c] = 8; + customName[c] = "自定义字段" + c; + } + } + } + } + + // 数据行 + for (int r = start; r < grid.size(); r++) { + final List cells = grid.get(r); + String name = null; + for (int c = 0; c < Math.min(cols, cells.size()); c++) { + if (kind[c] == 1 && !safe(cells.get(c)).isEmpty()) { + name = safe(cells.get(c)); + break; + } + } + if (name == null || name.isEmpty()) { + continue; // 空行跳过 + } + final ParsedRow row = new ParsedRow(); + row.name = name; + row.srcRow = r + 1; + Double subjectSum = 0.0; + boolean hasSubject = false; + for (int c = 0; c < Math.min(cols, cells.size()); c++) { + final String v = safe(cells.get(c)); + if (v.isEmpty()) { + continue; + } + switch (kind[c]) { + case 1: + break; + case 2: + row.gender = normalizeGender(v); + break; + case 3: + row.total = toDouble(v); + break; + case 4: + final Double sv = toDouble(v); + final String sub = customName[c]; + if (sv != null && !sub.isEmpty()) { + row.scores.put(sub, sv); + if (!result.subjects.contains(sub)) { + result.subjects.add(sub); + } + subjectSum += sv; + hasSubject = true; + } + break; + case 5: + row.school = v; + break; + case 9: + row.studentNo = v; + break; + case 10: + row.account = v; + break; + case 6: + row.remark = v; + break; + case 7: + row.presetRaw = v; + row.preset = parsePreset(v); + break; + case 8: + // 自定义字段类型延后按列数值占比判定 + break; + default: + break; + } + } + if (row.total == null && hasSubject) { + row.total = subjectSum; + } + result.rows.add(row); + } + + // 自定义字段类型判定(某列非空值 ≥80% 为数字 → 数值型,否则是否型) + for (int c = 0; c < cols; c++) { + if (kind[c] != 8) { + continue; + } + final String colName = customName[c]; + int nonEmpty = 0; + int numeric = 0; + for (int r = start; r < grid.size(); r++) { + final List cells = grid.get(r); + if (c >= cells.size()) { + continue; + } + final String v = safe(cells.get(c)); + if (v.isEmpty()) { + continue; + } + nonEmpty++; + if (toDouble(v) != null) { + numeric++; + } + } + final boolean asNum = nonEmpty > 0 && numeric * 1.0 / nonEmpty >= 0.8; + if (asNum) { + result.nums.add(colName); + for (int r = start; r < grid.size(); r++) { + final List cells = grid.get(r); + if (c < cells.size() && !safe(cells.get(c)).isEmpty()) { + final ParsedRow row = findRow(result, r + 1); + if (row != null) { + final Double d = toDouble(safe(cells.get(c))); + if (d != null) { + row.nums.put(colName, d); + } + } + } + } + } else { + result.tags.add(colName); + for (int r = start; r < grid.size(); r++) { + final List cells = grid.get(r); + if (c < cells.size() && !safe(cells.get(c)).isEmpty()) { + final ParsedRow row = findRow(result, r + 1); + if (row != null) { + row.tags.put(colName, parseBool(safe(cells.get(c)))); + } + } + } + } + } + + result.hasScore = !primary && (!result.subjects.isEmpty() || anyTotal(result)); + if (primary && result.hasScore) { + result.warn.add("小学版不使用成绩列,已忽略全部成绩数据"); + } + if (result.rows.isEmpty()) { + result.warn.add("未解析到姓名非空的数据行"); + } + return result; + } + + private static ParsedRow findRow(ParseResult result, int srcRow) { + for (ParsedRow row : result.rows) { + if (row.srcRow == srcRow) { + return row; + } + } + return null; + } + + private static boolean anyTotal(ParseResult result) { + for (ParsedRow row : result.rows) { + if (row.total != null) { + return true; + } + } + return false; + } + + private static int classify(String h, int index) { + final String head = safe(h); + if (head.isEmpty()) { + return index == 0 ? 1 : -1; // 无表头首列按姓名 + } + if (IGNORE.matcher(head).matches()) { + return -1; + } + // 标识列优先于其他关键词,避免「学号」等列被当自定义字段/单科 + if (STU_NO.matcher(head).matches()) { + return 9; + } + if (ACCOUNT.matcher(head).matches()) { + return 10; + } + if (NAME.matcher(head).matches()) { + return 1; + } + if (GENDER.matcher(head).matches()) { + return 2; + } + if (TOTAL.matcher(head).matches()) { + return 3; + } + if (SUBJECT.matcher(head).matches()) { + return 4; + } + if (SCHOOL.matcher(head).matches()) { + return 5; + } + if (REMARK.matcher(head).matches()) { + return 6; + } + if (PRESET.matcher(head).matches()) { + return 7; + } + return 8; + } + + /** 拆分为行列网格:优先 Tab(Excel 复制),其次逗号,最后连续空白 */ + static List> splitGrid(String text) { + final List> grid = new ArrayList<>(); + if (text == null || text.trim().isEmpty()) { + return grid; + } + final String[] lines = text.replace("\r\n", "\n").replace('\r', '\n').split("\n"); + for (String line : lines) { + if (line.trim().isEmpty()) { + continue; + } + final List cells = new ArrayList<>(); + if (line.contains("\t")) { + for (String c : line.split("\t", -1)) { + cells.add(clean(c)); + } + } else if (line.contains(",")) { + for (String c : splitCsvLine(line)) { + cells.add(clean(c)); + } + } else { + for (String c : line.trim().split("\\s+")) { + cells.add(clean(c)); + } + } + grid.add(cells); + } + return grid; + } + + private static List splitCsvLine(String line) { + final List out = new ArrayList<>(); + final StringBuilder cur = new StringBuilder(); + boolean inQuote = false; + for (int i = 0; i < line.length(); i++) { + final char ch = line.charAt(i); + if (inQuote) { + if (ch == '"') { + if (i + 1 < line.length() && line.charAt(i + 1) == '"') { + cur.append('"'); + i++; + } else { + inQuote = false; + } + } else { + cur.append(ch); + } + } else if (ch == '"') { + inQuote = true; + } else if (ch == ',') { + out.add(cur.toString()); + cur.setLength(0); + } else { + cur.append(ch); + } + } + out.add(cur.toString()); + return out; + } + + private static String clean(String cell) { + String v = cell == null ? "" : cell.trim(); + if (v.length() >= 2 && v.startsWith("\"") && v.endsWith("\"")) { + v = v.substring(1, v.length() - 1).replace("\"\"", "\""); + } + return v; + } + + private static String safe(String v) { + return v == null ? "" : v.trim(); + } + + /** 性别归一化:男/m/male/1/boy→1;女/f/female/0/girl→2;其余 0 */ + public static int normalizeGender(String v) { + final String s = safe(v).toLowerCase(); + if (s.isEmpty()) { + return 0; + } + if (s.equals("男") || s.equals("m") || s.equals("male") || s.equals("1") || s.equals("boy")) { + return 1; + } + if (s.equals("女") || s.equals("f") || s.equals("female") || s.equals("0") || s.equals("girl")) { + return 2; + } + return 0; + } + + /** 是否型取值:是/有/1/true/y/yes/√/✓/对→true;其余 false */ + public static boolean parseBool(String v) { + final String s = safe(v).toLowerCase(); + return s.equals("是") || s.equals("有") || s.equals("1") || s.equals("true") + || s.equals("y") || s.equals("yes") || s.equals("√") || s.equals("✓") || s.equals("对"); + } + + public static Double toDouble(String v) { + try { + return Double.valueOf(safe(v)); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * 预设班级解析:1 / 1班 → 固定班级;同班:张三 → 捆绑;不同班:李四 → 叉开。 + */ + public static Preset parsePreset(String raw) { + final String s = safe(raw); + if (s.isEmpty()) { + return null; + } + // 固定班级:数字开头(可带"班"字) + final java.util.regex.Matcher m = Pattern.compile("^(\\d+)\\s*班?$").matcher(s); + if (m.matches()) { + return new Preset("class", Integer.valueOf(m.group(1)), null); + } + // 同班 / 捆绑 / 绑定 / 同一班 / 双胞胎 : 目标姓名 + final java.util.regex.Matcher same = Pattern.compile( + "^(?:同班|捆绑|绑定|同一班|双胞胎)\\s*[::]\\s*(.+)$").matcher(s); + if (same.matches()) { + return new Preset("same", null, safe(same.group(1))); + } + // 不同班 / 叉开 / 分开 / 异班 / 避让 : 对方姓名 + final java.util.regex.Matcher diff = Pattern.compile( + "^(?:不同班|叉开|分开|异班|避让)\\s*[::]\\s*(.+)$").matcher(s); + if (diff.matches()) { + return new Preset("diff", null, safe(diff.group(1))); + } + return null; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/impl/AssignProjectService.java b/src/main/java/com/yunhai/acaclassassign/service/impl/AssignProjectService.java new file mode 100644 index 0000000..eb5a081 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/impl/AssignProjectService.java @@ -0,0 +1,579 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.alibaba.fastjson.JSON; +import com.yunhai.acaclassassign.entity.AssignClass; +import com.yunhai.acaclassassign.entity.AssignProject; +import com.yunhai.acaclassassign.entity.AssignResult; +import com.yunhai.acaclassassign.entity.AssignRule; +import com.yunhai.acaclassassign.entity.AssignStudent; +import com.yunhai.acaclassassign.model.SchTeaList; +import com.yunhai.acaclassassign.model.assign.AssignSubmitCheckVo; +import com.yunhai.acaclassassign.model.assign.BalanceVo; +import com.yunhai.acaclassassign.model.assign.ClassItemVo; +import com.yunhai.acaclassassign.model.assign.ConstraintVo; +import com.yunhai.acaclassassign.model.assign.ProjectInfoVo; +import com.yunhai.acaclassassign.model.assign.ProjectStatsVo; +import com.yunhai.acaclassassign.model.assign.RuleVo; +import com.yunhai.acaclassassign.model.assign.SaveClassesRequest; +import com.yunhai.acaclassassign.model.assign.SaveRulesRequest; +import com.yunhai.acaclassassign.model.bsch.NowYearTermVo; +import com.yunhai.acaclassassign.model.bsch.YearTermItemVo; +import com.yunhai.acaclassassign.repository.AssignClassRepository; +import com.yunhai.acaclassassign.repository.AssignProjectRepository; +import com.yunhai.acaclassassign.repository.AssignPushRepository; +import com.yunhai.acaclassassign.repository.AssignResultRepository; +import com.yunhai.acaclassassign.repository.AssignRuleRepository; +import com.yunhai.acaclassassign.repository.AssignStudentRepository; +import com.yunhai.acaclassassign.service.IAssignProjectService; +import com.yunhai.acaclassassign.service.IClassService; +import com.yunhai.acaclassassign.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.Month; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 分班项目服务实现。 + */ +@Service +public class AssignProjectService implements IAssignProjectService { + + private static final int STATUS_DRAFT = 0; + + private static final int STATUS_SUBMITTED = 1; + + private static final int MAX_CLASS_COUNT = 40; + + @Autowired + private AssignProjectRepository projectRepository; + + @Autowired + private AssignClassRepository classRepository; + + @Autowired + private AssignStudentRepository studentRepository; + + @Autowired + private AssignRuleRepository ruleRepository; + + @Autowired + private AssignResultRepository resultRepository; + + @Autowired + private AssignPushRepository pushRepository; + + @Autowired + private IClassService classService; + + // ==================== 查询 ==================== + + @Override + @Transactional + public ProjectInfoVo info(int stage, String year, int term) { + final AssignProject project = getOrCreate(stage, year, term); + final ProjectInfoVo vo = new ProjectInfoVo(); + vo.setProjectId(project.getId()); + vo.setStage(project.getStage()); + vo.setYear(project.getYear()); + vo.setTerm(project.getTerm()); + vo.setStatus(project.getStatus()); + vo.setSubmitTime(project.getSubmitTime()); + vo.setClasses(classVos(project.getId())); + vo.setRule(ruleVo(project.getId())); + vo.setStats(stats(project.getId())); + final ProjectInfoVo.CurrentYear cur = nowYearTerm(); + vo.setCurrentYear(cur.getYear()); + vo.setCurrentTerm(cur.getTerm()); + return vo; + } + + @Override + public ProjectInfoVo.CurrentYear nowYearTerm() { + final ProjectInfoVo.CurrentYear vo = new ProjectInfoVo.CurrentYear(); + try { + final NowYearTermVo remote = classService.getNowYearTerm(); + if (remote != null && remote.getYear() != null) { + vo.setYear(String.valueOf(remote.getYear())); + vo.setTerm(remote.getTerm() == null ? 1 : remote.getTerm()); + return vo; + } + } catch (Exception ignored) { + // 平台不可达时本地推断,不阻断工作台 + } + final LocalDateTime now = LocalDateTime.now(); + final int startYear = now.getMonth() == Month.JANUARY ? now.getYear() - 1 : now.getYear(); + vo.setYear(String.valueOf(startYear)); + vo.setTerm(now.getMonth() == Month.JANUARY || now.getMonth().getValue() >= 9 ? 1 : 2); + return vo; + } + + @Override + public List yearList() { + try { + final List list = classService.getYearTermList(); + return list == null ? Collections.emptyList() : list; + } catch (Exception e) { + // 平台不可达时返回空列表,前端回退本地推断的学年范围 + return Collections.emptyList(); + } + } + + // ==================== 班级设置 ==================== + + @Override + @Transactional + public List saveClasses(SaveClassesRequest request) { + if (request == null || request.getStage() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数缺失"); + } + final AssignProject project = getOrCreate(request.getStage(), request.getYear(), request.getTerm()); + checkNotSubmitted(project); + final List items = request.getClasses() == null ? new ArrayList<>() : request.getClasses(); + if (items.isEmpty() || items.size() > MAX_CLASS_COUNT) { + throw new BizException(ErrorCode.PARAM_ERROR, "班级数量须在 1-" + MAX_CLASS_COUNT + " 之间"); + } + // 校验:名称非空、无重名 + final Set names = new HashSet<>(); + for (ClassItemVo item : items) { + if (item.getName() == null || item.getName().trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "班级名称不能为空"); + } + if (!names.add(item.getName().trim())) { + throw new BizException(ErrorCode.PARAM_ERROR, "班级名称重复:" + item.getName()); + } + if (item.getTargetCount() != null && item.getTargetCount() <= 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "目标人数须为正整数"); + } + } + // 教师名册(teacherId → 姓名校验与冗余) + final Map teacherNames = teacherNameMap(); + // 全量替换:先删后插(分班结果一并清除,因班级清单变化使结果失效) + classRepository.deleteByProjectId(project.getId()); + resultRepository.deleteByProjectId(project.getId()); + final List entities = new ArrayList<>(); + int sort = 1; + for (ClassItemVo item : items) { + final AssignClass c = new AssignClass(); + c.setProjectId(project.getId()); + c.setSortOrder(sort++); + c.setName(item.getName().trim()); + if (item.getTeacherId() != null) { + final String tname = teacherNames.get(item.getTeacherId()); + if (tname == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "班主任教师id不存在(平台教师列表中未找到:" + item.getTeacherId() + ")"); + } + c.setTeacherId(item.getTeacherId()); + c.setTeacherName(tname); + } + c.setTargetCount(item.getTargetCount()); + c.setAbFlag(Boolean.TRUE.equals(item.getAbFlag())); + entities.add(c); + } + classRepository.saveAll(entities); + return classVos(project.getId()); + } + + // ==================== 规则 ==================== + + @Override + @Transactional + public RuleVo saveRules(SaveRulesRequest request) { + if (request == null || request.getStage() == null || request.getRule() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数缺失"); + } + final AssignProject project = getOrCreate(request.getStage(), request.getYear(), request.getTerm()); + checkNotSubmitted(project); + final RuleVo rule = request.getRule(); + validateRule(project, rule); + // A 班标记:layer 模式取前 K 个班 + if (rule.getAbEnabled() && "layer".equals(rule.getAbMode())) { + final List classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId()); + int i = 1; + for (AssignClass c : classes) { + c.setAbFlag(i++ <= safeInt(rule.getAbClassCount(), 1)); + } + classRepository.saveAll(classes); + } + AssignRule entity = ruleRepository.findByProjectId(project.getId()).orElse(null); + if (entity == null) { + entity = new AssignRule(); + entity.setProjectId(project.getId()); + } + entity.setBalanceJson(JSON.toJSONString(nvlBalance(rule.getBalance()))); + entity.setConstraintJson(JSON.toJSONString(nvlConstraint(rule.getConstraint()))); + entity.setBandMode(rule.getBandMode()); + entity.setBandCount(rule.getBandCount()); + entity.setBandStep(rule.getBandStep()); + entity.setAbEnabled(Boolean.TRUE.equals(rule.getAbEnabled())); + entity.setAbMode(rule.getAbMode()); + entity.setAbClassCount(rule.getAbClassCount()); + ruleRepository.save(entity); + return ruleVo(project.getId()); + } + + private void validateRule(AssignProject project, RuleVo rule) { + final String mode = rule.getBandMode(); + if (mode != null && !mode.isEmpty()) { + if (!"count".equals(mode) && !"step".equals(mode)) { + throw new BizException(ErrorCode.PARAM_ERROR, "分数段模式非法"); + } + if ("count".equals(mode)) { + final int n = safeInt(rule.getBandCount(), 4); + if (n < 2 || n > 20) { + throw new BizException(ErrorCode.PARAM_ERROR, "分数段段数须在 2-20 之间"); + } + } + if ("step".equals(mode)) { + final int step = safeInt(rule.getBandStep(), 10); + if (step < 1 || step > 200) { + throw new BizException(ErrorCode.PARAM_ERROR, "分数间隔须在 1-200 之间"); + } + } + } + if (rule.getAbEnabled()) { + if (project.getStage() != 4) { + throw new BizException(ErrorCode.PARAM_ERROR, "仅高中版支持 A/B 分层"); + } + final long classCount = classRepository.countByProjectId(project.getId()); + final int k = safeInt(rule.getAbClassCount(), 1); + if (classCount == 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "请先完成班级设置"); + } + if (k < 1 || k >= classCount) { + throw new BizException(ErrorCode.PARAM_ERROR, "A班数量须满足 1 ≤ K < 班级数"); + } + } + } + + // ==================== 提交 ==================== + + @Override + public AssignSubmitCheckVo submitCheck(int stage, String year, int term) { + final AssignProject project = require(stage, year, term); + final AssignSubmitCheckVo vo = new AssignSubmitCheckVo(); + vo.setStatus(project.getStatus()); + vo.setSubmitTime(project.getSubmitTime()); + vo.setYear(project.getYear()); + vo.setTerm(project.getTerm()); + final List classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId()); + final long studentCount = studentRepository.countByProjectId(project.getId()); + vo.setClassCount(classes.size()); + vo.setStudentCount(studentCount); + int teacherSet = 0; + for (AssignClass c : classes) { + if (c.getTeacherId() != null) { + teacherSet++; + } + } + vo.setTeacherSetCount(teacherSet); + final boolean assigned = resultRepository.findByProjectId(project.getId()).isPresent(); + vo.setAssigned(assigned); + vo.setWarnCount(0); + final AssignResult result = resultRepository.findByProjectId(project.getId()).orElse(null); + if (result != null && result.getWarnJson() != null) { + try { + vo.setWarnCount(JSON.parseArray(result.getWarnJson(), String.class).size()); + } catch (Exception ignored) { + // 警告解析失败不影响检查 + } + } + int minSize = Integer.MAX_VALUE; + int maxSize = Integer.MIN_VALUE; + if (assigned) { + final Map byClass = new HashMap<>(); + for (AssignStudent s : studentRepository.findByProjectId(project.getId())) { + if (s.getClassId() != null) { + byClass.merge(s.getClassId(), 1L, Long::sum); + } + } + for (AssignClass c : classes) { + final long n = byClass.getOrDefault(c.getId(), 0L); + minSize = Math.min(minSize, (int) n); + maxSize = Math.max(maxSize, (int) n); + } + vo.setCountSpread(maxSize - Math.max(minSize, 0)); + } + + final List checks = new ArrayList<>(); + checks.add(check(studentCount > 0, "学生数据已导入", + studentCount > 0 ? studentCount + " 人" : "尚未导入学生")); + checks.add(check(!classes.isEmpty(), "班级设置就绪", + classes.isEmpty() ? "未设置班级" : classes.size() + " 个班,班主任已指定 " + teacherSet + "/" + classes.size())); + checks.add(check(assigned, "分班已完成", assigned ? "均衡分班已执行" : "尚未执行分班")); + checks.add(check(!assigned || vo.getCountSpread() <= 1, "班间人数差 ≤ 1 人", + assigned ? "当前人数差 " + vo.getCountSpread() + " 人" : "未分班")); + checks.add(check(vo.getWarnCount() == 0, "分班校验无异常", + vo.getWarnCount() == 0 ? "校验通过" : "存在 " + vo.getWarnCount() + " 条校验提示")); + checks.add(check(project.getStatus() != STATUS_SUBMITTED, "提交状态", + project.getStatus() == STATUS_SUBMITTED ? "已提交(锁定)" : "待提交")); + vo.setChecks(checks); + return vo; + } + + private AssignSubmitCheckVo.CheckItem check(boolean ok, String name, String msg) { + final AssignSubmitCheckVo.CheckItem item = new AssignSubmitCheckVo.CheckItem(); + item.setName(name); + item.setLevel(ok ? "ok" : "warn"); + item.setMsg(msg); + return item; + } + + @Override + @Transactional + public void submit(int stage, String year, int term, boolean force) { + final AssignProject project = require(stage, year, term); + checkNotSubmitted(project); + final AssignSubmitCheckVo checks = submitCheck(stage, year, term); + if (checks.getStudentCount() <= 0 || !checks.isAssigned()) { + throw new BizException(ErrorCode.PARAM_ERROR, "尚未完成分班,无法提交"); + } + if (checks.getCountSpread() > 1 && !force) { + throw new BizException(ErrorCode.PARAM_ERROR, + "班间人数差为 " + checks.getCountSpread() + " 人(>1),请确认后强制提交"); + } + project.setStatus(STATUS_SUBMITTED); + project.setSubmitTime(LocalDateTime.now()); + projectRepository.save(project); + } + + @Override + @Transactional + public void reset(int stage, String year, int term) { + final AssignProject project = require(stage, year, term); + resultRepository.deleteByProjectId(project.getId()); + ruleRepository.deleteByProjectId(project.getId()); + studentRepository.deleteByProjectId(project.getId()); + classRepository.deleteByProjectId(project.getId()); + // 推送记录一并清除(远端班级/账号不随本地重置回收,见推送边界声明) + pushRepository.deleteByProjectId(project.getId()); + projectRepository.delete(project); + } + + // ==================== 项目存取 ==================== + + @Override + @Transactional + public AssignProject getOrCreate(int stage, String year, int term) { + final int schId = schId(); + final String y = year == null || year.isEmpty() + ? nowYearTerm().getYear() : year; + final int t = term; + return projectRepository.findBySchIdAndStageAndYearAndTerm(schId, stage, y, t) + .orElseGet(() -> { + final AssignProject p = new AssignProject(); + p.setSchId(schId); + p.setStage(stage); + p.setYear(y); + p.setTerm(t); + p.setStatus(STATUS_DRAFT); + return projectRepository.save(p); + }); + } + + @Override + public AssignProject require(int stage, String year, int term) { + final int schId = schId(); + final String y = year == null || year.isEmpty() + ? nowYearTerm().getYear() : year; + final int t = term; + return projectRepository.findBySchIdAndStageAndYearAndTerm(schId, stage, y, t) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "分班项目不存在,请先保存设置")); + } + + @Override + public void checkNotSubmitted(AssignProject project) { + if (project.getStatus() != null && project.getStatus() == STATUS_SUBMITTED) { + throw new BizException(ErrorCode.PARAM_ERROR, + "本学年学期分班结果已提交锁定为只读,不可修改(撤销只能重置全部)"); + } + } + + // ==================== 内部 ==================== + + private int schId() { + final Integer schId = UserContext.getSchId(); + return schId == null ? 0 : schId; + } + + private List classVos(Integer projectId) { + final Map presetCounts = presetCounts(projectId); + final List out = new ArrayList<>(); + for (AssignClass c : classRepository.findByProjectIdOrderBySortOrderAsc(projectId)) { + final ClassItemVo vo = new ClassItemVo(); + vo.setId(c.getId()); + vo.setSortOrder(c.getSortOrder()); + vo.setName(c.getName()); + vo.setTeacherId(c.getTeacherId()); + vo.setTeacherName(c.getTeacherName()); + vo.setTargetCount(c.getTargetCount()); + vo.setAbFlag(Boolean.TRUE.equals(c.getAbFlag())); + final long preset = presetCounts.getOrDefault(c.getSortOrder(), 0L); + vo.setPresetCount((int) preset); + if (vo.getTargetCount() != null) { + vo.setRemain(vo.getTargetCount() - (int) preset); + } + out.add(vo); + } + return out; + } + + /** 各班(按序号)预设固定学生数 */ + private Map presetCounts(Integer projectId) { + final Map out = new HashMap<>(); + for (AssignStudent s : studentRepository.findByProjectId(projectId)) { + if (s.getPresetJson() == null || s.getPresetJson().isEmpty()) { + continue; + } + try { + for (PresetVo p : JSON.parseArray(s.getPresetJson(), PresetVo.class)) { + if ("class".equals(p.type) && p.value != null) { + out.merge(p.value, 1L, Long::sum); + } + } + } catch (Exception ignored) { + // 预设解析失败不计入 + } + } + return out; + } + + /** 预设解析结果(与 StudentParser.Preset 对应) */ + public static class PresetVo { + + public String type; + + public Integer value; + + public String target; + } + + public RuleVo ruleVo(Integer projectId) { + final AssignRule entity = ruleRepository.findByProjectId(projectId).orElse(null); + final RuleVo vo = new RuleVo(); + if (entity == null) { + // subjects/nums/tags 不设值(null):前端按「未配置=全部识别列默认参与」渲染 + final BalanceVo b = new BalanceVo(); + b.setTotal(true); + b.setGender(true); + b.setBands(true); + vo.setBalance(b); + final ConstraintVo c = new ConstraintVo(); + c.setSameName(true); + c.setSameSound(true); + c.setDiffClass(true); + c.setSameClass(true); + vo.setConstraint(c); + vo.setBandMode("count"); + vo.setBandCount(4); + vo.setAbEnabled(false); + vo.setAbMode("layer"); + vo.setAbClassCount(2); + return vo; + } + try { + vo.setBalance(JSON.parseObject(entity.getBalanceJson(), BalanceVo.class)); + } catch (Exception e) { + vo.setBalance(new BalanceVo()); + } + try { + vo.setConstraint(JSON.parseObject(entity.getConstraintJson(), ConstraintVo.class)); + } catch (Exception e) { + vo.setConstraint(new ConstraintVo()); + } + vo.setBandMode(entity.getBandMode()); + vo.setBandCount(entity.getBandCount()); + vo.setBandStep(entity.getBandStep()); + vo.setAbEnabled(Boolean.TRUE.equals(entity.getAbEnabled())); + vo.setAbMode(entity.getAbMode()); + vo.setAbClassCount(entity.getAbClassCount()); + return vo; + } + + public ProjectStatsVo stats(Integer projectId) { + final ProjectStatsVo st = new ProjectStatsVo(); + long male = 0; + long female = 0; + long assigned = 0; + final Set schools = new HashSet<>(); + final List students = studentRepository.findByProjectId(projectId); + for (AssignStudent s : students) { + if (s.getGender() != null && s.getGender() == 1) { + male++; + } else if (s.getGender() != null && s.getGender() == 2) { + female++; + } + if (s.getClassId() != null) { + assigned++; + } + if (s.getGraduateSchool() != null && !s.getGraduateSchool().isEmpty()) { + schools.add(s.getGraduateSchool()); + } + } + st.setStudentCount(students.size()); + st.setMaleCount(male); + st.setFemaleCount(female); + st.setAssignedCount(assigned); + st.setSchoolCount(schools.size()); + return st; + } + + /** 平台教师名册(teacherId=userTeaSerID → 姓名) */ + private Map teacherNameMap() { + final Map out = new HashMap<>(); + try { + for (SchTeaList t : classService.getSchTeaList(schId())) { + if (t.getUserTeaSerID() != null) { + out.put(t.getUserTeaSerID(), t.getTeaName()); + } + } + } catch (Exception ignored) { + // 平台不可达时名单为空:已填教师 id 校验将失败提示 + } + return out; + } + + private static int safeInt(Integer v, int dft) { + return v == null ? dft : v; + } + + /** 均衡配置:subjects/nums/tags 保留 null(= 默认全部识别列参与),不做空列表规范化 */ + private static BalanceVo nvlBalance(BalanceVo b) { + if (b != null) { + return b; + } + final BalanceVo nb = new BalanceVo(); + nb.setTotal(true); + nb.setGender(true); + nb.setBands(true); + return nb; + } + + private static ConstraintVo nvlConstraint(ConstraintVo c) { + final ConstraintVo n = c != null ? c : new ConstraintVo(); + if (n.getSameName() == null) { + n.setSameName(true); + } + if (n.getSameSound() == null) { + n.setSameSound(true); + } + if (n.getDiffClass() == null) { + n.setDiffClass(true); + } + if (n.getSameClass() == null) { + n.setSameClass(true); + } + return n; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/impl/AssignRunService.java b/src/main/java/com/yunhai/acaclassassign/service/impl/AssignRunService.java new file mode 100644 index 0000000..8bb597a --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/impl/AssignRunService.java @@ -0,0 +1,1375 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.alibaba.fastjson.JSON; +import com.yunhai.acaclassassign.entity.AssignClass; +import com.yunhai.acaclassassign.entity.AssignProject; +import com.yunhai.acaclassassign.entity.AssignResult; +import com.yunhai.acaclassassign.entity.AssignRule; +import com.yunhai.acaclassassign.entity.AssignStudent; +import com.yunhai.acaclassassign.model.assign.AdjustRequest; +import com.yunhai.acaclassassign.model.assign.AdjustResultVo; +import com.yunhai.acaclassassign.model.assign.BalanceVo; +import com.yunhai.acaclassassign.model.assign.BandReportVo; +import com.yunhai.acaclassassign.model.assign.ChartsVo; +import com.yunhai.acaclassassign.model.assign.ClassCardVo; +import com.yunhai.acaclassassign.model.assign.ClassReportVo; +import com.yunhai.acaclassassign.model.assign.ConstraintVo; +import com.yunhai.acaclassassign.model.assign.MetricItemVo; +import com.yunhai.acaclassassign.model.assign.RunResultVo; +import com.yunhai.acaclassassign.model.assign.StudentVo; +import com.yunhai.acaclassassign.repository.AssignClassRepository; +import com.yunhai.acaclassassign.repository.AssignProjectRepository; +import com.yunhai.acaclassassign.repository.AssignResultRepository; +import com.yunhai.acaclassassign.repository.AssignRuleRepository; +import com.yunhai.acaclassassign.repository.AssignStudentRepository; +import com.yunhai.acaclassassign.service.IAssignProjectService; +import com.yunhai.acaclassassign.service.IAssignRunService; +import com.yunhai.acaclassassign.service.IAssignStudentService; +import com.yunhai.acaclassassign.service.assign.AssignAlgorithm; +import com.yunhai.acaclassassign.service.assign.SameSound; +import com.yunhai.acaclassassign.service.assign.StudentParser; +import com.yunhai.acaclassassign.utils.CsvUtil; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.text.Collator; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.servlet.http.HttpServletResponse; + +/** + * 分班执行与结果服务实现。 + */ +@Service +public class AssignRunService implements IAssignRunService { + + @Autowired + private AssignProjectRepository projectRepository; + + @Autowired + private AssignClassRepository classRepository; + + @Autowired + private AssignStudentRepository studentRepository; + + @Autowired + private AssignRuleRepository ruleRepository; + + @Autowired + private AssignResultRepository resultRepository; + + @Autowired + private IAssignProjectService projectService; + + @Autowired + private IAssignStudentService studentService; + + @Autowired + private com.yunhai.acaclassassign.service.IClassService classService; + + // ==================== 执行 ==================== + + @Override + @Transactional + public RunResultVo run(int stage, String year, Integer term) { + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + projectService.checkNotSubmitted(project); + final List classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId()); + final List students = studentRepository.findByProjectId(project.getId()); + if (classes.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请先完成班级设置"); + } + if (students.size() < classes.size()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "学生数(" + students.size() + ")须不少于班级数(" + classes.size() + ")"); + } + final AssignRule rule = ruleRepository.findByProjectId(project.getId()).orElse(null); + final BalanceVo balance = ruleVoOf(rule); + final ConstraintVo constraint = constraintVoOf(rule); + final boolean hasScore = stage != 2; + + // 组装算法入参 + final AssignAlgorithm.Config cfg = new AssignAlgorithm.Config(); + cfg.stage = stage; + cfg.hasScore = hasScore; + cfg.balance.total = hasScore && Boolean.TRUE.equals(balance.getTotal()); + // 三科平均分:null = 未配置(默认启用),语数英三科列齐全时生效 + cfg.balance.core3 = hasScore && !Boolean.FALSE.equals(balance.getCore3()) && hasCore3(students); + cfg.balance.gender = !Boolean.FALSE.equals(balance.getGender()); + cfg.balance.bands = hasScore && Boolean.TRUE.equals(balance.getBands()); + // 维度数组 null/空 = 未配置(默认全部识别列参与);非空数组 = 所选列 + if (hasScore) { + cfg.balance.subjects.addAll(isEmpty(balance.getSubjects()) + ? defaultFieldNames(students, AssignStudent::getScoresJson) : balance.getSubjects()); + cfg.balance.nums.addAll(isEmpty(balance.getNums()) + ? defaultFieldNames(students, AssignStudent::getNumsJson) : balance.getNums()); + } + cfg.balance.tags.addAll(isEmpty(balance.getTags()) + ? defaultFieldNames(students, AssignStudent::getTagsJson) : balance.getTags()); + cfg.constraint.sameName = !Boolean.FALSE.equals(constraint.getSameName()); + cfg.constraint.sameSound = !Boolean.FALSE.equals(constraint.getSameSound()); + cfg.constraint.diffClass = !Boolean.FALSE.equals(constraint.getDiffClass()); + cfg.constraint.sameClass = !Boolean.FALSE.equals(constraint.getSameClass()); + cfg.abEnabled = hasScore && stage == 4 && rule != null && Boolean.TRUE.equals(rule.getAbEnabled()); + cfg.abMode = rule == null || rule.getAbMode() == null ? "layer" : rule.getAbMode(); + cfg.abClassCount = rule == null || rule.getAbClassCount() == null ? 2 : rule.getAbClassCount(); + + // 学生转换 + final Map totals = new HashMap<>(); + for (AssignStudent s : students) { + if (s.getTotalScore() != null) { + totals.put(s.getName() + "#" + s.getId(), s.getTotalScore().doubleValue()); + } + } + final List presets = new ArrayList<>(); + final Map> presetByStudent = new HashMap<>(); + for (int i = 0; i < students.size(); i++) { + final AssignStudent s = students.get(i); + final AssignAlgorithm.AStudent a = new AssignAlgorithm.AStudent(); + a.idx = i; + a.id = s.getId(); + a.name = s.getName(); + a.soundKey = SameSound.soundKey(s.getName()); + a.gender = s.getGender() == null ? 0 : s.getGender(); + a.total = s.getTotalScore() == null ? 0 : s.getTotalScore().doubleValue(); + if (s.getScoresJson() != null) { + final Map m = parseMap(s.getScoresJson()); + for (Map.Entry e : m.entrySet()) { + final Double d = toDouble(e.getValue()); + if (d != null) { + a.scores.put(e.getKey(), d); + } + } + } + if (s.getTagsJson() != null) { + final Map m = parseMap(s.getTagsJson()); + for (Map.Entry e : m.entrySet()) { + a.tags.put(e.getKey(), Boolean.TRUE.equals(e.getValue()) + || "true".equalsIgnoreCase(String.valueOf(e.getValue()))); + } + } + if (s.getNumsJson() != null) { + final Map m = parseMap(s.getNumsJson()); + for (Map.Entry e : m.entrySet()) { + final Double d = toDouble(e.getValue()); + if (d != null) { + a.nums.put(e.getKey(), d); + } + } + } + if (s.getPresetJson() != null) { + try { + final List pvs = + JSON.parseArray(s.getPresetJson(), AssignProjectService.PresetVo.class); + if (pvs != null) { + for (AssignProjectService.PresetVo pv : pvs) { + final StudentParser.Preset p = new StudentParser.Preset(pv.type, pv.value, pv.target); + presets.add(p); + presetByStudent.computeIfAbsent(i, k -> new ArrayList<>()).add(p); + if ("class".equals(pv.type) && pv.value != null) { + if (a.fixedSort == null || pv.value < a.fixedSort) { + a.fixedSort = pv.value; + } + } + } + } + } catch (Exception ignored) { + // 预设解析失败按无预设处理 + } + } + cfg.students.add(a); + } + // 同班 / 不同班姓名对(按预设原文 target) + if (cfg.constraint.sameClass) { + for (Map.Entry> e : presetByStudent.entrySet()) { + for (StudentParser.Preset p : e.getValue()) { + if ("same".equals(p.type) && p.target != null) { + cfg.samePairs.add(new AssignAlgorithm.NamePair( + students.get(e.getKey()).getName(), p.target)); + } + } + } + } + if (cfg.constraint.diffClass) { + for (Map.Entry> e : presetByStudent.entrySet()) { + for (StudentParser.Preset p : e.getValue()) { + if ("diff".equals(p.type) && p.target != null) { + cfg.diffPairs.add(new AssignAlgorithm.NamePair( + students.get(e.getKey()).getName(), p.target)); + } + } + } + } + // 班级 + for (int ci = 0; ci < classes.size(); ci++) { + final AssignClass c = classes.get(ci); + final AssignAlgorithm.AClass ac = new AssignAlgorithm.AClass(); + ac.idx = ci; + ac.id = c.getId(); + ac.sortOrder = c.getSortOrder(); + ac.name = c.getName(); + ac.target = c.getTargetCount() == null ? -1 : c.getTargetCount(); + cfg.classes.add(ac); + } + // 分数段方案(有成绩且勾选 bands 时构建) + final boolean layerNow = cfg.abEnabled && "layer".equals(cfg.abMode); + final AssignAlgorithm.BandPlan plan = buildBandPlan(students, rule, cfg.balance.bands, layerNow); + cfg.bands = plan; + + // 运行 + final AssignAlgorithm algo = new AssignAlgorithm(cfg); + final AssignAlgorithm.Result r = algo.run(); + + // 落库:学生 classId/level + 结果实体(覆盖旧结果) + for (int i = 0; i < students.size(); i++) { + final AssignStudent s = students.get(i); + s.setClassId(classes.get(r.classIdx[i]).getId()); + s.setLevel(r.level[i] == null ? null : r.level[i]); + } + studentRepository.saveAll(students); + resultRepository.deleteByProjectId(project.getId()); + // 显式 flush:Hibernate 默认先执行 INSERT 再 DELETE,而 aca_assign_result.project_id + // 有唯一约束,重新分班时新结果会先插入并撞 uk_arl_project + resultRepository.flush(); + + final AssignResult entity = new AssignResult(); + entity.setProjectId(project.getId()); + entity.setAssignTime(LocalDateTime.now()); + entity.setIterations(r.iterations); + entity.setCostMs(r.costMs); + entity.setScore(BigDecimal.valueOf(r.metrics.score).setScale(2, BigDecimal.ROUND_HALF_UP)); + entity.setAdjusted(false); + entity.setMetricsJson(JSON.toJSONString(metricsMap(r))); + entity.setLogText(String.join("\n", r.logs)); + entity.setWarnJson(JSON.toJSONString(r.warns)); + entity.setBandCutsJson(JSON.toJSONString(bandCutsMap(plan))); + entity.setGroupsJson(JSON.toJSONString(groupsMap(cfg, r, classes))); + resultRepository.save(entity); + + final RunResultVo vo = buildResultVo(project, classes, students, entity, r.metrics, plan, cfg); + vo.setLogs(r.logs); + vo.setWarns(r.warns); + return vo; + } + + // ==================== 结果 ==================== + + @Override + public RunResultVo result(int stage, String year, Integer term) { + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + final List classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId()); + final List students = studentRepository.findByProjectId(project.getId()); + final AssignResult entity = resultRepository.findByProjectId(project.getId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "尚未执行分班")); + final AssignAlgorithm.MetricsOut metrics = new AssignAlgorithm.MetricsOut(); + if (entity.getMetricsJson() != null) { + try { + final Map m = parseMap(entity.getMetricsJson()); + metrics.weightedDev = toDouble(m.get("weightedDev")) == null ? 0 : toDouble(m.get("weightedDev")); + metrics.score = toDouble(m.get("score")) == null ? 0 : toDouble(m.get("score")); + metrics.countSpread = toDouble(m.get("countSpread")) == null ? 0 + : toDouble(m.get("countSpread")).intValue(); + metrics.avgSpread = toDouble(m.get("avgSpread")) == null ? 0 : toDouble(m.get("avgSpread")); + metrics.genderDev = toDouble(m.get("genderDev")) == null ? 0 : toDouble(m.get("genderDev")); + final Object items = m.get("items"); + if (items instanceof List) { + for (Object o : (List) items) { + final Map im = (Map) o; + final AssignAlgorithm.MetricItem mi = new AssignAlgorithm.MetricItem(); + mi.dimension = String.valueOf(im.get("dimension")); + mi.type = String.valueOf(im.get("type")); + mi.weight = toDouble(im.get("weight")) == null ? 0 : toDouble(im.get("weight")); + mi.minClass = String.valueOf(im.get("minClass")); + mi.maxClass = String.valueOf(im.get("maxClass")); + mi.minValue = toDouble(im.get("minValue")) == null ? 0 : toDouble(im.get("minValue")); + mi.maxValue = toDouble(im.get("maxValue")) == null ? 0 : toDouble(im.get("maxValue")); + mi.spread = toDouble(im.get("spread")) == null ? 0 : toDouble(im.get("spread")); + mi.relDev = toDouble(im.get("relDev")) == null ? 0 : toDouble(im.get("relDev")); + mi.grade = String.valueOf(im.get("grade")); + final Object pc = im.get("perClass"); + if (pc instanceof List) { + final double[] arr = new double[((List) pc).size()]; + for (int i = 0; i < arr.length; i++) { + arr[i] = toDouble(((List) pc).get(i)) == null ? 0 + : toDouble(((List) pc).get(i)); + } + mi.perClass = arr; + } + metrics.items.add(mi); + } + } + } catch (Exception ignored) { + // 指标解析失败按空处理 + } + } + final AssignAlgorithm.BandPlan plan = new AssignAlgorithm.BandPlan(); + if (entity.getBandCutsJson() != null) { + try { + final Map bc = parseMap(entity.getBandCutsJson()); + final List cuts = (List) bc.get("cuts"); + final List labels = (List) bc.get("labels"); + if (cuts != null) { + plan.cuts = cuts.stream().mapToDouble(AssignRunService::toDoublePrimitive).toArray(); + } + if (labels != null) { + plan.labels = labels.stream().map(String::valueOf).toArray(String[]::new); + } + } catch (Exception ignored) { + // 分数段解析失败按空处理 + } + } + final AssignAlgorithm.Config cfg = new AssignAlgorithm.Config(); + cfg.stage = stage; + cfg.hasScore = stage != 2; + final RunResultVo vo = buildResultVo(project, classes, students, entity, metrics, plan, cfg); + if (entity.getLogText() != null) { + vo.setLogs(new ArrayList<>(Arrays.asList(entity.getLogText().split("\n")))); + } + if (entity.getWarnJson() != null) { + try { + vo.setWarns(JSON.parseArray(entity.getWarnJson(), String.class)); + } catch (Exception ignored) { + // 警告解析失败按空处理 + } + } + return vo; + } + + // ==================== 手动调班 ==================== + + @Override + @Transactional + public AdjustResultVo adjust(AdjustRequest request) { + if (request == null || request.getStudentId() == null || request.getToClassId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数缺失"); + } + final AssignProject project = projectService.require( + request.getStage(), request.getYear(), request.getTerm() == null ? 1 : request.getTerm()); + projectService.checkNotSubmitted(project); + final AssignStudent s = studentRepository.findById(request.getStudentId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "学生不存在")); + if (!s.getProjectId().equals(project.getId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "学生不属于当前项目"); + } + if (s.getClassId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "该生尚未分班,请先执行分班"); + } + final AssignClass to = classRepository.findById(request.getToClassId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "目标班级不存在")); + final AssignClass from = classRepository.findById(s.getClassId()).orElse(null); + if (to.getId().equals(s.getClassId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "目标班级与当前班级相同"); + } + final List all = studentRepository.findByProjectId(project.getId()); + final Map classById = classRepository + .findByProjectIdOrderBySortOrderAsc(project.getId()).stream() + .collect(Collectors.toMap(AssignClass::getId, c -> c)); + // 约束校验 + final List conflicts = new ArrayList<>(); + final boolean checkName = true; + for (AssignStudent o : all) { + if (o.getId().equals(s.getId()) || !to.getId().equals(o.getClassId())) { + continue; + } + if (checkName && o.getName() != null && o.getName().equals(s.getName())) { + conflicts.add("同名学生:" + o.getName() + " 已在 " + to.getName()); + } else if (SameSound.soundKey(nvl(s.getName())).equals(SameSound.soundKey(nvl(o.getName())))) { + conflicts.add("同音学生:" + o.getName() + " 已在 " + to.getName()); + } + } + // 「不同班」互斥对 + for (AssignStudent o : all) { + if (o.getId().equals(s.getId()) || !to.getId().equals(o.getClassId())) { + continue; + } + if (presetTargets(s).contains(o.getName()) || presetTargets(o).contains(s.getName())) { + conflicts.add("预设「不同班」:" + s.getName() + " 与 " + o.getName() + " 需叉开"); + } + } + // 预设固定班级冲突 + for (String raw : presetRawList(s)) { + final StudentParser.Preset p = StudentParser.parsePreset(raw); + if (p != null && "class".equals(p.type) && p.classSort != null + && !p.classSort.equals(to.getSortOrder())) { + conflicts.add("预设固定班级为 " + p.classSort + " 班,与目标班 " + to.getName() + " 冲突"); + } + } + // 高中 layer 模式不得跨 A/B 层 + final AssignResult result = resultRepository.findByProjectId(project.getId()).orElse(null); + if (result != null && result.getGroupsJson() != null && s.getLevel() != null && !s.getLevel().isEmpty()) { + try { + final Map g = parseMap(result.getGroupsJson()); + final Boolean layer = Boolean.TRUE.equals(g.get("layerMode")); + if (layer) { + final String targetLevel = Boolean.TRUE.equals(to.getAbFlag()) ? "A" : "B"; + if (!targetLevel.equals(s.getLevel())) { + conflicts.add("高中分层模式不得跨 A/B 层调班(" + s.getName() + + " 现为 " + s.getLevel() + " 层,目标班为 " + targetLevel + " 班)"); + } + } + } catch (Exception ignored) { + // 分层信息解析失败不校验 + } + } + final AdjustResultVo out = new AdjustResultVo(); + if (!conflicts.isEmpty() && !Boolean.TRUE.equals(request.getForce())) { + out.setApplied(false); + out.setConflicts(conflicts); + return out; + } + // 执行 + final String fromName = from == null ? "未分班" : from.getName(); + s.setClassId(to.getId()); + studentRepository.save(s); + if (result != null) { + result.setAdjusted(true); + final String line = String.format("[手动调班] %s:%s %s → %s%s", + LocalDateTime.now().toLocalTime(), s.getName(), fromName, to.getName(), + conflicts.isEmpty() ? "" : "(存在约束冲突,已人工确认:" + String.join(";", conflicts) + ")"); + result.setLogText(nvl(result.getLogText()) + "\n" + line); + resultRepository.save(result); + } + out.setApplied(true); + return out; + } + + // ==================== 随机班主任 ==================== + + @Override + @Transactional + public List matchTeachers(int stage, String year, Integer term) { + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + projectService.checkNotSubmitted(project); + final List classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId()); + final List teacherIds = classes.stream() + .map(AssignClass::getTeacherId).filter(java.util.Objects::nonNull).distinct() + .collect(Collectors.toList()); + final List logs = new ArrayList<>(); + if (teacherIds.size() < 2) { + throw new BizException(ErrorCode.PARAM_ERROR, "已指定班主任的班级不足 2 个,无法随机匹配"); + } + final Map names = teacherNames(teacherIds); + final List shuffled = new ArrayList<>(teacherIds); + Collections.shuffle(shuffled); + int i = 0; + for (AssignClass c : classes) { + if (c.getTeacherId() != null) { + final Integer pick = shuffled.get(i++ % shuffled.size()); + c.setTeacherId(pick); + c.setTeacherName(names.get(pick)); + logs.add(String.format("[班主任匹配] %s → %s", c.getName(), names.get(pick))); + } + } + classRepository.saveAll(classes); + if (resultRepository.findByProjectId(project.getId()).isPresent()) { + final AssignResult result = resultRepository.findByProjectId(project.getId()).get(); + result.setLogText(nvl(result.getLogText()) + "\n" + String.join("\n", logs)); + resultRepository.save(result); + } + return logs; + } + + // ==================== 报表 ==================== + + @Override + public List classReport(int stage, String year, Integer term) { + if (stage == 2) { + throw new BizException(ErrorCode.PARAM_ERROR, "小学版无成绩,不支持班级统计报表"); + } + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + final List classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId()); + final List students = studentRepository.findByProjectId(project.getId()); + final Map> byClass = students.stream() + .filter(s -> s.getClassId() != null) + .collect(Collectors.groupingBy(AssignStudent::getClassId)); + final List rows = new ArrayList<>(); + for (AssignClass c : classes) { + final ClassReportVo vo = new ClassReportVo(); + vo.setClassId(c.getId()); + vo.setName(c.getName()); + vo.setTeacherName(c.getTeacherName()); + final List list = byClass.getOrDefault(c.getId(), new ArrayList<>()); + vo.setCount(list.size()); + long male = 0; + long female = 0; + for (AssignStudent s : list) { + if (s.getGender() != null && s.getGender() == 1) { + male++; + } else if (s.getGender() != null && s.getGender() == 2) { + female++; + } + } + vo.setMale((int) male); + vo.setFemale((int) female); + final Map> subj = new LinkedHashMap<>(); + double totalSum = 0; + int totalN = 0; + double core3 = 0; + int core3N = 0; + for (AssignStudent s : list) { + if (s.getScoresJson() != null) { + final Map m = parseMap(s.getScoresJson()); + double c3 = 0; + int c3n = 0; + for (Map.Entry e : m.entrySet()) { + final Double d = toDouble(e.getValue()); + if (d == null) { + continue; + } + subj.computeIfAbsent(e.getKey(), k -> new ArrayList<>()).add(d); + if ("语文".equals(e.getKey()) || "数学".equals(e.getKey()) || "英语".equals(e.getKey())) { + c3 += d; + c3n++; + } + } + if (c3n == 3) { + core3 += c3; + core3N++; + } + } + if (s.getTotalScore() != null) { + totalSum += s.getTotalScore().doubleValue(); + totalN++; + } + } + final Map avg = new LinkedHashMap<>(); + for (Map.Entry> e : subj.entrySet()) { + avg.put(e.getKey(), avgOf(e.getValue())); + } + vo.setSubjectAvg(avg); + vo.setTotalAvg(totalN == 0 ? null : BigDecimal.valueOf(totalSum / totalN) + .setScale(2, BigDecimal.ROUND_HALF_UP)); + vo.setCore3Avg(core3N == 0 ? null : BigDecimal.valueOf(core3 / core3N) + .setScale(2, BigDecimal.ROUND_HALF_UP)); + rows.add(vo); + } + return rows; + } + + @Override + public BandReportVo bandReport(int stage, String year, Integer term, + String subject, Double min, Double max, Double step) { + if (stage == 2) { + throw new BizException(ErrorCode.PARAM_ERROR, "小学版无成绩,不支持分数段报表"); + } + if (subject == null || subject.isEmpty()) { + subject = "总分"; + } + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + final List classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId()); + final List students = studentRepository.findByProjectId(project.getId()).stream() + .filter(s -> s.getClassId() != null) + .collect(Collectors.toList()); + // 取值 + final Map valueByStudent = new HashMap<>(); + double seenMin = Double.MAX_VALUE; + double seenMax = -Double.MAX_VALUE; + for (AssignStudent s : students) { + Double v = null; + if ("总分".equals(subject)) { + v = s.getTotalScore() == null ? null : s.getTotalScore().doubleValue(); + } else if (s.getScoresJson() != null) { + v = toDouble(parseMap(s.getScoresJson()).get(subject)); + } + if (v != null) { + valueByStudent.put(s.getId(), v); + seenMin = Math.min(seenMin, v); + seenMax = Math.max(seenMax, v); + } + } + if (valueByStudent.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "该学科无有效成绩数据"); + } + // 区间参数:缺省自动建议 + double lo = min == null ? Math.floor(seenMin) : min; + double hi = max == null ? Math.ceil(seenMax) : max; + double st = step == null ? suggestStep(hi - lo) : step; + if (st <= 0) { + st = 5; + } + // 段数上限 30 + int segCount = (int) Math.ceil((hi - lo) / st); + if (segCount > 30) { + st = Math.ceil((hi - lo) / 30); + segCount = (int) Math.ceil((hi - lo) / st); + } + final BandReportVo vo = new BandReportVo(); + vo.setSubject(subject); + vo.setMinScore(lo); + vo.setMaxScore(hi); + vo.setStep(st); + final List labels = new ArrayList<>(); + for (int i = 0; i < segCount; i++) { + final double a = lo + i * st; + final double b = Math.min(a + st, hi); + labels.add(numStr(a) + "–" + numStr(b)); + } + vo.setLabels(labels); + final List rows = new ArrayList<>(); + final int[] allCounts = new int[segCount]; + final Map classCounts = new HashMap<>(); + for (AssignClass c : classes) { + classCounts.put(c.getId(), new int[segCount]); + } + for (AssignStudent s : students) { + final Double v = valueByStudent.get(s.getId()); + if (v == null || v < lo || v > hi) { + continue; + } + int seg = (int) Math.min((v - lo) / st, segCount - 1); + allCounts[seg]++; + if (s.getClassId() != null) { + classCounts.get(s.getClassId())[seg]++; + } + vo.setTotal(vo.getTotal() + 1); + } + vo.setMaxScoreSeen(round2(seenMax)); + vo.setMinScoreSeen(round2(seenMin)); + for (int i = 0; i < segCount; i++) { + final BandReportVo.BandRow row = new BandReportVo.BandRow(); + row.setLabel(labels.get(i)); + row.getCounts().add((long) allCounts[i]); + for (AssignClass c : classes) { + row.getCounts().add((long) classCounts.get(c.getId())[i]); + } + rows.add(row); + } + return vo; + } + + // ==================== 导出 ==================== + + @Override + public void exportDetail(int stage, String year, Integer term, HttpServletResponse response) throws Exception { + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + final List classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId()); + final Map classById = classes.stream() + .collect(Collectors.toMap(AssignClass::getId, c -> c)); + final List students = studentRepository.findByProjectId(project.getId()); + final Collator collator = Collator.getInstance(Locale.CHINA); + final Map> byClass = new LinkedHashMap<>(); + for (AssignStudent s : students) { + if (s.getClassId() != null) { + byClass.computeIfAbsent(s.getClassId(), k -> new ArrayList<>()).add(s); + } + } + final StringBuilder sb = new StringBuilder(); + final List head = new ArrayList<>(Arrays.asList( + "班级", "班主任", "班内名次", "姓名", "账号", "学号", "性别")); + final boolean hasScore = stage != 2; + if (hasScore) { + head.add("总分"); + head.add("三科总分"); + } + head.add("层级"); + final List subjects = new ArrayList<>(); + final List tags = new ArrayList<>(); + final List nums = new ArrayList<>(); + for (AssignStudent s : students) { + collectFields(s, subjects, tags, nums); + } + for (String sub : subjects) { + head.add(sub); + } + for (String t : tags) { + head.add(t); + } + head.add("毕业学校"); + head.add("备注"); + head.add("预设班级"); + sb.append(CsvUtil.line(head)); + for (AssignClass c : classes) { + final List list = byClass.getOrDefault(c.getId(), new ArrayList<>()); + list.sort((a, b) -> { + final double ta = a.getTotalScore() == null ? 0 : a.getTotalScore().doubleValue(); + final double tb = b.getTotalScore() == null ? 0 : b.getTotalScore().doubleValue(); + if (ta != tb) { + return Double.compare(tb, ta); + } + return collator.compare(nvl(a.getName()), nvl(b.getName())); + }); + int rank = 1; + for (AssignStudent s : list) { + final List row = new ArrayList<>(Arrays.asList( + c.getName(), nvl(c.getTeacherName()), rank++, nvl(s.getName()), + nvl(s.getAccount()), nvl(s.getStudentNo()), genderName(s.getGender()))); + if (hasScore) { + row.add(CsvUtil.num(s.getTotalScore())); + row.add(core3Str(s)); + } + row.add(nvl(s.getLevel())); + for (String sub : subjects) { + row.add(CsvUtil.num(subjectValue(s, sub))); + } + for (String t : tags) { + row.add(Boolean.TRUE.equals(tagValue(s, t)) ? "是" : "否"); + } + row.add(nvl(s.getGraduateSchool())); + row.add(nvl(s.getRemark())); + row.add(nvl(s.getPresetRaw())); + sb.append(CsvUtil.line(row)); + } + } + final String fileName = stageName(stage) + "分班明细_" + project.getYear() + "学年" + + project.getTerm() + "学期_" + classes.size() + "班_" + students.size() + "人.csv"; + CsvUtil.prepare(response, fileName); + response.getOutputStream().write(sb.toString().getBytes(StandardCharsets.UTF_8)); + } + + @Override + public void exportClassStats(int stage, String year, Integer term, HttpServletResponse response) throws Exception { + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + final List classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId()); + final List students = studentRepository.findByProjectId(project.getId()); + final List report = stage == 2 ? new ArrayList<>() : classReport(stage, year, term); + // 各班总分最高/最低 + final Map minMax = new HashMap<>(); + if (stage != 2) { + for (AssignStudent s : students) { + if (s.getClassId() == null || s.getTotalScore() == null) { + continue; + } + final double t = s.getTotalScore().doubleValue(); + final double[] mm = minMax.computeIfAbsent(s.getClassId(), + k -> new double[]{Double.MAX_VALUE, Double.NEGATIVE_INFINITY}); + mm[0] = Math.min(mm[0], t); + mm[1] = Math.max(mm[1], t); + } + } + // 表头(科目列固定顺序,行数据用同一顺序) + final List subjects = new ArrayList<>(); + for (ClassReportVo r : report) { + for (String k : r.getSubjectAvg().keySet()) { + if (!subjects.contains(k)) { + subjects.add(k); + } + } + } + final StringBuilder sb = new StringBuilder(); + final List head = new ArrayList<>(Arrays.asList("班级", "班主任", "人数", "男生", "女生")); + if (stage != 2) { + head.add("总分均分"); + head.add("最高分"); + head.add("最低分"); + for (String sub : subjects) { + head.add(sub + "均分"); + } + } + sb.append(CsvUtil.line(head)); + for (AssignClass c : classes) { + final ClassReportVo r = report.stream() + .filter(x -> x.getClassId().equals(c.getId())).findFirst().orElse(null); + final List row = new ArrayList<>(Arrays.asList( + c.getName(), nvl(c.getTeacherName()), + r == null ? 0 : r.getCount(), r == null ? 0 : r.getMale(), r == null ? 0 : r.getFemale())); + if (stage != 2) { + row.add(r == null ? "" : CsvUtil.num(r.getTotalAvg())); + final double[] mm = minMax.get(c.getId()); + row.add(mm == null ? "" : CsvUtil.num(mm[1] == Double.NEGATIVE_INFINITY ? null : mm[1])); + row.add(mm == null ? "" : CsvUtil.num(mm[0] == Double.MAX_VALUE ? null : mm[0])); + for (String sub : subjects) { + row.add(r == null ? "" : CsvUtil.num(r.getSubjectAvg().get(sub))); + } + } + sb.append(CsvUtil.line(row)); + } + // 尾部:整体均衡指标 + final AssignResult result = resultRepository.findByProjectId(project.getId()).orElse(null); + if (result != null) { + sb.append(CsvUtil.line(Arrays.asList("综合均衡度", CsvUtil.num(result.getScore())))); + } + final String fileName = stageName(stage) + "班级统计_" + project.getYear() + "学年" + + project.getTerm() + "学期.csv"; + CsvUtil.prepare(response, fileName); + response.getOutputStream().write(sb.toString().getBytes(StandardCharsets.UTF_8)); + } + + // ==================== 内部 ==================== + + private RunResultVo buildResultVo(AssignProject project, List classes, + List students, AssignResult entity, + AssignAlgorithm.MetricsOut metrics, AssignAlgorithm.BandPlan plan, + AssignAlgorithm.Config cfg) { + final RunResultVo vo = new RunResultVo(); + vo.setStatus(project.getStatus()); + vo.setAssignTime(entity.getAssignTime()); + vo.setIterations(entity.getIterations()); + vo.setCostMs(entity.getCostMs()); + vo.setScore(entity.getScore()); + vo.setAdjusted(Boolean.TRUE.equals(entity.getAdjusted())); + vo.setTotal(students.size()); + vo.setCountSpread(metrics.countSpread); + vo.setAvgSpread(metrics.avgSpread == 0 ? null + : BigDecimal.valueOf(metrics.avgSpread).setScale(2, BigDecimal.ROUND_HALF_UP)); + vo.setGenderDev(metrics.genderDev); + vo.setWeightedDev(metrics.weightedDev); + for (AssignAlgorithm.MetricItem mi : metrics.items) { + final MetricItemVo m = new MetricItemVo(); + m.setDimension(mi.dimension); + m.setType(mi.type); + m.setWeight(mi.weight); + m.setMinClass(mi.minClass); + m.setMinValue(mi.minValue); + m.setMaxClass(mi.maxClass); + m.setMaxValue(mi.maxValue); + m.setSpread(mi.spread); + m.setRelDev(mi.relDev); + m.setGrade(mi.grade); + final List pc = new ArrayList<>(); + for (double v : mi.perClass) { + pc.add(v); + } + m.setPerClass(pc); + vo.getMetrics().add(m); + } + for (String l : plan.labels) { + vo.getBandLabels().add(l); + } + // 班级卡片 + 图表 + final Map> byClass = students.stream() + .filter(s -> s.getClassId() != null) + .collect(Collectors.groupingBy(AssignStudent::getClassId)); + final Map classNameById = new HashMap<>(); + for (AssignClass c : classes) { + classNameById.put(c.getId(), c.getName()); + } + final Map aClassIds = new HashMap<>(); + boolean groupsEnabled = false; + String groupsMode = null; + try { + final Map g = parseMap(entity.getGroupsJson()); + groupsEnabled = Boolean.TRUE.equals(g.get("enabled")); + groupsMode = g.get("mode") == null ? null : String.valueOf(g.get("mode")); + final Object ids = g.get("aClassIds"); + if (ids instanceof List) { + for (Object o : (List) ids) { + final Double d = toDouble(o); + if (d != null) { + aClassIds.put(d.intValue(), 1); + } + } + } + } catch (Exception ignored) { + // 分层信息解析失败按非分层处理 + } + int maleAll = 0; + for (AssignStudent s : students) { + if (s.getGender() != null && s.getGender() == 1) { + maleAll++; + } + } + int ci = 0; + int aCountAll = 0; + int bCountAll = 0; + double aSum = 0; + double bSum = 0; + for (AssignClass c : classes) { + final List list = byClass.getOrDefault(c.getId(), new ArrayList<>()); + final ClassCardVo card = new ClassCardVo(); + card.setClassId(c.getId()); + card.setName(c.getName()); + card.setSortOrder(c.getSortOrder()); + card.setTeacherName(c.getTeacherName()); + card.setCount(list.size()); + final boolean layerMode = !aClassIds.isEmpty(); + if (layerMode) { + card.setAbGroup(aClassIds.containsKey(c.getId()) ? "A" : "B"); + } + long male = 0; + double sum = 0; + int n = 0; + double max = -Double.MAX_VALUE; + double min = Double.MAX_VALUE; + final Map> subj = new LinkedHashMap<>(); + final Map> numMap = new LinkedHashMap<>(); + final Map tagMap = new LinkedHashMap<>(); + final long[] bandCounts = new long[Math.max(plan.labels.length, 0)]; + final List tops = new ArrayList<>(); + for (AssignStudent s : list) { + if (s.getGender() != null && s.getGender() == 1) { + male++; + } + if (s.getTotalScore() != null) { + final double t = s.getTotalScore().doubleValue(); + sum += t; + n++; + max = Math.max(max, t); + min = Math.min(min, t); + tops.add(s.getTotalScore()); + if (plan.cuts.length > 0) { + bandCounts[AssignAlgorithm.bandIndexOf(t, plan.cuts)]++; + } + } + if (s.getScoresJson() != null) { + for (Map.Entry e : parseMap(s.getScoresJson()).entrySet()) { + final Double d = toDouble(e.getValue()); + if (d != null) { + subj.computeIfAbsent(e.getKey(), k -> new ArrayList<>()).add(d); + } + } + } + if (s.getNumsJson() != null) { + for (Map.Entry e : parseMap(s.getNumsJson()).entrySet()) { + final Double d = toDouble(e.getValue()); + if (d != null) { + numMap.computeIfAbsent(e.getKey(), k -> new ArrayList<>()).add(d); + } + } + } + if (s.getTagsJson() != null) { + for (Map.Entry e : parseMap(s.getTagsJson()).entrySet()) { + if (Boolean.parseBoolean(String.valueOf(e.getValue()))) { + tagMap.merge(e.getKey(), 1L, Long::sum); + } + } + } + if (s.getLevel() != null && !s.getLevel().isEmpty()) { + card.getLevelCounts().merge(s.getLevel(), 1, Integer::sum); + if ("A".equals(s.getLevel())) { + aCountAll++; + aSum += s.getTotalScore() == null ? 0 : s.getTotalScore().doubleValue(); + } else { + bCountAll++; + bSum += s.getTotalScore() == null ? 0 : s.getTotalScore().doubleValue(); + } + } + } + card.setMale((int) male); + card.setFemale(list.size() - (int) male); + card.setAvg(n == 0 ? null : BigDecimal.valueOf(sum / n).setScale(2, BigDecimal.ROUND_HALF_UP)); + card.setMax(n == 0 ? null : BigDecimal.valueOf(max).setScale(2, BigDecimal.ROUND_HALF_UP)); + card.setMin(n == 0 ? null : BigDecimal.valueOf(min).setScale(2, BigDecimal.ROUND_HALF_UP)); + for (Map.Entry> e : subj.entrySet()) { + card.getSubjectAvg().put(e.getKey(), avgOf(e.getValue())); + } + for (Map.Entry> e : numMap.entrySet()) { + card.getNumAvg().put(e.getKey(), avgOf(e.getValue())); + } + card.setTagCount(tagMap); + for (long bc : bandCounts) { + card.getBandCounts().add(bc); + } + tops.sort(Collections.reverseOrder()); + for (int i = 0; i < Math.min(12, tops.size()); i++) { + card.getTopScores().add(tops.get(i)); + } + final Collator collator = Collator.getInstance(Locale.CHINA); + list.sort((a, b) -> collator.compare(nvl(a.getName()), nvl(b.getName()))); + for (AssignStudent s : list) { + card.getStudents().add(studentService.toVo(s, classNameById)); + } + vo.getClassCards().add(card); + // 图表 + final ChartsVo.Bar bar = new ChartsVo.Bar(); + bar.setName(c.getName()); + bar.setValue(card.getAvg() == null ? list.size() : card.getAvg().doubleValue()); + vo.getCharts().getAvgBars().add(bar); + final ChartsVo.GenderBar gb = new ChartsVo.GenderBar(); + gb.setName(c.getName()); + gb.setMale(card.getMale()); + gb.setFemale(card.getFemale()); + vo.getCharts().getGenderBars().add(gb); + final ChartsVo.BandStack bs = new ChartsVo.BandStack(); + bs.setName(c.getName()); + bs.setCounts(new ArrayList<>(Arrays.asList(box(bandCounts)))); + vo.getCharts().getBandStacks().add(bs); + ci++; + } + // A/B 层信息(高中):layer 由分层编班产生,within 由班内分层产生 + final boolean levelMode = groupsEnabled && ("layer".equals(groupsMode) || "within".equals(groupsMode)); + if (levelMode) { + final RunResultVo.AbInfo ab = new RunResultVo.AbInfo(); + ab.setEnabled(true); + ab.setMode(groupsMode == null ? "layer" : groupsMode); + ab.setAClassCount(aClassIds.size()); + ab.setACount(aCountAll); + ab.setBCount(bCountAll); + ab.setAAvg(aCountAll == 0 ? null : BigDecimal.valueOf(aSum / aCountAll) + .setScale(2, BigDecimal.ROUND_HALF_UP)); + ab.setBAvg(bCountAll == 0 ? null : BigDecimal.valueOf(bSum / bCountAll) + .setScale(2, BigDecimal.ROUND_HALF_UP)); + if (ab.getAAvg() != null && ab.getBAvg() != null) { + ab.setLayerDiff(ab.getAAvg().subtract(ab.getBAvg())); + } + vo.setAbInfo(ab); + } + return vo; + } + + /** 分数段方案构建(等人数分位 / 分数间隔) */ + private AssignAlgorithm.BandPlan buildBandPlan(List students, AssignRule rule, + boolean enabled, boolean layerMode) { + final AssignAlgorithm.BandPlan plan = new AssignAlgorithm.BandPlan(); + if (!enabled || rule == null || rule.getBandMode() == null || rule.getBandMode().isEmpty()) { + return plan; + } + final List totals = students.stream() + .filter(s -> s.getTotalScore() != null) + .map(s -> s.getTotalScore().doubleValue()) + .sorted() + .collect(Collectors.toList()); + if (totals.isEmpty()) { + return plan; + } + final List cuts = new ArrayList<>(); + final List labels = new ArrayList<>(); + if ("count".equals(rule.getBandMode())) { + int n = rule.getBandCount() == null ? 4 : rule.getBandCount(); + n = Math.min(Math.max(n, 2), 20); + for (int k = 1; k < n; k++) { + final int pos = (int) Math.round(k * totals.size() * 1.0 / n); + final double v = totals.get(Math.min(pos, totals.size() - 1)); + if (cuts.isEmpty() || v > cuts.get(cuts.size() - 1)) { + cuts.add(v); + } + } + for (int i = 0; i <= cuts.size(); i++) { + labels.add(bandLabelByCount(cuts.size() + 1, i)); + } + } else { + double step = rule.getBandStep() == null ? 10 : Math.max(1, rule.getBandStep()); + final double min = totals.get(0); + final double max = totals.get(totals.size() - 1); + double cut = Math.ceil(min / step) * step; + while (cut < max && cuts.size() < 20) { + cuts.add(cut); + cut += step; + } + double prev = Double.NEGATIVE_INFINITY; + for (double c : cuts) { + labels.add(numStr(prev == Double.NEGATIVE_INFINITY ? min : prev) + "–" + numStr(c - 1)); + prev = c; + } + labels.add("≥" + numStr(prev == Double.NEGATIVE_INFINITY ? min : prev)); + } + plan.cuts = cuts.stream().mapToDouble(Double::doubleValue).toArray(); + plan.labels = labels.toArray(new String[0]); + return plan; + } + + /** 区间步长建议:按极差给出 1/2/5×10^n 的整数步长 */ + private static double suggestStep(double range) { + final double r = Math.max(range, 1); + final double raw = r / 20; + final double mag = Math.pow(10, Math.floor(Math.log10(raw))); + final double norm = raw / mag; + if (norm <= 1) { + return mag; + } + if (norm <= 2) { + return 2 * mag; + } + if (norm <= 5) { + return 5 * mag; + } + return 10 * mag; + } + + /** 分数段标签:下标 0 为最低分段,按分数从低到高排列 */ + private static String bandLabelByCount(int n, int idx) { + if (n == 3) { + return Arrays.asList("中后段", "中段", "前段").get(idx); + } + if (n == 4) { + return Arrays.asList("待提升", "中等", "良好", "优秀").get(idx); + } + if (n == 5) { + return Arrays.asList("待提升", "中等偏下", "中等", "良好", "优秀").get(idx); + } + return "第" + (idx + 1) + "段"; + } + + private Map metricsMap(AssignAlgorithm.Result r) { + final Map m = new LinkedHashMap<>(); + m.put("score", r.metrics.score); + m.put("weightedDev", r.metrics.weightedDev); + m.put("countSpread", r.metrics.countSpread); + m.put("avgSpread", r.metrics.avgSpread); + m.put("genderDev", r.metrics.genderDev); + final List> items = new ArrayList<>(); + for (AssignAlgorithm.MetricItem mi : r.metrics.items) { + final Map im = new LinkedHashMap<>(); + im.put("dimension", mi.dimension); + im.put("type", mi.type); + im.put("weight", mi.weight); + im.put("minClass", mi.minClass); + im.put("maxClass", mi.maxClass); + im.put("minValue", mi.minValue); + im.put("maxValue", mi.maxValue); + im.put("spread", mi.spread); + im.put("relDev", mi.relDev); + im.put("grade", mi.grade); + im.put("perClass", mi.perClass); + items.add(im); + } + m.put("items", items); + return m; + } + + private Map bandCutsMap(AssignAlgorithm.BandPlan plan) { + final Map m = new LinkedHashMap<>(); + m.put("cuts", plan.cuts); + m.put("labels", plan.labels); + return m; + } + + private Map groupsMap(AssignAlgorithm.Config cfg, AssignAlgorithm.Result r, + List classes) { + final Map m = new LinkedHashMap<>(); + m.put("enabled", cfg.abEnabled); + m.put("mode", cfg.abMode); + m.put("layerMode", cfg.abEnabled && "layer".equals(cfg.abMode)); + final List aIds = new ArrayList<>(); + if (cfg.abEnabled && "layer".equals(cfg.abMode)) { + for (int idx : r.aClassIdx) { + aIds.add(classes.get(idx).getId()); + } + } + m.put("aClassIds", aIds); + return m; + } + + /** 均衡配置:subjects/nums/tags 保留 null(= 默认全部识别列参与),不做空列表规范化 */ + private BalanceVo ruleVoOf(AssignRule rule) { + if (rule != null && rule.getBalanceJson() != null) { + try { + final BalanceVo b = JSON.parseObject(rule.getBalanceJson(), BalanceVo.class); + if (b != null) { + return b; + } + } catch (Exception ignored) { + // 规则解析失败用默认 + } + } + final BalanceVo b = new BalanceVo(); + b.setTotal(true); + b.setGender(true); + b.setBands(true); + return b; + } + + private ConstraintVo constraintVoOf(AssignRule rule) { + if (rule != null && rule.getConstraintJson() != null) { + try { + final ConstraintVo c = JSON.parseObject(rule.getConstraintJson(), ConstraintVo.class); + if (c != null) { + return c; + } + } catch (Exception ignored) { + // 约束解析失败用默认 + } + } + return new ConstraintVo(); + } + + /** 未显式配置时的默认参与列:学生对应 JSON 列名并集(按导入顺序) */ + private List defaultFieldNames(List students, Function json) { final Set names = new LinkedHashSet<>(); + for (AssignStudent s : students) { + if (json.apply(s) != null) { + names.addAll(parseMap(json.apply(s)).keySet()); + } + } + return new ArrayList<>(names); + } + + private static boolean isEmpty(List list) { + return list == null || list.isEmpty(); + } + + /** 三科平均分是否可用:学生成绩里语文/数学/英语三列齐全 */ + private boolean hasCore3(List students) { + for (AssignStudent s : students) { + if (s.getScoresJson() == null) { + continue; + } + final Set keys = parseMap(s.getScoresJson()).keySet(); + boolean all = true; + for (String sub : AssignAlgorithm.CORE3_SUBJECTS) { + if (!keys.contains(sub)) { + all = false; + break; + } + } + if (all) { + return true; + } + } + return false; + } + + private void collectFields(AssignStudent s, List subjects, + List tags, List nums) { + if (s.getScoresJson() != null) { + for (String k : parseMap(s.getScoresJson()).keySet()) { + if (!subjects.contains(k)) { + subjects.add(k); + } + } + } + if (s.getTagsJson() != null) { + for (String k : parseMap(s.getTagsJson()).keySet()) { + if (!tags.contains(k)) { + tags.add(k); + } + } + } + } + + private Map teacherNames(List ids) { + final Map out = new HashMap<>(); + try { + for (com.yunhai.acaclassassign.model.SchTeaList t + : classService.getSchTeaList(currentSchId())) { + if (ids.contains(t.getUserTeaSerID())) { + out.put(t.getUserTeaSerID(), t.getTeaName()); + } + } + } catch (Exception e) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取平台教师列表失败"); + } + return out; + } + + private int currentSchId() { + final Integer schId = com.yunhai.acaclassassign.utils.UserContext.getSchId(); + return schId == null ? 0 : schId; + } + + private List presetRawList(AssignStudent s) { + if (s.getPresetRaw() == null || s.getPresetRaw().isEmpty()) { + return new ArrayList<>(); + } + return new ArrayList<>(Arrays.asList(s.getPresetRaw().split("[;;,,]"))); + } + + private List presetTargets(AssignStudent s) { + final List out = new ArrayList<>(); + for (String raw : presetRawList(s)) { + final StudentParser.Preset p = StudentParser.parsePreset(raw); + if (p != null && ("same".equals(p.type) || "diff".equals(p.type)) && p.target != null) { + out.add(p.target); + } + } + return out; + } + + private Double subjectValue(AssignStudent s, String subject) { + if (s.getScoresJson() == null) { + return null; + } + return toDouble(parseMap(s.getScoresJson()).get(subject)); + } + + private Boolean tagValue(AssignStudent s, String tag) { + if (s.getTagsJson() == null) { + return false; + } + return Boolean.parseBoolean(String.valueOf(parseMap(s.getTagsJson()).get(tag))); + } + + private String core3Str(AssignStudent s) { + if (s.getScoresJson() == null) { + return ""; + } + final Map m = parseMap(s.getScoresJson()); + final Double c = toDouble(m.get("语文")); + final Double mm = toDouble(m.get("数学")); + final Double e = toDouble(m.get("英语")); + if (c == null || mm == null || e == null) { + return ""; + } + return CsvUtil.num(c + mm + e); + } + + @SuppressWarnings("unchecked") + private static Map parseMap(String json) { + if (json == null || json.isEmpty()) { + return new LinkedHashMap<>(); + } + try { + return JSON.parseObject(json, LinkedHashMap.class); + } catch (Exception e) { + return new LinkedHashMap<>(); + } + } + + private static Double toDouble(Object v) { + if (v == null) { + return null; + } + if (v instanceof Number) { + return ((Number) v).doubleValue(); + } + try { + return Double.valueOf(String.valueOf(v)); + } catch (NumberFormatException e) { + return null; + } + } + + private static double toDoublePrimitive(Object v) { + final Double d = toDouble(v); + return d == null ? 0 : d; + } + + private static BigDecimal avgOf(List values) { + if (values == null || values.isEmpty()) { + return null; + } + double sum = 0; + for (double v : values) { + sum += v; + } + return BigDecimal.valueOf(sum / values.size()).setScale(2, BigDecimal.ROUND_HALF_UP); + } + + private static double round2(double v) { + return Math.round(v * 100.0) / 100.0; + } + + private static String numStr(double v) { + return v == Math.rint(v) && Math.abs(v) < 1e15 ? String.valueOf((long) v) : String.valueOf(round2(v)); + } + + private static Long[] box(long[] arr) { + final Long[] out = new Long[arr.length]; + for (int i = 0; i < arr.length; i++) { + out[i] = arr[i]; + } + return out; + } + + private static String nvl(String v) { + return v == null ? "" : v; + } + + private static String genderName(Integer gender) { + if (gender == null) { + return "未知"; + } + return gender == 1 ? "男" : gender == 2 ? "女" : "未知"; + } + + private static String stageName(int stage) { + return stage == 2 ? "小学" : stage == 3 ? "初中" : "高中"; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/impl/AssignStudentService.java b/src/main/java/com/yunhai/acaclassassign/service/impl/AssignStudentService.java new file mode 100644 index 0000000..2b132fc --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/impl/AssignStudentService.java @@ -0,0 +1,1116 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.alibaba.fastjson.JSON; +import com.yunhai.acaclassassign.entity.AssignClass; +import com.yunhai.acaclassassign.entity.AssignProject; +import com.yunhai.acaclassassign.entity.AssignStudent; +import com.yunhai.acaclassassign.model.BasePageResult; +import com.yunhai.acaclassassign.model.SchTeaList; +import com.yunhai.acaclassassign.model.assign.ImportPasteRequest; +import com.yunhai.acaclassassign.model.assign.ImportResultVo; +import com.yunhai.acaclassassign.model.assign.PresetBatchRequest; +import com.yunhai.acaclassassign.model.assign.PresetBatchResultVo; +import com.yunhai.acaclassassign.model.assign.SourceAnalysisVo; +import com.yunhai.acaclassassign.model.assign.StudentPageVo; +import com.yunhai.acaclassassign.model.assign.StudentStatsVo; +import com.yunhai.acaclassassign.model.assign.StudentUpdateRequest; +import com.yunhai.acaclassassign.model.assign.StudentVo; +import com.yunhai.acaclassassign.repository.AssignClassRepository; +import com.yunhai.acaclassassign.repository.AssignProjectRepository; +import com.yunhai.acaclassassign.repository.AssignResultRepository; +import com.yunhai.acaclassassign.repository.AssignStudentRepository; +import com.yunhai.acaclassassign.service.IAssignProjectService; +import com.yunhai.acaclassassign.service.IAssignStudentService; +import com.yunhai.acaclassassign.service.IClassService; +import com.yunhai.acaclassassign.service.assign.SampleDataGenerator; +import com.yunhai.acaclassassign.service.assign.StudentParser; +import com.yunhai.acaclassassign.utils.CsvUtil; +import com.yunhai.acaclassassign.utils.PinyinInitials; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.base.YschBaseOpenApi; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaGroup; +import org.apache.poi.hssf.usermodel.HSSFCell; +import org.apache.poi.hssf.usermodel.HSSFCellStyle; +import org.apache.poi.hssf.usermodel.HSSFFont; +import org.apache.poi.hssf.usermodel.HSSFRow; +import org.apache.poi.hssf.usermodel.HSSFSheet; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.util.CellRangeAddress; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.Collator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.servlet.http.HttpServletResponse; + +/** + * 分班学生服务实现。 + */ +@Service +public class AssignStudentService implements IAssignStudentService { + + @Autowired + private AssignProjectRepository projectRepository; + + @Autowired + private AssignStudentRepository studentRepository; + + @Autowired + private AssignClassRepository classRepository; + + @Autowired + private AssignResultRepository resultRepository; + + @Autowired + private IAssignProjectService projectService; + + @Autowired + private IClassService classService; + + // ==================== 导入 ==================== + + @Override + @Transactional + public ImportResultVo importPaste(ImportPasteRequest request) { + if (request == null || request.getStage() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数缺失"); + } + final AssignProject project = projectService.getOrCreate( + request.getStage(), request.getYear(), request.getTerm()); + projectService.checkNotSubmitted(project); + return doImport(project, request.getStage(), request.getText(), + request.getHasHeader(), request.getMode()); + } + + @Override + @Transactional + public ImportResultVo importFile(int stage, String year, Integer term, String mode, Boolean hasHeader, + MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择要导入的文件"); + } + final String name = file.getOriginalFilename() == null ? "" : file.getOriginalFilename().toLowerCase(); + if (!name.endsWith(".csv") && !name.endsWith(".txt") && !name.endsWith(".tsv")) { + throw new BizException(ErrorCode.PARAM_ERROR, "仅支持 csv / txt / tsv 文件"); + } + final AssignProject project = projectService.getOrCreate(stage, year, term == null ? 1 : term); + projectService.checkNotSubmitted(project); + String text; + try { + text = new String(file.getBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "文件读取失败"); + } + // 去除 UTF-8 BOM + if (!text.isEmpty() && text.charAt(0) == '\uFEFF') { + text = text.substring(1); + } + return doImport(project, stage, text, hasHeader, mode); + } + + @Override + public Map sampleText(int stage) { + // 只生成文本不落库:由前端填入粘贴框,用户确认后走 ImportPaste 通道入库 + return java.util.Collections.singletonMap("text", SampleDataGenerator.text(stage)); + } + + private ImportResultVo doImport(AssignProject project, int stage, String text, + Boolean hasHeader, String mode) { + final boolean replace = mode == null || "replace".equals(mode); + final StudentParser.ParseResult parsed = StudentParser.parse(text, stage, hasHeader); + final ImportResultVo out = new ImportResultVo(); + out.getWarn().addAll(parsed.warn); + if (parsed.rows.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, + parsed.warn.isEmpty() ? "未解析到有效数据" : String.join(";", parsed.warn)); + } + if (replace) { + studentRepository.deleteByProjectId(project.getId()); + resultRepository.deleteByProjectId(project.getId()); + } + // 预设序号合法性校验(超出班级数告警) + final int classCount = (int) classRepository.countByProjectId(project.getId()); + final List entities = new ArrayList<>(); + long male = 0; + long female = 0; + final Set schools = new HashSet<>(); + for (StudentParser.ParsedRow row : parsed.rows) { + final AssignStudent s = new AssignStudent(); + s.setProjectId(project.getId()); + s.setName(row.name); + s.setGender(row.gender); + if (row.total != null) { + s.setTotalScore(toBigDecimal(row.total)); + } + if (!row.scores.isEmpty()) { + s.setScoresJson(JSON.toJSONString(row.scores)); + } + if (!row.tags.isEmpty()) { + s.setTagsJson(JSON.toJSONString(row.tags)); + } + if (!row.nums.isEmpty()) { + s.setNumsJson(JSON.toJSONString(row.nums)); + } + s.setGraduateSchool(row.school); + s.setStudentNo(row.studentNo); + s.setAccount(row.account); + s.setRemark(row.remark); + s.setPresetRaw(row.presetRaw); + if (row.preset != null) { + final AssignProjectService.PresetVo pv = new AssignProjectService.PresetVo(); + pv.type = row.preset.type; + if ("class".equals(row.preset.type)) { + if (row.preset.classSort != null && row.preset.classSort > classCount && classCount > 0) { + out.getWarn().add(String.format( + "第 %d 行 %s:预设班级 %d 超出当前班级数 %d,分班前请调整班级设置", + row.srcRow, row.name, row.preset.classSort, classCount)); + } + pv.value = row.preset.classSort; + } else { + pv.target = row.preset.target; + } + s.setPresetJson(JSON.toJSONString(java.util.Collections.singletonList(pv))); + } + s.setSrcRow(row.srcRow); + entities.add(s); + if (row.gender == 1) { + male++; + } else if (row.gender == 2) { + female++; + } + if (row.school != null && !row.school.isEmpty()) { + schools.add(row.school); + } + } + studentRepository.saveAll(entities); + out.setInserted(entities.size()); + out.setTotal(studentRepository.countByProjectId(project.getId())); + out.setMale(male); + out.setFemale(female); + out.setSubjects(parsed.subjects); + out.setTags(parsed.tags); + out.setNums(parsed.nums); + out.setSchools(schools.size()); + return out; + } + + // ==================== 名单 ==================== + + @Override + public StudentPageVo page(int stage, String year, Integer term, Integer classId, + String keyword, int pageIndex, int pageSize) { + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + final Map classNames = classNameMap(project.getId()); + final Collator collator = Collator.getInstance(Locale.CHINA); + List rows = new ArrayList<>(); + for (AssignStudent s : studentRepository.findByProjectId(project.getId())) { + if (keyword != null && !keyword.isEmpty() + && (s.getName() == null || !s.getName().contains(keyword.trim()))) { + continue; + } + if (classId != null && classId > 0 && !classId.equals(s.getClassId())) { + continue; + } + rows.add(toVo(s, classNames)); + } + // 列名清单:全量学生按首次出现顺序归并(与导入列序一致),前端刷新后仍能渲染扩展列 + final Set subjects = new LinkedHashSet<>(); + final Set tags = new LinkedHashSet<>(); + final Set nums = new LinkedHashSet<>(); + for (StudentVo v : rows) { + if (v.getScores() != null) { + subjects.addAll(v.getScores().keySet()); + } + if (v.getTags() != null) { + tags.addAll(v.getTags().keySet()); + } + if (v.getNums() != null) { + nums.addAll(v.getNums().keySet()); + } + } + // 排序:已分班的按班序+姓名拼音,未分班的在后 + final Map sortOrderById = new HashMap<>(); + for (AssignClass c : classRepository.findByProjectIdOrderBySortOrderAsc(project.getId())) { + sortOrderById.put(c.getId(), c.getSortOrder()); + } + rows.sort((a, b) -> { + final int sa = a.getClassId() == null ? Integer.MAX_VALUE : sortOrderById.getOrDefault(a.getClassId(), 9999); + final int sb = b.getClassId() == null ? Integer.MAX_VALUE : sortOrderById.getOrDefault(b.getClassId(), 9999); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return collator.compare(nvl(a.getName()), nvl(b.getName())); + }); + final int total = rows.size(); + final int size = Math.max(pageSize, 1); + final int pages = (total + size - 1) / size; + final int idx = Math.max(pageIndex, 1); + final int from = Math.min((idx - 1) * size, total); + final int to = Math.min(from + size, total); + final StudentPageVo out = new StudentPageVo( + new BasePageResult<>(rows.subList(from, to), total, idx, size, pages, to < total)); + out.setSubjects(new ArrayList<>(subjects)); + out.setTags(new ArrayList<>(tags)); + out.setNums(new ArrayList<>(nums)); + out.setGradeName(resolveGradeName(project)); + return out; + } + + // ==================== 名单导出 ==================== + + @Override + public void exportList(int stage, String year, Integer term, Integer classId, String keyword, + HttpServletResponse response) throws Exception { + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + final Map classNames = classNameMap(project.getId()); + final Map sortOrderById = new HashMap<>(); + String classLabel = classId == null || classId <= 0 ? "全部班级" : "指定班级"; + for (AssignClass c : classRepository.findByProjectIdOrderBySortOrderAsc(project.getId())) { + sortOrderById.put(c.getId(), c.getSortOrder()); + if (classId != null && classId.equals(c.getId())) { + classLabel = c.getName(); + } + } + // 与 page 同口径筛选:姓名包含关键字 + 班级精确匹配 + final String kw = keyword == null ? "" : keyword.trim(); + final List rows = new ArrayList<>(); + for (AssignStudent s : studentRepository.findByProjectId(project.getId())) { + if (!kw.isEmpty() && (s.getName() == null || !s.getName().contains(kw))) { + continue; + } + if (classId != null && classId > 0 && !classId.equals(s.getClassId())) { + continue; + } + rows.add(s); + } + // 与 page 同排序:班序 → 姓名拼音,未分班在后 + final Collator collator = Collator.getInstance(Locale.CHINA); + rows.sort((a, b) -> { + final int sa = a.getClassId() == null ? Integer.MAX_VALUE + : sortOrderById.getOrDefault(a.getClassId(), 9999); + final int sb = b.getClassId() == null ? Integer.MAX_VALUE + : sortOrderById.getOrDefault(b.getClassId(), 9999); + if (sa != sb) { + return Integer.compare(sa, sb); + } + return collator.compare(nvl(a.getName()), nvl(b.getName())); + }); + // 按平台学生导入模板格式导出 .xls:标题「学生导入」+ 表头 年级|班级|姓名|账号|密码|身份证|学号|性别(男,女); + // 年级=学校年级字典名称(不可得回退 K12 标准新生年级名,同班级导入数据下载口径),身份证未采集留空, + // 模板外的成绩/备注等列不导出 + final String gradeName = resolveGradeName(project); + final HSSFWorkbook wb = new HSSFWorkbook(); + try { + final HSSFSheet sheet = wb.createSheet("学生导入"); + final HSSFFont titleFont = wb.createFont(); + titleFont.setBold(true); + titleFont.setFontHeightInPoints((short) 14); + final HSSFCellStyle titleStyle = wb.createCellStyle(); + titleStyle.setFont(titleFont); + titleStyle.setAlignment(HorizontalAlignment.CENTER); + final HSSFRow title = sheet.createRow(0); + final HSSFCell titleCell = title.createCell(0); + titleCell.setCellValue("学生导入"); + titleCell.setCellStyle(titleStyle); + sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 7)); + final HSSFRow head = sheet.createRow(1); + final String[] heads = {"年级", "班级", "姓名", "账号", "密码", "身份证", "学号", "性别(男,女)"}; + for (int i = 0; i < heads.length; i++) { + head.createCell(i).setCellValue(heads[i]); + } + int r = 2; + for (AssignStudent s : rows) { + final HSSFRow row = sheet.createRow(r++); + row.createCell(0).setCellValue(gradeName); + row.createCell(1).setCellValue(s.getClassId() == null ? "" : nvl(classNames.get(s.getClassId()))); + row.createCell(2).setCellValue(nvl(s.getName())); + row.createCell(3).setCellValue(nvl(s.getAccount())); + row.createCell(4).setCellValue(nvl(s.getPassword())); + row.createCell(6).setCellValue(nvl(s.getStudentNo())); + row.createCell(7).setCellValue(genderText(s.getGender())); + } + final int[] widths = {12, 14, 14, 24, 24, 26, 14, 14}; + for (int i = 0; i < widths.length; i++) { + sheet.setColumnWidth(i, 256 * widths[i]); + } + final String fileName = stageName(stage) + "学生名单_" + project.getYear() + "学年" + + project.getTerm() + "学期_" + classLabel + "_" + rows.size() + "人.xls"; + final String encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20"); + response.setContentType("application/vnd.ms-excel"); + response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + encoded); + wb.write(response.getOutputStream()); + } finally { + wb.close(); + } + } + + /** 模板性别列:仅男/女,未知留空 */ + private static String genderText(Integer gender) { + if (gender == null) { + return ""; + } + return gender == 1 ? "男" : gender == 2 ? "女" : ""; + } + + // ==================== 班级导入数据导出 ==================== + + @Override + public void exportClassImport(int stage, String year, Integer term, + HttpServletResponse response) throws Exception { + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + exportClassImport(project, schoolNameOf(project.getSchId()), resolveGradeName(project), response); + } + + /** + * 年级名称:优先经班级管理接口按 学校+学年+学段 查学校年级字典真实名称(如 小一/一年级/初一), + * 不可得(接口未部署/不可达/字典无匹配)回退 K12 标准新生年级名(小一/初一/高一)。 + */ + private String resolveGradeName(AssignProject project) { + final int stage = project.getStage() == null ? 0 : project.getStage(); + final String fallback = intakeGradeName(stage); + if (project.getSchId() == null || project.getYear() == null) { + return fallback; + } + try { + final int year = Integer.parseInt(project.getYear().trim()); + // 分班 stage(2小学/3初中/4高中)→ 班级管理学段(1小学/2初中/3高中),同推送 toOapiStage + final String name = classService.getGradeName(project.getSchId(), year, stage - 1); + return name == null || name.trim().isEmpty() ? fallback : name.trim(); + } catch (Exception e) { + return fallback; + } + } + + /** + * 生成班级导入数据 .xls(包内可见供测试注入学校名/年级名离线验证)。 + * 被推方导入契约(userclass SchClassImportService):标题 1 行 + 表头 1 行;年级按名称精确匹配学校 + * 年级字典,班级列提取班号归一化,班主任/副班主任按姓名精确匹配教师名册(导入只读第一个 sheet)。 + * 故班级列一律 {序号}班(纯语义名如"阳光班"提取不出班号会被导入拒绝),语义班名放别名列 + * (口径同推送 buildClassItem / isSeqClassName);年级列=调用方解析的学校年级字典名称。 + */ + void exportClassImport(AssignProject project, String schoolName, String gradeName, + HttpServletResponse response) throws Exception { + final List classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId()); + if (classes.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "暂无班级设置,请先在「班级设置」完成班级配置"); + } + final int stage = project.getStage() == null ? 0 : project.getStage(); + final HSSFWorkbook wb = new HSSFWorkbook(); + try { + final HSSFFont titleFont = wb.createFont(); + titleFont.setBold(true); + titleFont.setFontHeightInPoints((short) 14); + final HSSFCellStyle titleStyle = wb.createCellStyle(); + titleStyle.setFont(titleFont); + titleStyle.setAlignment(HorizontalAlignment.CENTER); + + // 数据页:sheet 名=学校名(与平台 downloadTemplate 一致) + final HSSFSheet sheet = wb.createSheet(sheetName(schoolName)); + final HSSFRow title = sheet.createRow(0); + final HSSFCell titleCell = title.createCell(0); + titleCell.setCellValue("班级导入"); + titleCell.setCellStyle(titleStyle); + sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 4)); + final HSSFRow head = sheet.createRow(1); + final String[] heads = {"年级", "班级", "班级别名", "班主任", "副班主任"}; + for (int i = 0; i < heads.length; i++) { + head.createCell(i).setCellValue(heads[i]); + } + int r = 2; + for (AssignClass c : classes) { + final HSSFRow row = sheet.createRow(r++); + row.createCell(0).setCellValue(gradeName); + row.createCell(1).setCellValue(c.getSortOrder() + "班"); + row.createCell(2).setCellValue(PushClassService.isSeqClassName(c.getName()) ? "" : nvl(c.getName())); + row.createCell(3).setCellValue(nvl(c.getTeacherName())); + // 副班主任本项目未采集,留空 + } + final int[] widths = {12, 14, 16, 14, 14}; + for (int i = 0; i < widths.length; i++) { + sheet.setColumnWidth(i, 256 * widths[i]); + } + + // 规则页:结构与平台模板一致,仅供人工参照 + final HSSFSheet rule = wb.createSheet("导入规则"); + final HSSFRow ruleTitle = rule.createRow(0); + final HSSFCell ruleTitleCell = ruleTitle.createCell(0); + ruleTitleCell.setCellValue("导入规则"); + ruleTitleCell.setCellStyle(titleStyle); + rule.addMergedRegion(new CellRangeAddress(0, 0, 0, 2)); + final HSSFRow ruleHead = rule.createRow(1); + final String[] ruleHeads = {"规则项", "说明", "填写内容"}; + for (int i = 0; i < ruleHeads.length; i++) { + ruleHead.createCell(i).setCellValue(ruleHeads[i]); + } + final String[][] rules = { + {"年级", "必填", gradeCandidates(stage)}, + {"班级", "必填", "1班、2班、3班~n班"}, + {"班主任、副班主任", "选填", String.join("、", teacherNames(project))}, + }; + for (int i = 0; i < rules.length; i++) { + final HSSFRow row = rule.createRow(2 + i); + for (int j = 0; j < rules[i].length; j++) { + row.createCell(j).setCellValue(rules[i][j]); + } + } + rule.setColumnWidth(2, 256 * 60); + + final String fileName = stageName(stage) + "班级导入数据_" + project.getYear() + "学年" + + project.getTerm() + "学期_" + classes.size() + "班.xls"; + final String encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20"); + response.setContentType("application/vnd.ms-excel"); + response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + encoded); + wb.write(response.getOutputStream()); + } finally { + wb.close(); + } + } + + /** 学校名称(用户中心组名;不可得回退空串,sheet 名退化「班级导入」,不影响数据) */ + private String schoolNameOf(Integer schId) { + if (schId == null) { + return ""; + } + try { + final BoaGroup school = YschBaseOpenApi.getGroupById(schId, false); + return school == null || school.getName() == null ? "" : school.getName().trim(); + } catch (Exception e) { + return ""; + } + } + + /** 教师名册(规则页候选提示):优先平台教师名册,不可得回退本项目班级已设置的班主任名 */ + private List teacherNames(AssignProject project) { + final List names = new ArrayList<>(); + try { + if (project.getSchId() != null) { + for (SchTeaList tea : classService.getSchTeaList(project.getSchId())) { + addUnique(names, tea == null ? null : tea.getTeaName()); + } + } + } catch (Exception e) { + names.clear(); + } + if (names.isEmpty()) { + for (AssignClass c : classRepository.findByProjectIdOrderBySortOrderAsc(project.getId())) { + addUnique(names, c.getTeacherName()); + } + } + return names; + } + + private static void addUnique(List out, String name) { + final String n = name == null ? "" : name.trim(); + if (!n.isEmpty() && !out.contains(n)) { + out.add(n); + } + } + + /** 新生年级名(K12 标准年级字典口径):小学=小一、初中=初一、高中=高一 */ + private static String intakeGradeName(int stage) { + return stage == 2 ? "小一" : stage == 3 ? "初一" : "高一"; + } + + /** 规则页年级候选(同 K12 标准年级字典) */ + private static String gradeCandidates(int stage) { + if (stage == 2) { + return "小一、小二、小三、小四、小五、小六"; + } + if (stage == 3) { + return "初一、初二、初三"; + } + return "高一、高二、高三"; + } + + /** sheet 名清洗:Excel 不允许 : \ / ? * [ ],上限 31 字符;空则退化「班级导入」 */ + private static String sheetName(String name) { + final String cleaned = (name == null ? "" : name.replaceAll("[:\\\\/?*\\[\\]]", "")).trim(); + if (cleaned.isEmpty()) { + return "班级导入"; + } + return cleaned.length() > 31 ? cleaned.substring(0, 31) : cleaned; + } + + // ==================== 名单统计 ==================== + + @Override + public StudentStatsVo stats(int stage, String year, Integer term) { + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + final StudentStatsVo out = new StudentStatsVo(); + final List students = studentRepository.findByProjectId(project.getId()); + out.setTotal(students.size()); + // 字段聚合器:是否型计取真人数,数值型求均值;按首次出现顺序 + final Map fieldMap = new LinkedHashMap<>(); + final Map numAgg = new LinkedHashMap<>(); + final Set schools = new HashSet<>(); + double sum = 0; + double max = -Double.MAX_VALUE; + double min = Double.MAX_VALUE; + int scoreCount = 0; + long male = 0; + long female = 0; + for (AssignStudent s : students) { + if (s.getGender() != null) { + if (s.getGender() == 1) { + male++; + } else if (s.getGender() == 2) { + female++; + } + } + if (s.getTotalScore() != null) { + final double t = s.getTotalScore().doubleValue(); + sum += t; + max = Math.max(max, t); + min = Math.min(min, t); + scoreCount++; + } + for (Map.Entry e : parseMap(s.getTagsJson()).entrySet()) { + if (!Boolean.TRUE.equals(e.getValue())) { + continue; + } + final StudentStatsVo.FieldStat fs = fieldMap.computeIfAbsent(e.getKey(), k -> { + final StudentStatsVo.FieldStat f = new StudentStatsVo.FieldStat(); + f.setName(k); + f.setKind("tag"); + f.setCount(0L); + return f; + }); + fs.setCount(fs.getCount() + 1); + } + for (Map.Entry e : parseMap(s.getNumsJson()).entrySet()) { + if (!(e.getValue() instanceof Number)) { + continue; + } + final long[] agg = numAgg.computeIfAbsent(e.getKey(), k -> new long[2]); + agg[0] += Math.round(((Number) e.getValue()).doubleValue() * 100.0); + agg[1]++; + if (!fieldMap.containsKey(e.getKey())) { + final StudentStatsVo.FieldStat f = new StudentStatsVo.FieldStat(); + f.setName(e.getKey()); + f.setKind("num"); + fieldMap.put(e.getKey(), f); + } + } + if (s.getGraduateSchool() != null && !s.getGraduateSchool().isEmpty()) { + schools.add(s.getGraduateSchool()); + } + } + out.setMale(male); + out.setFemale(female); + if (scoreCount > 0) { + out.setAvgScore(round2(sum / scoreCount)); + out.setMaxScore(round2(max)); + out.setMinScore(round2(min)); + } + for (Map.Entry e : numAgg.entrySet()) { + final StudentStatsVo.FieldStat f = fieldMap.get(e.getKey()); + f.setAvg(e.getValue()[1] == 0 ? 0.0 : round2(e.getValue()[0] / 100.0 / e.getValue()[1])); + } + out.setFields(new ArrayList<>(fieldMap.values())); + out.setSchoolCount(schools.size()); + return out; + } + + @Override + @Transactional + public void update(StudentUpdateRequest request) { + if (request == null || request.getStudent() == null || request.getStudent().getId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数缺失"); + } + final AssignProject project = projectService.require( + request.getStage(), request.getYear(), request.getTerm() == null ? 1 : request.getTerm()); + projectService.checkNotSubmitted(project); + final AssignStudent s = studentRepository.findById(request.getStudent().getId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "学生不存在")); + if (!s.getProjectId().equals(project.getId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "学生不属于当前项目"); + } + final StudentVo v = request.getStudent(); + if (v.getName() != null && !v.getName().trim().isEmpty()) { + s.setName(v.getName().trim()); + } + if (v.getGender() != null) { + s.setGender(v.getGender()); + } + if (v.getTotalScore() != null) { + s.setTotalScore(v.getTotalScore()); + } + if (v.getScores() != null) { + s.setScoresJson(v.getScores().isEmpty() ? null : JSON.toJSONString(v.getScores())); + } + if (v.getTags() != null) { + s.setTagsJson(v.getTags().isEmpty() ? null : JSON.toJSONString(v.getTags())); + } + if (v.getNums() != null) { + s.setNumsJson(v.getNums().isEmpty() ? null : JSON.toJSONString(v.getNums())); + } + if (v.getGraduateSchool() != null) { + s.setGraduateSchool(v.getGraduateSchool()); + } + if (v.getStudentNo() != null) { + s.setStudentNo(v.getStudentNo()); + } + if (v.getAccount() != null) { + s.setAccount(v.getAccount()); + } + if (v.getRemark() != null) { + s.setRemark(v.getRemark()); + } + if (v.getPresetRaw() != null) { + s.setPresetRaw(v.getPresetRaw()); + final StudentParser.Preset p = StudentParser.parsePreset(v.getPresetRaw()); + if (p != null) { + final AssignProjectService.PresetVo pv = new AssignProjectService.PresetVo(); + pv.type = p.type; + pv.value = p.classSort; + pv.target = p.target; + s.setPresetJson(JSON.toJSONString(java.util.Collections.singletonList(pv))); + } else { + s.setPresetJson(null); + } + } + studentRepository.save(s); + } + + @Override + @Transactional + public void delete(int stage, String year, Integer term, Integer id) { + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + projectService.checkNotSubmitted(project); + studentRepository.deleteById(id); + } + + @Override + @Transactional + public void clear(int stage, String year, Integer term) { + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + projectService.checkNotSubmitted(project); + studentRepository.deleteByProjectId(project.getId()); + resultRepository.deleteByProjectId(project.getId()); + } + + // ==================== 批量预设 ==================== + + @Override + @Transactional + public PresetBatchResultVo presetBatch(PresetBatchRequest request) { + if (request == null || request.getNames() == null || request.getNames().isEmpty() + || request.getClassId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数缺失"); + } + final AssignProject project = projectService.require( + request.getStage(), request.getYear(), request.getTerm() == null ? 1 : request.getTerm()); + projectService.checkNotSubmitted(project); + final AssignClass target = classRepository.findById(request.getClassId()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "目标班级不存在")); + if (!target.getProjectId().equals(project.getId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "目标班级不属于当前项目"); + } + final Set names = request.getNames().stream() + .map(String::trim).filter(s -> !s.isEmpty()).collect(Collectors.toSet()); + int updated = 0; + final Set matched = new HashSet<>(); + for (AssignStudent s : studentRepository.findByProjectId(project.getId())) { + if (s.getName() != null && names.contains(s.getName().trim())) { + final AssignProjectService.PresetVo pv = new AssignProjectService.PresetVo(); + pv.type = "class"; + pv.value = target.getSortOrder(); + s.setPresetRaw(target.getSortOrder() + "班"); + s.setPresetJson(JSON.toJSONString(java.util.Collections.singletonList(pv))); + studentRepository.save(s); + matched.add(s.getName().trim()); + updated++; + } + } + final PresetBatchResultVo out = new PresetBatchResultVo(); + out.setUpdated(updated); + for (String n : names) { + if (!matched.contains(n)) { + out.getUnmatched().add(n); + } + } + return out; + } + + // ==================== 生源分析 ==================== + + @Override + public SourceAnalysisVo sourceAnalysis(int stage, String year, Integer term, List cuts) { + if (stage == 2) { + throw new BizException(ErrorCode.PARAM_ERROR, "小学版无成绩,不支持生源分析"); + } + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + final List students = studentRepository.findByProjectId(project.getId()).stream() + .filter(s -> s.getTotalScore() != null) + .collect(Collectors.toList()); + if (students.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "无含总分的学生数据"); + } + // 分数线:留空按极差 8 等分自动生成 7 条 + double min = Double.MAX_VALUE; + double max = -Double.MAX_VALUE; + for (AssignStudent s : students) { + final double t = s.getTotalScore().doubleValue(); + min = Math.min(min, t); + max = Math.max(max, t); + } + List lines = cuts; + if (lines == null || lines.isEmpty()) { + lines = new ArrayList<>(); + final double step = (max - min) / 8; + for (int i = 1; i <= 7; i++) { + lines.add(round2(min + step * i)); + } + } else { + lines = lines.stream().sorted().limit(12).collect(Collectors.toList()); + } + final SourceAnalysisVo vo = new SourceAnalysisVo(); + vo.setCuts(lines); + final List labels = new ArrayList<>(); + double prev = Double.NEGATIVE_INFINITY; + for (double cut : lines) { + labels.add(bandLabel(prev, cut)); + prev = cut; + } + labels.add(bandLabel(prev, Double.POSITIVE_INFINITY)); + vo.setLabels(labels); + // 学校 × 档 + final Map rowsMap = new LinkedHashMap<>(); + for (AssignStudent s : students) { + final String school = s.getGraduateSchool() == null || s.getGraduateSchool().isEmpty() + ? "(未填)" : s.getGraduateSchool(); + final long[] counts = rowsMap.computeIfAbsent(school, k -> new long[labels.size()]); + counts[bandIndexOf(s.getTotalScore().doubleValue(), lines)]++; + } + final List rows = new ArrayList<>(); + final List sums = new ArrayList<>(); + for (int i = 0; i < labels.size(); i++) { + sums.add(0L); + } + long total = 0; + for (Map.Entry e : rowsMap.entrySet()) { + final SourceAnalysisVo.SchoolRow r = new SourceAnalysisVo.SchoolRow(); + r.setSchool(e.getKey()); + final List counts = new ArrayList<>(); + long rt = 0; + for (int i = 0; i < e.getValue().length; i++) { + counts.add(e.getValue()[i]); + sums.set(i, sums.get(i) + e.getValue()[i]); + rt += e.getValue()[i]; + } + r.setCounts(counts); + r.setTotal(rt); + total += rt; + rows.add(r); + } + rows.sort((a, b) -> Long.compare(b.getTotal(), a.getTotal())); + vo.setRows(rows); + vo.setSums(sums); + vo.setTotal(total); + return vo; + } + + @Override + public void exportSource(int stage, String year, Integer term, List cuts, + HttpServletResponse response) throws Exception { + final SourceAnalysisVo vo = sourceAnalysis(stage, year, term, cuts); + final StringBuilder sb = new StringBuilder(); + final List head = new ArrayList<>(); + head.add("编号"); + head.add("毕业学校"); + head.addAll(vo.getLabels()); + head.add("录取人数"); + sb.append(CsvUtil.line(head)); + int no = 1; + for (SourceAnalysisVo.SchoolRow r : vo.getRows()) { + final List cells = new ArrayList<>(); + cells.add(no++); + cells.add(r.getSchool()); + cells.addAll(r.getCounts()); + cells.add(r.getTotal()); + sb.append(CsvUtil.line(cells)); + } + final List sumRow = new ArrayList<>(); + sumRow.add(""); + sumRow.add("合计"); + sumRow.addAll(vo.getSums()); + sumRow.add(vo.getTotal()); + sb.append(CsvUtil.line(sumRow)); + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + final String termText = project.getTerm() != null && project.getTerm() == 2 ? "第二学期" : "第一学期"; + CsvUtil.prepare(response, "生源分析_" + project.getYear() + "学年" + termText + + "_" + vo.getRows().size() + "所学校_" + vo.getTotal() + "人.csv"); + response.getOutputStream().write(sb.toString().getBytes(StandardCharsets.UTF_8)); + } + + // ==================== 模板 ==================== + + @Override + public void template(int stage, HttpServletResponse response) throws Exception { + final StringBuilder sb = new StringBuilder(); + final List head = stage == 2 + ? Arrays.asList("姓名", "账号", "学号", "性别", "备注", "预设班级") + : Arrays.asList("姓名", "账号", "学号", "性别", "总分", "语文", "数学", "英语", + "特长生", "住宿", "毕业学校", "备注", "预设班级"); + sb.append(String.join(",", head)).append("\r\n"); + if (stage == 2) { + sb.append("张小明,stu001,2026001,男,,\r\n"); + sb.append("李小红,stu002,2026002,女,,同班:张小明\r\n"); + } else { + sb.append("张小明,stu001,2026001,男,285.5,95,96,94.5,是,否,实验一小,三好学生,1\r\n"); + sb.append("李小红,stu002,2026002,女,279,92,94,93,否,是,实验二小,,不同班:王强\r\n"); + sb.append("王强,stu003,2026003,男,288,97,95,96,否,否,实验二小,,\r\n"); + } + CsvUtil.prepare(response, "分班导入模板.csv"); + response.getOutputStream().write(sb.toString().getBytes(StandardCharsets.UTF_8)); + } + + // ==================== 学号/账号生成 ==================== + + @Override + @Transactional + public Map genStudentNo(int stage, String year, Integer term, String scope) { + final AssignProject project = prepareScope(stage, year, term, scope); + return doRenumber(project, "class".equalsIgnoreCase(scope), null); + } + + @Override + @Transactional + public Map genStudentAccount(int stage, String year, Integer term, String scope) { + final AssignProject project = prepareScope(stage, year, term, scope); + return doRenumber(project, "class".equalsIgnoreCase(scope), accountPrefix(project)); + } + + /** 生成学号/账号共通前置:校验 scope、解析项目 */ + private AssignProject prepareScope(int stage, String year, Integer term, String scope) { + if (!"class".equalsIgnoreCase(scope) && !"grade".equalsIgnoreCase(scope)) { + throw new BizException(ErrorCode.PARAM_ERROR, "scope 仅支持 class / grade"); + } + return projectService.require(stage, year, term == null ? 1 : term); + } + + /** + * 编号核心:仅已分班学生参与编号,未分班学生的学号/账号保持不动。 + * 成绩模式(项目内有总分):总分降序(空分垫底)、同分姓名拼音;序号模式(无任何总分,如小学):班级序+姓名拼音(与名单页默认排序一致)。 + * accountPrefix 为 null 写学号(class=班内名次2位 01…;grade=学年+名次4位 如 20250001); + * 否则写账号(class=前缀+班序2位+班内序2位 如 dngjxxs20260101;grade=前缀+年级序4位 如 dngjxxs20260001)。 + * 包内可见:测试注入固定前缀即可离线验证编号格式。返回 {scope, mode: score|seq, count}。 + */ + Map doRenumber(AssignProject project, boolean byClass, String accountPrefix) { + final List assigned = new ArrayList<>(); + for (AssignStudent s : studentRepository.findByProjectId(project.getId())) { + if (s.getClassId() != null) { + assigned.add(s); + } + } + if (assigned.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "暂无已分班学生,请先执行分班"); + } + final boolean scoreMode = assigned.stream().anyMatch(s -> s.getTotalScore() != null); + final Collator collator = Collator.getInstance(Locale.CHINA); + final Map sortOrderById = classSortOrder(project.getId()); + final Comparator byScore = Comparator + .comparing(AssignStudent::getTotalScore, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(s -> nvl(s.getName()), collator); + final Comparator byName = Comparator.comparing(s -> nvl(s.getName()), collator); + final Comparator bySeq = Comparator + .comparing((AssignStudent s) -> sortOrderById.getOrDefault(s.getClassId(), 9999)) + .thenComparing(byName); + int count = 0; + if (byClass) { + final Map> groups = new LinkedHashMap<>(); + for (AssignStudent s : assigned) { + groups.computeIfAbsent(s.getClassId(), k -> new ArrayList<>()).add(s); + } + for (Map.Entry> e : groups.entrySet()) { + e.getValue().sort(scoreMode ? byScore : byName); + int seq = 1; + for (AssignStudent s : e.getValue()) { + if (accountPrefix == null) { + s.setStudentNo(String.format("%02d", seq++)); + } else { + s.setAccount(accountPrefix + + String.format("%02d%02d", sortOrderById.getOrDefault(e.getKey(), 99), seq++)); + } + count++; + } + } + } else { + assigned.sort(scoreMode ? byScore : bySeq); + int seq = 1; + for (AssignStudent s : assigned) { + if (accountPrefix == null) { + s.setStudentNo(String.format("%s%04d", project.getYear(), seq++)); + } else { + s.setAccount(accountPrefix + String.format("%04d", seq++)); + } + count++; + } + } + studentRepository.saveAll(assigned); + final Map out = new HashMap<>(); + out.put("scope", byClass ? "class" : "grade"); + out.put("mode", scoreMode ? "score" : "seq"); + out.put("count", count); + return out; + } + + private Map classSortOrder(Integer projectId) { + final Map sortOrderById = new HashMap<>(); + for (AssignClass c : classRepository.findByProjectIdOrderBySortOrderAsc(projectId)) { + sortOrderById.put(c.getId(), c.getSortOrder()); + } + return sortOrderById; + } + + /** 账号前缀:学校名称拼音首字母(小写) + "s" + 学年,如 大宁国际小学 2026 → dngjxxs2026 */ + private String accountPrefix(AssignProject project) { + if (project.getSchId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "无法获取学校信息,无法生成账号"); + } + final BoaGroup school = YschBaseOpenApi.getGroupById(project.getSchId(), false); + final String initials = PinyinInitials.initials(school == null ? null : school.getName()); + if (initials.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "无法获取学校名称,无法生成账号"); + } + return initials + "s" + project.getYear(); + } + + @Override + @Transactional + public Map genStudentPassword(int stage, String year, Integer term) { + final AssignProject project = projectService.require(stage, year, term == null ? 1 : term); + // 仅已分班且有账号的学生参与;无账号跳过计数(导入账号同样作为密码源) + final List targets = new ArrayList<>(); + int skipped = 0; + for (AssignStudent s : studentRepository.findByProjectId(project.getId())) { + if (s.getClassId() == null) { + continue; + } + if (s.getAccount() == null || s.getAccount().trim().isEmpty()) { + skipped++; + continue; + } + targets.add(s); + } + if (targets.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, + skipped > 0 ? "已分班学生均无账号,请先生成账号" : "暂无已分班学生,请先执行分班"); + } + for (AssignStudent s : targets) { + s.setPassword(s.getAccount().trim() + "@"); + } + studentRepository.saveAll(targets); + final Map out = new HashMap<>(); + out.put("count", targets.size()); + out.put("skipped", skipped); + return out; + } + + // ==================== 转换 ==================== + + @Override + public StudentVo toVo(AssignStudent s, Map classNameById) { + final StudentVo vo = new StudentVo(); + vo.setId(s.getId()); + vo.setName(s.getName()); + vo.setGender(s.getGender()); + vo.setTotalScore(s.getTotalScore()); + vo.setGraduateSchool(s.getGraduateSchool()); + vo.setStudentNo(s.getStudentNo()); + vo.setAccount(s.getAccount()); + vo.setPassword(s.getPassword()); + vo.setRemark(s.getRemark()); + vo.setPresetRaw(s.getPresetRaw()); + vo.setClassId(s.getClassId()); + if (s.getClassId() != null) { + vo.setClassName(classNameById.get(s.getClassId())); + } + vo.setLevel(s.getLevel()); + vo.setSrcRow(s.getSrcRow()); + vo.setScores(parseMap(s.getScoresJson())); + vo.setTags(parseMap(s.getTagsJson())); + vo.setNums(parseMap(s.getNumsJson())); + return vo; + } + + @SuppressWarnings("unchecked") + private static Map parseMap(String json) { + if (json == null || json.isEmpty()) { + return new LinkedHashMap<>(); + } + try { + return JSON.parseObject(json, LinkedHashMap.class); + } catch (Exception e) { + return new LinkedHashMap<>(); + } + } + + private Map classNameMap(Integer projectId) { + final Map out = new HashMap<>(); + for (AssignClass c : classRepository.findByProjectIdOrderBySortOrderAsc(projectId)) { + out.put(c.getId(), c.getName()); + } + return out; + } + + private static int bandIndexOf(double total, List lines) { + int i = 0; + while (i < lines.size() && total >= lines.get(i)) { + i++; + } + return i; + } + + private static String bandLabel(double low, double high) { + final String lo = low == Double.NEGATIVE_INFINITY ? "" : "≥" + trimNum(low); + final String hi = high == Double.POSITIVE_INFINITY ? "" : "<" + trimNum(high); + if (lo.isEmpty()) { + return hi; + } + if (hi.isEmpty()) { + return lo; + } + return lo + "且" + hi; + } + + private static String trimNum(double v) { + return v == Math.rint(v) ? String.valueOf((long) v) : String.valueOf(v); + } + + private static java.math.BigDecimal toBigDecimal(double v) { + return java.math.BigDecimal.valueOf(Math.round(v * 100.0) / 100.0); + } + + private static double round2(double v) { + return Math.round(v * 100.0) / 100.0; + } + + private static String stageName(int stage) { + return stage == 2 ? "小学" : stage == 3 ? "初中" : "高中"; + } + + private static String nvl(String v) { + return v == null ? "" : v; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/impl/ClassService.java b/src/main/java/com/yunhai/acaclassassign/service/impl/ClassService.java new file mode 100644 index 0000000..5872846 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/impl/ClassService.java @@ -0,0 +1,723 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.acaclassassign.model.SchTeaList; +import com.yunhai.acaclassassign.model.bsch.ClassTreeNodeVo; +import com.yunhai.acaclassassign.model.bsch.NowYearTermVo; +import com.yunhai.acaclassassign.model.bsch.SubjectVo; +import com.yunhai.acaclassassign.model.bsch.YearTermItemVo; +import com.yunhai.acaclassassign.service.IClassService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +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.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 班级管理(bschclass)开放接口实现。 + * 独立 RestTemplate 直连;学生树做 5 分钟内存缓存(TTL)。 + * 学生树层级:学校(type=1)→学段(1)→年级(1)→班级(1)→学生(2)。 + * 派生查询(班级树/学生数/学生定位/名单)基于缓存树一次遍历建索引后计算。 + */ +@Service +public class ClassService implements IClassService { + + private static final Logger log = LoggerFactory.getLogger(ClassService.class); + + /** 缓存有效期 ms */ + private static final long CACHE_TTL_MS = 5 * 60 * 1000L; + + /** 班级管理服务基址 */ + @Value("${yh.open.bschclass:}") + private String bschClassHost; + + private final RestTemplate restTemplate; + + /** 学生树缓存:schId -> {time, tree} */ + private final ConcurrentHashMap stuTreeCache = new ConcurrentHashMap<>(); + + /** 学科列表缓存:schId -> {time, subjects} */ + private final ConcurrentHashMap subjectCache = new ConcurrentHashMap<>(); + + /** 任教列表缓存:schId:teaId -> {time, list} */ + private final ConcurrentHashMap teachCache = new ConcurrentHashMap<>(); + + /** 班级列表缓存:schId -> {time, list} */ + private final ConcurrentHashMap classListCache = new ConcurrentHashMap<>(); + + private static class CacheEntry { + final long time; + final List> tree; + + CacheEntry(long time, List> tree) { + this.time = time; + this.tree = tree; + } + } + + private static class SubjectCacheEntry { + final long time; + final List subjects; + + SubjectCacheEntry(long time, List subjects) { + this.time = time; + this.subjects = subjects; + } + } + + /** 通用 Map 列表缓存条目(任教列表 / 班级列表) */ + private static class MapCacheEntry { + final long time; + final List> list; + + MapCacheEntry(long time, List> list) { + this.time = time; + this.list = list; + } + } + + /** 学生树一次性索引:年级/班级/学生平铺 */ + private static class StuIndex { + /** classId -> 年级名 */ + final Map gradeNames = new LinkedHashMap<>(); + /** classId -> 年级ID */ + final Map gradeIds = new LinkedHashMap<>(); + /** classId -> 班级名 */ + final Map classNames = new LinkedHashMap<>(); + /** classId -> 班级节点(挂学生) */ + final Map> classNodes = new LinkedHashMap<>(); + /** studentId -> {studentId, studentName, classId, className} */ + final Map> students = new LinkedHashMap<>(); + } + + public ClassService() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(10000); + this.restTemplate = new RestTemplate(factory); + } + + // ============================ 教师 ============================ + + @Override + public List getSchTeaList(int schId) { + requireHost(); + String url = bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId; + try { + ResponseEntity> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>() { + }); + List list = resp.getBody(); + log.info("[getSchTeaList] schId={}, 获取教师 {} 人", schId, list == null ? 0 : list.size()); + return list != null ? list : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[getSchTeaList] 请求班级管理接口失败, schId={}, url={}", schId, url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取教师数据失败"); + } + } + + // ============================ 学年学期 ============================ + + /** 学年学期缓存(全局单条,5 分钟):学年学期切换以天计,短缓存省去每次分班操作的远程调用 */ + private volatile YearTermCacheEntry yearTermCache; + + private static class YearTermCacheEntry { + final long time; + final NowYearTermVo vo; + + YearTermCacheEntry(long time, NowYearTermVo vo) { + this.time = time; + this.vo = vo; + } + } + + /** + * 当前学年学期(班级服务 GET /oapi/sch/getNowYearTerm,5 分钟缓存)。 + * 缓存缺失且接口不可达时抛业务异常(学年学期是分班数据的定位维度,不做静默降级); + * 接口失败但已有缓存时回退旧值。 + */ + @Override + public NowYearTermVo getNowYearTerm() { + YearTermCacheEntry cached = yearTermCache; + if (cached != null && System.currentTimeMillis() - cached.time < CACHE_TTL_MS) { + return cached.vo; + } + synchronized (this) { + cached = yearTermCache; + if (cached != null && System.currentTimeMillis() - cached.time < CACHE_TTL_MS) { + return cached.vo; + } + NowYearTermVo vo = fetchNowYearTerm(); + if (vo == null) { + if (cached != null) { + return cached.vo; + } + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取学年学期失败"); + } + yearTermCache = new YearTermCacheEntry(System.currentTimeMillis(), vo); + return vo; + } + } + + private NowYearTermVo fetchNowYearTerm() { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[fetchNowYearTerm] 班级管理接口未配置(yh.open.bschclass)"); + return null; + } + String url = bschClassHost + "/oapi/sch/getNowYearTerm"; + try { + ResponseEntity resp = restTemplate.getForEntity(url, NowYearTermVo.class); + NowYearTermVo vo = resp.getBody(); + if (vo == null || vo.getYear() == null || vo.getTerm() == null) { + log.error("[fetchNowYearTerm] 学年学期接口返回不完整, url={}", url); + return null; + } + log.info("[fetchNowYearTerm] year={}, term={}", vo.getYear(), vo.getTerm()); + return vo; + } catch (RestClientException e) { + log.error("[fetchNowYearTerm] 请求学年学期接口失败, url={}", url, e); + return null; + } + } + + /** 学年列表缓存(全局单条,5 分钟):学年以年计变化,短缓存省去每次进工作台的远程调用 */ + private volatile YearListCacheEntry yearListCache; + + private static class YearListCacheEntry { + final long time; + final List list; + + YearListCacheEntry(long time, List list) { + this.time = time; + this.list = list; + } + } + + /** + * 全部学年列表(班级服务 GET /oapi/sch/getYearTermList,按学年倒序,5 分钟缓存)。 + * 不可达时返回空列表降级(前端回退本地推断),不阻断分班业务。 + */ + @Override + public List getYearTermList() { + long now = System.currentTimeMillis(); + YearListCacheEntry cached = yearListCache; + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.list; + } + synchronized (this) { + cached = yearListCache; + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.list; + } + List list = fetchYearTermList(); + if (list == null) { + // 拉取失败回退旧缓存(若有),否则空列表由调用方降级 + return cached != null ? cached.list : Collections.emptyList(); + } + yearListCache = new YearListCacheEntry(now, list); + return list; + } + } + + private List fetchYearTermList() { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[fetchYearTermList] 班级管理接口未配置(yh.open.bschclass)"); + return Collections.emptyList(); + } + String url = bschClassHost + "/oapi/sch/getYearTermList"; + try { + ResponseEntity>> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>>() { + }); + List> body = resp.getBody(); + List list = new ArrayList<>(); + if (body != null) { + for (Map row : body) { + Integer year = asInt(row.get("year")); + if (year == null) { + continue; + } + YearTermItemVo vo = new YearTermItemVo(); + vo.setId(asInt(row.get("id"))); + vo.setYear(year); + vo.setNowYear(Boolean.TRUE.equals(row.get("nowYear"))); + list.add(vo); + } + } + log.info("[fetchYearTermList] 学年 {} 项", list.size()); + return list; + } catch (RestClientException e) { + log.error("[fetchYearTermList] 请求学年列表失败, url={}", url, e); + return null; + } + } + + // ============================ 学科 ============================ + + /** + * 学校学科列表(5 分钟缓存;不可达时返回空列表降级,不阻断分班业务)。 + */ + @Override + public List getSubjectList(int schId) { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[getSubjectList] 班级管理接口未配置(yh.open.bschclass)"); + return Collections.emptyList(); + } + long now = System.currentTimeMillis(); + SubjectCacheEntry cached = subjectCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.subjects; + } + synchronized (this) { + cached = subjectCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.subjects; + } + List subjects = fetchSubjects(schId); + if (subjects == null) { + // 拉取失败回退旧缓存(若有) + return cached != null ? cached.subjects : Collections.emptyList(); + } + subjectCache.put(schId, new SubjectCacheEntry(now, subjects)); + return subjects; + } + } + + private List fetchSubjects(int schId) { + String url = bschClassHost + "/oapi/sch/getSchSubjectList?schId=" + schId; + try { + ResponseEntity>> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>>() { + }); + List> body = resp.getBody(); + if (body == null) { + return Collections.emptyList(); + } + List subjects = new ArrayList<>(); + for (Map row : body) { + SubjectVo vo = new SubjectVo(); + vo.setId(asInt(row.get("id"))); + vo.setCode(asStr(row.get("code"))); + String name = asStr(row.get("name")); + if (!StringUtils.hasText(name)) { + continue; + } + vo.setName(name); + subjects.add(vo); + } + log.info("[fetchSubjects] schId={}, 获取学科 {} 项", schId, subjects.size()); + return subjects; + } catch (RestClientException e) { + log.error("[fetchSubjects] 请求学科列表失败, schId={}, url={}", schId, url, e); + return null; + } + } + + // ============================ 任教 / 班级列表 ============================ + + /** + * 教师任教列表(GET /oapi/sch/getTeacherTeachList,5 分钟缓存;不可达返回空列表降级)。 + */ + @Override + public List> getTeacherTeachList(int schId, int teaId) { + String key = schId + ":" + teaId; + long now = System.currentTimeMillis(); + MapCacheEntry cached = teachCache.get(key); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.list; + } + synchronized (this) { + cached = teachCache.get(key); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.list; + } + List> list = fetchTeachList(schId, teaId); + if (list == null) { + return cached != null ? cached.list : Collections.emptyList(); + } + teachCache.put(key, new MapCacheEntry(now, list)); + return list; + } + } + + private List> fetchTeachList(int schId, int teaId) { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[fetchTeachList] 班级管理接口未配置(yh.open.bschclass)"); + return Collections.emptyList(); + } + String url = bschClassHost + "/oapi/sch/getTeacherTeachList?schId=" + schId + "&teaId=" + teaId; + try { + ResponseEntity>> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>>() { + }); + List> body = resp.getBody(); + log.info("[fetchTeachList] schId={}, teaId={}, 任教记录 {} 条", schId, teaId, body == null ? 0 : body.size()); + return body != null ? body : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[fetchTeachList] 请求任教列表失败, schId={}, teaId={}, url={}", schId, teaId, url, e); + return null; + } + } + + /** + * 学校班级列表(GET /oapi/sch/getSchClassList,不含毕业班,5 分钟缓存;不可达返回空列表降级)。 + */ + @Override + public List> getSchClassList(int schId) { + long now = System.currentTimeMillis(); + MapCacheEntry cached = classListCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.list; + } + synchronized (this) { + cached = classListCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.list; + } + List> list = fetchSchClassList(schId); + if (list == null) { + return cached != null ? cached.list : Collections.emptyList(); + } + classListCache.put(schId, new MapCacheEntry(now, list)); + return list; + } + } + + private List> fetchSchClassList(int schId) { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[fetchSchClassList] 班级管理接口未配置(yh.open.bschclass)"); + return Collections.emptyList(); + } + String url = bschClassHost + "/oapi/sch/getSchClassList?schId=" + schId + "&includeGraduate=false"; + try { + ResponseEntity>> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>>() { + }); + List> body = resp.getBody(); + log.info("[fetchSchClassList] schId={}, 班级 {} 个", schId, body == null ? 0 : body.size()); + return body != null ? body : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[fetchSchClassList] 请求班级列表失败, schId={}, url={}", schId, url, e); + return null; + } + } + + // ============================ 年级名称 ============================ + + /** 年级名称缓存:schId:year:stage -> {time, name}(null 结果同样缓存,字典缺失学校不反复打远程) */ + private final ConcurrentHashMap gradeNameCache = new ConcurrentHashMap<>(); + + private static class ValueCacheEntry { + final long time; + final String name; + + ValueCacheEntry(long time, String name) { + this.time = time; + this.name = name; + } + } + + /** + * 年级名称(班级服务 GET /oapi/sch/getGradeName,按学校+入学年份+学段查学校年级字典,5 分钟缓存)。 + * 不可达 / 无匹配返回 null,由调用方降级(班级导入数据下载回退 K12 标准新生年级名)。 + */ + @Override + public String getGradeName(int schId, int year, int stage) { + final String key = schId + ":" + year + ":" + stage; + final long now = System.currentTimeMillis(); + ValueCacheEntry cached = gradeNameCache.get(key); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.name; + } + synchronized (this) { + cached = gradeNameCache.get(key); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.name; + } + String name = fetchGradeName(schId, year, stage); + gradeNameCache.put(key, new ValueCacheEntry(now, name)); + return name; + } + } + + private String fetchGradeName(int schId, int year, int stage) { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[fetchGradeName] 班级管理接口未配置(yh.open.bschclass)"); + return null; + } + String url = bschClassHost + "/oapi/sch/getGradeName?schId=" + schId + "&year=" + year + "&stage=" + stage; + try { + ResponseEntity> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>() { + }); + Map body = resp.getBody(); + String name = body == null ? null : asStr(body.get("gradeName")); + log.info("[fetchGradeName] schId={}, year={}, stage={} -> {}", schId, year, stage, name); + return StringUtils.hasText(name) ? name : null; + } catch (RestClientException e) { + log.error("[fetchGradeName] 请求年级名称接口失败, url={}", url, e); + return null; + } + } + + // ============================ 学生树 ============================ + @Override + public List> getStuTree(int schId) { + long now = System.currentTimeMillis(); + CacheEntry cached = stuTreeCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.tree; + } + synchronized (this) { + cached = stuTreeCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.tree; + } + List> tree = fetchStuTree(schId); + if (tree == null) { + // 拉取失败时回退旧值(若有),避免外部抖动击穿业务 + return cached != null ? cached.tree : null; + } + stuTreeCache.put(schId, new CacheEntry(now, tree)); + return tree; + } + } + + @SuppressWarnings("unchecked") + private List> fetchStuTree(int schId) { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[fetchStuTree] 班级管理接口未配置(yh.open.bschclass)"); + return null; + } + String url = bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId + "&toUser=true"; + try { + ResponseEntity>> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>>() { + }); + List> body = resp.getBody(); + log.info("[fetchStuTree] schId={}, 根节点 {} 个", schId, body == null ? 0 : body.size()); + return body; + } catch (RestClientException e) { + log.error("[fetchStuTree] 请求学生树失败, schId={}, url={}", schId, url, e); + return null; + } + } + + // ============================ 派生查询 ============================ + + @Override + public List getClassTree(int schId) { + List> tree = getStuTree(schId); + List result = new ArrayList<>(); + if (tree == null) { + return result; + } + StuIndex index = buildIndex(tree); + Map> gradeClasses = new LinkedHashMap<>(); + Map gradeNodeNames = new LinkedHashMap<>(); + for (Map.Entry e : index.classNames.entrySet()) { + Integer classId = e.getKey(); + String gradeName = index.gradeNames.getOrDefault(classId, ""); + Integer gradeId = index.gradeIds.getOrDefault(classId, 0); + gradeClasses.computeIfAbsent(gradeId, k -> new ArrayList<>()) + .add(toClassNode(classId, index)); + gradeNodeNames.putIfAbsent(gradeId, gradeName); + } + for (Map.Entry> e : gradeClasses.entrySet()) { + ClassTreeNodeVo gradeNode = new ClassTreeNodeVo(); + gradeNode.setId(e.getKey()); + gradeNode.setName(gradeNodeNames.get(e.getKey())); + gradeNode.setParent(true); + gradeNode.setChildren(e.getValue()); + result.add(gradeNode); + } + return result; + } + + @Override + public int studentCountOf(int schId, Integer classId) { + StuIndex index = indexOrNull(schId); + if (index == null || classId == null) { + return 0; + } + Map node = index.classNodes.get(classId); + return node == null ? 0 : countStudents(node); + } + + @Override + public String classNameOf(int schId, Integer classId) { + StuIndex index = indexOrNull(schId); + return index == null || classId == null ? null : index.classNames.get(classId); + } + + @Override + public String gradeNameOf(int schId, Integer classId) { + StuIndex index = indexOrNull(schId); + return index == null || classId == null ? null : index.gradeNames.get(classId); + } + + @Override + public Map resolveStudent(int schId, Integer studentId) { + StuIndex index = indexOrNull(schId); + return index == null || studentId == null ? null : index.students.get(studentId); + } + + @Override + public List> studentsOfClasses(int schId, List classIds) { + StuIndex index = indexOrNull(schId); + List> result = new ArrayList<>(); + if (index == null || classIds == null || classIds.isEmpty()) { + return result; + } + for (Integer classId : classIds) { + Map node = index.classNodes.get(classId); + if (node != null) { + collectStudents(node, classId, index.classNames.get(classId), result); + } + } + return result; + } + + // ============================ 内部辅助 ============================ + + private StuIndex indexOrNull(int schId) { + List> tree = getStuTree(schId); + return tree == null ? null : buildIndex(tree); + } + + /** 一次遍历学生树,平铺出年级名/班级名/班级节点/学生定位索引 */ + @SuppressWarnings("unchecked") + private StuIndex buildIndex(List> tree) { + StuIndex index = new StuIndex(); + for (Map school : tree) { + Object systems = school.get("children"); + if (!(systems instanceof List)) { + continue; + } + for (Object sysObj : (List) systems) { + Map system = (Map) sysObj; + Object grades = system.get("children"); + if (!(grades instanceof List)) { + continue; + } + for (Object gradeObj : (List) grades) { + Map grade = (Map) gradeObj; + String gradeName = asStr(grade.get("name")); + Object classes = grade.get("children"); + if (!(classes instanceof List)) { + continue; + } + for (Object classObj : (List) classes) { + Map clazz = (Map) classObj; + Integer classId = asInt(clazz.get("id")); + if (classId == null) { + continue; + } + index.gradeNames.put(classId, gradeName); + index.gradeIds.put(classId, asInt(grade.get("id"))); + index.classNames.put(classId, asStr(clazz.get("name"))); + index.classNodes.put(classId, clazz); + for (Object stuObj : (List) getChildren(clazz)) { + Map stu = (Map) stuObj; + Integer stuId = asInt(stu.get("id")); + if (stuId == null) { + continue; + } + Map info = new LinkedHashMap<>(); + info.put("studentId", stuId); + info.put("studentName", asStr(stu.get("name"))); + info.put("classId", classId); + info.put("className", asStr(clazz.get("name"))); + index.students.put(stuId, info); + } + } + } + } + } + return index; + } + + @SuppressWarnings("unchecked") + private List getChildren(Map node) { + Object children = node.get("children"); + return children instanceof List ? (List) children : Collections.emptyList(); + } + + private int countStudents(Map classNode) { + int count = 0; + for (Object stuObj : getChildren(classNode)) { + Map stu = (Map) stuObj; + if (Integer.valueOf(2).equals(asInt(stu.get("type")))) { + count++; + } + } + return count; + } + + private void collectStudents(Map classNode, Integer classId, String className, + List> result) { + for (Object stuObj : getChildren(classNode)) { + Map stu = (Map) stuObj; + if (!Integer.valueOf(2).equals(asInt(stu.get("type")))) { + continue; + } + Map info = new LinkedHashMap<>(); + info.put("studentId", asInt(stu.get("id"))); + info.put("studentName", asStr(stu.get("name"))); + info.put("classId", classId); + info.put("className", className); + result.add(info); + } + } + + private ClassTreeNodeVo toClassNode(Integer classId, StuIndex index) { + ClassTreeNodeVo node = new ClassTreeNodeVo(); + node.setId(classId); + node.setName(index.classNames.get(classId)); + node.setParent(false); + Map classNode = index.classNodes.get(classId); + node.setStudentCount(classNode == null ? 0 : countStudents(classNode)); + return node; + } + + private void requireHost() { + if (!StringUtils.hasText(bschClassHost)) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)"); + } + } + + private Integer asInt(Object v) { + if (v == null) { + return null; + } + if (v instanceof Number) { + return ((Number) v).intValue(); + } + try { + return Integer.valueOf(String.valueOf(v)); + } catch (NumberFormatException e) { + return null; + } + } + + private String asStr(Object v) { + return v == null ? null : String.valueOf(v); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/impl/PushClassService.java b/src/main/java/com/yunhai/acaclassassign/service/impl/PushClassService.java new file mode 100644 index 0000000..f5ebd18 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/impl/PushClassService.java @@ -0,0 +1,468 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.yunhai.acaclassassign.entity.AssignClass; +import com.yunhai.acaclassassign.entity.AssignProject; +import com.yunhai.acaclassassign.entity.AssignPush; +import com.yunhai.acaclassassign.entity.AssignStudent; +import com.yunhai.acaclassassign.model.assign.PushStatusVo; +import com.yunhai.acaclassassign.model.bsch.SyncClassModels; +import com.yunhai.acaclassassign.repository.AssignClassRepository; +import com.yunhai.acaclassassign.repository.AssignPushRepository; +import com.yunhai.acaclassassign.repository.AssignStudentRepository; +import com.yunhai.acaclassassign.service.IPushClassService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.crypto.SignUtil; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.apache.commons.lang3.StringUtils; +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.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.apache.commons.lang3.StringUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 分班结果推送班级管理实现。 + *

推送客户端仿 {@link ClassService}(RestTemplate 直连 userclass oapi),差异:写操作、 + * 读超时放宽到 60s(学生批量一次提交)、带 X-App-Key/X-Timestamp/X-Signature 三件套头 + * (服务端未强制验签时带头无副作用,先带头后启用)。 + * 签名口径:待签串=方法+"\n"+target+"\n"+ts,target 仅路径+字典序 query,body 不参与签名。

+ *

刻意不加 @Transactional:推送含远程调用(最长 60s+),各写点(置推送中/落结果)独立提交, + * 崩溃后凭 status=4 的 updateTime 陈旧检测解锁重推。

+ */ +@Service +public class PushClassService implements IPushClassService { + + private static final Logger log = LoggerFactory.getLogger(PushClassService.class); + + /** status=4 视为陈旧(进程崩溃残留)的阈值:超时即允许重新推送 */ + private static final int STALE_PUSHING_MINUTES = 15; + + @Value("${yh.open.bschclass:}") + private String bschClassHost; + + @Value("${yh.app-auth.app-key:}") + private String appKey; + + @Value("${yh.app-auth.app-secret:}") + private String appSecret; + + /** + * 学生建号初始密码(用户中心强制校验:8-32 位、含符号、无连续自然数序列,账号/密码均必填—— + * 本地联调实测,计划 §九.3/4 验证项结论:空 account/pwd 不允许,须推送侧兜底)。 + */ + @Value("${yh.push.default-student-pwd:Yun@Hai7x}") + private String defaultStudentPwd; + + /** + * 是否带应用间签名三件套头。⚠️ 实测(2026-10-21):被推方 app-auth 见 X-App-Key 即校验, + * appKey 未登记其 trusted-apps 时直接 401(比"未强制验签则无副作用"的计划假设更严)。 + * 故默认 false;被推方登记 100026 并启用 enforce 后置 true。 + */ + @Value("${yh.push.sign-enabled:false}") + private boolean signEnabled; + + @Autowired + private AssignClassRepository classRepository; + + @Autowired + private AssignStudentRepository studentRepository; + + @Autowired + private AssignPushRepository pushRepository; + + private final RestTemplate restTemplate; + + public PushClassService() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + // 学生批量单次提交(单校数千人),读超时放宽 + factory.setReadTimeout(60000); + this.restTemplate = new RestTemplate(factory); + } + + // ==================== 推送 ==================== + + @Override + public PushStatusVo push(AssignProject project, boolean retry) { + if (project.getStatus() == null || project.getStatus() != 1) { + throw new BizException(ErrorCode.PARAM_ERROR, "请先提交分班结果,再推送到班级管理"); + } + AssignPush record = lockForPush(project.getId(), retry); + boolean onlyFailed = retry; + Map failedStudents = null; + if (retry) { + List fails = parseFails(record.getFailJson()); + if (!fails.isEmpty()) { + failedStudents = new LinkedHashMap<>(); + for (AssignStudent s : studentRepository.findByProjectId(project.getId())) { + for (PushStatusVo.FailItem f : fails) { + if (s.getId().equals(f.getStudentId())) { + failedStudents.put(s.getId(), s); + break; + } + } + } + } + // 失败明细为空(如 AddClass 阶段整体失败)→ 退化为整体重推 + onlyFailed = failedStudents != null && !failedStudents.isEmpty(); + } + try { + doPush(project, record, onlyFailed ? failedStudents : null); + } catch (Exception e) { + String msg = e instanceof BizException ? e.getMessage() : "推送异常:" + e.getMessage(); + log.error("[push] projectId={} 推送失败", project.getId(), e); + record.setStatus(AssignPush.STATUS_FAIL); + record.setMsg(msg); + record.setPushTime(LocalDateTime.now()); + } + pushRepository.save(record); + return status(project); + } + + /** + * 执行推送:AddClass(幂等)→ AddClassStudent(聚合模式)→ 汇总写 aca_assign_push。 + * + * @param failedOnly 非空=只推这些学生(重推失败项);null=全量 + */ + private void doPush(AssignProject project, AssignPush record, Map failedOnly) { + Integer schId = project.getSchId(); + Integer enrollYear = parseYear(project.getYear()); + int stage = toOapiStage(project.getStage()); + + List classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId()); + if (classes.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "无班级设置,无法推送"); + } + + // Ⅰ AddClass:同步组(学生组→[学段组]→年级组→班级组+sch_class),返回本地班号→平台 classId + List> classItems = new ArrayList<>(); + for (AssignClass c : classes) { + classItems.add(buildClassItem(c)); + } + Map addClassBody = new HashMap<>(); + addClassBody.put("schId", schId); + addClassBody.put("stage", stage); + addClassBody.put("enrollYear", enrollYear); + addClassBody.put("classes", classItems); + SyncClassModels.SyncClassResult classResult = + postJson("/oapi/class/AddClass", addClassBody, SyncClassModels.SyncClassResult.class); + + Map sortToPlatform = new HashMap<>(); + for (SyncClassModels.ClassItem ci : classResult.getClasses()) { + if (ci.getSort() != null && ci.getClassId() != null) { + sortToPlatform.put(ci.getSort(), ci.getClassId()); + } + } + Map localToPlatform = new LinkedHashMap<>(); + for (AssignClass c : classes) { + Integer platformId = sortToPlatform.get(c.getSortOrder()); + if (platformId != null) { + localToPlatform.put(c.getId(), platformId); + } + } + if (localToPlatform.size() != classes.size()) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, + "推送班级返回映射不完整(" + localToPlatform.size() + "/" + classes.size() + ")"); + } + record.setClassMapJson(JSON.toJSONString(localToPlatform)); + + // Ⅱ AddClassStudent:按平台 classId 分组逐生推送(聚合模式,个别失败不中断) + List students = failedOnly != null + ? new ArrayList<>(failedOnly.values()) + : studentRepository.findByProjectId(project.getId()); + List> stuItems = new ArrayList<>(); + // 推送名单中无法定位平台班级/未分班的学生,直接记失败(不发起建号) + Map newFails = new LinkedHashMap<>(); + List pushable = new ArrayList<>(); + // 推送序号:同班内未提供账号的学生按此生成 p{year}{班号}{序号}(确定性,重推同号) + Map classSeq = new HashMap<>(); + for (AssignStudent s : students) { + Integer platformClassId = s.getClassId() == null ? null : localToPlatform.get(s.getClassId()); + if (platformClassId == null) { + PushStatusVo.FailItem f = new PushStatusVo.FailItem(); + f.setStudentId(s.getId()); + f.setName(s.getName()); + f.setReason(s.getClassId() == null ? "未分班" : "班级映射缺失"); + newFails.put(s.getId(), f); + continue; + } + Map item = new HashMap<>(); + item.put("classId", platformClassId); + item.put("name", s.getName()); + item.put("gender", toOapiGender(s.getGender())); + item.put("account", accountOf(s, classes, project.getYear(), classSeq)); + item.put("stuClassNo", s.getStudentNo()); + // 用户中心建号 pwd 必填(空则报"密码不能为空");优先学生自带密码(生成密码=账号+@),无则统一初始密码 + item.put("pwd", s.getPassword() == null || s.getPassword().trim().isEmpty() + ? defaultStudentPwd : s.getPassword().trim()); + stuItems.add(item); + pushable.add(s); + } + + if (!stuItems.isEmpty()) { + Map addStuBody = new HashMap<>(); + addStuBody.put("schId", schId); + addStuBody.put("students", stuItems); + SyncClassModels.SyncStudentResult stuResult = + postJson("/oapi/class/AddClassStudent", addStuBody, SyncClassModels.SyncStudentResult.class); + List results = stuResult.getResults() == null + ? new ArrayList<>() : stuResult.getResults(); + for (int i = 0; i < pushable.size() && i < results.size(); i++) { + SyncClassModels.StudentItem r = results.get(i); + if (Boolean.TRUE.equals(r.getOk())) { + continue; + } + PushStatusVo.FailItem f = new PushStatusVo.FailItem(); + f.setStudentId(pushable.get(i).getId()); + f.setName(pushable.get(i).getName()); + f.setReason(r.getReason() == null ? "推送失败" : r.getReason()); + newFails.put(f.getStudentId(), f); + } + if (results.size() != pushable.size()) { + log.error("[push] AddClassStudent 返回结果数 {} 与推送数 {} 不一致", results.size(), pushable.size()); + } + } + + // 汇总:重推场景与本批失败合并(newFails 已含本批全部失败;重推成功项自然消失) + record.setFailJson(newFails.isEmpty() ? null : JSON.toJSONString(new ArrayList<>(newFails.values()))); + int okCount = countAssigned(project.getId()) - newFails.size(); + int classCount = localToPlatform.size(); + if (newFails.isEmpty()) { + record.setStatus(AssignPush.STATUS_OK); + record.setMsg("推送成功:" + classCount + " 班 " + okCount + " 人"); + } else if (okCount > 0) { + record.setStatus(AssignPush.STATUS_PARTIAL); + record.setMsg("部分失败:成功 " + okCount + " 人,失败 " + newFails.size() + " 人"); + } else { + record.setStatus(AssignPush.STATUS_FAIL); + record.setMsg("推送失败:失败 " + newFails.size() + " 人"); + } + record.setPushTime(LocalDateTime.now()); + log.info("[push] projectId={} 完成:status={}, msg={}, classMap={} 条, fails={} 条", + project.getId(), record.getStatus(), record.getMsg(), classCount, newFails.size()); + } + + // ==================== 状态查询 ==================== + + @Override + public PushStatusVo status(AssignProject project) { + AssignPush record = pushRepository.findByProjectId(project.getId()).orElse(null); + PushStatusVo vo = new PushStatusVo(); + List fails; + if (record == null) { + vo.setStatus(AssignPush.STATUS_NONE); + fails = new ArrayList<>(); + } else { + vo.setStatus(record.getStatus()); + vo.setPushTime(record.getPushTime()); + vo.setMsg(record.getMsg()); + fails = parseFails(record.getFailJson()); + } + vo.setStatusText(PushStatusVo.textOf(vo.getStatus())); + vo.setFails(fails); + vo.setFailCount(fails.size()); + vo.setOkCount(Math.max(countAssigned(project.getId()) - fails.size(), 0)); + JSONObject map = parseClassMap(record == null ? null : record.getClassMapJson()); + vo.setClassCount(map.size()); + return vo; + } + + // ==================== 内部 ==================== + + /** + * 组装 AddClass 单班条目。班级别名口径:仅语义班名(如"阳光班/苹果班")才带 name + * (被推方非空才写 cname);按序号生成的班名("1班/2班"这种)不带,平台侧别名留空。 + */ + static Map buildClassItem(AssignClass c) { + Map item = new HashMap<>(); + item.put("sort", c.getSortOrder()); + if (!isSeqClassName(c.getName())) { + item.put("name", c.getName()); + } + item.put("teacherId", c.getTeacherId()); + return item; + } + + /** 序号风格班名(纯数字+"班",如 1班/2班/01班)或空名:默认生成的序号名,无语义,不作别名推送 */ + static boolean isSeqClassName(String name) { + String n = name == null ? "" : name.trim(); + return n.isEmpty() || n.matches("\\d{1,4}班"); + } + + /** 置「推送中」并防并发:status=4 且非陈旧(updateTime 15 分钟内)时拒绝 */ + private AssignPush lockForPush(Integer projectId, boolean retry) { + AssignPush record = pushRepository.findByProjectId(projectId).orElseGet(() -> { + AssignPush r = new AssignPush(); + r.setProjectId(projectId); + r.setStatus(AssignPush.STATUS_NONE); + return r; + }); + if (record.getStatus() != null && record.getStatus() == AssignPush.STATUS_PUSHING && !isStale(record)) { + throw new BizException(ErrorCode.PARAM_ERROR, "推送进行中,请勿重复操作"); + } + record.setStatus(AssignPush.STATUS_PUSHING); + record.setMsg(retry ? "重推失败项中…" : "推送中…"); + // failJson 不在此清空:推送中途崩溃时保留旧失败明细,完成时由 doPush 整体覆盖 + return pushRepository.save(record); + } + + private boolean isStale(AssignPush record) { + return record.getUpdateTime() == null + || record.getUpdateTime().isBefore(LocalDateTime.now().minusMinutes(STALE_PUSHING_MINUTES)); + } + + /** 已分班(有 classId)学生数:推送成功口径的分母 */ + private int countAssigned(Integer projectId) { + int assigned = 0; + for (AssignStudent s : studentRepository.findByProjectId(projectId)) { + if (s.getClassId() != null) { + assigned++; + } + } + return assigned; + } + + private List parseFails(String failJson) { + List out = new ArrayList<>(); + if (StringUtils.isBlank(failJson)) { + return out; + } + try { + JSONArray arr = JSON.parseArray(failJson); + for (int i = 0; i < arr.size(); i++) { + JSONObject o = arr.getJSONObject(i); + PushStatusVo.FailItem f = new PushStatusVo.FailItem(); + f.setStudentId(o.getInteger("studentId")); + f.setName(o.getString("name")); + f.setReason(o.getString("reason")); + out.add(f); + } + } catch (Exception e) { + log.warn("[parseFails] 失败明细解析失败:{}", failJson, e); + } + return out; + } + + private JSONObject parseClassMap(String classMapJson) { + if (StringUtils.isNotBlank(classMapJson)) { + try { + return JSON.parseObject(classMapJson); + } catch (Exception e) { + log.warn("[parseClassMap] 班级映射解析失败", e); + } + } + return new JSONObject(); + } + + /** 分班侧 gender(1男/2女/0未知)→ 班级管理口径(1男/0女/null未知) */ + private Integer toOapiGender(Integer gender) { + if (gender == null) { + return null; + } + if (gender == 1) { + return 1; + } + if (gender == 2) { + return 0; + } + return null; + } + + /** + * 推送账号:导入 account 优先,空则学号,仍空则生成 p{year}{班号两位}{本班序号两位} + * (用户中心 account 必填;生成号确定可重推,studentId 保证全局唯一)。 + */ + private String accountOf(AssignStudent s, List classes, String year, Map classSeq) { + if (StringUtils.isNotBlank(s.getAccount())) { + return s.getAccount().trim(); + } + if (StringUtils.isNotBlank(s.getStudentNo())) { + return s.getStudentNo().trim(); + } + int sort = 1; + for (AssignClass c : classes) { + if (c.getId().equals(s.getClassId())) { + sort = c.getSortOrder(); + break; + } + } + int seq = classSeq.merge(s.getClassId(), 1, Integer::sum); + return "p" + year + String.format("%02d%02d", sort, seq) + s.getId(); + } + + /** 分班侧 stage(2小学/3初中/4高中)→ AcademicStage 口径(1小学/2初中/3高中) */ + private int toOapiStage(Integer stage) { + int s = stage == null ? 0 : stage - 1; + if (s < 1 || s > 3) { + throw new BizException(ErrorCode.PARAM_ERROR, "学段参数非法:" + stage); + } + return s; + } + + private Integer parseYear(String year) { + try { + return Integer.parseInt(year); + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, "学年参数非法(须为纯数字年份):" + year); + } + } + + /** + * POST JSON 并反序列化;带应用间签名三件套头。 + * 被推方业务异常以 HTTP 200 + {code,msg,data} 返回(ValidationExceptionHandler),code!=0 视为失败。 + */ + private T postJson(String path, Object body, Class type) { + if (StringUtils.isBlank(bschClassHost)) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)"); + } + String url = bschClassHost + path; + String json = JSON.toJSONString(body); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + // 签名:target 仅路径(无 query),body 不参与待签串;仅在被推方登记本应用 appKey 后启用 + if (signEnabled && StringUtils.isNotBlank(appKey) && StringUtils.isNotBlank(appSecret)) { + String ts = String.valueOf(System.currentTimeMillis()); + headers.set("X-App-Key", appKey); + headers.set("X-Timestamp", ts); + headers.set("X-Signature", SignUtil.signRequest("POST", path, ts, appSecret)); + } + try { + org.springframework.http.ResponseEntity resp = + restTemplate.postForEntity(url, new HttpEntity<>(json, headers), String.class); + String respBody = resp.getBody(); + if (respBody == null || respBody.isEmpty()) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口空响应:" + path); + } + JSONObject obj = JSON.parseObject(respBody); + Integer code = obj.getInteger("code"); + if (code != null && code != 0) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, + "班级管理接口返回失败(" + path + "):" + obj.getString("msg")); + } + // 成功响应为裸 VO(ResponseEntity 绕过包装);若被 {code,msg,data} 包装则从 data 取 + String payload = obj.containsKey("data") && obj.getJSONObject("data") != null + ? obj.getJSONObject("data").toJSONString() : respBody; + return JSON.parseObject(payload, type); + } catch (RestClientException e) { + log.error("[postJson] 请求班级管理接口失败 url={}", url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口不可达:" + path); + } + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/impl/SysOperationLogService.java b/src/main/java/com/yunhai/acaclassassign/service/impl/SysOperationLogService.java new file mode 100644 index 0000000..ebed824 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/impl/SysOperationLogService.java @@ -0,0 +1,73 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.yunhai.acaclassassign.aspect.SysOperationLogAspect; +import com.yunhai.acaclassassign.entity.SysOperationLog; +import com.yunhai.acaclassassign.model.BasePageQuery; +import com.yunhai.acaclassassign.model.BasePageResult; +import com.yunhai.acaclassassign.repository.SysOperationLogRepository; +import com.yunhai.acaclassassign.service.ISysOperationLogService; +import com.yunhai.acaclassassign.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.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import javax.persistence.criteria.Predicate; +import java.util.ArrayList; +import java.util.List; + +/** + * 系统审计日志业务实现:@Async 异步落库,失败仅记日志不抛出。 + */ +@Service +public class SysOperationLogService implements ISysOperationLogService { + + private static final Logger log = LoggerFactory.getLogger(SysOperationLogService.class); + + @Autowired + private SysOperationLogRepository repository; + + @Override + @Async + public void saveAsync(SysOperationLog entity) { + // 不加 @Transactional:save() 单条自原子;若外层事务在异常后提交 rollback-only + // 会抛 UnexpectedRollbackException 逃出 try/catch,被异步线程兜底处理器当成未捕获异常 + try { + repository.save(entity); + } catch (Exception e) { + log.warn("[saveAsync] 审计日志落库失败, uri={}", entity == null ? null : entity.getRequestUri()); + } + } + + @Override + public BasePageResult page(BasePageQuery query, String operationType, Integer statusCode) { + Specification spec = (root, cq, cb) -> { + List ps = new ArrayList<>(); + if (StringUtils.hasText(operationType)) { + ps.add(cb.equal(root.get("operationType"), operationType)); + } + if (statusCode != null) { + ps.add(cb.equal(root.get("statusCode"), statusCode)); + } + if (StringUtils.hasText(query.getKeyword())) { + String kw = "%" + query.getKeyword().trim() + "%"; + Predicate uriLike = cb.like(root.get("requestUri"), kw); + Predicate nameLike = cb.like(root.get("userName"), kw); + Predicate methodLike = cb.like(root.get("classMethod"), kw); + ps.add(cb.or(uriLike, nameLike, methodLike)); + } + return cb.and(ps.toArray(new Predicate[0])); + }; + Pageable pageable = PageUtils.toPageable(query, Sort.by(Sort.Direction.DESC, "createTime")); + Page page = repository.findAll(spec, pageable); + page.getContent().forEach(e -> e.setResponseData(null)); + return PageUtils.toResult(page); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/service/impl/UserCenterService.java b/src/main/java/com/yunhai/acaclassassign/service/impl/UserCenterService.java new file mode 100644 index 0000000..cd2386f --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/service/impl/UserCenterService.java @@ -0,0 +1,268 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.yunhai.acaclassassign.model.SchTeaList; +import com.yunhai.acaclassassign.service.IClassService; +import com.yunhai.acaclassassign.service.IUserCenterService; +import com.yunhai.platform.openapi.base.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); + } + + /** + * 将扁平模块列表组装为树(对应原 NoticeController.buildModuleTree)。 + * 顶层取 parentId=0 或父节点不在列表中的节点,子节点按 orderNumber 排序。 + */ + private List> buildModuleTree(List modules) { + Map moduleMap = modules.stream() + .collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a)); + + // 每个模块节点(含 children 占位) + Map> nodeMap = new LinkedHashMap<>(); + for (BoaModule m : modules) { + Map node = new LinkedHashMap<>(); + node.put("id", m.getId()); + node.put("modId", m.getId()); + node.put("name", m.getName()); + node.put("parentId", m.getParentId() != null ? m.getParentId() : 0); + node.put("page", m.getPath() != null ? m.getPath() : ""); + node.put("level", m.getLevel() != null ? m.getLevel() : 0); + node.put("icon", m.getIcon() != null ? m.getIcon() : ""); + node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0); + node.put("usable", true); + node.put("children", new ArrayList<>()); + nodeMap.put(m.getId(), node); + } + + // 挂父子关系 + List> roots = new ArrayList<>(); + for (BoaModule m : modules) { + Map node = nodeMap.get(m.getId()); + int parentId = m.getParentId() != null ? m.getParentId() : 0; + if (parentId == 0 || !moduleMap.containsKey(parentId)) { + roots.add(node); + } else { + @SuppressWarnings("unchecked") + List> children = (List>) nodeMap.get(parentId).get("children"); + children.add(node); + } + } + + // 保持 SDK(用户中心)返回顺序,不再按 orderNumber 重排 + return roots; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/acaclassassign/utils/CsvUtil.java b/src/main/java/com/yunhai/acaclassassign/utils/CsvUtil.java new file mode 100644 index 0000000..8f4a884 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/utils/CsvUtil.java @@ -0,0 +1,65 @@ +package com.yunhai.acaclassassign.utils; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import javax.servlet.http.HttpServletResponse; + +/** + * CSV 导出辅助:UTF-8 BOM(Excel 直接打开不乱码)+ 下载响应头。 + */ +public final class CsvUtil { + + private CsvUtil() { + } + + /** 拼接一行(自动转义逗号/引号/换行) */ + public static String line(List cells) { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < cells.size(); i++) { + if (i > 0) { + sb.append(','); + } + sb.append(escape(cells.get(i) == null ? "" : String.valueOf(cells.get(i)))); + } + sb.append("\r\n"); + return sb.toString(); + } + + private static String escape(String v) { + if (v.contains(",") || v.contains("\"") || v.contains("\n") || v.contains("\r")) { + return '"' + v.replace("\"", "\"\"") + '"'; + } + return v; + } + + /** 设置 CSV 下载响应头并写入 BOM */ + public static void prepare(HttpServletResponse response, String fileName) throws Exception { + final String encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20"); + response.setContentType("text/csv;charset=UTF-8"); + response.setHeader("Content-Disposition", + "attachment;filename*=UTF-8''" + encoded); + response.setCharacterEncoding("UTF-8"); + response.getOutputStream().write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}); + } + + public static String nvl(Object v) { + return v == null ? "" : String.valueOf(v); + } + + /** 数字去掉多余的 .0 */ + public static String num(Object v) { + if (v == null) { + return ""; + } + if (v instanceof Double || v instanceof Float) { + final double d = ((Number) v).doubleValue(); + if (d == Math.rint(d) && !Double.isInfinite(d)) { + return String.valueOf((long) d); + } + return String.valueOf(Math.round(d * 100.0) / 100.0); + } + return String.valueOf(v); + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/utils/GzipUtil.java b/src/main/java/com/yunhai/acaclassassign/utils/GzipUtil.java new file mode 100644 index 0000000..3d9f820 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/utils/GzipUtil.java @@ -0,0 +1,27 @@ +package com.yunhai.acaclassassign.utils; + +import java.io.ByteArrayOutputStream; +import java.util.zip.GZIPOutputStream; + +/** + * GZIP 压缩工具(审计日志响应体压缩落库用)。 + */ +public class GzipUtil { + + private GzipUtil() { + } + + public static byte[] compress(String data) { + if (data == null || data.isEmpty()) { + return new byte[0]; + } + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); + GZIPOutputStream gzip = new GZIPOutputStream(bos)) { + gzip.write(data.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + gzip.finish(); + return bos.toByteArray(); + } catch (Exception e) { + return new byte[0]; + } + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/utils/IpUtils.java b/src/main/java/com/yunhai/acaclassassign/utils/IpUtils.java new file mode 100644 index 0000000..725d7e2 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/utils/IpUtils.java @@ -0,0 +1,44 @@ +package com.yunhai.acaclassassign.utils; + +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; + +/** + * 客户端 IP 解析(移植自 UserclassService OtherUtil.getClientIp)。 + * 按 X-Forwarded-For 等代理头链路取真实 IP,多级代理取第一个。 + */ +public class IpUtils { + + private IpUtils() { + } + + public static String getClientIp() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return "unknown"; + } + HttpServletRequest request = attributes.getRequest(); + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_CLIENT_IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/utils/PageUtils.java b/src/main/java/com/yunhai/acaclassassign/utils/PageUtils.java new file mode 100644 index 0000000..8e6c1f4 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/utils/PageUtils.java @@ -0,0 +1,37 @@ +package com.yunhai.acaclassassign.utils; + +import com.yunhai.acaclassassign.model.BasePageQuery; +import com.yunhai.acaclassassign.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/acaclassassign/utils/PinyinInitials.java b/src/main/java/com/yunhai/acaclassassign/utils/PinyinInitials.java new file mode 100644 index 0000000..216ee4e --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/utils/PinyinInitials.java @@ -0,0 +1,74 @@ +package com.yunhai.acaclassassign.utils; + +import java.io.UnsupportedEncodingException; + +/** + * GB2312 一级汉字 → 拼音首字母(利用一级汉字按拼音排序的区位特性,无需第三方拼音库)。 + *

局限:仅覆盖 GB2312 一级常用字;多音字按区位序取字母(如「重」归 z)。 + * 非汉字字符:字母/数字原样保留(转小写),其他字符与无法编码的汉字丢弃。

+ */ +public final class PinyinInitials { + + /** GB2312 一级汉字 23 个拼音分区(a~z,无 i/u/v)的首字,启动时换算成编码边界值 */ + private static final String BOUNDARY_CHARS = "啊芭擦搭蛾发噶哈击喀垃妈拿哦啪期然撒塌挖昔压匝"; + private static final char[] LETTERS = "ABCDEFGHJKLMNOPQRSTWXYZ".toCharArray(); + private static final int[] BOUNDARIES = buildBoundaries(); + + private PinyinInitials() { + } + + /** 学校名等字符串 → 小写拼音首字母串,如 大宁国际小学 → dngjxx */ + public static String initials(String text) { + if (text == null || text.isEmpty()) { + return ""; + } + final StringBuilder sb = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + append(sb, text.charAt(i)); + } + return sb.toString(); + } + + private static void append(StringBuilder sb, char c) { + if (c < 128) { + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + sb.append(c); + } else if (c >= 'A' && c <= 'Z') { + sb.append((char) (c + 32)); + } + return; + } + final int code; + try { + final byte[] bytes = String.valueOf(c).getBytes("GB2312"); + if (bytes.length != 2) { + return; + } + code = ((bytes[0] & 0xFF) << 8) | (bytes[1] & 0xFF); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException("JDK 缺少 GB2312 字符集", e); + } + if (code < BOUNDARIES[0]) { + return; // 二级汉字/生僻字/全角符号等,丢弃 + } + for (int i = BOUNDARIES.length - 1; i >= 0; i--) { + if (code >= BOUNDARIES[i]) { + sb.append(Character.toLowerCase(LETTERS[i])); + return; + } + } + } + + private static int[] buildBoundaries() { + try { + final byte[] all = BOUNDARY_CHARS.getBytes("GB2312"); + final int[] out = new int[BOUNDARY_CHARS.length()]; + for (int i = 0; i < out.length; i++) { + out[i] = ((all[2 * i] & 0xFF) << 8) | (all[2 * i + 1] & 0xFF); + } + return out; + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException("JDK 缺少 GB2312 字符集", e); + } + } +} diff --git a/src/main/java/com/yunhai/acaclassassign/utils/UserContext.java b/src/main/java/com/yunhai/acaclassassign/utils/UserContext.java new file mode 100644 index 0000000..cba08e7 --- /dev/null +++ b/src/main/java/com/yunhai/acaclassassign/utils/UserContext.java @@ -0,0 +1,140 @@ +package com.yunhai.acaclassassign.utils; + +import lombok.Value; + +/** + * 当前登录用户上下文(ThreadLocal 静态持有器)。 + *

移植自 UserclassService(com.yunhai.bschclassmanage.utils.UserContext), + * 由 {@link com.yunhai.acaclassassign.aspect.ControllerUserContextAspect} 在每个请求进入 controller 前填充, + * 请求结束后 clear,防止线程复用串号。

+ */ +public class UserContext { + 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<>(); + + /** 当前角色切换上下文(来自 currentrole header),家长场景 childId 即学生ID */ + private static final ThreadLocal CHILD_ID = new ThreadLocal<>(); + + public static void setUserId(Integer userId) { + USER_ID.set(userId); + } + + public static Integer getUserId() { + return USER_ID.get(); + } + + public static void setUserName(String userName) { + USER_NAME.set(userName); + } + + public static String getUserName() { + return USER_NAME.get(); + } + + 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); + } + + public static Integer getSchId() { + return SCH_ID.get(); + } + + 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(); + } + + public static void setChildId(Integer childId) { + CHILD_ID.set(childId); + } + + /** 家长端当前孩子(学生)ID;主账号/教师为 0 或 null */ + public static Integer getChildId() { + return CHILD_ID.get(); + } + + /** 用户信息快照(用于异步任务传递) */ + @Value + public static class UserInfo { + Integer userId; + String userName; + Integer roleId; + Integer schId; + String ip; + Integer type; + Integer childId; + } + + public static UserInfo getCurrentUser() { + return new UserInfo( + USER_ID.get(), + USER_NAME.get(), + ROLE_ID.get(), + SCH_ID.get(), + IP.get(), + TYPE.get(), + CHILD_ID.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()); + CHILD_ID.set(info.getChildId()); + } + + /** + * 把当前用户快照带入子线程执行异步任务,执行完 clear。 + */ + 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(); + CHILD_ID.remove(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..d574fa7 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,82 @@ +server: + port: 8109 + +spring: + mvc: + date-format: yyyy-MM-dd HH:mm:ss + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + + datasource: + url: jdbc:mysql://localhost:3306/aca_classassign?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 + +# 短信模块配置 +sms: + enabled: true + module-id: acaclassassign + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + sso: + server-url-prefix: http://sso.yunhaixingtong.com + client-host-url: http://localhost:8109 + allow-headers: all + allow-origins: all + url-pattern: /api/* + mobi-api-pattern: /mobiapi/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用) + app-key: 100026 + app-secret: 73c08fceae3b48788358195760bf5977 + # 提供方强制验签(参考 noticeservice /oapi 试点):/oapi/** 匿名/浏览器直访 401, + # 注册应用带 X-App-Key/X-Timestamp/X-Signature 三件套直调通过(/oapi/timetable/Get) + server: + enforce-patterns: [/oapi/*] + open: + enabled: true + base-host: http://base.yunhaixingtong.com + user-center-url: http://base.yunhaixingtong.com/userbase + # 班级管理(bschclass)服务基址 + bschclass: http://base.yunhaixingtong.com/userclass + # 分班结果推送(班级管理 oapi)配置 + push: + # 学生建号初始密码:用户中心强制 8-32 位、含符号、无连续自然数序列(账号/密码均必填) + default-student-pwd: Yun@Hai7x + # 应用间签名开关:被推方 app-auth 见 X-App-Key 即校验,未登记 trusted-apps 会 401(实测), + # 故默认不带签名头;班级管理登记 100026 并启用 enforce 后置 true + sign-enabled: false + +FILE_PATH: D:/data/upload +FILE_WEB_PATH: http://base.yunhaixingtong.com/userbase/upload/ diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..cc5e039 --- /dev/null +++ b/src/main/resources/bootstrap.yml @@ -0,0 +1,33 @@ +spring: + application: + name: acaclassassign + 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},即 acaclassassign.yaml + file-extension: yaml + encode: UTF-8 + # nacos 不可达时不强制退出,回退到本地 application.yml 兜底 + fail-fast: false + # 只读账号 + username: ${SPRING_CLOUD_NACOS_USERNAME:yh-reader} + password: ${SPRING_CLOUD_NACOS_PASSWORD:123456} + # 共享公共配置:public 空间下的 common.properties + shared-configs: + # ① 1 号(最低优先级):public 命名空间(namespace="" 固定写空字符串)的 application-default.yaml + - data-id: application-default.yaml + group: DEFAULT_GROUP + namespace: "" # ⭐ 固定 = "",强制指向 Nacos "public" 命名空间,不读上面的全局 prod namespace + refresh: false + + # ② 2 号(中等优先级):prod 命名空间(不写 namespace = 继承顶部全局 prod namespace id)的 application-infra.yaml + - data-id: application-infra.yaml + group: DEFAULT_GROUP + refresh: false diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..20612de --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,35 @@ + + + + + + + + + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET} + + + + + + + /app/logs/app.log + + /app/logs/app.%d{yyyy-MM-dd}.log + 30 + 5GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/sql/acaclassassign_schema.sql b/src/main/resources/sql/acaclassassign_schema.sql new file mode 100644 index 0000000..54de3aa --- /dev/null +++ b/src/main/resources/sql/acaclassassign_schema.sql @@ -0,0 +1,126 @@ +-- ============================================================ +-- AcaClassAssign(分班管理)业务表结构 +-- 说明:本地/生产依赖 hibernate ddl-auto=update 自动建表, +-- 本脚本仅作表结构说明与手工建库兜底。 +-- 索引与唯一键以实体 @Table(indexes/uniqueConstraints) 为准(与脚本一致)。 +-- 约束:全部业务表不含教师/学生账号字段;班级仅保存平台教师 id。 +-- ============================================================ + +-- 1. 分班项目(每校 × 学段 × 学年 × 学期一条,承载提交锁定) +CREATE TABLE IF NOT EXISTS aca_assign_project ( + id INT AUTO_INCREMENT PRIMARY KEY, + sch_id INT NOT NULL COMMENT '学校ID', + stage TINYINT NOT NULL COMMENT '学段:2小学 3初中 4高中', + year VARCHAR(16) NOT NULL COMMENT '学年起始年,如2025', + term TINYINT NOT NULL COMMENT '学期:1第一学期 2第二学期', + status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已提交', + submit_time DATETIME(3) NULL COMMENT '提交时间', + create_by VARCHAR(64) NULL, + create_time DATETIME(3) NULL, + update_by VARCHAR(64) NULL, + update_time DATETIME(3) NULL, + UNIQUE KEY uk_ap_sch_stage_year_term (sch_id, stage, year, term), + KEY idx_ap_sch_stage (sch_id, stage) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='分班项目'; + +-- 2. 班级设置(班主任只存平台教师 id,teacher_name 为展示冗余) +CREATE TABLE IF NOT EXISTS aca_assign_class ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id INT NOT NULL COMMENT '所属项目', + sort_order INT NOT NULL COMMENT '班级序号(1起)', + name VARCHAR(32) NOT NULL COMMENT '班级名称', + teacher_id INT NULL COMMENT '班主任平台教师id(无账号字段)', + teacher_name VARCHAR(64) NULL COMMENT '班主任姓名展示冗余', + target_count INT NULL COMMENT '目标人数(空=自动均分)', + ab_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否A班(高中分层)', + create_by VARCHAR(64) NULL, + create_time DATETIME(3) NULL, + update_by VARCHAR(64) NULL, + update_time DATETIME(3) NULL, + KEY idx_ac_project (project_id), + KEY idx_ac_teacher (teacher_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='分班班级设置'; + +-- 3. 学生(仅业务字段,无任何账号/学号/联系方式) +CREATE TABLE IF NOT EXISTS aca_assign_student ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id INT NOT NULL COMMENT '所属项目', + name VARCHAR(64) NOT NULL COMMENT '姓名', + gender TINYINT NOT NULL DEFAULT 0 COMMENT '1男 2女 0未知', + total_score DECIMAL(6, 2) NULL COMMENT '总分(小学恒空)', + scores_json TEXT NULL COMMENT '单科成绩 JSON', + tags_json TEXT NULL COMMENT '是否型字段 JSON', + nums_json TEXT NULL COMMENT '数值型字段 JSON', + graduate_school VARCHAR(128) NULL COMMENT '毕业学校(仅展示/生源分析)', + remark VARCHAR(255) NULL COMMENT '备注', + preset_raw VARCHAR(255) NULL COMMENT '预设班级原文', + preset_json TEXT NULL COMMENT '预设解析结果 JSON', + class_id INT NULL COMMENT '分班结果班级id', + level VARCHAR(4) NULL COMMENT 'A/B 层(高中)', + src_row INT NULL COMMENT '导入行号', + create_by VARCHAR(64) NULL, + create_time DATETIME(3) NULL, + update_by VARCHAR(64) NULL, + update_time DATETIME(3) NULL, + KEY idx_as_project (project_id), + KEY idx_as_project_class (project_id, class_id), + KEY idx_as_name (name) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='分班学生'; + +-- 4. 分班规则(每项目一条) +CREATE TABLE IF NOT EXISTS aca_assign_rule ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id INT NOT NULL COMMENT '所属项目', + balance_json TEXT NOT NULL COMMENT '均衡维度勾选 JSON', + constraint_json TEXT NOT NULL COMMENT '约束开关 JSON', + band_mode VARCHAR(8) NULL COMMENT 'count按段数/step按分数间隔', + band_count INT NULL COMMENT '段数 2-20', + band_step INT NULL COMMENT '分数间隔 1-200', + ab_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '启用A/B分层(仅高中)', + ab_mode VARCHAR(8) NULL COMMENT 'layer按总分分层/within班内分层', + ab_class_count INT NULL COMMENT 'A班数量', + create_by VARCHAR(64) NULL, + create_time DATETIME(3) NULL, + update_by VARCHAR(64) NULL, + update_time DATETIME(3) NULL, + UNIQUE KEY uk_ar_project (project_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='分班规则'; + +-- 5. 分班结果(每项目保留最新一次) +CREATE TABLE IF NOT EXISTS aca_assign_result ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id INT NOT NULL COMMENT '所属项目', + assign_time DATETIME(3) NOT NULL COMMENT '分班时间', + iterations INT NULL COMMENT '迭代次数', + cost_ms BIGINT NULL COMMENT '算法耗时ms', + score DECIMAL(5, 2) NULL COMMENT '综合均衡度0-100', + adjusted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '含手动调整', + metrics_json TEXT NULL COMMENT '指标+均衡明细 JSON', + log_text TEXT NULL COMMENT '算法/校验/调班/班主任匹配日志', + warn_json TEXT NULL COMMENT '警告 JSON', + band_cuts_json TEXT NULL COMMENT '分数段切点 JSON', + groups_json TEXT NULL COMMENT 'A/B分层元信息 JSON', + create_by VARCHAR(64) NULL, + create_time DATETIME(3) NULL, + update_by VARCHAR(64) NULL, + update_time DATETIME(3) NULL, + UNIQUE KEY uk_arl_project (project_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='分班结果'; + +-- 6. 操作日志表 sys_operation_log 见 sys_operation_log.sql(移植自 UserclassService,结构不变) + +-- 7. 推送记录(提交分班结果 → 推送班级管理;每项目一条,状态机 0未推送/1已推送/2部分失败/3失败/4推送中) +CREATE TABLE IF NOT EXISTS aca_assign_push ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id INT NOT NULL COMMENT '所属项目(唯一)', + status TINYINT NOT NULL DEFAULT 0 COMMENT '0未推送 1已推送 2部分失败 3失败 4推送中', + push_time DATETIME(3) NULL COMMENT '最近一次推送完成时间', + class_map_json TEXT NULL COMMENT '本地班级id→平台班级id映射 JSON', + fail_json TEXT NULL COMMENT '未成功学生明细 JSON [{studentId,name,reason}]', + msg VARCHAR(512) NULL COMMENT '摘要(成功N班M人/失败原因)', + create_by VARCHAR(64) NULL, + create_time DATETIME(3) NULL, + update_by VARCHAR(64) NULL, + update_time DATETIME(3) NULL, + UNIQUE KEY uk_apush_project (project_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='分班结果推送记录'; diff --git a/src/main/resources/sql/sys_operation_log.sql b/src/main/resources/sql/sys_operation_log.sql new file mode 100644 index 0000000..0884918 --- /dev/null +++ b/src/main/resources/sql/sys_operation_log.sql @@ -0,0 +1,31 @@ +-- ============================================================ +-- AcaClassAssign(分班管理)审计日志表 +-- 与业务表物理隔离;索引在此创建,实体不含 @Table(indexes), +-- 避免 ddl-auto=update 启动时重复建索引告警。 +-- MySQL 5.7+;幂等(不存在才建)。 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `sys_operation_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', + `log_level` TINYINT NOT NULL COMMENT '1-重要(删除) 2-次要(新增/编辑) 3-一般(查询)', + `operation_type` VARCHAR(16) NOT NULL COMMENT 'CREATE/UPDATE/DELETE/QUERY', + `user_id` INT NULL COMMENT '操作人用户ID(UserContext)', + `user_name` VARCHAR(64) NULL COMMENT '操作人姓名', + `sch_id` INT NULL COMMENT '学校ID(多校隔离)', + `request_ip` VARCHAR(64) NOT NULL COMMENT '客户端IP', + `request_uri` VARCHAR(512) NOT NULL COMMENT '请求URI', + `request_method` VARCHAR(16) NOT NULL COMMENT 'HTTP方法', + `class_method` VARCHAR(512) NOT NULL COMMENT '类.方法', + `request_params` TEXT NULL COMMENT '请求参数', + `response_data` MEDIUMBLOB NULL COMMENT '响应JSON(GZIP 压缩)', + `status_code` INT NOT NULL COMMENT '业务状态码:0 成功 / 非 0 失败', + `cost_time` BIGINT NOT NULL COMMENT '耗时 ms', + `user_agent` VARCHAR(512) NULL, + `error_msg` TEXT NULL COMMENT '异常信息(截断 8000)', + `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_sol_create_time` (`create_time`), + KEY `idx_sol_sch_level` (`sch_id`, `log_level`), + KEY `idx_sol_user` (`user_id`), + KEY `idx_sol_op_type` (`operation_type`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='系统审计日志(双轨之审计轨)'; diff --git a/src/test/java/com/yunhai/acaclassassign/service/assign/AssignAlgorithmTest.java b/src/test/java/com/yunhai/acaclassassign/service/assign/AssignAlgorithmTest.java new file mode 100644 index 0000000..5932c15 --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/service/assign/AssignAlgorithmTest.java @@ -0,0 +1,359 @@ +package com.yunhai.acaclassassign.service.assign; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 均衡分班算法测试:人数均衡 / 同名不同班 / 捆绑不拆分 / 叉开 / 小学无成绩模式。 + */ +class AssignAlgorithmTest { + + private AssignAlgorithm.Config baseConfig(int stage, int nStu, int nClass) { + final AssignAlgorithm.Config cfg = new AssignAlgorithm.Config(); + cfg.stage = stage; + cfg.hasScore = stage != 2; + cfg.balance.total = true; + cfg.balance.gender = true; + cfg.balance.bands = false; + for (int i = 0; i < nStu; i++) { + final AssignAlgorithm.AStudent s = new AssignAlgorithm.AStudent(); + s.idx = i; + s.id = i + 1; + s.name = "学生" + String.format("%03d", i); + s.soundKey = SameSound.soundKey(s.name); + s.gender = i % 2 == 0 ? 1 : 2; + s.total = 150 + (i * 37) % 150; // 150-299 分散分布 + s.scores.put("语文", 50.0 + (i * 7) % 50); + s.scores.put("数学", 50.0 + (i * 11) % 50); + cfg.students.add(s); + } + cfg.balance.subjects.add("语文"); + cfg.balance.subjects.add("数学"); + for (int c = 0; c < nClass; c++) { + final AssignAlgorithm.AClass ac = new AssignAlgorithm.AClass(); + ac.idx = c; + ac.id = c + 1; + ac.sortOrder = c + 1; + ac.name = (c + 1) + "班"; + cfg.classes.add(ac); + } + return cfg; + } + + private int classOf(AssignAlgorithm.Result r, int idx) { + return r.classIdx[idx]; + } + + @Test + void balanceCountAndScore() { + final AssignAlgorithm.Config cfg = baseConfig(3, 300, 6); + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + final int[] sizes = new int[6]; + for (int i = 0; i < 300; i++) { + sizes[classOf(r, i)]++; + } + for (int c = 0; c < 6; c++) { + assertTrue(sizes[c] >= 49 && sizes[c] <= 51, + "班 " + (c + 1) + " 人数 " + sizes[c] + " 超出 50±1"); + } + assertTrue(r.metrics.score > 80, "综合均衡度应 >80,实际 " + r.metrics.score); + assertTrue(r.metrics.countSpread <= 1, "人数极差应 ≤1,实际 " + r.metrics.countSpread); + } + + @Test + void sameNameInDifferentClasses() { + final AssignAlgorithm.Config cfg = baseConfig(3, 120, 6); + // 追加两名同名学生 + final AssignAlgorithm.AStudent a = new AssignAlgorithm.AStudent(); + a.idx = 120; + a.name = "张三"; + a.soundKey = SameSound.soundKey("张三"); + a.gender = 1; + a.total = 220; + a.scores.put("语文", 90.0); + a.scores.put("数学", 85.0); + cfg.students.add(a); + final AssignAlgorithm.AStudent b = new AssignAlgorithm.AStudent(); + b.idx = 121; + b.name = "张三"; + b.soundKey = SameSound.soundKey("张三"); + b.gender = 2; + b.total = 200; + b.scores.put("语文", 88.0); + b.scores.put("数学", 80.0); + cfg.students.add(b); + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + assertNotEquals(classOf(r, 120), classOf(r, 121), "同名学生必须分到不同班"); + } + + @Test + void bundleStaysTogetherAndDiffPairSeparated() { + final AssignAlgorithm.Config cfg = baseConfig(3, 120, 6); + // 双胞胎捆绑:李大/李二 同班;矛盾学生:赵六/钱七 不同班 + rename(cfg, 10, "李大"); + rename(cfg, 11, "李二"); + cfg.samePairs.add(new AssignAlgorithm.NamePair("李大", "李二")); + rename(cfg, 20, "赵六"); + rename(cfg, 21, "钱七"); + cfg.diffPairs.add(new AssignAlgorithm.NamePair("赵六", "钱七")); + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + assertEquals(classOf(r, 10), classOf(r, 11), "预设「同班」捆绑组不得拆分"); + assertNotEquals(classOf(r, 20), classOf(r, 21), "预设「不同班」必须叉开"); + assertTrue(r.warns.isEmpty(), "无硬约束告警,实际:" + r.warns); + } + + @Test + void primaryStageNoScoreBalance() { + final AssignAlgorithm.Config cfg = baseConfig(2, 80, 4); + cfg.balance.total = false; + cfg.balance.subjects.clear(); + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + final int[] sizes = new int[4]; + for (int i = 0; i < 80; i++) { + sizes[classOf(r, i)]++; + } + for (int c = 0; c < 4; c++) { + assertEquals(20, sizes[c], "小学 80 人 4 班应精确 20 人/班"); + } + } + + @Test + void fixedPresetClassLocked() { + final AssignAlgorithm.Config cfg = baseConfig(3, 60, 6); + cfg.students.get(0).fixedSort = 3; // 学生0 预设固定 3 班 + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + assertEquals(2, classOf(r, 0), "预设固定班级应锁定为 3 班(下标 2)"); + } + + @Test + void highSchoolLayerMode() { + final AssignAlgorithm.Config cfg = baseConfig(4, 120, 6); + cfg.abEnabled = true; + cfg.abMode = "layer"; + cfg.abClassCount = 2; + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + // 高分学生应集中在 A 班(前 2 个班):最高分 40 人里至少 30 人在 A 班 + final List byTotal = new ArrayList<>(); + for (int i = 0; i < 120; i++) { + byTotal.add(i); + } + byTotal.sort((x, y) -> Double.compare(cfg.students.get(y).total, cfg.students.get(x).total)); + int inA = 0; + for (int i = 0; i < 40; i++) { + if (classOf(r, byTotal.get(i)) < 2) { + inA++; + } + } + assertTrue(inA >= 30, "前 40 名应有 ≥30 人在 A 班,实际 " + inA); + assertTrue(r.aClassIdx.size() == 2, "A 班数量应为 2"); + } + + @Test + void layerModeWithPresetKeepsClassSizesBalanced() { + // 预设固定班落在 A 层:分层切池必须先把这些单元划入 A 池, + // 否则 A 池人数超出 A 班座位数,各班人数会变成 55/54/49... + final AssignAlgorithm.Config cfg = baseConfig(4, 300, 6); + cfg.abEnabled = true; + cfg.abMode = "layer"; + cfg.abClassCount = 2; + for (int i = 0; i < 9; i++) { + cfg.students.get(i * 13).fixedSort = 1; + } + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + final int[] sizes = new int[6]; + for (int i = 0; i < 300; i++) { + sizes[classOf(r, i)]++; + } + for (int c = 0; c < 6; c++) { + assertEquals(50, sizes[c], "预设锁定后各班人数仍应精确为 50,实际 " + java.util.Arrays.toString(sizes)); + } + for (int i = 0; i < 9; i++) { + assertEquals(0, classOf(r, i * 13), "预设固定班级的学生应落在 1 班"); + } + assertTrue(r.metrics.countSpread <= 1, "组内人数极差应 ≤1,实际 " + r.metrics.countSpread); + } + + @Test + void layerMetricsUseWithinGroupSpread() { + // A/B 层间总分差是分层设计的结果,均衡度只应看同层内各班差异 + final AssignAlgorithm.Config cfg = baseConfig(4, 300, 6); + cfg.abEnabled = true; + cfg.abMode = "layer"; + cfg.abClassCount = 2; + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + assertTrue(r.metrics.avgSpread < 2.0, + "组内均分极差应很小(跨层差值不计入),实际 " + r.metrics.avgSpread); + assertEquals(0.0, r.metrics.items.get(0).relDev, 1e-9, "人数维度应无偏差"); + assertTrue(r.metrics.score > 90, "综合均衡度应 >90,实际 " + r.metrics.score); + } + + @Test + void layerModeMarksStudentLevels() { + final AssignAlgorithm.Config cfg = baseConfig(4, 120, 6); + cfg.abEnabled = true; + cfg.abMode = "layer"; + cfg.abClassCount = 2; + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + int a = 0; + for (int i = 0; i < 120; i++) { + final String expected = classOf(r, i) < 2 ? "A" : "B"; + assertEquals(expected, r.level[i], "学生层级应与所在 A/B 班一致"); + if ("A".equals(r.level[i])) { + a++; + } + } + assertEquals(40, a, "120 人 6 班、A 层 2 班时 A 层应为 40 人"); + } + + @Test + void core3AverageDimensionBalanced() { + final AssignAlgorithm.Config cfg = baseConfig(3, 300, 6); + for (int i = 0; i < 300; i++) { + cfg.students.get(i).scores.put("英语", 40.0 + (i * 13) % 60); + } + cfg.balance.core3 = true; + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + AssignAlgorithm.MetricItem item = null; + for (AssignAlgorithm.MetricItem m : r.metrics.items) { + if ("三科平均分".equals(m.dimension)) { + item = m; + } + } + assertTrue(item != null, "应产出「三科平均分」均衡维度"); + assertEquals(3.0, item.weight, 1e-9, "三科平均分权重应为 3"); + assertTrue(item.relDev <= 0.5, "三科平均分相对偏差应达「优」(≤0.5%),实际 " + item.relDev + "%"); + } + + @Test + void core3MissingSubjectYieldsZeroSpread() { + // 只有语数、没有英语:三科平均分全为 0,维度存在但不产生偏斜 + final AssignAlgorithm.Config cfg = baseConfig(3, 60, 6); + cfg.balance.core3 = true; + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + boolean found = false; + for (AssignAlgorithm.MetricItem m : r.metrics.items) { + if ("三科平均分".equals(m.dimension)) { + found = true; + assertEquals(0.0, m.spread, 1e-9, "缺科时三科平均分极差应为 0"); + } + } + assertTrue(found, "维度应存在"); + } + + @Test + void scoreBalanceDoesNotDependOnGenderDimension() { + for (int seed = 1; seed <= 3; seed++) { + final AssignAlgorithm.Config cfg = randomConfig(3, seed); + cfg.balance.gender = false; + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + assertTrue(r.costBefore < 0.3, "关闭性别均衡后仍应按成绩均衡初始落位"); + assertTrue(r.metrics.avgSpread <= 0.2, "总分均分极差应 ≤0.2,实际 " + r.metrics.avgSpread); + assertTrue(r.metrics.score >= 99.8, "成绩多维均衡度应 ≥99.8,实际 " + r.metrics.score); + } + } + + @Test + void perturbationKeepsBestPrimaryAssignment() { + for (int seed = 1; seed <= 3; seed++) { + final AssignAlgorithm.Config cfg = randomConfig(2, seed); + cfg.balance.tags.add("住宿"); + cfg.balance.tags.add("特长"); + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + assertTrue(r.costAfter <= r.costBefore + 1e-9, "扰动后不得劣于初始方案"); + assertEquals(100, r.metrics.score, 1e-9, "有可行均衡解时,扰动不得丢失最优方案"); + for (AssignAlgorithm.MetricItem item : r.metrics.items) { + assertTrue(item.spread <= 1, item.dimension + "人数极差应 ≤1,实际 " + item.spread); + } + assertTrue(r.warns.isEmpty(), "均衡不得引入硬约束冲突:" + r.warns); + } + } + + @Test + void selectedBandsAndTagsRemainBalancedWithScores() { + final AssignAlgorithm.Config cfg = randomConfig(3, 1); + cfg.balance.tags.add("住宿"); + cfg.balance.tags.add("特长"); + cfg.balance.bands = true; + cfg.bands.cuts = new double[]{180, 210, 240, 270}; + cfg.bands.labels = new String[]{"低分", "中低", "中等", "中高", "高分"}; + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + assertTrue(r.metrics.score >= 99.8, "多维均衡度应 ≥99.8,实际 " + r.metrics.score); + assertEquals(0, r.metrics.countSpread, "306 人 6 班应各 51 人"); + for (AssignAlgorithm.MetricItem item : r.metrics.items) { + if ("count".equals(item.type) && !"样本过小".equals(item.grade)) { + assertTrue(item.spread <= 1, item.dimension + "人数极差应 ≤1,实际 " + item.spread); + } + } + } + + @Test + void optimizationPreservesCombinedConstraintsAndIsRepeatable() { + final AssignAlgorithm.Config cfg = randomConfig(4, 1); + cfg.abEnabled = true; + cfg.abClassCount = 2; + cfg.students.get(0).fixedSort = 1; + cfg.students.get(1).fixedSort = 3; + rename(cfg, 10, "李大"); + rename(cfg, 11, "李二"); + cfg.samePairs.add(new AssignAlgorithm.NamePair("李大", "李二")); + cfg.diffPairs.add(new AssignAlgorithm.NamePair(cfg.students.get(20).name, cfg.students.get(21).name)); + rename(cfg, 30, "张三"); + rename(cfg, 31, "张三"); + rename(cfg, 32, "章三"); + final AssignAlgorithm.Result r = new AssignAlgorithm(cfg).run(); + assertEquals(0, r.classIdx[0], "预设 A 班不得移动"); + assertEquals(2, r.classIdx[1], "预设 B 班不得移动"); + assertEquals(r.classIdx[10], r.classIdx[11], "捆绑不得拆分"); + assertNotEquals(r.classIdx[20], r.classIdx[21], "不同班约束必须满足"); + assertNotEquals(r.classIdx[30], r.classIdx[31], "同名必须不同班"); + assertNotEquals(r.classIdx[30], r.classIdx[32], "同音必须不同班"); + assertNotEquals(r.classIdx[31], r.classIdx[32], "同音必须不同班"); + assertEquals(0, r.metrics.countSpread); + assertTrue(r.costAfter <= r.costBefore + 1e-9); + assertTrue(r.warns.isEmpty(), "所有硬约束应满足:" + r.warns); + final AssignAlgorithm.Result repeated = new AssignAlgorithm(cfg).run(); + assertArrayEquals(r.classIdx, repeated.classIdx, "相同规则和名单必须产生相同分班"); + assertArrayEquals(r.level, repeated.level, "A/B 层级必须可复现"); + for (int i = 0; i < r.classIdx.length; i++) { + assertEquals(r.classIdx[i] < 2 ? "A" : "B", r.level[i]); + } + } + + private AssignAlgorithm.Config randomConfig(int stage, int seed) { + final AssignAlgorithm.Config cfg = baseConfig(stage, 306, 6); + cfg.balance.subjects.clear(); + cfg.balance.core3 = cfg.hasScore; + if (cfg.hasScore) { + for (String subject : AssignAlgorithm.CORE3_SUBJECTS) { + cfg.balance.subjects.add(subject); + } + } + final Random random = new Random(seed); + for (AssignAlgorithm.AStudent s : cfg.students) { + s.gender = random.nextBoolean() ? 1 : 2; + s.total = 0; + s.scores.clear(); + for (String subject : AssignAlgorithm.CORE3_SUBJECTS) { + final double value = 40 + random.nextInt(61); + s.scores.put(subject, value); + s.total += value; + } + s.tags.put("住宿", random.nextInt(3) == 0); + s.tags.put("特长", random.nextInt(5) == 0); + } + return cfg; + } + + private void rename(AssignAlgorithm.Config cfg, int idx, String name) { + cfg.students.get(idx).name = name; + cfg.students.get(idx).soundKey = SameSound.soundKey(name); + } +} diff --git a/src/test/java/com/yunhai/acaclassassign/service/assign/SampleDataGeneratorTest.java b/src/test/java/com/yunhai/acaclassassign/service/assign/SampleDataGeneratorTest.java new file mode 100644 index 0000000..30e5f01 --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/service/assign/SampleDataGeneratorTest.java @@ -0,0 +1,38 @@ +package com.yunhai.acaclassassign.service.assign; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** 服务端示例名单:内容与前端旧示例同口径,且必须能被导入解析器完整识别 */ +class SampleDataGeneratorTest { + + @Test + void primarySampleParses() { + final StudentParser.ParseResult parsed = StudentParser.parse(SampleDataGenerator.text(2), 2, true); + assertEquals(60, parsed.rows.size()); + assertEquals("stu0001", parsed.rows.get(0).account); + assertEquals("20260001", parsed.rows.get(0).studentNo); + assertEquals("学生001", parsed.rows.get(0).name); + assertTrue(parsed.subjects.isEmpty(), "小学示例不应带成绩列"); + assertTrue(parsed.warn.isEmpty(), String.join(";", parsed.warn)); + } + + @Test + void middleSampleParsesWithConstraints() { + final StudentParser.ParseResult parsed = StudentParser.parse(SampleDataGenerator.text(3), 3, true); + assertEquals(306, parsed.rows.size()); + assertEquals(3, parsed.subjects.size()); + assertEquals(2, parsed.tags.size()); + assertTrue(parsed.rows.stream().anyMatch(r -> r.preset != null && "class".equals(r.preset.type))); + assertTrue(parsed.rows.stream().anyMatch(r -> r.preset != null && "same".equals(r.preset.type))); + assertTrue(parsed.rows.stream().anyMatch(r -> r.preset != null && "diff".equals(r.preset.type))); + assertTrue(parsed.warn.isEmpty(), String.join(";", parsed.warn)); + } + + @Test + void highSampleUsesSameRosterAsMiddle() { + assertEquals(SampleDataGenerator.text(3), SampleDataGenerator.text(4)); + } +} diff --git a/src/test/java/com/yunhai/acaclassassign/service/assign/StudentExportListTest.java b/src/test/java/com/yunhai/acaclassassign/service/assign/StudentExportListTest.java new file mode 100644 index 0000000..43169c5 --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/service/assign/StudentExportListTest.java @@ -0,0 +1,174 @@ +package com.yunhai.acaclassassign.service.assign; + +import com.yunhai.acaclassassign.entity.AssignClass; +import com.yunhai.acaclassassign.entity.AssignProject; +import com.yunhai.acaclassassign.entity.AssignStudent; +import com.yunhai.acaclassassign.repository.AssignClassRepository; +import com.yunhai.acaclassassign.repository.AssignProjectRepository; +import com.yunhai.acaclassassign.repository.AssignResultRepository; +import com.yunhai.acaclassassign.repository.AssignStudentRepository; +import com.yunhai.acaclassassign.service.IAssignProjectService; +import com.yunhai.acaclassassign.service.impl.AssignStudentService; +import org.apache.poi.hssf.usermodel.HSSFRow; +import org.apache.poi.hssf.usermodel.HSSFSheet; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayInputStream; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** 学生名单导出测试:按平台学生导入模板生成 .xls(标题+固定表头),筛选口径与排序不变 */ +class StudentExportListTest { + + private AssignStudentService service; + private AssignStudentRepository studentRepository; + private AssignClassRepository classRepository; + private IAssignProjectService projectService; + + @BeforeEach + void setUp() { + service = new AssignStudentService(); + studentRepository = Mockito.mock(AssignStudentRepository.class); + classRepository = Mockito.mock(AssignClassRepository.class); + projectService = Mockito.mock(IAssignProjectService.class); + ReflectionTestUtils.setField(service, "studentRepository", studentRepository); + ReflectionTestUtils.setField(service, "projectRepository", Mockito.mock(AssignProjectRepository.class)); + ReflectionTestUtils.setField(service, "classRepository", classRepository); + ReflectionTestUtils.setField(service, "resultRepository", Mockito.mock(AssignResultRepository.class)); + ReflectionTestUtils.setField(service, "projectService", projectService); + + // project.stage 跟随请求参数(resolveGradeName 按 project.getStage() 解析年级名) + when(projectService.require(anyInt(), anyString(), anyInt())).thenAnswer(inv -> { + final AssignProject project = new AssignProject(); + project.setId(7); + project.setYear(inv.getArgument(1)); + project.setTerm(1); + project.setStage(inv.getArgument(0)); + return project; + }); + + final AssignClass c1 = new AssignClass(); + c1.setId(11); + c1.setName("1班"); + c1.setSortOrder(1); + final AssignClass c2 = new AssignClass(); + c2.setId(12); + c2.setName("2班"); + c2.setSortOrder(2); + when(classRepository.findByProjectIdOrderBySortOrderAsc(7)) + .thenReturn(new ArrayList<>(Arrays.asList(c1, c2))); + when(studentRepository.findByProjectId(7)).thenAnswer(inv -> students()); + } + + private static List students() { + final AssignStudent a = new AssignStudent(); + a.setName("赵六"); + a.setGender(1); + a.setClassId(12); + a.setStudentNo("2026003"); + a.setAccount("dngjxxs20260201"); + a.setPassword("dngjxxs20260201@"); + + final AssignStudent b = new AssignStudent(); + b.setName("张三"); + b.setGender(2); + b.setClassId(11); + + final AssignStudent c = new AssignStudent(); + c.setName("未分"); + c.setGender(null); + c.setClassId(null); + + return new ArrayList<>(Arrays.asList(a, b, c)); + } + + private static HSSFWorkbook workbook(MockHttpServletResponse response) throws Exception { + return new HSSFWorkbook(new ByteArrayInputStream(response.getContentAsByteArray())); + } + + /** 读文本单元格;null/空返回 "" */ + private static String cell(HSSFSheet sheet, int r, int c) { + final HSSFRow row = sheet.getRow(r); + if (row == null || row.getCell(c) == null) { + return ""; + } + return row.getCell(c).toString(); + } + + @Test + void exportsTemplateFormatWithOrderAndValues() throws Exception { + final MockHttpServletResponse response = new MockHttpServletResponse(); + service.exportList(4, "2026-2027", 1, null, null, response); + + try (HSSFWorkbook wb = workbook(response)) { + assertEquals(1, wb.getNumberOfSheets()); + final HSSFSheet sheet = wb.getSheetAt(0); + assertEquals("学生导入", sheet.getSheetName()); + // 行0 标题 + 行1 固定表头(与平台学生导入模板一致) + assertEquals("学生导入", cell(sheet, 0, 0)); + final String[] heads = {"年级", "班级", "姓名", "账号", "密码", "身份证", "学号", "性别(男,女)"}; + for (int i = 0; i < heads.length; i++) { + assertEquals(heads[i], cell(sheet, 1, i), "表头第" + (i + 1) + "列不符"); + } + // 排序:班序(1班→2班),未分班在后;账号/密码/学号/性别取值正确; + // 年级=回退新生年级名(测试项目无 schId,走 K12 标准名),身份证留空 + assertEquals("1班", cell(sheet, 2, 1)); + assertEquals("张三", cell(sheet, 2, 2)); + assertEquals("女", cell(sheet, 2, 7)); + assertEquals("高一", cell(sheet, 2, 0)); + assertEquals("2班", cell(sheet, 3, 1)); + assertEquals("赵六", cell(sheet, 3, 2)); + assertEquals("dngjxxs20260201", cell(sheet, 3, 3)); + assertEquals("dngjxxs20260201@", cell(sheet, 3, 4)); + assertEquals("2026003", cell(sheet, 3, 6)); + assertEquals("男", cell(sheet, 3, 7)); + // 未分班学生:班级留空、性别未知留空 + assertEquals("未分", cell(sheet, 4, 2)); + assertEquals("", cell(sheet, 4, 1)); + assertEquals("", cell(sheet, 4, 7)); + assertEquals(5, sheet.getLastRowNum() + 1); + // 文件名沿用学年学期口径,扩展名 .xls + final String disposition = response.getHeader("Content-Disposition"); + assertTrue(disposition.contains(".xls"), "应为 .xls 下载:" + disposition); + } + } + + @Test + void filtersByClassAndKeyword() throws Exception { + final MockHttpServletResponse response = new MockHttpServletResponse(); + service.exportList(4, "2026-2027", 1, 11, "张", response); + try (HSSFWorkbook wb = workbook(response)) { + final HSSFSheet sheet = wb.getSheetAt(0); + // 标题 + 表头 + 1 行数据 + assertEquals(3, sheet.getLastRowNum() + 1); + assertEquals("张三", cell(sheet, 2, 2)); + } + } + + @Test + void primaryStageUsesSameTemplateHeader() throws Exception { + // 模板格式与学段无关:小学导出同样是固定 8 列表头(无成绩列概念) + final MockHttpServletResponse response = new MockHttpServletResponse(); + service.exportList(2, "2026-2027", 1, null, null, response); + try (HSSFWorkbook wb = workbook(response)) { + final HSSFSheet sheet = wb.getSheetAt(0); + assertEquals("学号", cell(sheet, 1, 6)); + assertEquals("性别(男,女)", cell(sheet, 1, 7)); + assertEquals("小一", cell(sheet, 2, 0)); + assertEquals(5, sheet.getLastRowNum() + 1); + } + } +} diff --git a/src/test/java/com/yunhai/acaclassassign/service/assign/StudentPageGradeTest.java b/src/test/java/com/yunhai/acaclassassign/service/assign/StudentPageGradeTest.java new file mode 100644 index 0000000..b9bdaa6 --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/service/assign/StudentPageGradeTest.java @@ -0,0 +1,85 @@ +package com.yunhai.acaclassassign.service.assign; + +import com.yunhai.acaclassassign.entity.AssignClass; +import com.yunhai.acaclassassign.entity.AssignProject; +import com.yunhai.acaclassassign.entity.AssignStudent; +import com.yunhai.acaclassassign.model.assign.StudentPageVo; +import com.yunhai.acaclassassign.repository.AssignClassRepository; +import com.yunhai.acaclassassign.repository.AssignProjectRepository; +import com.yunhai.acaclassassign.repository.AssignResultRepository; +import com.yunhai.acaclassassign.repository.AssignStudentRepository; +import com.yunhai.acaclassassign.service.IAssignProjectService; +import com.yunhai.acaclassassign.service.impl.AssignStudentService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * 学生名单分页的年级字段:项目全体学生同一入学年级,gradeName 挂在分页响应上(不随行重复)。 + * 测试项目无 schId,走 K12 标准新生年级名回退(高中=高一),不触达班级管理远程接口。 + */ +class StudentPageGradeTest { + + private AssignStudentService service; + private AssignStudentRepository studentRepository; + private AssignClassRepository classRepository; + private IAssignProjectService projectService; + + @BeforeEach + void setUp() { + service = new AssignStudentService(); + studentRepository = Mockito.mock(AssignStudentRepository.class); + classRepository = Mockito.mock(AssignClassRepository.class); + projectService = Mockito.mock(IAssignProjectService.class); + ReflectionTestUtils.setField(service, "studentRepository", studentRepository); + ReflectionTestUtils.setField(service, "projectRepository", Mockito.mock(AssignProjectRepository.class)); + ReflectionTestUtils.setField(service, "classRepository", classRepository); + ReflectionTestUtils.setField(service, "resultRepository", Mockito.mock(AssignResultRepository.class)); + ReflectionTestUtils.setField(service, "projectService", projectService); + + // project.stage 跟随请求参数(resolveGradeName 按 project.getStage() 解析年级名) + when(projectService.require(anyInt(), anyString(), anyInt())).thenAnswer(inv -> { + final AssignProject project = new AssignProject(); + project.setId(7); + project.setYear(inv.getArgument(1)); + project.setTerm(1); + project.setStage(inv.getArgument(0)); + return project; + }); + + final AssignClass c1 = new AssignClass(); + c1.setId(11); + c1.setName("1班"); + c1.setSortOrder(1); + when(classRepository.findByProjectIdOrderBySortOrderAsc(7)) + .thenReturn(new ArrayList<>(Arrays.asList(c1))); + + final AssignStudent s = new AssignStudent(); + s.setName("张三"); + s.setClassId(11); + when(studentRepository.findByProjectId(7)).thenReturn(new ArrayList<>(Arrays.asList(s))); + } + + @Test + void pageCarriesProjectLevelGradeName() { + final StudentPageVo page = service.page(4, "2026-2027", 1, null, null, 1, 50); + assertEquals("高一", page.getGradeName()); + assertEquals(1, page.getContent().size()); + assertEquals("1班", page.getContent().get(0).getClassName()); + } + + @Test + void primaryStageFallsBackToIntakeGradeName() { + final StudentPageVo page = service.page(2, "2026-2027", 1, null, null, 1, 50); + assertEquals("小一", page.getGradeName()); + } +} diff --git a/src/test/java/com/yunhai/acaclassassign/service/assign/StudentParserTest.java b/src/test/java/com/yunhai/acaclassassign/service/assign/StudentParserTest.java new file mode 100644 index 0000000..f767d5d --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/service/assign/StudentParserTest.java @@ -0,0 +1,96 @@ +package com.yunhai.acaclassassign.service.assign; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** 学生名单解析器测试:表头识别 / 列分类 / 性别归一化 / 字段类型推断 / 预设解析 */ +class StudentParserTest { + + @Test + void parseWithHeaderAndTabSeparated() { + final String text = "姓名\t性别\t总分\t语文\t数学\t特长生\t视力分\t毕业学校\t备注\t预设班级\n" + + "张小明\t男\t185\t90\t95\t是\t4.8\t实验一小\t三好学生\t1\n" + + "李小红\t女\t180\t88\t92\t否\t5.0\t实验二小\t\t不同班:王强\n" + + "王强\tmale\t190\t95\t95\t1\t\t实验二小\t\t同班:李小红\n"; + final StudentParser.ParseResult r = StudentParser.parse(text, 3, null); + assertEquals(3, r.rows.size()); + assertTrue(r.subjects.contains("语文")); + assertTrue(r.subjects.contains("数学")); + assertTrue(r.tags.contains("特长生")); + assertTrue(r.nums.contains("视力分")); + assertTrue(r.hasScore); + + final StudentParser.ParsedRow first = r.rows.get(0); + assertEquals("张小明", first.name); + assertEquals(1, first.gender); + assertEquals(185.0, first.total, 0.001); + assertEquals(90.0, first.scores.get("语文"), 0.001); + assertTrue(first.tags.get("特长生")); + assertEquals(4.8, first.nums.get("视力分"), 0.001); + assertEquals("实验一小", first.school); + assertNotNull(first.preset); + assertEquals("class", first.preset.type); + assertEquals(Integer.valueOf(1), first.preset.classSort); + + final StudentParser.ParsedRow second = r.rows.get(1); + assertEquals(2, second.gender); + assertEquals("diff", second.preset.type); + assertEquals("王强", second.preset.target); + + final StudentParser.ParsedRow third = r.rows.get(2); + assertEquals(1, third.gender); // male → 男 + assertEquals("same", third.preset.type); + } + + @Test + void parsePrimaryWithoutHeader() { + final String text = "张小明 男\n李小红 女\n王二 X\n"; + final StudentParser.ParseResult r = StudentParser.parse(text, 2, false); + assertEquals(3, r.rows.size()); + assertTrue(r.subjects.isEmpty()); + assertFalse(r.hasScore); + assertEquals(1, r.rows.get(0).gender); + assertEquals(2, r.rows.get(1).gender); + assertEquals(0, r.rows.get(2).gender); // 未知 + } + + @Test + void parseCommaSeparatedWithBom() { + final String text = "姓名,性别,总分,预设班级\r\n张三,女,250,2班\r\n"; + final StudentParser.ParseResult r = StudentParser.parse(text, 3, true); + assertEquals(1, r.rows.size()); + assertEquals(2, r.rows.get(0).gender); + assertEquals("class", r.rows.get(0).preset.type); + assertEquals(Integer.valueOf(2), r.rows.get(0).preset.classSort); + } + + @Test + void presetParsing() { + assertNull(StudentParser.parsePreset("")); + assertNull(StudentParser.parsePreset("随便写的")); + final StudentParser.Preset a = StudentParser.parsePreset("1"); + assertEquals("class", a.type); + final StudentParser.Preset b = StudentParser.parsePreset("3班"); + assertEquals(Integer.valueOf(3), b.classSort); + final StudentParser.Preset c = StudentParser.parsePreset("同班:张三"); + assertEquals("same", c.type); + assertEquals("张三", c.target); + final StudentParser.Preset d = StudentParser.parsePreset("叉开:李四"); + assertEquals("diff", d.type); + assertEquals("李四", d.target); + } + + @Test + void ignoreResultColumn() { + final String text = "姓名\t性别\t班级\n张小明\t男\t3班\n"; + final StudentParser.ParseResult r = StudentParser.parse(text, 3, null); + // 「班级」列被忽略,不参与任何字段 + assertEquals(1, r.rows.size()); + assertTrue(r.subjects.isEmpty()); + } +} diff --git a/src/test/java/com/yunhai/acaclassassign/service/assign/StudentStatsTest.java b/src/test/java/com/yunhai/acaclassassign/service/assign/StudentStatsTest.java new file mode 100644 index 0000000..05028ae --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/service/assign/StudentStatsTest.java @@ -0,0 +1,114 @@ +package com.yunhai.acaclassassign.service.assign; + +import com.yunhai.acaclassassign.entity.AssignProject; +import com.yunhai.acaclassassign.entity.AssignStudent; +import com.yunhai.acaclassassign.model.assign.StudentStatsVo; +import com.yunhai.acaclassassign.repository.AssignClassRepository; +import com.yunhai.acaclassassign.repository.AssignProjectRepository; +import com.yunhai.acaclassassign.repository.AssignResultRepository; +import com.yunhai.acaclassassign.repository.AssignStudentRepository; +import com.yunhai.acaclassassign.service.IAssignProjectService; +import com.yunhai.acaclassassign.service.impl.AssignStudentService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** 名单简单统计测试:性别计数 / 总分三值 / 是否-数值字段聚合 / 学校去重 / 空名单 */ +class StudentStatsTest { + + private AssignStudentService service; + private AssignStudentRepository studentRepository; + private IAssignProjectService projectService; + + @BeforeEach + void setUp() { + service = new AssignStudentService(); + studentRepository = Mockito.mock(AssignStudentRepository.class); + projectService = Mockito.mock(IAssignProjectService.class); + ReflectionTestUtils.setField(service, "studentRepository", studentRepository); + ReflectionTestUtils.setField(service, "projectRepository", Mockito.mock(AssignProjectRepository.class)); + ReflectionTestUtils.setField(service, "classRepository", Mockito.mock(AssignClassRepository.class)); + ReflectionTestUtils.setField(service, "resultRepository", Mockito.mock(AssignResultRepository.class)); + ReflectionTestUtils.setField(service, "projectService", projectService); + + final AssignProject project = new AssignProject(); + project.setId(7); + when(projectService.require(anyInt(), anyString(), anyInt())).thenReturn(project); + when(studentRepository.findByProjectId(7)).thenAnswer(inv -> students()); + } + + private static List students() { + // 3 人:2 男 1 女;总分 100/200/300 → 均分 200 最高 300 最低 100 + final AssignStudent a = new AssignStudent(); + a.setName("甲"); + a.setGender(1); + a.setTotalScore(BigDecimal.valueOf(100)); + a.setTagsJson("{\"特长生\":true,\"住宿\":false}"); + a.setNumsJson("{\"视力分\":4.5}"); + a.setGraduateSchool("光明中学"); + + final AssignStudent b = new AssignStudent(); + b.setName("乙"); + b.setGender(1); + b.setTotalScore(BigDecimal.valueOf(200)); + b.setTagsJson("{\"特长生\":true}"); + b.setNumsJson("{\"视力分\":5.0}"); + b.setGraduateSchool("外语中学"); + + final AssignStudent c = new AssignStudent(); + c.setName("丙"); + c.setGender(2); + c.setTotalScore(BigDecimal.valueOf(300)); + c.setTagsJson("{\"特长生\":false}"); + c.setNumsJson("{}"); + c.setGraduateSchool("光明中学"); // 与甲重复,去重后 2 所 + + return new ArrayList<>(Arrays.asList(a, b, c)); + } + + @Test + void aggregatesCountsScoresAndFields() { + final StudentStatsVo out = service.stats(4, "2026", 1); + assertEquals(3, out.getTotal()); + assertEquals(2, out.getMale()); + assertEquals(1, out.getFemale()); + assertEquals(200.0, out.getAvgScore(), 0.001); + assertEquals(300.0, out.getMaxScore(), 0.001); + assertEquals(100.0, out.getMinScore(), 0.001); + // 是否型:特长生取真 2 人;数值型:视力分均值 (4.5+5.0)/2=4.75 + assertEquals(2, out.getFields().size()); + assertEquals("特长生", out.getFields().get(0).getName()); + assertEquals("tag", out.getFields().get(0).getKind()); + assertEquals(2L, out.getFields().get(0).getCount()); + assertEquals("视力分", out.getFields().get(1).getName()); + assertEquals("num", out.getFields().get(1).getKind()); + assertEquals(4.75, out.getFields().get(1).getAvg(), 0.001); + assertEquals(2, out.getSchoolCount()); + } + + @Test + void emptyRosterYieldsZeroesAndNullScores() { + when(studentRepository.findByProjectId(7)).thenReturn(new ArrayList<>()); + final StudentStatsVo out = service.stats(4, "2026", 1); + assertEquals(0, out.getTotal()); + assertEquals(0, out.getMale()); + assertEquals(0, out.getFemale()); + assertNull(out.getAvgScore()); + assertNull(out.getMaxScore()); + assertNull(out.getMinScore()); + assertEquals(0, out.getFields().size()); + assertEquals(0, out.getSchoolCount()); + } +} diff --git a/src/test/java/com/yunhai/acaclassassign/service/impl/AssignRunRerunTest.java b/src/test/java/com/yunhai/acaclassassign/service/impl/AssignRunRerunTest.java new file mode 100644 index 0000000..00c6962 --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/service/impl/AssignRunRerunTest.java @@ -0,0 +1,110 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.yunhai.acaclassassign.entity.AssignClass; +import com.yunhai.acaclassassign.entity.AssignProject; +import com.yunhai.acaclassassign.entity.AssignStudent; +import com.yunhai.acaclassassign.repository.AssignClassRepository; +import com.yunhai.acaclassassign.repository.AssignProjectRepository; +import com.yunhai.acaclassassign.repository.AssignResultRepository; +import com.yunhai.acaclassassign.repository.AssignStudentRepository; +import com.yunhai.acaclassassign.service.IAssignRunService; +import com.yunhai.acaclassassign.utils.UserContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 重新分班回归测试:aca_assign_result.project_id 有唯一约束, + * run() 里必须保证 DELETE 先于 INSERT 落库,否则第二次执行撞 uk_arl_project。 + */ +@SpringBootTest +@Transactional +class AssignRunRerunTest { + + private static final int SCH_ID = 3; + private static final int STAGE = 2; + private static final String YEAR = "2099"; + private static final int TERM = 1; + + @Autowired + private IAssignRunService runService; + + @Autowired + private AssignProjectRepository projectRepository; + + @Autowired + private AssignClassRepository classRepository; + + @Autowired + private AssignStudentRepository studentRepository; + + @Autowired + private AssignResultRepository resultRepository; + + @BeforeEach + void setUp() { + UserContext.setSchId(SCH_ID); + UserContext.setUserName("junit"); + + final AssignProject project = new AssignProject(); + project.setSchId(SCH_ID); + project.setStage(STAGE); + project.setYear(YEAR); + project.setTerm(TERM); + project.setStatus(0); + projectRepository.save(project); + + final List classes = new ArrayList<>(); + for (int i = 1; i <= 2; i++) { + final AssignClass c = new AssignClass(); + c.setProjectId(project.getId()); + c.setSortOrder(i); + c.setName(i + "班"); + c.setAbFlag(false); + classes.add(c); + } + classRepository.saveAll(classes); + + final List students = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + final AssignStudent s = new AssignStudent(); + s.setProjectId(project.getId()); + s.setName("测试学生" + String.format("%02d", i)); + s.setGender(i % 2 == 0 ? 1 : 2); + students.add(s); + } + studentRepository.saveAll(students); + } + + @AfterEach + void tearDown() { + UserContext.clear(); + } + + @Test + void rerunOverwritesPreviousResult() { + assertNotNull(runService.run(STAGE, YEAR, TERM).getScore()); + assertTrue(resultRepository.findByProjectId(projectId()).isPresent()); + + // 修复前这里会抛 ConstraintViolationException: uk_arl_project + assertNotNull(runService.run(STAGE, YEAR, TERM).getScore()); + assertTrue(resultRepository.findByProjectId(projectId()).isPresent()); + assertEquals(1, resultRepository.findAll().stream() + .filter(r -> r.getProjectId().equals(projectId())).count()); + } + + private Integer projectId() { + return projectRepository.findBySchIdAndStageAndYearAndTerm(SCH_ID, STAGE, YEAR, TERM) + .orElseThrow(IllegalStateException::new).getId(); + } +} diff --git a/src/test/java/com/yunhai/acaclassassign/service/impl/ClassExportImportTest.java b/src/test/java/com/yunhai/acaclassassign/service/impl/ClassExportImportTest.java new file mode 100644 index 0000000..52ef0ed --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/service/impl/ClassExportImportTest.java @@ -0,0 +1,239 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.yunhai.acaclassassign.entity.AssignClass; +import com.yunhai.acaclassassign.entity.AssignProject; +import com.yunhai.acaclassassign.model.SchTeaList; +import com.yunhai.acaclassassign.repository.AssignClassRepository; +import com.yunhai.acaclassassign.repository.AssignProjectRepository; +import com.yunhai.acaclassassign.repository.AssignResultRepository; +import com.yunhai.acaclassassign.repository.AssignStudentRepository; +import com.yunhai.acaclassassign.service.IAssignProjectService; +import com.yunhai.acaclassassign.service.IClassService; +import com.yunhai.acaclassassign.service.impl.AssignStudentService; +import com.yunhai.platform.apistd.BizException; +import org.apache.poi.hssf.usermodel.HSSFRow; +import org.apache.poi.hssf.usermodel.HSSFSheet; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** 班级导入数据导出测试:按班级管理「班级导入」模板生成 .xls(数据页 + 导入规则页) */ +class ClassExportImportTest { + + private AssignStudentService service; + private AssignClassRepository classRepository; + private IClassService classService; + + @BeforeEach + void setUp() { + service = new AssignStudentService(); + classRepository = Mockito.mock(AssignClassRepository.class); + classService = Mockito.mock(IClassService.class); + ReflectionTestUtils.setField(service, "studentRepository", Mockito.mock(AssignStudentRepository.class)); + ReflectionTestUtils.setField(service, "projectRepository", Mockito.mock(AssignProjectRepository.class)); + ReflectionTestUtils.setField(service, "classRepository", classRepository); + ReflectionTestUtils.setField(service, "resultRepository", Mockito.mock(AssignResultRepository.class)); + ReflectionTestUtils.setField(service, "projectService", Mockito.mock(IAssignProjectService.class)); + ReflectionTestUtils.setField(service, "classService", classService); + + // 3 班:2 个序号名 + 1 个语义名(阳光班),班主任各一名 + final AssignClass c1 = new AssignClass(); + c1.setId(11); + c1.setName("1班"); + c1.setSortOrder(1); + c1.setTeacherId(101); + c1.setTeacherName("张三"); + final AssignClass c2 = new AssignClass(); + c2.setId(12); + c2.setName("2班"); + c2.setSortOrder(2); + final AssignClass c3 = new AssignClass(); + c3.setId(13); + c3.setName("阳光班"); + c3.setSortOrder(3); + c3.setTeacherId(103); + c3.setTeacherName("王五"); + when(classRepository.findByProjectIdOrderBySortOrderAsc(7)) + .thenReturn(new ArrayList<>(Arrays.asList(c1, c2, c3))); + // 平台教师名册(规则页候选),含重复与空白以验证去重 + when(classService.getSchTeaList(anyInt())).thenReturn(new ArrayList<>(Arrays.asList( + teacher("陈星"), teacher("张三"), teacher("王五"), teacher("张三"), teacher(" ")))); + } + + private static SchTeaList teacher(String name) { + final SchTeaList t = new SchTeaList(); + t.setTeaName(name); + return t; + } + + private static AssignProject project(int stage) { + final AssignProject p = new AssignProject(); + p.setId(7); + p.setSchId(27); + p.setStage(stage); + p.setYear("2026"); + p.setTerm(1); + return p; + } + + private static HSSFWorkbook workbook(MockHttpServletResponse response) throws Exception { + return new HSSFWorkbook(new ByteArrayInputStream(response.getContentAsByteArray())); + } + + /** 读文本单元格;null/空返回 "" */ + private static String cell(HSSFSheet sheet, int r, int c) { + final HSSFRow row = sheet.getRow(r); + if (row == null || row.getCell(c) == null) { + return ""; + } + return row.getCell(c).toString(); + } + + @Test + void exportsClassImportTemplate() throws Exception { + final MockHttpServletResponse response = new MockHttpServletResponse(); + service.exportClassImport(project(4), "云海中学", "高一", response); + + try (HSSFWorkbook wb = workbook(response)) { + assertEquals(2, wb.getNumberOfSheets()); + assertEquals("云海中学", wb.getSheetAt(0).getSheetName()); + assertEquals("导入规则", wb.getSheetAt(1).getSheetName()); + + // 数据页:标题 + 表头 + 每班一行;班级列归一化 {序号}班,语义班名进别名列 + final HSSFSheet sheet = wb.getSheetAt(0); + assertEquals("班级导入", cell(sheet, 0, 0)); + final String[] heads = {"年级", "班级", "班级别名", "班主任", "副班主任"}; + for (int i = 0; i < heads.length; i++) { + assertEquals(heads[i], cell(sheet, 1, i), "表头第" + (i + 1) + "列不符"); + } + // 年级列=传入的学校字典名称;副班主任留空 + assertEquals("高一", cell(sheet, 2, 0)); + assertEquals("1班", cell(sheet, 2, 1)); + assertEquals("", cell(sheet, 2, 2)); + assertEquals("张三", cell(sheet, 2, 3)); + assertEquals("", cell(sheet, 2, 4)); + assertEquals("2班", cell(sheet, 3, 1)); + // 2班未设班主任:留空(导入契约选填) + assertEquals("", cell(sheet, 3, 3)); + assertEquals("3班", cell(sheet, 4, 1)); + assertEquals("阳光班", cell(sheet, 4, 2)); + assertEquals("王五", cell(sheet, 4, 3)); + assertEquals(5, sheet.getLastRowNum() + 1); + + // 规则页:三行规则,年级候选=高中字典,教师名册去重去空白 + final HSSFSheet rule = wb.getSheetAt(1); + assertEquals("导入规则", cell(rule, 0, 0)); + assertEquals("规则项", cell(rule, 1, 0)); + assertEquals("说明", cell(rule, 1, 1)); + assertEquals("填写内容", cell(rule, 1, 2)); + assertEquals("年级", cell(rule, 2, 0)); + assertEquals("高一、高二、高三", cell(rule, 2, 2)); + assertEquals("班级", cell(rule, 3, 0)); + assertEquals("1班、2班、3班~n班", cell(rule, 3, 2)); + assertEquals("班主任、副班主任", cell(rule, 4, 0)); + assertEquals("陈星、张三、王五", cell(rule, 4, 2)); + + final String disposition = response.getHeader("Content-Disposition"); + assertTrue(disposition.contains(".xls"), "应为 .xls 下载:" + disposition); + // filename*=UTF-8'' 为百分号编码,解码后核对学段与用途 + final String encoded = disposition.replaceFirst("(?i).*filename\\*=UTF-8''", ""); + final String fileName = java.net.URLDecoder.decode(encoded, "UTF-8"); + assertTrue(fileName.contains("高中班级导入数据_2026学年1学期_3班.xls"), + "文件名应含学段/用途/班数:" + fileName); + } + } + + @Test + void gradeNameFlowsToCellsAndStageDrivesCandidates() throws Exception { + // 年级列=调用方解析的字典名(可为任意学校别称),规则页候选仍按学段给标准字典 + final MockHttpServletResponse primary = new MockHttpServletResponse(); + service.exportClassImport(project(2), "云海小学", "一年级", primary); + try (HSSFWorkbook wb = workbook(primary)) { + final HSSFSheet sheet = wb.getSheetAt(0); + assertEquals("一年级", cell(sheet, 2, 0)); + assertEquals("一年级", cell(sheet, 4, 0)); + assertEquals("小一、小二、小三、小四、小五、小六", cell(wb.getSheetAt(1), 2, 2)); + } + + final MockHttpServletResponse middle = new MockHttpServletResponse(); + service.exportClassImport(project(3), "云海中学", "七年级", middle); + try (HSSFWorkbook wb = workbook(middle)) { + assertEquals("七年级", cell(wb.getSheetAt(0), 2, 0)); + assertEquals("初一、初二、初三", cell(wb.getSheetAt(1), 2, 2)); + } + } + + @Test + void fallsBackToClassTeachersWhenRosterUnavailable() throws Exception { + when(classService.getSchTeaList(anyInt())).thenThrow(new RuntimeException("平台不可达")); + final MockHttpServletResponse response = new MockHttpServletResponse(); + service.exportClassImport(project(4), "", "高一", response); + try (HSSFWorkbook wb = workbook(response)) { + // 学校名不可得:sheet 名退化「班级导入」;教师名册回退项目班级已设置的班主任 + assertEquals("班级导入", wb.getSheetAt(0).getSheetName()); + assertEquals("张三、王五", cell(wb.getSheetAt(1), 4, 2)); + } + } + + @Test + void throwsWhenNoClasses() { + when(classRepository.findByProjectIdOrderBySortOrderAsc(7)) + .thenReturn(new ArrayList<>(Collections.emptyList())); + final MockHttpServletResponse response = new MockHttpServletResponse(); + final BizException e = assertThrows(BizException.class, + () -> service.exportClassImport(project(4), "云海中学", "高一", response)); + assertTrue(e.getMessage().contains("班级设置"), "应提示先完成班级配置:" + e.getMessage()); + } + + @Test + void publicEntrancePrefersRemoteGradeName() throws Exception { + // 公开入口:年级名经班级管理接口(getGradeName)取学校字典真实名称 + final IAssignProjectService projectService = (IAssignProjectService) + ReflectionTestUtils.getField(service, "projectService"); + when(projectService.require(anyInt(), anyString(), anyInt())).thenReturn(project(4)); + when(classService.getGradeName(anyInt(), anyInt(), anyInt())).thenReturn("高一"); + final MockHttpServletResponse response = new MockHttpServletResponse(); + service.exportClassImport(4, "2026", 1, response); + try (HSSFWorkbook wb = workbook(response)) { + assertEquals("高一", cell(wb.getSheetAt(0), 2, 0)); + } + } + + @Test + void publicEntranceFallsBackWhenGradeNameUnavailable() throws Exception { + // 接口未部署/不可达/无匹配(返回 null 或抛异常)→ 回退 K12 标准新生年级名 + final IAssignProjectService projectService = (IAssignProjectService) + ReflectionTestUtils.getField(service, "projectService"); + when(projectService.require(anyInt(), anyString(), anyInt())).thenReturn(project(3)); + when(classService.getGradeName(anyInt(), anyInt(), anyInt())).thenReturn(null); + final MockHttpServletResponse response = new MockHttpServletResponse(); + service.exportClassImport(3, "2026", 1, response); + try (HSSFWorkbook wb = workbook(response)) { + assertEquals("初一", cell(wb.getSheetAt(0), 2, 0)); + } + + when(classService.getGradeName(anyInt(), anyInt(), anyInt())).thenThrow(new RuntimeException("不可达")); + final AssignProject p = project(2); + when(projectService.require(anyInt(), anyString(), anyInt())).thenReturn(p); + final MockHttpServletResponse primary = new MockHttpServletResponse(); + service.exportClassImport(2, "2026", 1, primary); + try (HSSFWorkbook wb = workbook(primary)) { + assertEquals("小一", cell(wb.getSheetAt(0), 2, 0)); + } + } +} diff --git a/src/test/java/com/yunhai/acaclassassign/service/impl/GenStudentAccountFlowTest.java b/src/test/java/com/yunhai/acaclassassign/service/impl/GenStudentAccountFlowTest.java new file mode 100644 index 0000000..3a58cc6 --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/service/impl/GenStudentAccountFlowTest.java @@ -0,0 +1,192 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.yunhai.acaclassassign.entity.AssignClass; +import com.yunhai.acaclassassign.entity.AssignProject; +import com.yunhai.acaclassassign.entity.AssignStudent; +import com.yunhai.acaclassassign.model.assign.ImportPasteRequest; +import com.yunhai.acaclassassign.model.assign.ImportResultVo; +import com.yunhai.acaclassassign.repository.AssignClassRepository; +import com.yunhai.acaclassassign.repository.AssignStudentRepository; +import com.yunhai.acaclassassign.service.IAssignProjectService; +import com.yunhai.acaclassassign.utils.UserContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.text.Collator; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 按成绩/序号生成账号流程:导入示例 → 建班并指派 classId → doRenumber(accountPrefix)。 + * 账号前缀需平台取校名(远程),测试注入固定前缀 dngjxxs2026(大宁国际小学 2026)离线验证编号格式与排序; + * 前缀解析本身由 PinyinInitialsTest 覆盖。事务内执行并回滚,不污染本地联调数据。 + */ +@SpringBootTest +@Transactional +class GenStudentAccountFlowTest { + + /** 需求基准例:大宁国际小学 2026 学年的账号前缀 */ + private static final String PREFIX = "dngjxxs2026"; + + @Autowired + private IAssignProjectService projectService; + + /** doRenumber 是实现类包内方法(接口无此入口),注入实现类 */ + @Autowired + private AssignStudentService studentService; + + @Autowired + private AssignStudentRepository studentRepository; + + @Autowired + private AssignClassRepository classRepository; + + @BeforeEach + void setUp() { + UserContext.setSchId(3); + UserContext.setUserName("junit-genacc"); + } + + @AfterEach + void tearDown() { + UserContext.clear(); + } + + @Test + void scoreModeAccountHasClassSeqAndGradeSeq() { + // 高中示例 306 人(有总分),建 2 个班轮转指派 305 人 + studentService.importPaste(request(4, studentService.sampleText(4).get("text"))); + final AssignProject project = projectService.require(4, null, 1); + final AssignClass c1 = saveClass(project.getId(), 1, "1班"); + final AssignClass c2 = saveClass(project.getId(), 2, "2班"); + final List all = studentRepository.findByProjectId(project.getId()); + for (int i = 0; i < all.size() - 1; i++) { + all.get(i).setClassId(i % 2 == 0 ? c1.getId() : c2.getId()); + } + studentRepository.saveAll(all); + + // 班级口径:前缀+班序2位+班内名次2位(如 dngjxxs20260101) + final Map out = studentService.doRenumber(project, true, PREFIX); + assertEquals("score", out.get("mode")); + assertEquals(305, ((Number) out.get("count")).intValue()); + final Map sortById = new HashMap<>(); + sortById.put(c1.getId(), c1.getSortOrder()); + sortById.put(c2.getId(), c2.getSortOrder()); + final List after = studentRepository.findByProjectId(project.getId()); + for (AssignClass c : Arrays.asList(c1, c2)) { + final List members = after.stream() + .filter(s -> c.getId().equals(s.getClassId())) + .sorted(scoreOrder()) + .collect(Collectors.toList()); + for (int i = 0; i < members.size(); i++) { + assertEquals(PREFIX + String.format("%02d%02d", c.getSortOrder(), i + 1), + members.get(i).getAccount(), + c.getName() + " 第" + (i + 1) + "名账号不符"); + } + } + + // 年级口径:前缀+年级名次4位(如 dngjxxs20260001),覆盖上次生成 + final Map out2 = studentService.doRenumber(project, false, PREFIX); + assertEquals("score", out2.get("mode")); + final List graded = studentRepository.findByProjectId(project.getId()).stream() + .filter(s -> s.getClassId() != null) + .sorted(scoreOrder()) + .collect(Collectors.toList()); + for (int i = 0; i < graded.size(); i++) { + assertEquals(PREFIX + String.format("%04d", i + 1), graded.get(i).getAccount(), + "年级第" + (i + 1) + "名账号不符"); + } + assertTrue(graded.stream().allMatch(s -> s.getAccount().startsWith(PREFIX))); + } + + @Test + void noScoreProjectAccountsBySequence() { + // 小学示例无成绩列 → 序号模式:class=前缀+班序+班内拼音序;grade=前缀+4位连续序(班级序+拼音) + studentService.importPaste(request(2, studentService.sampleText(2).get("text"))); + final AssignProject project = projectService.require(2, null, 1); + final AssignClass c1 = saveClass(project.getId(), 1, "1班"); + final AssignClass c2 = saveClass(project.getId(), 2, "2班"); + final List all = studentRepository.findByProjectId(project.getId()); + assertEquals(60, all.size()); + for (int i = 0; i < all.size(); i++) { + all.get(i).setClassId(i % 2 == 0 ? c1.getId() : c2.getId()); + } + studentRepository.saveAll(all); + + final Map out = studentService.doRenumber(project, true, PREFIX); + assertEquals("seq", out.get("mode")); + assertEquals(60, ((Number) out.get("count")).intValue()); + final List after = studentRepository.findByProjectId(project.getId()); + for (AssignClass c : Arrays.asList(c1, c2)) { + final List members = after.stream() + .filter(s -> c.getId().equals(s.getClassId())) + .sorted(nameOrder()) + .collect(Collectors.toList()); + for (int i = 0; i < members.size(); i++) { + assertEquals(PREFIX + String.format("%02d%02d", c.getSortOrder(), i + 1), + members.get(i).getAccount(), + c.getName() + " 第" + (i + 1) + "序账号不符"); + } + } + + final Map out2 = studentService.doRenumber(project, false, PREFIX); + assertEquals("seq", out2.get("mode")); + final Map sortById = new HashMap<>(); + sortById.put(c1.getId(), c1.getSortOrder()); + sortById.put(c2.getId(), c2.getSortOrder()); + final List graded = after.stream() + .sorted(Comparator + .comparing((AssignStudent s) -> sortById.get(s.getClassId())) + .thenComparing(s -> s.getName() == null ? "" : s.getName(), Collator.getInstance(Locale.CHINA))) + .collect(Collectors.toList()); + for (int i = 0; i < graded.size(); i++) { + assertEquals(PREFIX + String.format("%04d", i + 1), graded.get(i).getAccount(), + "年级第" + (i + 1) + "序账号不符"); + } + } + + /** 成绩模式期望顺序:总分降序(空分垫底)+ 姓名拼音 */ + private static Comparator scoreOrder() { + final Collator collator = Collator.getInstance(Locale.CHINA); + return Comparator + .comparing(AssignStudent::getTotalScore, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(s -> s.getName() == null ? "" : s.getName(), collator); + } + + /** 序号模式班内顺序:姓名拼音 */ + private static Comparator nameOrder() { + final Collator collator = Collator.getInstance(Locale.CHINA); + return Comparator.comparing(s -> s.getName() == null ? "" : s.getName(), collator); + } + + private AssignClass saveClass(Integer projectId, int sortOrder, String name) { + final AssignClass c = new AssignClass(); + c.setProjectId(projectId); + c.setSortOrder(sortOrder); + c.setName(name); + c.setAbFlag(false); + return classRepository.save(c); + } + + private ImportPasteRequest request(int stage, String text) { + final ImportPasteRequest req = new ImportPasteRequest(); + req.setStage(stage); + req.setTerm(1); // getOrCreate 的 term 是基本类型,必须给值(前端总会传) + req.setText(text); + req.setHasHeader(Boolean.TRUE); + req.setMode("replace"); + return req; + } +} diff --git a/src/test/java/com/yunhai/acaclassassign/service/impl/GenStudentNoFlowTest.java b/src/test/java/com/yunhai/acaclassassign/service/impl/GenStudentNoFlowTest.java new file mode 100644 index 0000000..4d81709 --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/service/impl/GenStudentNoFlowTest.java @@ -0,0 +1,198 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.yunhai.acaclassassign.entity.AssignClass; +import com.yunhai.acaclassassign.entity.AssignProject; +import com.yunhai.acaclassassign.entity.AssignStudent; +import com.yunhai.acaclassassign.model.assign.ImportPasteRequest; +import com.yunhai.acaclassassign.model.assign.ImportResultVo; +import com.yunhai.acaclassassign.repository.AssignClassRepository; +import com.yunhai.acaclassassign.repository.AssignStudentRepository; +import com.yunhai.acaclassassign.service.IAssignProjectService; +import com.yunhai.acaclassassign.service.IAssignStudentService; +import com.yunhai.acaclassassign.utils.UserContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.text.Collator; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 按成绩生成学号流程:导入示例 → 建班并指派 classId → GenNo(class/grade)。 + * 与页面「分班查询 → 学生名单 → 生成学号」链路一致;事务内执行并回滚,不污染本地联调数据。 + */ +@SpringBootTest +@Transactional +class GenStudentNoFlowTest { + + @Autowired + private IAssignStudentService studentService; + + @Autowired + private IAssignProjectService projectService; + + @Autowired + private AssignStudentRepository studentRepository; + + @Autowired + private AssignClassRepository classRepository; + + @BeforeEach + void setUp() { + UserContext.setSchId(3); + UserContext.setUserName("junit-genno"); + } + + @AfterEach + void tearDown() { + UserContext.clear(); + } + + @Test + void classScopeNumbersByClassAndGradeScopeNumbersGlobally() { + // 高中示例 306 人(有总分),建 2 个班轮转指派 305 人,留 1 人不指派 + studentService.importPaste(request(4, studentService.sampleText(4).get("text"))); + final AssignProject project = projectService.require(4, null, 1); + final AssignClass c1 = saveClass(project.getId(), 1, "1班"); + final AssignClass c2 = saveClass(project.getId(), 2, "2班"); + final List all = studentRepository.findByProjectId(project.getId()); + assertEquals(306, all.size()); + final AssignStudent unassigned = all.get(305); + final String unassignedNo = unassigned.getStudentNo(); + final List assigned = new ArrayList<>(); + for (int i = 0; i < 305; i++) { + all.get(i).setClassId(i % 2 == 0 ? c1.getId() : c2.getId()); + assigned.add(all.get(i)); + } + studentRepository.saveAll(all); + + // 班级口径:班内名次2位(各班独立从 01 起);未指派学生学号不动 + final Map out = studentService.genStudentNo(4, null, 1, "class"); + assertEquals("class", out.get("scope")); + assertEquals("score", out.get("mode")); + assertEquals(305, ((Number) out.get("count")).intValue()); + final List after = studentRepository.findByProjectId(project.getId()); + final AssignStudent kept = after.stream() + .filter(s -> s.getId().equals(unassigned.getId())).findFirst().orElseThrow(IllegalStateException::new); + assertEquals(unassignedNo, kept.getStudentNo()); + for (AssignClass c : Arrays.asList(c1, c2)) { + final List members = after.stream() + .filter(s -> c.getId().equals(s.getClassId())) + .sorted(expectedOrder()) + .collect(Collectors.toList()); + assertTrue(members.size() > 100, c.getName() + "应承接轮转指派的大部分学生"); + for (int i = 0; i < members.size(); i++) { + assertEquals(String.format("%02d", i + 1), + members.get(i).getStudentNo(), c.getName() + " 第" + (i + 1) + "名学号不符"); + } + } + + // 年级口径:学年+全年级名次4位,覆盖上次生成结果 + final Map out2 = studentService.genStudentNo(4, null, 1, "grade"); + assertEquals("grade", out2.get("scope")); + assertEquals(305, ((Number) out2.get("count")).intValue()); + final List graded = studentRepository.findByProjectId(project.getId()).stream() + .filter(s -> s.getClassId() != null) + .sorted(expectedOrder()) + .collect(Collectors.toList()); + final String yearPrefix = java.util.regex.Pattern.quote(project.getYear()); + for (int i = 0; i < graded.size(); i++) { + assertEquals(project.getYear() + String.format("%04d", i + 1), graded.get(i).getStudentNo(), + "年级第" + (i + 1) + "名学号不符"); + } + // 两个班的学生学号已全部重编为「学年+名次」,班内号不复存在 + assertTrue(graded.stream().allMatch(s -> s.getStudentNo().matches(yearPrefix + "\\d{4}"))); + } + + @Test + void noScoreProjectNumbersBySequence() { + // 小学示例无成绩列 → 序号模式:class=班内姓名拼音 01、02…;grade=学年+4位序号(班级序+拼音连续编号) + studentService.importPaste(request(2, studentService.sampleText(2).get("text"))); + final AssignProject project = projectService.require(2, null, 1); + final AssignClass c1 = saveClass(project.getId(), 1, "1班"); + final AssignClass c2 = saveClass(project.getId(), 2, "2班"); + final List all = studentRepository.findByProjectId(project.getId()); + assertEquals(60, all.size()); + for (int i = 0; i < all.size(); i++) { + all.get(i).setClassId(i % 2 == 0 ? c1.getId() : c2.getId()); + } + studentRepository.saveAll(all); + + final Map out = studentService.genStudentNo(2, null, 1, "class"); + assertEquals("seq", out.get("mode")); + assertEquals(60, ((Number) out.get("count")).intValue()); + final List after = studentRepository.findByProjectId(project.getId()); + for (AssignClass c : Arrays.asList(c1, c2)) { + final List members = after.stream() + .filter(s -> c.getId().equals(s.getClassId())) + .sorted(nameOrder()) + .collect(Collectors.toList()); + for (int i = 0; i < members.size(); i++) { + assertEquals(String.format("%02d", i + 1), + members.get(i).getStudentNo(), c.getName() + " 第" + (i + 1) + "序学号不符"); + } + } + + final Map out2 = studentService.genStudentNo(2, null, 1, "grade"); + assertEquals("seq", out2.get("mode")); + assertEquals(60, ((Number) out2.get("count")).intValue()); + final Map sortById = new HashMap<>(); + sortById.put(c1.getId(), c1.getSortOrder()); + sortById.put(c2.getId(), c2.getSortOrder()); + final List graded = after.stream() + .sorted(Comparator + .comparing((AssignStudent s) -> sortById.get(s.getClassId())) + .thenComparing(s -> s.getName() == null ? "" : s.getName(), Collator.getInstance(Locale.CHINA))) + .collect(Collectors.toList()); + for (int i = 0; i < graded.size(); i++) { + assertEquals(project.getYear() + String.format("%04d", i + 1), graded.get(i).getStudentNo(), + "年级第" + (i + 1) + "序学号不符"); + } + } + + /** 与实现同口径的期望顺序:总分降序(空分垫底)+ 姓名拼音 */ + private static Comparator expectedOrder() { + final Collator collator = Collator.getInstance(Locale.CHINA); + return Comparator + .comparing(AssignStudent::getTotalScore, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(s -> s.getName() == null ? "" : s.getName(), collator); + } + + /** 序号模式班内顺序:姓名拼音 */ + private static Comparator nameOrder() { + final Collator collator = Collator.getInstance(Locale.CHINA); + return Comparator.comparing(s -> s.getName() == null ? "" : s.getName(), collator); + } + + private AssignClass saveClass(Integer projectId, int sortOrder, String name) { + final AssignClass c = new AssignClass(); + c.setProjectId(projectId); + c.setSortOrder(sortOrder); + c.setName(name); + c.setAbFlag(false); + return classRepository.save(c); + } + + private ImportPasteRequest request(int stage, String text) { + final ImportPasteRequest req = new ImportPasteRequest(); + req.setStage(stage); + req.setTerm(1); // getOrCreate 的 term 是基本类型,必须给值(前端总会传) + req.setText(text); + req.setHasHeader(Boolean.TRUE); + req.setMode("replace"); + return req; + } +} diff --git a/src/test/java/com/yunhai/acaclassassign/service/impl/GenStudentPasswordFlowTest.java b/src/test/java/com/yunhai/acaclassassign/service/impl/GenStudentPasswordFlowTest.java new file mode 100644 index 0000000..7357199 --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/service/impl/GenStudentPasswordFlowTest.java @@ -0,0 +1,117 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.yunhai.acaclassassign.entity.AssignClass; +import com.yunhai.acaclassassign.entity.AssignProject; +import com.yunhai.acaclassassign.entity.AssignStudent; +import com.yunhai.acaclassassign.model.assign.ImportPasteRequest; +import com.yunhai.acaclassassign.model.assign.ImportResultVo; +import com.yunhai.acaclassassign.repository.AssignClassRepository; +import com.yunhai.acaclassassign.repository.AssignStudentRepository; +import com.yunhai.acaclassassign.service.IAssignProjectService; +import com.yunhai.acaclassassign.utils.UserContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 生成密码=账号+@ 流程:导入示例 → 建班指派 → GenPassword。 + * 覆盖两条路径:导入账号直接作密码源;重新生成账号后密码跟随新账号。 + * 事务内执行并回滚,不污染本地联调数据。 + */ +@SpringBootTest +@Transactional +class GenStudentPasswordFlowTest { + + /** doRenumber 是实现类包内方法(接口无此入口),注入实现类 */ + @Autowired + private AssignStudentService studentService; + + @Autowired + private IAssignProjectService projectService; + + @Autowired + private AssignStudentRepository studentRepository; + + @Autowired + private AssignClassRepository classRepository; + + @BeforeEach + void setUp() { + UserContext.setSchId(3); + UserContext.setUserName("junit-genpwd"); + } + + @AfterEach + void tearDown() { + UserContext.clear(); + } + + @Test + void passwordEqualsAccountPlusAtAndFollowsRegeneratedAccount() { + // 高中示例 306 人(导入自带 stu 账号),建 2 班轮转指派 305 人,留 1 人未分班 + studentService.importPaste(request(4, studentService.sampleText(4).get("text"))); + final AssignProject project = projectService.require(4, null, 1); + final AssignClass c1 = saveClass(project.getId(), 1, "1班"); + final AssignClass c2 = saveClass(project.getId(), 2, "2班"); + final List all = studentRepository.findByProjectId(project.getId()); + assertEquals(306, all.size()); + final AssignStudent unassigned = all.get(305); + for (int i = 0; i < 305; i++) { + all.get(i).setClassId(i % 2 == 0 ? c1.getId() : c2.getId()); + } + studentRepository.saveAll(all); + + // 第一轮:导入账号直接作密码源(stu0001 → stu0001@);未分班学生不参与也不计入 skipped + final Map out = studentService.genStudentPassword(4, null, 1); + assertEquals(305, ((Number) out.get("count")).intValue()); + assertEquals(0, ((Number) out.get("skipped")).intValue()); + for (AssignStudent s : studentRepository.findByProjectId(project.getId())) { + if (s.getClassId() == null) { + assertNull(s.getPassword(), "未分班学生不应生成密码"); + } else { + assertEquals(s.getAccount() + "@", s.getPassword()); + } + } + + // 第二轮:重新生成账号(注入前缀)后密码跟随新账号 + studentService.doRenumber(project, true, "dngjxxs2026"); + final Map out2 = studentService.genStudentPassword(4, null, 1); + assertEquals(305, ((Number) out2.get("count")).intValue()); + for (AssignStudent s : studentRepository.findByProjectId(project.getId())) { + if (s.getClassId() != null) { + assertEquals(s.getAccount() + "@", s.getPassword()); + assertTrue(s.getPassword().startsWith("dngjxxs2026"), + "密码应跟随重新生成的账号:" + s.getPassword()); + } + } + } + + private AssignClass saveClass(Integer projectId, int sortOrder, String name) { + final AssignClass c = new AssignClass(); + c.setProjectId(projectId); + c.setSortOrder(sortOrder); + c.setName(name); + c.setAbFlag(false); + return classRepository.save(c); + } + + private ImportPasteRequest request(int stage, String text) { + final ImportPasteRequest req = new ImportPasteRequest(); + req.setStage(stage); + req.setTerm(1); // getOrCreate 的 term 是基本类型,必须给值(前端总会传) + req.setText(text); + req.setHasHeader(Boolean.TRUE); + req.setMode("replace"); + return req; + } +} diff --git a/src/test/java/com/yunhai/acaclassassign/service/impl/ImportPasteFlowTest.java b/src/test/java/com/yunhai/acaclassassign/service/impl/ImportPasteFlowTest.java new file mode 100644 index 0000000..d384380 --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/service/impl/ImportPasteFlowTest.java @@ -0,0 +1,62 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.yunhai.acaclassassign.model.assign.ImportPasteRequest; +import com.yunhai.acaclassassign.model.assign.ImportResultVo; +import com.yunhai.acaclassassign.service.IAssignStudentService; +import com.yunhai.acaclassassign.utils.UserContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * 粘贴导入流程回归(2026-10-20 排障遗留):真实 Excel 粘贴文本(Tab 分隔、含表头、 + * 备注含"同名"、预设列含纯数字 1),覆盖解析→实体→落库全流程。 + * year 传 null 模拟前端 store.year 未就绪时的请求(服务端 getOrCreate 会回退当前学年)。 + */ +@SpringBootTest +@Transactional +class ImportPasteFlowTest { + + private static final String PASTE = "姓名\t性别\t总分\t语文\t数学\t英语\t特长生\t住宿\t毕业学校\t备注\t预设班级\n" + + "学生001\t男\t213\t71\t66\t76\t否\t否\t毕业学校2\t\t\n" + + "学生002\t女\t216\t72\t67\t77\t否\t否\t毕业学校3\t\t\n" + + "学生003\t男\t219\t73\t68\t78\t是\t否\t毕业学校4\t同名\t\n" + + "学生004\t女\t222\t74\t69\t79\t否\t是\t毕业学校5\t\t\n" + + "学生005\t男\t225\t75\t70\t80\t否\t否\t毕业学校6\t\t1\n"; + + @Autowired + private IAssignStudentService studentService; + + @BeforeEach + void setUp() { + UserContext.setSchId(3); + UserContext.setUserName("junit-import"); + } + + @AfterEach + void tearDown() { + UserContext.clear(); + } + + @Test + void importPasteWithRealExcelText() { + ImportPasteRequest req = new ImportPasteRequest(); + req.setStage(4); + req.setYear(null); + req.setTerm(1); + req.setText(PASTE); + req.setHasHeader(Boolean.TRUE); + req.setMode("replace"); + ImportResultVo out = studentService.importPaste(req); + assertNotNull(out); + assertEquals(5, out.getInserted()); + assertEquals(3, out.getSubjects().size()); + assertEquals(2, out.getTags().size()); + } +} diff --git a/src/test/java/com/yunhai/acaclassassign/service/impl/PushClassAliasTest.java b/src/test/java/com/yunhai/acaclassassign/service/impl/PushClassAliasTest.java new file mode 100644 index 0000000..50d6775 --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/service/impl/PushClassAliasTest.java @@ -0,0 +1,52 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.yunhai.acaclassassign.entity.AssignClass; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 推送班级别名口径:按序号生成的班名(纯数字+"班")不带 name 字段(平台 cname 留空), + * 语义班名(阳光班/苹果班这种)才作为别名推送。 + */ +class PushClassAliasTest { + + private AssignClass clazz(int sort, String name, Integer teacherId) { + AssignClass c = new AssignClass(); + c.setSortOrder(sort); + c.setName(name); + c.setTeacherId(teacherId); + return c; + } + + @Test + void 序号班名不推别名() { + for (String name : new String[]{"1班", "2班", "3班", "10班", "01班"}) { + assertTrue(PushClassService.isSeqClassName(name), name); + Map item = PushClassService.buildClassItem(clazz(1, name, 88)); + assertFalse(item.containsKey("name"), name + " 不应带 name(别名)"); + assertEquals(1, item.get("sort")); + assertEquals(88, item.get("teacherId")); + } + } + + @Test + void 语义班名推别名() { + for (String name : new String[]{"阳光班", "苹果班", "1班(实验)", "实验1班", "高一1班"}) { + assertFalse(PushClassService.isSeqClassName(name), name); + Map item = PushClassService.buildClassItem(clazz(2, name, null)); + assertEquals(name, item.get("name"), name + " 应作为别名推送"); + } + } + + @Test + void 空班名不推别名() { + assertTrue(PushClassService.isSeqClassName(null)); + assertTrue(PushClassService.isSeqClassName(" ")); + assertFalse(PushClassService.buildClassItem(clazz(1, " ", null)).containsKey("name")); + } +} diff --git a/src/test/java/com/yunhai/acaclassassign/service/impl/SampleTextFlowTest.java b/src/test/java/com/yunhai/acaclassassign/service/impl/SampleTextFlowTest.java new file mode 100644 index 0000000..cd8087e --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/service/impl/SampleTextFlowTest.java @@ -0,0 +1,75 @@ +package com.yunhai.acaclassassign.service.impl; + +import com.yunhai.acaclassassign.model.assign.ImportPasteRequest; +import com.yunhai.acaclassassign.model.assign.ImportResultVo; +import com.yunhai.acaclassassign.model.assign.StudentPageVo; +import com.yunhai.acaclassassign.service.IAssignStudentService; +import com.yunhai.acaclassassign.utils.UserContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 示例数据流程:SampleText 生成文本(不落库)→ 前端填粘贴框 → ImportPaste 落库。 + * 与页面实际操作链路一致;事务内执行并回滚,不污染本地联调项目数据。 + */ +@SpringBootTest +@Transactional +class SampleTextFlowTest { + + @Autowired + private IAssignStudentService studentService; + + @BeforeEach + void setUp() { + UserContext.setSchId(3); + UserContext.setUserName("junit-sample"); + } + + @AfterEach + void tearDown() { + UserContext.clear(); + } + + @Test + void sampleFillsPasteBoxThenImportsViaPasteChannel() { + final String text = studentService.sampleText(4).get("text"); + assertNotNull(text); + assertTrue(text.startsWith("姓名\t")); + + // 与前端同流程:粘贴框文本 → ImportPaste 整表替换落库 + final ImportResultVo out = studentService.importPaste(request(4, text)); + assertEquals(306, out.getInserted()); + assertEquals(306, out.getTotal()); + assertEquals(3, out.getSubjects().size()); + assertEquals(2, out.getTags().size()); + + final StudentPageVo page = studentService.page(4, null, 1, null, "张三", 1, 10); + assertEquals(2, page.getContent().size()); + assertTrue(page.getContent().stream().allMatch(s -> "张三".equals(s.getName()))); + } + + @Test + void primarySampleHasNoScoreColumns() { + final ImportResultVo out = studentService.importPaste(request(2, studentService.sampleText(2).get("text"))); + assertEquals(60, out.getInserted()); + assertTrue(out.getSubjects().isEmpty()); + } + + private ImportPasteRequest request(int stage, String text) { + final ImportPasteRequest req = new ImportPasteRequest(); + req.setStage(stage); + req.setTerm(1); // getOrCreate 的 term 是基本类型,必须给值(前端总会传) + req.setText(text); + req.setHasHeader(Boolean.TRUE); + req.setMode("replace"); + return req; + } +} diff --git a/src/test/java/com/yunhai/acaclassassign/utils/PinyinInitialsTest.java b/src/test/java/com/yunhai/acaclassassign/utils/PinyinInitialsTest.java new file mode 100644 index 0000000..744e4d3 --- /dev/null +++ b/src/test/java/com/yunhai/acaclassassign/utils/PinyinInitialsTest.java @@ -0,0 +1,33 @@ +package com.yunhai.acaclassassign.utils; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** 校名拼音首字母(GB2312 边界表法);校名样本含需求给出的「大宁国际小学」基准例 */ +class PinyinInitialsTest { + + @Test + void schoolNames() { + assertEquals("dngjxx", PinyinInitials.initials("大宁国际小学")); + assertEquals("yhzx", PinyinInitials.initials("云海中学")); + assertEquals("yhxx", PinyinInitials.initials("云海小学")); + assertEquals("xyzx", PinyinInitials.initials("信阳中学")); + assertEquals("dyzx", PinyinInitials.initials("第一中学")); + assertEquals("syzx", PinyinInitials.initials("实验中学")); + assertEquals("zxxx", PinyinInitials.initials("中心小学")); + assertEquals("rmxx", PinyinInitials.initials("人民小学")); + assertEquals("fsxx", PinyinInitials.initials("附属小学")); + } + + @Test + void mixedAndEdge() { + // 字母数字原样保留(转小写)、全角符号丢弃、空串安全 + assertEquals("d1zx", PinyinInitials.initials("第1中学")); + assertEquals("azx", PinyinInitials.initials("A中学")); + assertEquals("syxx", PinyinInitials.initials("(实验)小学")); + assertEquals("ab12", PinyinInitials.initials("AB12")); + assertEquals("", PinyinInitials.initials(null)); + assertEquals("", PinyinInitials.initials("")); + } +}