commit
build / build-push (push) Successful in 1m29s

This commit is contained in:
wanghu
2026-10-21 15:00:26 +08:00
parent b857575710
commit 9e83208f35
117 changed files with 13358 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
target/
.idea/
*.iml
*.log
.DS_Store
+13
View File
@@ -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
+52
View File
@@ -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
+9
View File
@@ -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
+211
View File
@@ -0,0 +1,211 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yunhai</groupId>
<artifactId>acaclassassign</artifactId>
<version>0.0.1</version>
<name>AcaClassAssign</name>
<description>分班管理子应用(新生入学分班)</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencyManagement>
<!-- 基础平台版本管理(含 yh-* 架包与 spring-boot-dependencies 对齐) -->
<dependencies>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-platform-dependencies</artifactId>
<version>1.0.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring Cloud Alibaba(Nacos 配置中心,对齐 Boot 2.7) -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2021.0.5.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- 仅引入 Spring Security 的加密模块(BCryptPasswordEncoder),不引入整套安全框架 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- AOP(UserContext 填充切面 + 审计日志切面) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- Boot 2.4+ 默认不再处理 bootstrap.yml,需显式引入以继续从 Nacos 拉取配置 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
<version>3.1.8</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JSON 序列化(fastjson,主类开启安全模式) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.80</version>
</dependency>
<!-- Swagger / OpenAPI 文档(springdoc,兼容 Boot 2.7) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.7.0</version>
</dependency>
<!-- 基础平台全家桶(聚合 starter):认证(CAS/会话/SSO)+ 应用间签名鉴权 + 用户中心静态门面
+ 统一响应 + actuator 健康探活,一行带齐(模块构成见 BasePlatform/后端项目知识库.md)。
约定:标准业务服务一律引聚合 starter;平台能力提供方(如 UserBase)按需单引。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-spring-boot-starter</artifactId>
</dependency>
<!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。
版本 3.5.1 已在本地 .m2 仓库。 -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.5.1</version>
</dependency>
<!-- Excel 导入导出(EasyExcel 3.x,注解驱动、流式写入内存友好) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.2</version>
</dependency>
<!-- 名单导出按平台学生导入模板生成 .xls(HSSF 直接操作工作簿);版本与 easyexcel 传递依赖一致 -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>4.1.2</version>
</dependency>
<!-- 不引 spring-boot-starter-data-redis / cache:代码零使用,data-redis 会自动装配
RedisHealthIndicator,连不上 redis 会把 /actuator/health 整体拉成 DOWN(503)。 -->
</dependencies>
<build>
<finalName>acaclassassign</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- 关闭增量编译,避免 Windows 下 maven-status 状态文件被锁导致
"Error while storing the mojo status / 拒绝访问" -->
<useIncrementalCompilation>false</useIncrementalCompilation>
</configuration>
</plugin>
<!-- 打可执行 fat jar(Docker 镜像直接 java -jar 启动) -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.yunhai.acaclassassign.AcaClassAssignApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>nexus-public</id>
<name>bsch/nexus public</name>
<url>http://nexus.yunhaixingtong.com/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
<!--
ci 构建档案:将构建产物重定向到 IDE(IntelliJ/Eclipse)索引目录之外,
避免 IDE 后台进程持续锁定 target/maven-status/.../createdFiles.lst 导致
"Error while storing the mojo status / 拒绝访问"。
输出目录需从未被 IDE 打开过。常规本地构建无需激活此 profile。
-->
<profiles>
<profile>
<id>ci</id>
<build>
<directory>C:/aca-build/acaclassassign</directory>
</build>
</profile>
</profiles>
</project>
@@ -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);
}
}
@@ -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)。
* <p>@Order(1) 先于审计日志切面(Order 3)执行,保证日志能取到 UserContext。
* 取用户优先级:方法参数 BoaUser → BoaUserContext 静态持有者 → request attribute;
* 角色切换信息从 currentrole(Header/Query,URL 编码 JSON)解析。</p>
*/
@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<String> 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());
}
}
@@ -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)。
* <p>全量拦截 controller 包所有方法,不依赖注解;排除日志查询控制器自身(防递归);
* 用户信息来自 {@link ControllerUserContextAspect}(Order 1)填充的 UserContext;
* 响应体 GZIP 压缩落库,落库走 @Async 不阻塞请求。</p>
*/
@Aspect
@Component
@Order(3)
public class SysOperationLogAspect {
private static final Logger log = LoggerFactory.getLogger(SysOperationLogAspect.class);
/** 排除的控制器(日志查询自身,防递归刷屏) */
private static final List<String> 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<Object> 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";
}
}
@@ -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();
}
}
@@ -0,0 +1,10 @@
package com.yunhai.acaclassassign.common.validation;
/**
* JSR-303 分组校验标记接口(对齐 UserBase 规范)。
* Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。
*/
public interface ValidationGroups {
interface Create {}
interface Update {}
}
@@ -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";
}
@@ -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));
}
}
@@ -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"));
}
}
@@ -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)。
* <p>工作台信息、班级设置、规则、提交锁定。返回裸对象由 ApiResponseAdvice 包装。</p>
*/
@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<YearTermItemVo> yearList() {
return projectService.yearList();
}
/** 保存班级设置 */
@PostMapping("SaveClasses")
public List<ClassItemVo> 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));
}
}
@@ -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<String> matchTeachers(@RequestParam Integer stage,
@RequestParam(required = false) String year,
@RequestParam(required = false) Integer term) {
return runService.matchTeachers(stage, year, term);
}
/** 班级均分报表(高中/初中) */
@GetMapping("ClassReport")
public List<ClassReportVo> 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);
}
}
@@ -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<String, String> 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<Double> 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<Double> 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<String, Object> 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<String, Object> 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<String, Object> genPassword(@RequestParam Integer stage,
@RequestParam(required = false) String year,
@RequestParam(required = false) Integer term) {
return studentService.genStudentPassword(stage, year, term);
}
}
@@ -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/)。
* <p>承载:GetUserInfo / GetUserMod / GetSchoolAll / GetClassTree / SetRole / Logout。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
*/
@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<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
Integer schId = schIdOf(authUserWithDept);
return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId);
}
// ============================ GetSchoolAll ============================
/**
* 获取当前用户学校组及教师(树形结构,选人数据源)。
*/
@GetMapping("GetSchoolAll")
public List<Map<String, Object>> getSchoolAll(BoaUser authUserWithDept) {
return userCenterService.getSchoolAllTree(schIdOf(authUserWithDept));
}
// ============================ GetClassTree ============================
/**
* 年级→班级树(叶子=班级,附学生数)。数据来自班级服务学生树(5 分钟缓存)。
*/
@GetMapping("GetClassTree")
public List<ClassTreeNodeVo> 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;
}
}
@@ -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/)。
* <p>本控制器被 SysOperationLogAspect 排除,避免日志查询自身再产生日志(防递归)。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/log/")
public class LogController {
@Autowired
private ISysOperationLogService logService;
/** 分页查询审计日志(responseData 不返回) */
@GetMapping("Page")
public BasePageResult<SysOperationLog> page(BasePageQuery query,
@RequestParam(value = "operationType", required = false) String operationType,
@RequestParam(value = "statusCode", required = false) Integer statusCode) {
return logService.page(query, operationType, statusCode);
}
}
@@ -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班标记)。
* <p>班主任只保存平台教师 id(teacherId),教师候选来自基础平台 GetSchoolAll;
* teacherName 为保存时刻的展示冗余,不建任何账号字段。</p>
*/
@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;
}
@@ -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;
/**
* 分班项目:每校 × 学段 × 学年 × 学期一条,承载提交锁定状态。
* <p>提交后该学年学期锁定为只读(不可改数据/规则/重新分班),撤销只能重置全部。</p>
*/
@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;
}
@@ -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;
/**
* 分班结果推送记录:每项目一条(提交分班结果 → 推送班级管理)。
* <p>推送与提交解耦:推送异常不回滚提交锁定,只影响本表状态;
* 状态机 0未推送 → 4推送中 → 1已推送 / 2部分失败 / 3失败(推送中拒绝重复 Push)。
* 重推幂等依赖被推方按 code/account 查重,本表只收敛状态与失败明细。</p>
*/
@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;
}
@@ -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;
/**
* 分班结果:每项目保留最新一次运行(重新分班覆盖)。
* <p>学生落位写回 aca_assign_student.class_id/level;本表存指标、日志与分层元信息。</p>
*/
@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;
}
@@ -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;
/**
* 分班规则:每项目一条(首次保存时创建)。
* <p>均衡维度勾选与权重、约束开关、分数段方案、高中 A/B 分层设置。</p>
*/
@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;
}
@@ -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;
/**
* 分班学生:导入名单的数据行。学号/账号为标识文本列(仅展示/导出,不参与均衡),
* 不存联系方式,也不与平台账号做关联。
* <p>单科成绩、是否型/数值型自定义字段为动态结构,以 JSON 文本列存储;
* 分班结果写回 classId(与 level A/B 层)。</p>
*/
@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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,33 @@
package com.yunhai.acaclassassign.model;
import lombok.Data;
import java.util.List;
/**
* 统一分页响应外壳。
*/
@Data
public class BasePageResult<T> {
/** 总记录数 */
private int total;
/** 当前页数据 */
private List<T> content;
/** 当前页码(从 1 起) */
private int pageIndex;
/** 每页条数 */
private int pageSize;
/** 总页数 */
private int totalPages;
/** 是否还有下一页 */
private boolean hasNext;
public BasePageResult(List<T> 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;
}
}
@@ -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<Integer> 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<Integer> getGroupIds() {
return groupIds;
}
public void setGroupIds(List<Integer> groupIds) {
this.groupIds = groupIds;
}
}
@@ -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;
}
@@ -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;
}
@@ -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<String> conflicts = new ArrayList<>();
}
@@ -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<CheckItem> checks = new ArrayList<>();
@Data
public static class CheckItem {
private String name;
/** ok / warn / bad */
private String level;
private String msg;
}
}
@@ -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<String> subjects;
/** 参与均衡的数值型字段列表 */
private List<String> nums;
/** 参与均衡的是否型字段列表 */
private List<String> tags;
}
@@ -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<String> labels = new ArrayList<>();
/** 每段一行:全部 + 各班人数 */
private List<BandRow> rows = new ArrayList<>();
private long total;
private double maxScoreSeen;
private double minScoreSeen;
@Data
public static class BandRow {
/** 区间标签 */
private String label;
/** 各班人数,key=班名;含 "全部" */
private List<Long> counts = new ArrayList<>();
}
}
@@ -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<Bar> avgBars = new ArrayList<>();
/** 各班性别结构(堆叠条形) */
private List<GenderBar> genderBars = new ArrayList<>();
/** 各班分数段分布 */
private List<BandStack> 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<Long> counts = new ArrayList<>();
}
}
@@ -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<String, BigDecimal> subjectAvg = new LinkedHashMap<>();
/** 数值字段均分 */
private Map<String, BigDecimal> numAvg = new LinkedHashMap<>();
/** 是否型字段人数 {字段:人数} */
private Map<String, Long> tagCount = new LinkedHashMap<>();
/** 分数段人数(与 bandCuts 对齐) */
private List<Long> bandCounts = new ArrayList<>();
/** A/B 层人数 {A:n,B:m}(within 模式) */
private Map<String, Integer> levelCounts = new LinkedHashMap<>();
/** 前若干名(mini 柱状图,按总分降序,最多12) */
private List<BigDecimal> topScores = new ArrayList<>();
/** 全班名单 */
private List<StudentVo> students = new ArrayList<>();
}
@@ -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;
}
@@ -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<String, BigDecimal> subjectAvg = new LinkedHashMap<>();
/** 三科总分均分(语数英齐才时,参考项) */
private BigDecimal core3Avg;
/** 总分均分 */
private BigDecimal totalAvg;
}
@@ -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;
}
@@ -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;
}
@@ -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<String> warn = new ArrayList<>();
/** 男生数 */
private long male;
/** 女生数 */
private long female;
/** 识别到的单科列 */
private List<String> subjects = new ArrayList<>();
/** 是否型字段 */
private List<String> tags = new ArrayList<>();
/** 数值型字段 */
private List<String> nums = new ArrayList<>();
/** 毕业学校数 */
private int schools;
}
@@ -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<Double> perClass = new ArrayList<>();
}
@@ -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<String> names;
/** 目标班级id */
private Integer classId;
}
@@ -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<String> unmatched = new ArrayList<>();
}
@@ -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<ClassItemVo> 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;
}
}
@@ -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;
}
@@ -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<FailItem> 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 "未推送";
}
}
}
@@ -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;
}
@@ -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<MetricItemVo> metrics = new ArrayList<>();
/** 加权相对偏差(%) */
private double weightedDev;
/** 警告 */
private List<String> warns = new ArrayList<>();
/** 日志(算法/校验/手动调班/班主任匹配) */
private List<String> logs = new ArrayList<>();
/** 分数段切点标签 */
private List<String> bandLabels = new ArrayList<>();
private ChartsVo charts = new ChartsVo();
private List<ClassCardVo> 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;
}
}
@@ -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<ClassItemVo> classes;
}
@@ -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;
}
@@ -0,0 +1,37 @@
package com.yunhai.acaclassassign.model.assign;
import lombok.Data;
import java.util.List;
/**
* 生源分析:毕业学校 × 分数档交叉表(高中/初中;仅结构分析,不影响分班)。
*/
@Data
public class SourceAnalysisVo {
/** 分数线(升序) */
private List<Double> cuts;
/** 分数档标签(左闭右开) */
private List<String> labels;
/** 每校一行:counts 与 labels 对齐 */
private List<SchoolRow> rows;
/** 各档合计 */
private List<Long> sums;
/** 录取总人数 */
private long total;
@Data
public static class SchoolRow {
private String school;
private List<Long> counts;
private long total;
}
}
@@ -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;
}
@@ -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<StudentVo> {
/** 单科列名(按导入出现序,如 语文、数学、英语、物理、化学) */
private List<String> subjects;
/** 是否型列名(按导入出现序,如 特长生、住宿) */
private List<String> tags;
/** 数值型列名(按导入出现序,如 视力分) */
private List<String> nums;
/** 年级名称(项目全体学生同一入学年级;班级管理字典名,不可得回退 K12 标准新生年级名) */
private String gradeName;
public StudentPageVo(BasePageResult<StudentVo> page) {
super(page.getContent(), page.getTotal(), page.getPageIndex(),
page.getPageSize(), page.getTotalPages(), page.isHasNext());
}
}
@@ -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<FieldStat> 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;
}
}
@@ -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;
}
@@ -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<String, Object> scores;
/** 是否型字段 {"特长生":true} */
private Map<String, Object> tags;
/** 数值型字段 {"视力分":4.8} */
private Map<String, Object> 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;
}
@@ -0,0 +1,12 @@
package com.yunhai.acaclassassign.model.base;
import lombok.Data;
/**
* 角色切换入参(对齐前端 baseApi.setRole)。
*/
@Data
public class SetRoleRequest {
/** "teacher" 或 "parent" */
private String as;
}
@@ -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;
}
@@ -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<ClassTreeNodeVo> children;
public void setParent(boolean parent) {
this.parent = parent;
}
public boolean isParent() {
return parent;
}
}
@@ -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;
}
@@ -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;
}
@@ -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<ClassItem> 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<StudentItem> results;
}
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class StudentItem {
private String name;
private String account;
private Boolean ok;
private Integer stuId;
/** 失败原因 / 幂等命中说明 */
private String reason;
}
}
@@ -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;
}
@@ -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<AssignClass, Integer> {
List<AssignClass> findByProjectIdOrderBySortOrderAsc(Integer projectId);
long countByProjectId(Integer projectId);
void deleteByProjectId(Integer projectId);
}
@@ -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<AssignProject, Integer> {
Optional<AssignProject> findBySchIdAndStageAndYearAndTerm(Integer schId, Integer stage, String year, Integer term);
}
@@ -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<AssignPush, Integer> {
/** 每项目唯一一条推送记录 */
Optional<AssignPush> findByProjectId(Integer projectId);
/** 项目重置时一并清除推送记录(远端数据不随本地重置回收) */
void deleteByProjectId(Integer projectId);
}
@@ -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<AssignResult, Integer> {
Optional<AssignResult> findByProjectId(Integer projectId);
void deleteByProjectId(Integer projectId);
}
@@ -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<AssignRule, Integer> {
Optional<AssignRule> findByProjectId(Integer projectId);
void deleteByProjectId(Integer projectId);
}
@@ -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<AssignStudent, Integer> {
List<AssignStudent> findByProjectId(Integer projectId);
long countByProjectId(Integer projectId);
void deleteByProjectId(Integer projectId);
}
@@ -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<SysOperationLog, Long>, JpaSpecificationExecutor<SysOperationLog> {
}
@@ -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;
/**
* 分班项目:学年学期维度的工作台信息、班级设置、规则、提交锁定。
* <p>schId 统一取 {@code UserContext.getSchId()}(多校隔离)。</p>
*/
public interface IAssignProjectService {
/** 工作台信息(项目不存在则自动创建草稿) */
ProjectInfoVo info(int stage, String year, int term);
/** 当前学年学期(平台接口优先,失败本地推断) */
ProjectInfoVo.CurrentYear nowYearTerm();
/** 学年下拉列表(班级管理学年学期接口,按学年倒序;不可达返回空列表由前端回退) */
List<YearTermItemVo> yearList();
/** 保存班级设置(全量替换;班主任只存平台 teacherId) */
List<ClassItemVo> 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);
}
@@ -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<String> matchTeachers(int stage, String year, Integer term);
/** 班级均分报表(高中/初中) */
List<ClassReportVo> 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;
}
@@ -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<String, String> 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<Double> cuts);
/** 生源分析交叉表 CSV 下载(与页面 SourceAnalysis 同口径) */
void exportSource(int stage, String year, Integer term, List<Double> 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<String, Object> 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<String, Object> genStudentAccount(int stage, String year, Integer term, String scope);
/**
* 生成密码=账号+@(覆盖已有密码)。仅已分班且有账号的学生参与,无账号的跳过并计数;
* 全部无账号报错提示先生成账号。返回 {count, skipped}。
*/
java.util.Map<String, Object> genStudentPassword(int stage, String year, Integer term);
/** 实体 → VO */
StudentVo toVo(com.yunhai.acaclassassign.entity.AssignStudent entity,
java.util.Map<Integer, String> classNameById);
}
@@ -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}。
* <ul>
* <li>教师:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}}</li>
* <li>学生树:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?schId={schId}&toUser=true}
* (学校→学段→年级→班级→学生)</li>
* <li>学科:GET {@code {bschclass}/oapi/sch/getSchSubjectList?schId={schId}}</li>
* <li>当前学年学期:GET {@code {bschclass}/oapi/sch/getNowYearTerm}</li>
* <li>学年学期列表:GET {@code {bschclass}/oapi/sch/getYearTermList}(按学年倒序,当前学年带 nowYear 标记)</li>
* </ul>
*/
public interface IClassService {
/**
* 获取当前学年学期。
* 返回当前学年 year、当前学期 term(1第一学期 2第二学期)及学年/学期起止日期。
*/
NowYearTermVo getNowYearTerm();
/**
* 获取全部学年列表(带 5 分钟内存缓存,按学年倒序)。
* 外部接口不可达时返回空列表(调用方降级,不阻断分班业务)。
*/
List<YearTermItemVo> getYearTermList();
/**
* 获取指定学校全部教师(含所属组 GroupIds)。
*/
List<SchTeaList> getSchTeaList(int schId);
/**
* 获取学校学科列表(带 5 分钟内存缓存)。
* 外部接口不可达时返回空列表(调用方降级,不阻断分班业务)。
*/
List<SubjectVo> getSubjectList(int schId);
/**
* 获取学校学生树原始节点(带 5 分钟内存缓存)。
* 节点字段:id/name/pid/type(1组 2学生)/isParent/children。
* 外部接口不可达时返回 null(调用方降级处理,不阻断业务)。
*/
List<Map<String, Object>> getStuTree(int schId);
/**
* 教师任教列表(bschclass GET /oapi/sch/getTeacherTeachList,带 5 分钟内存缓存)。
* 项字段:id/schId/classId(班级组ID)/className/gradeId/gradeName/teaId/teaName/subId/subName。
* 外部接口不可达时返回空列表(调用方降级,不阻断业务)。
*/
List<Map<String, Object>> getTeacherTeachList(int schId, int teaId);
/**
* 学校班级列表(bschclass GET /oapi/sch/getSchClassList,不含毕业班,带 5 分钟内存缓存)。
* 项字段:id/classId(班级组ID)/name/gradeId/gradeName/firstTeacherId/firstTeacherName/
* secondTeacherId/secondTeacherName/graduate 等。
* 外部接口不可达时返回空列表(调用方降级,不阻断业务)。
*/
List<Map<String, Object>> getSchClassList(int schId);
/**
* 年级名称(bschclass GET /oapi/sch/getGradeName,按学校+入学年份+学段查学校年级字典别称,
* 带 5 分钟内存缓存)。stage 为班级管理学段口径(1小学/2初中/3高中)。
* 接口不可达 / 学制或年级无匹配时返回 null(调用方降级,不阻断业务)。
*/
String getGradeName(int schId, int year, int stage);
/**
* 年级→班级树(叶子=班级,附 studentCount),由学生树派生。
* 外部接口不可达时返回空列表。
*/
List<ClassTreeNodeVo> 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<String, Object> resolveStudent(int schId, Integer studentId);
/**
* 班级→学生名单(roster):{studentId, studentName, classId, className} 列表。
* classIds 为空集合时返回空列表。
*/
List<Map<String, Object>> studentsOfClasses(int schId, List<Integer> classIds);
}
@@ -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)服务。
* <p>链路:AddClass(同步组,返回 本地班→平台classId 映射)→ AddClassStudent(用户中心建号+sch_student 落库)。
* 推送与提交解耦:推送异常不影响提交锁定状态。</p>
*/
public interface IPushClassService {
/**
* 推送分班结果(整体:全部班级 + 全部已分班学生)。
* retry=true 时只重推 fail_json 中的失败学生(AddClass 幂等仍会整体调用)。
*/
PushStatusVo push(AssignProject project, boolean retry);
/** 查询推送状态(无记录返回未推送状态,不抛错) */
PushStatusVo status(AssignProject project);
}
@@ -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<SysOperationLog> page(BasePageQuery query, String operationType, Integer statusCode);
}
@@ -0,0 +1,51 @@
package com.yunhai.acaclassassign.service;
import java.util.List;
import java.util.Map;
/**
* 用户中心门面服务(对齐 UserBase {@code UserCenterService} 模式)。
* <p>封装 {@code YschBaseOpenApi} / {@code OpenApiUserClient} 静态门面调用,
* 把原本散落在 Controller 的组树组装、教师挂组、成员解析等业务逻辑下沉到 Service 层,
* Controller 只保留"取参 → 调 Service → 返回"的薄壳。</p>
*/
public interface IUserCenterService {
/**
* 获取当前用户学校组及用户(树形结构)。
* <p>组装规则(从原 BaseApiController.getSchoolAll 下沉):</p>
* <ol>
* <li>userbase 读取当前学校整棵组树(getGroupById(schId, withSub=true)),剔除学生组(type=8)及其子组;</li>
* <li>bschclass 获取该校全部教师(/oapi/DbClass/GetSchteaList?Group=schId),按 GroupIds 挂到对应组下,
* 无有效组(如所属组全被剔除)的教师兜底挂学校根节点;</li>
* <li>扁平节点按 pid 递归组装为树(空组剔除,同 C# FormatUserTree)。</li>
* </ol>
*
* @param schId 学校ID(用户当前角色所属学校)
* @return 用户树形结构(组节点 type=1 / 教师节点 type=2)
*/
List<Map<String, Object>> getSchoolAllTree(Integer schId);
/**
* 获取指定组内用户(教师 + 管理员)。
* <p>对应前端 base/GetGroupUser。原实现为空桩返回空列表,现按 C# 语义
* (ITeainfoBLL.GetGroupTeainfo + IAdminfoBLL.GetGroupAdminfo)接入用户中心。
* 若用户中心不可用或未配置对应开放接口,则抛出带语义的异常(显式报错而非静默失能)。</p>
*
* @param gid 组ID
* @param role 角色(默认 2=教师;1=管理员等)
* @return 组内用户列表
*/
List<Map<String, Object>> getGroupUser(Integer gid, Integer role);
/**
* 获取当前用户可访问的模块树(左侧菜单)。
* <p>从基础平台拉取当前用户模块列表(含学校上下文),组装为前端 layout 期望的树结构
* ({@code modId/page/children}),对应前端 GetUserMod 接口。</p>
*
* @param userId 用户ID
* @param schId 学校ID(可为 null)
* @return 模块树(顶层 List,含 children 嵌套)
*/
List<Map<String, Object>> getUserModuleTree(Integer userId, Integer schId);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
package com.yunhai.acaclassassign.service.assign;
import java.util.HashMap;
import java.util.Map;
/**
* 姓名同音判定:内置常用同音字组(姓氏 + 名字常用字)。
* <p>同组内任意字视为同音。soundKey 将姓名逐字映射为组代表字/原字后拼接。</p>
*/
public final class SameSound {
private SameSound() {
}
/** 同音字组(移植自分班原型 45 组常用同音字表) */
private static final String[] GROUPS = {
"张章彰樟", "李理莉鲤里", "王旺望", "刘留流榴", "陈晨尘辰橙",
"杨扬洋阳羊", "黄煌凰皇", "赵兆照罩", "吴梧吾无芜", "徐绪储",
"孙损笋", "马玛码", "朱珠株珠诸", "胡湖糊蝴狐", "郭锅国帼",
"何合河荷和", "高膏糕羔", "林淋琳霖麟", "罗锣萝箩络", "郑正证政挣",
"梁粱良凉", "谢卸蟹泄", "宋送诵颂", "唐糖塘堂棠", "许序絮绪",
"韩含寒涵涵函", "冯逢缝凤", "邓灯登等", "曹槽草漕", "彭澎蓬鹏棚",
"曾增憎", "肖削消宵萧潇霄", "田填甜佃", "董懂冬", "袁元园原源圆员",
"蔡菜采彩", "蒋桨讲奖", "余鱼渔愉于娱俞豫", "杜肚度渡镀", "叶页夜业",
"程成城诚呈橙乘", "苏酥俗", "魏位未卫味畏", "吕铝侣旅履", "丁钉叮盯叮宁"
};
private static final Map<Character, Character> 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();
}
}
@@ -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();
}
}
@@ -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 文件共用)。
* <p>移植原型列识别规则:表头关键词识别、性别归一化、是否/数值型字段推断、预设班级解析。</p>
*/
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<String, Double> scores = new LinkedHashMap<>();
public final Map<String, Boolean> tags = new LinkedHashMap<>();
public final Map<String, Double> 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<ParsedRow> rows = new ArrayList<>();
public final List<String> warn = new ArrayList<>();
/** 识别到的列(保持顺序) */
public final List<String> subjects = new ArrayList<>();
public final List<String> tags = new ArrayList<>();
public final List<String> 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<List<String>> grid = splitGrid(text);
if (grid.isEmpty()) {
result.warn.add("未解析到有效数据行");
return result;
}
// 表头检测:显式指定优先,否则首行出现任一已知关键词即视为表头
boolean header = false;
List<String> head = null;
final List<String> 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<String> 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<String> 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<String> 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<String> 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<List<String>> splitGrid(String text) {
final List<List<String>> 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<String> 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<String> splitCsvLine(String line) {
final List<String> 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;
}
}
@@ -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<YearTermItemVo> yearList() {
try {
final List<YearTermItemVo> list = classService.getYearTermList();
return list == null ? Collections.emptyList() : list;
} catch (Exception e) {
// 平台不可达时返回空列表,前端回退本地推断的学年范围
return Collections.emptyList();
}
}
// ==================== 班级设置 ====================
@Override
@Transactional
public List<ClassItemVo> 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<ClassItemVo> 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<String> 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<Integer, String> teacherNames = teacherNameMap();
// 全量替换:先删后插(分班结果一并清除,因班级清单变化使结果失效)
classRepository.deleteByProjectId(project.getId());
resultRepository.deleteByProjectId(project.getId());
final List<AssignClass> 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<AssignClass> 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<AssignClass> 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<Integer, Long> 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<AssignSubmitCheckVo.CheckItem> 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<ClassItemVo> classVos(Integer projectId) {
final Map<Integer, Long> presetCounts = presetCounts(projectId);
final List<ClassItemVo> 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<Integer, Long> presetCounts(Integer projectId) {
final Map<Integer, Long> 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<String> schools = new HashSet<>();
final List<AssignStudent> 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<Integer, String> teacherNameMap() {
final Map<Integer, String> 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;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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<Integer, CacheEntry> stuTreeCache = new ConcurrentHashMap<>();
/** 学科列表缓存:schId -> {time, subjects} */
private final ConcurrentHashMap<Integer, SubjectCacheEntry> subjectCache = new ConcurrentHashMap<>();
/** 任教列表缓存:schId:teaId -> {time, list} */
private final ConcurrentHashMap<String, MapCacheEntry> teachCache = new ConcurrentHashMap<>();
/** 班级列表缓存:schId -> {time, list} */
private final ConcurrentHashMap<Integer, MapCacheEntry> classListCache = new ConcurrentHashMap<>();
private static class CacheEntry {
final long time;
final List<Map<String, Object>> tree;
CacheEntry(long time, List<Map<String, Object>> tree) {
this.time = time;
this.tree = tree;
}
}
private static class SubjectCacheEntry {
final long time;
final List<SubjectVo> subjects;
SubjectCacheEntry(long time, List<SubjectVo> subjects) {
this.time = time;
this.subjects = subjects;
}
}
/** 通用 Map 列表缓存条目(任教列表 / 班级列表) */
private static class MapCacheEntry {
final long time;
final List<Map<String, Object>> list;
MapCacheEntry(long time, List<Map<String, Object>> list) {
this.time = time;
this.list = list;
}
}
/** 学生树一次性索引:年级/班级/学生平铺 */
private static class StuIndex {
/** classId -> 年级名 */
final Map<Integer, String> gradeNames = new LinkedHashMap<>();
/** classId -> 年级ID */
final Map<Integer, Integer> gradeIds = new LinkedHashMap<>();
/** classId -> 班级名 */
final Map<Integer, String> classNames = new LinkedHashMap<>();
/** classId -> 班级节点(挂学生) */
final Map<Integer, Map<String, Object>> classNodes = new LinkedHashMap<>();
/** studentId -> {studentId, studentName, classId, className} */
final Map<Integer, Map<String, Object>> students = new LinkedHashMap<>();
}
public ClassService() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(10000);
this.restTemplate = new RestTemplate(factory);
}
// ============================ 教师 ============================
@Override
public List<SchTeaList> getSchTeaList(int schId) {
requireHost();
String url = bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId;
try {
ResponseEntity<List<SchTeaList>> resp = restTemplate.exchange(
url, HttpMethod.GET, null,
new ParameterizedTypeReference<List<SchTeaList>>() {
});
List<SchTeaList> 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<NowYearTermVo> 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<YearTermItemVo> list;
YearListCacheEntry(long time, List<YearTermItemVo> list) {
this.time = time;
this.list = list;
}
}
/**
* 全部学年列表(班级服务 GET /oapi/sch/getYearTermList,按学年倒序,5 分钟缓存)。
* 不可达时返回空列表降级(前端回退本地推断),不阻断分班业务。
*/
@Override
public List<YearTermItemVo> 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<YearTermItemVo> list = fetchYearTermList();
if (list == null) {
// 拉取失败回退旧缓存(若有),否则空列表由调用方降级
return cached != null ? cached.list : Collections.emptyList();
}
yearListCache = new YearListCacheEntry(now, list);
return list;
}
}
private List<YearTermItemVo> fetchYearTermList() {
if (!StringUtils.hasText(bschClassHost)) {
log.warn("[fetchYearTermList] 班级管理接口未配置(yh.open.bschclass)");
return Collections.emptyList();
}
String url = bschClassHost + "/oapi/sch/getYearTermList";
try {
ResponseEntity<List<Map<String, Object>>> resp = restTemplate.exchange(
url, HttpMethod.GET, null,
new ParameterizedTypeReference<List<Map<String, Object>>>() {
});
List<Map<String, Object>> body = resp.getBody();
List<YearTermItemVo> list = new ArrayList<>();
if (body != null) {
for (Map<String, Object> 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<SubjectVo> 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<SubjectVo> subjects = fetchSubjects(schId);
if (subjects == null) {
// 拉取失败回退旧缓存(若有)
return cached != null ? cached.subjects : Collections.emptyList();
}
subjectCache.put(schId, new SubjectCacheEntry(now, subjects));
return subjects;
}
}
private List<SubjectVo> fetchSubjects(int schId) {
String url = bschClassHost + "/oapi/sch/getSchSubjectList?schId=" + schId;
try {
ResponseEntity<List<Map<String, Object>>> resp = restTemplate.exchange(
url, HttpMethod.GET, null,
new ParameterizedTypeReference<List<Map<String, Object>>>() {
});
List<Map<String, Object>> body = resp.getBody();
if (body == null) {
return Collections.emptyList();
}
List<SubjectVo> subjects = new ArrayList<>();
for (Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<List<Map<String, Object>>> resp = restTemplate.exchange(
url, HttpMethod.GET, null,
new ParameterizedTypeReference<List<Map<String, Object>>>() {
});
List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> list = fetchSchClassList(schId);
if (list == null) {
return cached != null ? cached.list : Collections.emptyList();
}
classListCache.put(schId, new MapCacheEntry(now, list));
return list;
}
}
private List<Map<String, Object>> 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<List<Map<String, Object>>> resp = restTemplate.exchange(
url, HttpMethod.GET, null,
new ParameterizedTypeReference<List<Map<String, Object>>>() {
});
List<Map<String, Object>> 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<String, ValueCacheEntry> 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<Map<String, Object>> resp = restTemplate.exchange(
url, HttpMethod.GET, null,
new ParameterizedTypeReference<Map<String, Object>>() {
});
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<List<Map<String, Object>>> resp = restTemplate.exchange(
url, HttpMethod.GET, null,
new ParameterizedTypeReference<List<Map<String, Object>>>() {
});
List<Map<String, Object>> 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<ClassTreeNodeVo> getClassTree(int schId) {
List<Map<String, Object>> tree = getStuTree(schId);
List<ClassTreeNodeVo> result = new ArrayList<>();
if (tree == null) {
return result;
}
StuIndex index = buildIndex(tree);
Map<Integer, List<ClassTreeNodeVo>> gradeClasses = new LinkedHashMap<>();
Map<Integer, String> gradeNodeNames = new LinkedHashMap<>();
for (Map.Entry<Integer, String> 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<Integer, List<ClassTreeNodeVo>> 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<String, Object> 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<String, Object> resolveStudent(int schId, Integer studentId) {
StuIndex index = indexOrNull(schId);
return index == null || studentId == null ? null : index.students.get(studentId);
}
@Override
public List<Map<String, Object>> studentsOfClasses(int schId, List<Integer> classIds) {
StuIndex index = indexOrNull(schId);
List<Map<String, Object>> result = new ArrayList<>();
if (index == null || classIds == null || classIds.isEmpty()) {
return result;
}
for (Integer classId : classIds) {
Map<String, Object> node = index.classNodes.get(classId);
if (node != null) {
collectStudents(node, classId, index.classNames.get(classId), result);
}
}
return result;
}
// ============================ 内部辅助 ============================
private StuIndex indexOrNull(int schId) {
List<Map<String, Object>> tree = getStuTree(schId);
return tree == null ? null : buildIndex(tree);
}
/** 一次遍历学生树,平铺出年级名/班级名/班级节点/学生定位索引 */
@SuppressWarnings("unchecked")
private StuIndex buildIndex(List<Map<String, Object>> tree) {
StuIndex index = new StuIndex();
for (Map<String, Object> school : tree) {
Object systems = school.get("children");
if (!(systems instanceof List)) {
continue;
}
for (Object sysObj : (List<Object>) systems) {
Map<String, Object> system = (Map<String, Object>) sysObj;
Object grades = system.get("children");
if (!(grades instanceof List)) {
continue;
}
for (Object gradeObj : (List<Object>) grades) {
Map<String, Object> grade = (Map<String, Object>) gradeObj;
String gradeName = asStr(grade.get("name"));
Object classes = grade.get("children");
if (!(classes instanceof List)) {
continue;
}
for (Object classObj : (List<Object>) classes) {
Map<String, Object> clazz = (Map<String, Object>) 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<Object>) getChildren(clazz)) {
Map<String, Object> stu = (Map<String, Object>) stuObj;
Integer stuId = asInt(stu.get("id"));
if (stuId == null) {
continue;
}
Map<String, Object> 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<Object> getChildren(Map<String, Object> node) {
Object children = node.get("children");
return children instanceof List ? (List<Object>) children : Collections.emptyList();
}
private int countStudents(Map<String, Object> classNode) {
int count = 0;
for (Object stuObj : getChildren(classNode)) {
Map<String, Object> stu = (Map<String, Object>) stuObj;
if (Integer.valueOf(2).equals(asInt(stu.get("type")))) {
count++;
}
}
return count;
}
private void collectStudents(Map<String, Object> classNode, Integer classId, String className,
List<Map<String, Object>> result) {
for (Object stuObj : getChildren(classNode)) {
Map<String, Object> stu = (Map<String, Object>) stuObj;
if (!Integer.valueOf(2).equals(asInt(stu.get("type")))) {
continue;
}
Map<String, Object> 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<String, Object> 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);
}
}
@@ -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;
/**
* 分班结果推送班级管理实现。
* <p>推送客户端仿 {@link ClassService}(RestTemplate 直连 userclass oapi),差异:写操作、
* 读超时放宽到 60s(学生批量一次提交)、带 X-App-Key/X-Timestamp/X-Signature 三件套头
* (服务端未强制验签时带头无副作用,先带头后启用)。
* 签名口径:待签串=方法+"\n"+target+"\n"+ts,target 仅路径+字典序 query,body 不参与签名。</p>
* <p>刻意不加 @Transactional:推送含远程调用(最长 60s+),各写点(置推送中/落结果)独立提交,
* 崩溃后凭 status=4 的 updateTime 陈旧检测解锁重推。</p>
*/
@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<Integer, AssignStudent> failedStudents = null;
if (retry) {
List<PushStatusVo.FailItem> 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<Integer, AssignStudent> failedOnly) {
Integer schId = project.getSchId();
Integer enrollYear = parseYear(project.getYear());
int stage = toOapiStage(project.getStage());
List<AssignClass> classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId());
if (classes.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "无班级设置,无法推送");
}
// Ⅰ AddClass:同步组(学生组→[学段组]→年级组→班级组+sch_class),返回本地班号→平台 classId
List<Map<String, Object>> classItems = new ArrayList<>();
for (AssignClass c : classes) {
classItems.add(buildClassItem(c));
}
Map<String, Object> 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<Integer, Integer> sortToPlatform = new HashMap<>();
for (SyncClassModels.ClassItem ci : classResult.getClasses()) {
if (ci.getSort() != null && ci.getClassId() != null) {
sortToPlatform.put(ci.getSort(), ci.getClassId());
}
}
Map<Integer, Integer> 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<AssignStudent> students = failedOnly != null
? new ArrayList<>(failedOnly.values())
: studentRepository.findByProjectId(project.getId());
List<Map<String, Object>> stuItems = new ArrayList<>();
// 推送名单中无法定位平台班级/未分班的学生,直接记失败(不发起建号)
Map<Integer, PushStatusVo.FailItem> newFails = new LinkedHashMap<>();
List<AssignStudent> pushable = new ArrayList<>();
// 推送序号:同班内未提供账号的学生按此生成 p{year}{班号}{序号}(确定性,重推同号)
Map<Integer, Integer> 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<String, Object> 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<String, Object> addStuBody = new HashMap<>();
addStuBody.put("schId", schId);
addStuBody.put("students", stuItems);
SyncClassModels.SyncStudentResult stuResult =
postJson("/oapi/class/AddClassStudent", addStuBody, SyncClassModels.SyncStudentResult.class);
List<SyncClassModels.StudentItem> 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<PushStatusVo.FailItem> 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<String, Object> buildClassItem(AssignClass c) {
Map<String, Object> 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<PushStatusVo.FailItem> parseFails(String failJson) {
List<PushStatusVo.FailItem> 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<AssignClass> classes, String year, Map<Integer, Integer> 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> T postJson(String path, Object body, Class<T> 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<String> 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);
}
}
}
@@ -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<SysOperationLog> page(BasePageQuery query, String operationType, Integer statusCode) {
Specification<SysOperationLog> spec = (root, cq, cb) -> {
List<Predicate> 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<SysOperationLog> page = repository.findAll(spec, pageable);
page.getContent().forEach(e -> e.setResponseData(null));
return PageUtils.toResult(page);
}
}
@@ -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;
/**
* 用户中心门面服务实现。
* <p>原 BaseApiController.getSchoolAll 的组树组装、教师挂组、递归建树、排序逻辑整体下沉至此;
* GetGroupUser 空桩改为显式报错(未接入用户中心对应开放接口时提示,而非静默返回空列表)。
* 跨仓调用统一收敛到 {@link YschBaseOpenApi} 门面,Controller 不再直接静态调用。</p>
*/
@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<Map<String, Object>> getSchoolAllTree(Integer schId) {
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败");
}
// 1. userbase 组树(整棵学校子树),剔除学生组
List<Map<String, Object>> 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<Integer> treeGroupIds = new HashSet<>();
for (Map<String, Object> node : flatNodes) {
treeGroupIds.add((Integer) node.get("id"));
}
Map<Integer, SchTeaList> teacherMap = new LinkedHashMap<>();
Map<Integer, Set<Integer>> teacherPids = new LinkedHashMap<>();
List<SchTeaList> teachers = classService.getSchTeaList(schId);
for (SchTeaList tea : teachers) {
if (tea.getUserTeaSerID() == null) {
continue;
}
// 同一教师多行(多组/接口重复数据)合并,按 (教师, 组) 去重
teacherMap.putIfAbsent(tea.getUserTeaSerID(), tea);
// 接口现行字段为单个 GroupID,兼容旧字段 GroupIds
List<Integer> 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<Integer, SchTeaList> entry : teacherMap.entrySet()) {
int teaId = entry.getKey();
SchTeaList tea = entry.getValue();
Set<Integer> pids = teacherPids.get(teaId);
if (pids == null || pids.isEmpty()) {
// 教师所属组均被剔除(如学生组)或无组信息,挂学校根节点
pids = new HashSet<>(Collections.singletonList(treeGroupIds.contains(schId) ? schId : 0));
}
for (Integer pid : pids) {
Map<String, Object> 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<Map<String, Object>> nodes) {
if (group == null || group.getId() == null) {
return;
}
if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) {
return;
}
Map<String, Object> 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<Map<String, Object>> formatUserTree(List<Map<String, Object>> treeList, int pid) {
List<Map<String, Object>> data;
if (pid == 0) {
Set<Integer> allIds = new HashSet<>();
for (Map<String, Object> n : treeList) {
allIds.add((Integer) n.get("id"));
}
data = new ArrayList<>();
for (Map<String, Object> n : treeList) {
int p = (Integer) n.get("pid");
if (p == 0 || !allIds.contains(p)) {
data.add(n);
}
}
} else {
data = new ArrayList<>();
for (Map<String, Object> n : treeList) {
if ((Integer) n.get("pid") == pid) {
data.add(n);
}
}
}
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> item : data) {
int itemId = (Integer) item.get("id");
int itemType = (Integer) item.get("type");
List<Map<String, Object>> children = formatUserTree(treeList, itemId);
// type=1(组) 且无子节点 → 不加入(C# 逻辑)
if (itemType == 1 && children.isEmpty()) {
continue;
}
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> getUserModuleTree(Integer userId, Integer schId) {
if (userId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空");
}
List<BoaModule> 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<Map<String, Object>> buildModuleTree(List<BoaModule> modules) {
Map<Integer, BoaModule> moduleMap = modules.stream()
.collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a));
// 每个模块节点(含 children 占位)
Map<Integer, Map<String, Object>> nodeMap = new LinkedHashMap<>();
for (BoaModule m : modules) {
Map<String, Object> 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<Map<String, Object>> roots = new ArrayList<>();
for (BoaModule m : modules) {
Map<String, Object> 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<Map<String, Object>> children = (List<Map<String, Object>>) nodeMap.get(parentId).get("children");
children.add(node);
}
}
// 保持 SDK(用户中心)返回顺序,不再按 orderNumber 重排
return roots;
}
}
@@ -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);
}
}
@@ -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];
}
}
}
@@ -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;
}
}
@@ -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 <T> BasePageResult<T> toResult(Page<T> page) {
return new BasePageResult<T>(
page.getContent(),
(int) page.getTotalElements(),
page.getNumber() + 1,
page.getSize(),
page.getTotalPages(),
page.hasNext()
);
}
}
@@ -0,0 +1,74 @@
package com.yunhai.acaclassassign.utils;
import java.io.UnsupportedEncodingException;
/**
* GB2312 一级汉字 → 拼音首字母(利用一级汉字按拼音排序的区位特性,无需第三方拼音库)。
* <p>局限:仅覆盖 GB2312 一级常用字;多音字按区位序取字母(如「重」归 z)。
* 非汉字字符:字母/数字原样保留(转小写),其他字符与无法编码的汉字丢弃。</p>
*/
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);
}
}
}
@@ -0,0 +1,140 @@
package com.yunhai.acaclassassign.utils;
import lombok.Value;
/**
* 当前登录用户上下文(ThreadLocal 静态持有器)。
* <p>移植自 UserclassService(com.yunhai.bschclassmanage.utils.UserContext),
* 由 {@link com.yunhai.acaclassassign.aspect.ControllerUserContextAspect} 在每个请求进入 controller 前填充,
* 请求结束后 clear,防止线程复用串号。</p>
*/
public class UserContext {
private static final ThreadLocal<Integer> USER_ID = new ThreadLocal<>();
private static final ThreadLocal<String> USER_NAME = new ThreadLocal<>();
private static final ThreadLocal<Integer> ROLE_ID = new ThreadLocal<>();
private static final ThreadLocal<Integer> SCH_ID = new ThreadLocal<>();
private static final ThreadLocal<String> IP = new ThreadLocal<>();
private static final ThreadLocal<Integer> TYPE = new ThreadLocal<>();
/** 当前角色切换上下文(来自 currentrole header),家长场景 childId 即学生ID */
private static final ThreadLocal<Integer> 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();
}
}
+82
View File
@@ -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/
+33
View File
@@ -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
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 引入 Spring Boot 默认彩色日志变量 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<!-- 控制台:所有环境都有,且带颜色2 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>${CONSOLE_LOG_CHARSET}</charset>
</encoder>
</appender>
<!-- 文件日志:只有非 local 环境才启用 -->
<springProfile name="!local">
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/app/logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/app/logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
</encoder>
</appender>
</springProfile>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<springProfile name="!local">
<appender-ref ref="FILE" />
</springProfile>
</root>
</configuration>

Some files were not shown because too many files have changed in this diff Show More