commit
build / build-push (push) Successful in 32s

This commit is contained in:
wanghu
2026-09-23 08:27:45 +08:00
parent 2775457c75
commit 37a7df76c5
90 changed files with 7715 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
target/
.idea/
*.iml
*.log
.DS_Store
+57
View File
@@ -0,0 +1,57 @@
# HomeLeave 生产配置(线上以 nacos 的 homeleave.yaml 为准,本文件是 nacos 不可达时的兜底)
# 真正上线请在 Nacos 新建 dataId=homeleave.yaml、group=OA_APP 的配置覆盖以下项
# (尤其 datasource 的库名/账号、yh.sso.client-host-url)。
server:
port: 8070
servlet:
# 对外经 caddy 暴露为 http://base.yunhaixingtong.com/homeleave
context-path: /homeleave
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;库名为 homeleave(需先在 mysql8 上建库)
url: jdbc:mysql://mysql8:3306/homeleave?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 'CVf^x$sW*b6CGHxh'
driver-class-name: com.mysql.cj.jdbc.Driver
min-idle: 10
max-active: 30
jpa:
database: MySQL
show-sql: false
hibernate:
ddl-auto: update
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
properties:
hibernate.order_by.default_null_ordering: last
open-in-view: true
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh:
env-config:
source:
# 前端运行时配置(JSON 字符串),线上由 Nacos/projman 下发。
# 端点 /api/env-config 由平台 yh-env-config-spring-boot-starter 提供(免登录 SSO 旁路)。
front: ""
sso:
server-url-prefix: http://sso.yunhaixingtong.com
client-host-url: http://base.yunhaixingtong.com/homeleave
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
# HomeLeave 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml)
# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到
# 镜像内烤好的 application-prod.yml 兜底默认。
set -e
echo "[run-app] starting HomeLeave (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..."
exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/homeleave.jar \
--spring.profiles.active=prod
+1
View File
File diff suppressed because one or more lines are too long
+198
View File
@@ -0,0 +1,198 @@
<?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>homeleave</artifactId>
<version>0.0.1</version>
<name>HomeLeave</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>
<!-- 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>
<!-- 不引 spring-boot-starter-data-redis / cache:代码零使用,data-redis 会自动装配
RedisHealthIndicator,连不上 redis 会把 /actuator/health 整体拉成 DOWN(503)。 -->
</dependencies>
<build>
<finalName>homeleave</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.homeleave.HomeLeaveApplication</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:/homeleave-build/homeleave</directory>
</build>
</profile>
</profiles>
</project>
@@ -0,0 +1,18 @@
package com.yunhai.homeleave;
import com.alibaba.fastjson.parser.ParserConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication(scanBasePackages = "com.yunhai.homeleave")
@EnableTransactionManagement
public class HomeLeaveApplication {
public static void main(String[] args) {
// 开启 fastjson 安全模式
ParserConfig.getGlobalInstance().setSafeMode(true);
SpringApplication.run(HomeLeaveApplication.class, args);
}
}
@@ -0,0 +1,42 @@
package com.yunhai.homeleave.common.listener;
import com.yunhai.homeleave.entity.BaseEntity;
import com.yunhai.homeleave.utils.UserContext;
import org.springframework.stereotype.Component;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import java.time.LocalDateTime;
/**
* JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(平台当前登录人)与时间戳。
* 对应 {@link BaseEntity} 的 createBy / updateBy / createTime / updateTime 字段。
* 当前登录人取自 {@link UserContext}(用户中心数字ID,直接以 INT 落审计列)。
*/
@Component
public class EntityAuditListener {
@PrePersist
public void prePersist(BaseEntity entity) {
LocalDateTime now = LocalDateTime.now();
entity.setCreateTime(now);
entity.setUpdateTime(now);
Integer userId = UserContext.getUserId();
if (userId != null) {
if (entity.getCreateBy() == null) {
entity.setCreateBy(userId);
}
entity.setUpdateBy(userId);
}
}
@PreUpdate
public void preUpdate(BaseEntity entity) {
entity.setUpdateTime(LocalDateTime.now());
Integer userId = UserContext.getUserId();
if (userId != null) {
entity.setUpdateBy(userId);
}
}
}
@@ -0,0 +1,244 @@
package com.yunhai.homeleave.common.log;
import com.alibaba.fastjson.JSON;
import com.yunhai.homeleave.entity.SysOperationLog;
import com.yunhai.homeleave.service.ISysOperationLogService;
import com.yunhai.homeleave.utils.LogGzipUtils;
import com.yunhai.homeleave.utils.LogMaskUtils;
import com.yunhai.homeleave.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.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 javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* 系统审计日志切面(参考 UserclassService SysOperationLogAspect)。
* <ul>
* <li>全量拦截 controller 包所有方法,不依赖注解;</li>
* <li>排除日志查询控制器自身(防递归刷屏);</li>
* <li>用户信息取 {@link UserContext}(平台登录态惰性解析);</li>
* <li>响应体 GZIP 压缩落库,参数脱敏;异步落库,失败仅记错误日志。</li>
* </ul>
*/
@Aspect
@Component
@Order(3)
public class OperationLogAspect {
/** 排除的控制器(审计日志查询自身,防递归) */
private static final List<String> EXCLUDE_CONTROLLERS = Arrays.asList(
"com.yunhai.homeleave.controller.api.SysOperationLogController");
private static final int MAX_RESPONSE_SIZE = 10240;
private static final int MAX_ERROR_MSG = 8000;
private static final String[] DELETE_KEYWORDS = {"delete", "del", "remove"};
private static final String[] CREATE_KEYWORDS = {"add", "create", "insert", "save", "import", "upload", "audit"};
@Autowired
private ISysOperationLogService sysOperationLogService;
@Around("execution(* com.yunhai.homeleave.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);
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.setUserId(UserContext.getUserId());
e.setUserName(StringUtils.hasText(UserContext.getUserName()) ? UserContext.getUserName() : "anonymous");
e.setSchId(UserContext.getSchId());
e.setRequestIp(StringUtils.hasText(UserContext.getIp()) ? UserContext.getIp() : clientIp(request));
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, uri, methodName));
e.setOperationType(resolveOperationType(httpMethod, uri, methodName));
if (request != null) {
e.setUserAgent(truncate(request.getHeader("User-Agent"), 512));
e.setRequestParams(LogMaskUtils.mask(buildRequestParams(point, request)));
}
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) {
continue;
}
filtered.add(arg);
}
return filtered.isEmpty() ? "" : JSON.toJSONString(filtered);
} catch (Exception ex) {
return "";
}
}
private void fillResponse(SysOperationLog e, Object result) {
if (result == null) {
return;
}
try {
String resp = LogMaskUtils.mask(JSON.toJSONString(result));
if (resp.length() > MAX_RESPONSE_SIZE) {
resp = resp.substring(0, MAX_RESPONSE_SIZE) + "...[截断]";
}
e.setResponseData(LogGzipUtils.compress(resp));
} catch (Exception ex) {
// 响应序列化失败不影响业务
}
}
/** 成功路径:裸对象由平台 ApiResponseAdvice 统一包装为 success(code=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();
}
/** 异常路径:DException(含 BizException)取业务码,兜底 500 */
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();
}
/** 1 重要(删除);2 次要(新增/编辑);3 一般(查询) */
private int resolveLevel(String httpMethod, String uri, String methodName) {
if (!StringUtils.hasText(httpMethod)) {
return 2;
}
String m = httpMethod.toUpperCase();
if ("GET".equals(m)) {
return 3;
}
if ("DELETE".equals(m) || containsAny(uri, methodName, DELETE_KEYWORDS)) {
return 1;
}
return 2;
}
private String resolveOperationType(String httpMethod, String uri, String methodName) {
if (!StringUtils.hasText(httpMethod)) {
return "UPDATE";
}
String m = httpMethod.toUpperCase();
if ("GET".equals(m)) {
return "QUERY";
}
if ("DELETE".equals(m) || containsAny(uri, methodName, DELETE_KEYWORDS)) {
return "DELETE";
}
if (containsAny(uri, methodName, CREATE_KEYWORDS)) {
return "CREATE";
}
return "UPDATE";
}
private boolean containsAny(String uri, String methodName, String[] keywords) {
String text = (uri == null ? "" : uri.toLowerCase()) + " " + methodName.toLowerCase();
for (String kw : keywords) {
if (text.contains(kw)) {
return true;
}
}
return false;
}
private HttpServletRequest getRequest() {
ServletRequestAttributes a = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return a != null ? a.getRequest() : null;
}
private String clientIp(HttpServletRequest request) {
if (request == null) {
return "";
}
String ip = request.getHeader("X-Forwarded-For");
if (StringUtils.hasText(ip) && !"unknown".equalsIgnoreCase(ip)) {
int i = ip.indexOf(',');
return i > 0 ? ip.substring(0, i).trim() : ip.trim();
}
return request.getRemoteAddr();
}
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 ? null : (s.length() > max ? s.substring(0, max) : s);
}
}
@@ -0,0 +1,30 @@
package com.yunhai.homeleave.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* 异步任务配置:审计日志专用线程池(切面 finally 中异步落库,失败不影响业务请求)。
*/
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("auditLogExecutor")
public ThreadPoolTaskExecutor auditLogExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(2000);
executor.setThreadNamePrefix("audit-log-");
// 队列满时丢弃并由线程池自身静默处理:审计日志允许少量丢失,不允许拖慢业务线程
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(5);
return executor;
}
}
@@ -0,0 +1,11 @@
package com.yunhai.homeleave.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.homeleave.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,13 @@
package com.yunhai.homeleave.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 开启 @Scheduled 定时任务(预留:请假模块当前无定时任务)。
* 单实例假设。
*/
@Configuration
@EnableScheduling
public class SchedulingConfig {
}
@@ -0,0 +1,22 @@
package com.yunhai.homeleave.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 homeLeaveOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("课程作业接口文档")
.description("课程作业子应用:发作业、列表、删除、编辑、管理员查看、统计(springdoc-openapi)")
.version("1.0"));
}
}
@@ -0,0 +1,52 @@
package com.yunhai.homeleave.config;
import com.yunhai.homeleave.utils.UserContext;
import com.yunhai.platform.auth.spring.CurrentBoaUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* {@link UserContext} 生命周期配置:
* 启动时向 UserContext 注入平台 BoaUser 供应器(取值在首次访问时惰性解析),
* 并注册拦截器在每个请求结束后清理 ThreadLocal(Tomcat 线程复用防串号)。
*/
@Configuration
public class UserContextConfig {
@Autowired
private CurrentBoaUser currentBoaUser;
@PostConstruct
public void init() {
UserContext.init(currentBoaUser);
}
@Bean
public HandlerInterceptor userContextCleanInterceptor() {
return new HandlerInterceptor() {
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
UserContext.clear();
}
};
}
@Bean
public WebMvcConfigurer userContextConfigurer(HandlerInterceptor userContextCleanInterceptor) {
return new WebMvcConfigurer() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userContextCleanInterceptor).addPathPatterns("/**");
}
};
}
}
@@ -0,0 +1,110 @@
package com.yunhai.homeleave.controller.api;
import com.yunhai.homeleave.config.Config;
import com.yunhai.homeleave.model.UpFileResultVo;
import com.yunhai.homeleave.service.IFileService;
import com.yunhai.homeleave.service.IUserCenterService;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
import com.yunhai.platform.utils.model.BoaUser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
/**
* 平台代理域接口(base/)。
* <p>路径前缀:{@code Config.API_PATH + "/base/"}(原硬编码已修正)。
* 承载平台代理接口:GetUserInfo / GetUserMod / GetSchoolAll / GetGroupUser / Logout,
* 业务逻辑已下沉 {@link IUserCenterService},本控制器仅做"取参 → 调 Service → 返回"。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/base/")
public class BaseApiController {
private static final Logger log = LoggerFactory.getLogger(BaseApiController.class);
@Autowired
private IUserCenterService userCenterService;
@Autowired
private IFileService fileService;
// ============================ FileUpload ============================
/**
* 附件上传(单文件)。
* 前端: POST base/FileUpload (form-data: file)
* 返回: { fileid, host, fileurl, name, type, size }
*/
@PostMapping("FileUpload")
public UpFileResultVo fileUpload(@RequestParam("file") MultipartFile file) {
return fileService.uploadToResult(file, "leave");
}
// ============================ GetUserInfo ============================
/**
* 获取当前登录用户信息(CAS 会话注入)。
*/
@GetMapping("GetUserInfo")
public BoaUser getUserInfo(BoaUser authUserWithDept) {
return authUserWithDept;
}
// ============================ GetUserMod ============================
/**
* 获取当前用户已授权的模块列表(左侧菜单树)。
* 模块树组装已下沉 IUserCenterService。
*/
@GetMapping("GetUserMod")
public List<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
Integer schId = authUserWithDept.getCurrentRole() != null
? authUserWithDept.getCurrentRole().getSchId() : null;
return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId);
}
// ============================ GetSchoolAll ============================
/**
* 获取当前用户学校组及用户(树形结构)。
* 组树组装/教师挂组/递归建树逻辑已下沉 IUserCenterService。
*/
@GetMapping("GetSchoolAll")
public List<Map<String, Object>> getSchoolAll(BoaUser authUserWithDept) {
Integer schId = authUserWithDept.getCurrentRole() != null
? authUserWithDept.getCurrentRole().getSchId() : null;
return userCenterService.getSchoolAllTree(schId);
}
// ============================ GetGroupUser ============================
/**
* 获取指定组内用户(教师 + 管理员)。
* 空桩已改为显式报错(避免前端误以为组内无用户),由 IUserCenterService 统一处理。
*/
@GetMapping("GetGroupUser")
public List<Map<String, Object>> getGroupUser(
@RequestParam("gid") Integer gid,
@RequestParam(value = "role", defaultValue = "2") Integer role) {
return userCenterService.getGroupUser(gid, role);
}
// ============================ Logout ============================
/**
* 退出登录。
* 认证由平台网关处理,此处仅返回成功。
*/
@PostMapping("Logout")
public void logout() {
LoginUser user = UserContextHolder.get();
log.info("[logout] 用户退出登录: {}", user == null ? null : user.getLoginName());
}
}
@@ -0,0 +1,114 @@
package com.yunhai.homeleave.controller.api;
import com.yunhai.homeleave.config.Config;
import com.yunhai.homeleave.service.IDictService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 系统字典接口(发文部门域)。
* <p>路径前缀:{@code Config.API_PATH + "/dict/"}(原 base/ 前缀已修正)。
* 写操作一律 POST(原 DelDept/OrdbyDicIds 为 GET 写操作,已 POST 化)。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/dict/")
public class DictController {
@Autowired
private IDictService dictService;
/**
* 获取系统字典(下拉框用)。
*/
@GetMapping("List")
public List<Map<String, Object>> list(@RequestParam(value = "type", defaultValue = "0") Integer type) {
return dictService.getSysDic(type);
}
/**
* 获取系统字典管理列表。
*/
@GetMapping("ManageList")
public List<Map<String, Object>> manageList(@RequestParam(value = "type", defaultValue = "dept") String type) {
return dictService.getDictMangeList(type);
}
/**
* 添加发文单位。
*/
@PostMapping("Add")
public void add(@RequestParam("name") String name) {
dictService.addDeptDic(name);
}
/**
* 更新发文单位。
*/
@PostMapping("Update")
public void update(
@RequestParam("SerID") Integer serID,
@RequestParam("Name") String name,
@RequestParam(value = "Enable", required = false, defaultValue = "1") Integer enable) {
dictService.updateDeptDic(serID, name, enable);
}
/**
* 删除发文单位(原 GET DelDept,已 POST 化)。
*/
@PostMapping("Delete")
public void delete(@RequestParam("serid") Integer serID) {
dictService.delDept(serID);
}
/**
* 发文单位排序(原 GET OrdbyDicIds,已 POST 化)。
*/
@PostMapping("Sort")
public void sort(@RequestParam("ids") String ids) {
dictService.ordbyDicIds(ids);
}
// ============================ 请假类型字典(应用设置) ============================
/** 请假类型下拉项(启用):[{k, v}],k 为数字型 key 字符串 */
@GetMapping("LeaveTypeOptions")
public List<Map<String, Object>> leaveTypeOptions() {
return dictService.getLeaveTypeOptions();
}
/** 请假类型管理列表(含禁用):[{SerID, CodeID, Name, Des, Enable}] */
@GetMapping("LeaveTypeList")
public List<Map<String, Object>> leaveTypeList() {
return dictService.getLeaveTypeManageList();
}
/** 新增请假类型(key 自动递增),返回新记录 SerID */
@PostMapping("LeaveTypeAdd")
public Integer addLeaveType(@RequestParam("name") String name) {
return dictService.addLeaveType(name);
}
/** 更新请假类型(名称/启用) */
@PostMapping("LeaveTypeUpdate")
public void updateLeaveType(
@RequestParam("serid") Integer serID,
@RequestParam(value = "name", required = false) String name,
@RequestParam(value = "enable", required = false) Integer enable) {
dictService.updateLeaveType(serID, name, enable);
}
/** 删除请假类型(已被请假记录引用时禁止,提示停用) */
@PostMapping("LeaveTypeDelete")
public void deleteLeaveType(@RequestParam("serid") Integer serID) {
dictService.deleteLeaveType(serID);
}
/** 请假类型排序(ids 按前端顺序升序重排) */
@PostMapping("LeaveTypeSort")
public void sortLeaveTypes(@RequestParam("ids") String ids) {
dictService.sortLeaveTypes(ids);
}
}
@@ -0,0 +1,88 @@
package com.yunhai.homeleave.controller.api;
import com.yunhai.homeleave.config.Config;
import com.yunhai.homeleave.model.UpFileResultVo;
import com.yunhai.homeleave.model.UpFileVo;
import com.yunhai.homeleave.service.IFileService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 附件域接口(file/)。
* <p>路径前缀:{@code Config.API_PATH + "/file/"}(原 base/ 前缀已修正为独立文件域)。
* 方法名统一 PascalCase;写操作一律 POST。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/file/")
public class FileController {
@Autowired
private IFileService fileService;
/**
* 附件上传(单文件)。
* 前端: POST file/Upload (form-data)
* 返回: { fileid, host, fileurl, name, type, size }
*/
@PostMapping("Upload")
public UpFileResultVo upload(@RequestParam("file") MultipartFile file) {
return fileService.uploadToResult(file, "leave");
}
/**
* 图片上传(多文件,返回数组)。
* 前端: POST file/UploadImage (form-data, accept=image/*)
*/
@PostMapping("UploadImage")
public List<UpFileVo> uploadImage(@RequestParam("file") MultipartFile[] files) {
return fileService.uploadImages(files, "leave");
}
/**
* 文件下载(浏览器直接 location.href 跳转)。
*/
@GetMapping("Down")
public void down(@RequestParam("id") Integer id, HttpServletResponse response) {
fileService.download(id, response);
}
/**
* 文件在线预览(inline 输出,前端取 Blob 交给 open-file-viewer 渲染)。
*/
@GetMapping("Preview")
public void preview(@RequestParam("id") Integer id,
@RequestParam(value = "type", required = false) Integer type,
HttpServletResponse response) {
fileService.preview(id, response);
}
/**
* 删除文件。
*/
@PostMapping("Delete")
public void delete(@RequestParam("fileId") Integer fileId) {
fileService.delete(fileId);
}
/**
* 查询某作业下的附件列表。
*/
@GetMapping("List")
public List<UpFileVo> list(@RequestParam("topicId") Integer topicId) {
return fileService.listByTopic("leave", topicId);
}
/**
* 批量绑定附件到作业。
*/
@PostMapping("Bind")
public void bind(@RequestParam("fileIds") List<Integer> fileIds,
@RequestParam("topicId") Integer topicId) {
fileService.bindToTopic(fileIds, topicId);
}
}
@@ -0,0 +1,128 @@
package com.yunhai.homeleave.controller.api;
import com.yunhai.homeleave.config.Config;
import com.yunhai.homeleave.model.BasePageResult;
import com.yunhai.homeleave.model.leave.ClassStudentVo;
import com.yunhai.homeleave.model.leave.LeaveAuditRequest;
import com.yunhai.homeleave.model.leave.LeaveBatchPassRequest;
import com.yunhai.homeleave.model.leave.LeaveDetailVo;
import com.yunhai.homeleave.model.leave.LeaveQuery;
import com.yunhai.homeleave.model.leave.LeaveReportBackRequest;
import com.yunhai.homeleave.model.leave.LeaveRequest;
import com.yunhai.homeleave.model.leave.LeaveVo;
import com.yunhai.homeleave.model.leave.TeachingClassVo;
import com.yunhai.homeleave.service.ILeaveService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.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;
/**
* 学生请假接口(学生端 + 教师端)。
* <p>路径前缀:{@code Config.API_PATH + "/leave/"};方法名 PascalCase,写操作一律 POST。
* 身份与数据范围由服务层经 UserContext 收敛:学生=名册本人,教师=班主任班级,校管理员全校;
* 接口不收账号参数。返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/leave/")
public class LeaveController {
@Autowired
private ILeaveService leaveService;
// ============================ 提交请假 ============================
/**
* 提交请假:学生申请(type=2 → 待审核);教师登记(type=1 → 免审直接通过)。
* 返回新请假ID。
*/
@PostMapping("Save")
public Integer save(@RequestBody @Validated LeaveRequest request) {
return leaveService.saveLeave(request);
}
// ============================ 列表 / 详情 ============================
/** 分页列表(学生本人 / 教师任教班级 / 校管理员全校) */
@GetMapping("List")
public BasePageResult<LeaveVo> list(LeaveQuery query) {
return leaveService.getLeaveList(query);
}
/** 请假详情(含时间段、销假时间段、附件、按钮状态) */
@GetMapping("Details")
public LeaveDetailVo details(@RequestParam("id") Integer id) {
return leaveService.getLeaveDetails(id);
}
// ============================ 审批 / 撤销 / 销假 ============================
/**
* 教师审批:审核(status=1 通过 / 8 拒绝)、撤销审批(3/4)、销假审批(6/7)。
*/
@PostMapping("Audit")
public void audit(@RequestBody @Validated LeaveAuditRequest request) {
leaveService.auditLeave(request);
}
/** 批量通过(body: {classId} 一键通过该班全部待审核,或 {ids:[...]} 勾选通过),返回通过条数 */
@PostMapping("BatchPass")
public Integer batchPass(@RequestBody @Validated LeaveBatchPassRequest request) {
return leaveService.batchPass(request);
}
/**
* 撤销请假:学生申请撤销(待审核直接撤销;已通过→待撤销);
* 教师直接撤销(生效中/流程中的记录直接作废)。
*/
@PostMapping("Withdraw")
public void withdraw(@RequestParam("id") Integer id) {
leaveService.withdrawLeave(id);
}
/** 教师删除请假记录(带时间段/销假记录/附件级联),仅已撤销、已销假、已拒绝可删除 */
@PostMapping("Delete")
public void delete(@RequestParam("id") Integer id) {
leaveService.deleteLeave(id);
}
/**
* 销假:学生申请销假(→待销假5);教师直接销假(→已销假6,回写实际时间)。
*/
@PostMapping("ReportBack")
public void reportBack(@RequestBody @Validated LeaveReportBackRequest request) {
leaveService.saveReportBackLeave(request);
}
// ============================ 登记辅助 ============================
/** 我的班级:学生=所在班级;教师=任教班级;校管理员=全校班级 */
@GetMapping("MyClasses")
public List<TeachingClassVo> myClasses() {
return leaveService.myClasses();
}
/** 班级学生名单(教师登记请假选择学生) */
@GetMapping("ClassStudents")
public List<ClassStudentVo> classStudents(@RequestParam("classId") Integer classId) {
return leaveService.classStudents(classId);
}
/** 状态字典(前端状态标签渲染,值与 LeaveStatusEnum 对齐) */
@GetMapping("StatusDict")
public List<java.util.Map<String, Object>> statusDict() {
return java.util.Arrays.stream(com.yunhai.homeleave.enums.LeaveStatusEnum.values())
.map(e -> {
java.util.Map<String, Object> m = new java.util.LinkedHashMap<>();
m.put("id", e.getId());
m.put("name", e.getName());
return m;
}).collect(java.util.stream.Collectors.toList());
}
}
@@ -0,0 +1,38 @@
package com.yunhai.homeleave.controller.api;
import com.yunhai.homeleave.config.Config;
import com.yunhai.homeleave.model.leave.LeaveStatsVo;
import com.yunhai.homeleave.service.ILeaveService;
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;
/**
* 请假统计接口(参考历史 StatsController:按班级/日期/类型统计)。
* 统计范围同列表权限:学生本人、教师任教班级、校管理员全校。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/leavestats/")
public class LeaveStatsController {
@Autowired
private ILeaveService leaveService;
/**
* 请假统计。
*
* @param startDate 统计范围起(yyyy-MM-dd,可空)
* @param endDate 统计范围止(yyyy-MM-dd,可空)
* @param classId 班级过滤(可空)
* @param leaveType 请假类型过滤(可空)
*/
@GetMapping("Stats")
public LeaveStatsVo stats(@RequestParam(value = "startDate", required = false) String startDate,
@RequestParam(value = "endDate", required = false) String endDate,
@RequestParam(value = "classId", required = false) Integer classId,
@RequestParam(value = "leaveType", required = false) Integer leaveType) {
return leaveService.stats(startDate, endDate, classId, leaveType);
}
}
@@ -0,0 +1,57 @@
package com.yunhai.homeleave.controller.api;
import com.yunhai.homeleave.config.Config;
import com.yunhai.homeleave.model.BasePageResult;
import com.yunhai.homeleave.service.ISysOperationLogService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 审计日志查询接口(切面对本控制器自身排除,防递归)。
* 多租户:schId 由服务端按当前登录学校收敛,仅查本校日志。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/oplog/")
public class SysOperationLogController {
@Autowired
private ISysOperationLogService sysOperationLogService;
/** 分页查询审计日志(不含响应体大字段) */
@GetMapping("List")
public BasePageResult<?> list(
@RequestParam(value = "uri", required = false) String uri,
@RequestParam(value = "method", required = false) String method,
@RequestParam(value = "statusCode", required = false) Integer statusCode,
@RequestParam(value = "logLevel", required = false) Integer logLevel,
@RequestParam(value = "userName", required = false) String userName,
@RequestParam(value = "startDate", required = false) String startDate,
@RequestParam(value = "endDate", required = false) String endDate,
@RequestParam(value = "pageIndex", defaultValue = "1") int pageIndex,
@RequestParam(value = "pageSize", defaultValue = "10") int pageSize) {
return sysOperationLogService.pageQuery(null, uri, method, statusCode, logLevel,
userName, startDate, endDate, pageIndex, pageSize);
}
/** 查看某条请求的响应原文(GZIP 解压) */
@GetMapping("Response")
public Map<String, Object> response(@RequestParam("id") Long id) {
String data = sysOperationLogService.getResponseData(id);
if (data == null) {
throw new BizException(ErrorCode.FORBIDDEN, "日志记录不存在或无响应体");
}
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", id);
m.put("response", data);
return m;
}
}
@@ -0,0 +1,49 @@
package com.yunhai.homeleave.entity;
import com.yunhai.homeleave.common.listener.EntityAuditListener;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link EntityAuditListener} 自动填充)。
*/
@MappedSuperclass
@Getter
@Setter
@EntityListeners(EntityAuditListener.class)
public abstract class BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
/**
* 创建人ID(用户中心数字ID,与业务人员标识同一 ID 空间)
*/
@Column(name = "create_by", updatable = false)
private Integer createBy;
/**
* 创建时间
*/
@Column(name = "create_time", updatable = false)
private LocalDateTime createTime;
/**
* 更新人ID(用户中心数字ID)
*/
@Column(name = "update_by")
private Integer updateBy;
/**
* 更新时间
*/
@Column(name = "update_time")
private LocalDateTime updateTime;
}
@@ -0,0 +1,79 @@
package com.yunhai.homeleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 请假主表(学生请假/教师登记)。
* <p>表名不用 {@code leave}:MySQL 保留字,JPA 生成 SQL 需处处反引号,易踩坑。</p>
* <p>人员只存 id(stuId 为用户中心数字ID,与 bschclass 学生ID同域),
* 创建人/时间由 {@link BaseEntity}(EntityAuditListener)自动填充。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "stu_leave")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class Leave extends BaseEntity {
/** 学校ID(多租户隔离) */
@Column(name = "sch_id")
private Integer schId;
/** 班级ID(bschclass sch_class.class_id) */
@Column(name = "class_id")
private Integer classId;
/** 班级名称(冗余展示用) */
@Column(name = "class_name")
private String className;
/** 学生ID(用户中心数字ID,不存账号) */
@Column(name = "stu_id")
private Integer stuId;
/** 学生姓名(冗余展示用) */
@Column(name = "stu_name")
private String stuName;
/** 学生头像 URL(冗余展示用,可空) */
@Column(name = "stu_photo")
private String stuPhoto;
/** 登记类型:1 教师登记(免审直接通过);2 学生申请(待审核) */
@Column(name = "type")
private Integer type;
/** 请假类型:1 病假;2 事假及其他 */
@Column(name = "leave_type")
private Integer leaveType;
/** 开始时间 yyyy-MM-dd HH:mm:ss */
@Column(name = "start_date", length = 19)
private String startDate;
/** 结束时间 yyyy-MM-dd HH:mm:ss */
@Column(name = "end_date", length = 19)
private String endDate;
/** 是否连续请假:0 否;1 是(每天一个时间段,见 leave_time) */
@Column(name = "continuous_leave")
private Integer continuousLeave;
/** 连续请假次数(leave_time 行数) */
@Column(name = "continuous_leave_num")
private Integer continuousLeaveNum;
/** 状态:见 {@link com.yunhai.homeleave.enums.LeaveStatusEnum} */
@Column(name = "status")
private Integer status;
/** 请假事由 */
@Column(name = "remarks", columnDefinition = "text")
private String remarks;
}
@@ -0,0 +1,41 @@
package com.yunhai.homeleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 销假时间段表(历史 report_back_leave_time):学生申请销假待审核期间记录
* 学生填报的实际返校时间;教师通过销假后回写 leave_time 并删除本表记录。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_report_back")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveReportBack extends BaseEntity {
/** 请假主表ID(stu_leave.id) */
@Column(name = "leave_id")
private Integer leaveId;
/** 对应请假时间段ID(leave_time.id) */
@Column(name = "leave_time_id")
private Integer leaveTimeId;
/** 学生ID */
@Column(name = "stu_id")
private Integer stuId;
/** 实际开始时间 yyyy-MM-dd HH:mm:ss */
@Column(name = "start_date", length = 19)
private String startDate;
/** 实际结束时间 yyyy-MM-dd HH:mm:ss */
@Column(name = "end_date", length = 19)
private String endDate;
}
@@ -0,0 +1,37 @@
package com.yunhai.homeleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 请假时间段表(历史 continuous_leave_time)。
* <p>不连续请假也落一行(与主表起止时间一致);连续请假每天一行。</p>
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "leave_time")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class LeaveTime extends BaseEntity {
/** 请假主表ID(stu_leave.id) */
@Column(name = "leave_id")
private Integer leaveId;
/** 学生ID */
@Column(name = "stu_id")
private Integer stuId;
/** 开始时间 yyyy-MM-dd HH:mm:ss */
@Column(name = "start_date", length = 19)
private String startDate;
/** 结束时间 yyyy-MM-dd HH:mm:ss */
@Column(name = "end_date", length = 19)
private String endDate;
}
@@ -0,0 +1,47 @@
package com.yunhai.homeleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 系统字典(发文部门等)。对应原 .NET Common_DeptDic 表。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sys_dict")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysDict extends BaseEntity {
/**
* 学校ID(多租户隔离:新增时从当前用户角色填充,查询时按校过滤;
* 存量数据为 NULL,视为全局字典,全校可见)。
*/
@Column(name = "sch_id")
private Integer schId;
/** 字典类型(如 "dept"=发文部门) */
@Column(name = "dict_type", length = 50)
private String dictType;
/** 字典 key(原 CodeID) */
@Column(name = "dict_key", length = 100)
private String dictKey;
/** 字典 value(显示名称,原 Name) */
@Column(name = "dict_value", length = 200)
private String dictValue;
/** 排序号 */
@Column(name = "sort_order")
private Integer sortOrder = 0;
/** 是否启用:0 禁用 / 1 启用(原 Enable) */
@Column(name = "enable_flag")
private Integer enableFlag = 1;
}
@@ -0,0 +1,91 @@
package com.yunhai.homeleave.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,精简重试相关字段)。
* 由 {@link com.yunhai.homeleave.common.log.OperationLogAspect} 全量拦截 controller 包写入,
* 建表脚本 sql/sys_operation_log.sql;开发环境 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;
/** DELETE / CREATE / UPDATE / QUERY */
@Column(name = "operation_type", nullable = false, length = 16)
private String operationType;
/** 操作人ID(UserContext.getUserId()) */
@Column(name = "user_id")
private Integer userId;
@Column(name = "user_name", length = 64)
private String userName;
/** 学校ID(多校隔离) */
@Column(name = "sch_id")
private Integer schId;
/** 客户端IP */
@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;
/** 响应 JSON(GZIP 压缩存储) */
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
private byte[] responseData;
/** 业务状态码:0 成功 / 非 0 失败 */
@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,47 @@
package com.yunhai.homeleave.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 附件文件记录。对应原 .NET Common_UpFiles 表。
* <p>
* 存储文件元信息(真实文件名、存储路径、大小、类型),物理文件存储在
* application.yml 的 file.upload-path 目录下。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "up_file")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class UpFile extends BaseEntity {
/** 应用类型(如 "leave"),用于区分不同业务的附件 */
@Column(name = "topic_type", length = 50)
private String topicType;
/** 关联主题ID(如作业 serid) */
@Column(name = "topic_id")
private Integer topicId;
/** 文件真实名称(用户上传时的原始文件名) */
@Column(name = "file_true_name", length = 255)
private String fileTrueName;
/** 文件存储路径(相对路径,如 leave/20260823/uuid.pdf) */
@Column(name = "file_name", length = 500)
private String fileName;
/** 文件大小(字节) */
@Column(name = "file_size")
private Integer fileSize;
/** 文件后缀名(如 pdf, docx, jpg) */
@Column(name = "file_type", length = 20)
private String fileType;
}
@@ -0,0 +1,35 @@
package com.yunhai.homeleave.enums;
/**
* 请假登记类型:1 教师登记(教师给学生请假,免审直接通过);2 学生申请(学生向老师请假,待审核)。
*/
public enum LeaveRegisterTypeEnum {
TEACHER(1, "教师登记"),
STUDENT(2, "学生申请");
private final Integer id;
private final String name;
LeaveRegisterTypeEnum(Integer id, String name) {
this.id = id;
this.name = name;
}
public Integer getId() {
return id;
}
public String getName() {
return name;
}
public static String nameById(Integer id) {
if (TEACHER.getId().equals(id)) {
return TEACHER.name;
}
if (STUDENT.getId().equals(id)) {
return STUDENT.name;
}
return null;
}
}
@@ -0,0 +1,65 @@
package com.yunhai.homeleave.enums;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
/**
* 请假状态。
* 0-7 沿用历史 HomeSchLeave 语义;8 为本模块新增(教师审核拒绝学生申请)。
*/
public enum LeaveStatusEnum {
PENDING(0, "待审核"),
APPROVED(1, "已通过"),
WITHDRAWING(2, "待撤销"),
WITHDRAWN(3, "已撤销"),
WITHDRAW_REJECTED(4, "撤销拒绝"),
REPORT_BACKING(5, "待销假"),
REPORT_BACKED(6, "已销假"),
REPORT_BACK_REJECTED(7, "销假拒绝"),
REJECTED(8, "已拒绝");
private final Integer id;
private final String name;
LeaveStatusEnum(Integer id, String name) {
this.id = id;
this.name = name;
}
public Integer getId() {
return id;
}
public String getName() {
return name;
}
public static String nameById(Integer id) {
for (LeaveStatusEnum status : list()) {
if (Objects.equals(status.getId(), id)) {
return status.getName();
}
}
return null;
}
/** 已撤销:请假时间段可被新请假覆盖(时间冲突校验排除该状态) */
public static boolean isWithdrawn(Integer status) {
return WITHDRAWN.getId().equals(status);
}
private static List<LeaveStatusEnum> list;
private static List<LeaveStatusEnum> list() {
if (list != null) {
return list;
}
list = new ArrayList<>();
for (LeaveStatusEnum status : EnumSet.allOf(LeaveStatusEnum.class)) {
list.add(status);
}
return list;
}
}
@@ -0,0 +1,56 @@
package com.yunhai.homeleave.enums;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
/**
* 请假类型(历史 LeaveTypeEnum 语义:1 病假;2 事假及其他原因)。
*/
public enum LeaveTypeEnum {
SICK(1, "病假"),
PERSONAL(2, "事假及其他");
private final Integer id;
private final String name;
LeaveTypeEnum(Integer id, String name) {
this.id = id;
this.name = name;
}
public Integer getId() {
return id;
}
public String getName() {
return name;
}
public static String nameById(Integer id) {
for (LeaveTypeEnum type : list()) {
if (Objects.equals(type.getId(), id)) {
return type.getName();
}
}
return null;
}
public static boolean isValid(Integer id) {
return nameById(id) != null;
}
private static List<LeaveTypeEnum> list;
private static List<LeaveTypeEnum> list() {
if (list != null) {
return list;
}
list = new ArrayList<>();
for (LeaveTypeEnum type : EnumSet.allOf(LeaveTypeEnum.class)) {
list.add(type);
}
return list;
}
}
@@ -0,0 +1,14 @@
package com.yunhai.homeleave.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.homeleave.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.homeleave.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,30 @@
package com.yunhai.homeleave.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* 文件上传结果视图对象(替代原 Map&lt;String,Object&gt; 返回)。
* 使用 @JsonProperty 保持旧字段名兼容前端渲染。
*/
@Data
public class UpFileResultVo {
@JsonProperty("fileid")
private Integer fileid;
@JsonProperty("name")
private String name;
@JsonProperty("fileurl")
private String fileurl;
@JsonProperty("host")
private String host;
@JsonProperty("type")
private String type;
@JsonProperty("size")
private Integer size;
}
@@ -0,0 +1,31 @@
package com.yunhai.homeleave.model;
import lombok.Data;
/**
* 附件视图对象(对应原 .NET FileObj)。
*/
@Data
public class UpFileVo {
/** 文件记录ID */
private Integer id;
/** 文件真实名称 */
private String fileTrueName;
/** 文件存储路径(相对路径) */
private String fileName;
/** 文件大小(字节) */
private Integer fileSize;
/** 文件后缀名 */
private String fileType;
/** 关联主题ID */
private Integer topicId;
/** 应用类型 */
private String topicType;
}
@@ -0,0 +1,29 @@
package com.yunhai.homeleave.model.bsch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* bschclass 班级 DTO(/oapi/VSchClass/GetVClassList,SchClass 视图)。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchClassDto {
/** 班级业务ID(sch_class.class_id,scopeType=班级 的 scopeId;学生 ClassID 指向它) */
@JsonProperty("ClassID")
private Integer classId;
/** 班级名称 */
@JsonProperty("ClassName")
private String className;
/** 年级ID(= gradeId) */
@JsonProperty("Levell")
private Integer gradeId;
/** 排序号 */
@JsonProperty("sx")
private Integer sort;
}
@@ -0,0 +1,46 @@
package com.yunhai.homeleave.model.bsch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data;
/**
* bschclass 班级 DTO(/oapi/sch/getSchClassList,含班主任)。
* 上游 OapiClassVO 无 @JsonProperty 注解,JSON 字段为 camelCase;
* 班级名优先取 name,缺失回退 cname。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchClassMasterDto {
/** 班级业务ID(sch_class.class_id) */
private Integer classId;
/** 班级名称 */
private String name;
/** 班级组名称(name 缺失时的回退) */
private String cname;
/** 年级ID(班级显示名「年级名+班级名」的对应关系) */
private Integer gradeId;
/** 年级名称(如「高一」;本接口已携带,免再查年级表) */
private String gradeName;
/** 班主任ID(用户中心教师ID) */
private Integer firstTeacherId;
/** 班主任姓名 */
private String firstTeacherName;
/** 副班主任ID */
private Integer secondTeacherId;
/** 是否毕业班 */
private Boolean graduate;
/** 班级名称(name 优先,回退 cname) */
public String className() {
return name != null && !name.isEmpty() ? name : cname;
}
}
@@ -0,0 +1,25 @@
package com.yunhai.homeleave.model.bsch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* bschclass 年级 DTO(/oapi/Schoolen/GetSchLenLevellist,BaseSchsystemGrade)。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchGradeDto {
/** 年级ID(=gradeId,scopeType=年级 的 scopeId;班级 Levell 指向它) */
@JsonProperty("Id")
private Integer id;
/** 年级名称(gradeNickname,如「初一」) */
@JsonProperty("Name")
private String name;
/** 所属学段(学制)ID */
@JsonProperty("OwnerId")
private Integer ownerId;
}
@@ -0,0 +1,25 @@
package com.yunhai.homeleave.model.bsch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* bschclass 学校信息 DTO(/oapi/SchInfo/GetUserSchFirst)。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchInfoDto {
/** 学校 GUID(bschclass 侧 SchSerId) */
@JsonProperty("SchSerId")
private String schSerId;
/** 用户中心学校组ID(与 CampusNotice schId 同域) */
@JsonProperty("UserSchSerId")
private Integer userSchSerId;
/** 学校名称 */
@JsonProperty("SchName")
private String schName;
}
@@ -0,0 +1,35 @@
package com.yunhai.homeleave.model.bsch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* bschclass 学生 DTO(/oapi/OAStu/*,SchStudent 视图)。
* account 仅为上游接口契约字段(兼容反序列化),本系统落库/身份映射不再使用;
* 学生身份以 stuId(UserStuSerID,= 平台用户ID)+ stuName 承载。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchStudentDto {
/** 学员登录账号(上游契约字段,仅兼容保留,不落库) */
@JsonProperty("Account")
private String account;
/** 学生ID */
@JsonProperty("UserStuSerID")
private Integer stuId;
/** 学生姓名 */
@JsonProperty("StuName")
private String stuName;
/** 所在班级ID(sch_class.class_id) */
@JsonProperty("ClassID")
private Integer classId;
/** 所在组ID */
@JsonProperty("GroupID")
private Integer groupId;
}
@@ -0,0 +1,29 @@
package com.yunhai.homeleave.model.bsch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* bschclass 学校学科 DTO(/oapi/SubInfo/GetSchSublist)。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchSubjectDto {
/** 学校 GUID(bschclass 侧 SchSerId) */
@JsonProperty("SchSerID")
private String schSerId;
/** 学科ID(学科编码;字典无编码时为字典ID字符串) */
@JsonProperty("SubID")
private String subId;
/** 学科名称 */
@JsonProperty("SubName")
private String subName;
/** 排序号 */
@JsonProperty("No")
private Integer no;
}
@@ -0,0 +1,25 @@
package com.yunhai.homeleave.model.bsch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* bschclass 学段(学制)DTO(/oapi/Schoolen/GetSchLenlist,BaseSchsystem)。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchSystemDto {
/** 学制ID(scopeType=学部 的 scopeId) */
@JsonProperty("Id")
private Integer id;
/** 学段名称(如「小学部」「初中部」) */
@JsonProperty("Name")
private String name;
/** 学制类型 */
@JsonProperty("Type")
private Integer type;
}
@@ -0,0 +1,50 @@
package com.yunhai.homeleave.model.bsch;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.Date;
/**
* bschclass 任教关系 DTO(/oapi/TeachInfo/*,sch_class_teach 视图)。
* 一条记录 = 某学校某班级中某教师任教某学科。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchTeachDto {
/** 任教记录ID(sch_class_teach.id) */
@JsonProperty("SerID")
private Integer serId;
/** 学校 GUID(bschclass 侧 SchSerId) */
@JsonProperty("SchSerID")
private String schSerId;
/** 教师ID(= 平台用户ID) */
@JsonProperty("UserTeaSerID")
private Integer teaId;
/** 学科ID(学科编码) */
@JsonProperty("SubID")
private String subId;
/** 班级业务ID(sch_class.class_id) */
@JsonProperty("ClassID")
private Integer classId;
/** 班级ID(与 ClassID 同值,上游兼容字段) */
@JsonProperty("UserClassID")
private Integer userClassId;
/**
* 创建时间。bschclass 返回 "yyyy-MM-dd HH:mm:ss"(空格分隔、非 ISO),
* ClassService 的独立 RestTemplate 不带全局 spring.jackson.date-format,
* 必须在注解上指定格式,否则反序列化抛 InvalidFormatException(任教获取失败的根因)。
*/
@JsonProperty("CreationTime")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date creationTime;
}
@@ -0,0 +1,41 @@
package com.yunhai.homeleave.model.bsch;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.List;
/**
* bschclass 组织树节点 DTO(/oapi/SchGroup/GetTeaTree、GetStuTree,TeaTreeVO 视图)。
* type=1 组节点(学校/学段/年级/班级、教师组),type=2 人员叶子(学生/教师)。
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class SchTreeNodeDto {
/** 节点ID(学校/学段/年级/班级为对应业务ID,叶子为学生 stuId 或教师 teaId) */
private Integer id;
/** 节点名称 */
private String name;
/** 父节点ID(根节点为 0) */
private Integer pid;
/** 排序号(组取 sort,叶子固定 0) */
private Integer no;
/** 1=组节点,2=人员叶子 */
private Integer type;
/** 是否父节点(组 true,叶子 false) */
@JsonProperty("isParent")
private Boolean isParent;
/** 头像(当前固定空串) */
private String photo;
/** 子节点(叶子为 null,组节点非 null) */
private List<SchTreeNodeDto> children;
}
@@ -0,0 +1,22 @@
package com.yunhai.homeleave.model.leave;
import lombok.Data;
/**
* 班级学生 VO(教师登记请假选择学生用)。
*/
@Data
public class ClassStudentVo {
/** 学生ID(用户中心数字ID) */
private Integer stuId;
/** 学生姓名 */
private String stuName;
/** 班级ID */
private Integer classId;
/** 班级名称 */
private String className;
}
@@ -0,0 +1,25 @@
package com.yunhai.homeleave.model.leave;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* 审批入参(教师):审核请假 / 审批撤销 / 审批销假。
*/
@Data
public class LeaveAuditRequest {
/** 请假ID */
@NotNull(message = "请假ID不能为空")
private Integer id;
/**
* 目标动作(LeaveStatusEnum.id 语义):
* 审核:1 通过 / 8 拒绝(status=0 时);
* 撤销审批:3 通过撤销 / 4 拒绝撤销(status=2 时);
* 销假审批:6 通过销假 / 7 拒绝销假(status=5 时)。
*/
@NotNull(message = "审批动作不能为空")
private Integer status;
}
@@ -0,0 +1,18 @@
package com.yunhai.homeleave.model.leave;
import lombok.Data;
import java.util.List;
/**
* 批量通过入参:按班级(该班全部待审核)或按 ID 列表,二选一。
*/
@Data
public class LeaveBatchPassRequest {
/** 班级ID(一键通过该班全部待审核) */
private Integer classId;
/** 请假ID列表(勾选通过) */
private List<Integer> ids;
}
@@ -0,0 +1,33 @@
package com.yunhai.homeleave.model.leave;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.ArrayList;
import java.util.List;
/**
* 请假详情 VO:列表项 + 销假时间段 + 按钮状态。
* <p>btnStatus 按钮状态:0 不显示;1 审核(通过/拒绝);2 销假;3 撤销/销假审批(通过/拒绝)。</p>
* <pre>
* 状态 学生可见 教师可见
* 0 待审核 2 销假(=撤销申请) 1 审核
* 1 已通过 2 销假/申请撤销 type=1→2 销假(教师登记)
* 2 待撤销 — 3 撤销审批
* 5 待销假 — 3 销假审批
* 4/6/7/8 4/7 可再次申请销假 —
* </pre>
*/
@Data
@EqualsAndHashCode(callSuper = false)
public class LeaveDetailVo extends LeaveVo {
/** 销假申请时间段(status=5 待销假时有值) */
private List<LeaveTimeItem> reportBackLeaveTime = new ArrayList<>();
/** 按钮状态:0 不显示;1 审核;2 销假;3 撤销/销假审批 */
private Integer btnStatus;
/** 当前登录人是否学生(前端按身份渲染按钮) */
private Boolean viewerIsStudent;
}
@@ -0,0 +1,35 @@
package com.yunhai.homeleave.model.leave;
import com.yunhai.homeleave.model.BasePageQuery;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 请假列表查询入参。
* 数据范围由服务端按登录身份收敛:学生只能看自己,教师按任教班级(校管理员全校)。
*/
@Data
@EqualsAndHashCode(callSuper = false)
public class LeaveQuery extends BasePageQuery {
/** 班级过滤 */
private Integer classId;
/** 学生过滤(教师端指定学生) */
private Integer stuId;
/** 状态过滤(LeaveStatusEnum.id) */
private Integer status;
/** 请假类型过滤(1 病假;2 事假) */
private Integer leaveType;
/** 登记类型过滤(1 教师登记;2 学生申请) */
private Integer type;
/** 时间范围-起(yyyy-MM-dd,按请假时间段重叠过滤) */
private String startDate;
/** 时间范围-止(yyyy-MM-dd) */
private String endDate;
}
@@ -0,0 +1,30 @@
package com.yunhai.homeleave.model.leave;
import lombok.Data;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
* 销假申请入参(学生申请销假 / 教师直接销假)。
*/
@Data
public class LeaveReportBackRequest {
/** 请假ID */
@NotNull(message = "请假ID不能为空")
private Integer id;
/** 是否连续请假(沿用主表值回传) */
@NotNull(message = "连续请假标识不能为空")
private Integer continuousLeave;
/** 实际开始时间(不连续请假) */
private String startDate;
/** 实际结束时间(不连续请假) */
private String endDate;
/** 连续请假时各时间段实际时间(id 为 leave_time 行ID) */
private List<LeaveTimeItem> continuousLeaveTime;
}
@@ -0,0 +1,55 @@
package com.yunhai.homeleave.model.leave;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.util.List;
/**
* 提交请假入参(学生申请 / 教师登记)。
* 时间格式 yyyy-MM-dd HH:mm:ss(仅日期时服务端补 00:00:00 / 23:59:59)。
*/
@Data
public class LeaveRequest {
/** 学生ID(教师登记必填;学生申请忽略,取当前登录人) */
private Integer stuId;
/** 班级ID(教师登记必填;学生申请忽略,取当前登录人名册班级) */
private Integer classId;
/** 登记类型:1 教师登记;2 学生申请(学生端固定 2) */
@NotNull(message = "登记类型不能为空")
private Integer type;
/** 请假类型:1 病假;2 事假及其他 */
@NotNull(message = "请假类型不能为空")
private Integer leaveType;
/** 开始时间 */
@NotBlank(message = "开始时间不能为空")
private String startDate;
/** 结束时间 */
@NotBlank(message = "结束时间不能为空")
private String endDate;
/** 是否连续请假:0 否;1 是 */
private Integer continuousLeave = 0;
/** 连续请假次数(continuousLeave=1 时必填 >0) */
private Integer continuousLeaveNum;
/** 连续请假时间段(continuousLeave=1 时必填,每天一个 {startDate,endDate}) */
private List<LeaveTimeItem> continuousLeaveTime;
/** 请假事由 */
@NotBlank(message = "请假事由不能为空")
@Size(max = 1000, message = "请假事由不能超过1000字")
private String remarks;
/** 佐证附件文件ID列表(file/Upload 上传后返回的 fileid) */
private List<Integer> fileIds;
}
@@ -0,0 +1,54 @@
package com.yunhai.homeleave.model.leave;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 请假统计 VO(按班级/日期/类型聚合,统计范围同列表权限:教师任教班级、校管理员全校、学生本人)。
*/
@Data
public class LeaveStatsVo {
/** 统计范围请假总条数(不含已撤销) */
private int totalCount;
/** 涉及学生数(去重) */
private int studentCount;
/** 待审核条数 */
private int pendingCount;
/** 按请假类型分布 */
private List<TypeDist> byLeaveType = new ArrayList<>();
/** 按班级分布 */
private List<ClassDist> byClass = new ArrayList<>();
/** 按日期分布(请假时间段覆盖的每天,按 start_date 计) */
private List<DateDist> byDate = new ArrayList<>();
@Data
public static class TypeDist {
private Integer leaveType;
private String leaveTypeName;
private int count;
}
@Data
public static class ClassDist {
private Integer classId;
private String className;
private int count;
/** 该班请假学生数(去重) */
private int studentCount;
}
@Data
public static class DateDist {
/** yyyy-MM-dd */
private String date;
private int count;
}
}
@@ -0,0 +1,53 @@
package com.yunhai.homeleave.model.leave;
import lombok.Data;
import lombok.EqualsAndHashCode;
import com.yunhai.homeleave.utils.LeaveTimeText;
import javax.validation.constraints.NotBlank;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
/**
* 请假时间段项(连续请假每天一个 / 销假时回传实际时间)。
* id 为 leave_time 行ID(销假回传时使用,新请假不填)。
*/
@Data
@EqualsAndHashCode(callSuper = false)
public class LeaveTimeItem {
/** leave_time 行ID(销假/调整时间时回传,新请假不填) */
private Integer id;
@NotBlank(message = "开始时间不能为空")
private String startDate;
@NotBlank(message = "结束时间不能为空")
private String endDate;
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/** 开始日期(yyyy-MM-dd),非法日期返回 null */
public LocalDate startDay() {
try {
return LocalDate.parse(startDate.substring(0, 10), DAY);
} catch (Exception e) {
return null;
}
}
/** 结束日期(yyyy-MM-dd),非法日期返回 null */
public LocalDate endDay() {
try {
return LocalDate.parse(endDate.substring(0, 10), DAY);
} catch (Exception e) {
return null;
}
}
/** 日期区间展示(冲突提示用):精确到分钟,同一天折叠 */
public String range() {
return LeaveTimeText.range(startDate, endDate);
}
}
@@ -0,0 +1,60 @@
package com.yunhai.homeleave.model.leave;
import com.yunhai.homeleave.model.UpFileVo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 请假列表项 VO。
*/
@Data
public class LeaveVo {
private Integer id;
private Integer classId;
private String className;
private Integer stuId;
private String stuName;
private String stuPhoto;
/** 登记类型:1 教师登记;2 学生申请 */
private Integer type;
private String typeName;
/** 请假类型:1 病假;2 事假及其他 */
private Integer leaveType;
private String leaveTypeName;
private String startDate;
private String endDate;
private Integer continuousLeave;
private Integer continuousLeaveNum;
/** 请假时间段(不连续为单条) */
private List<LeaveTimeItem> continuousLeaveTime = new ArrayList<>();
/** 状态(LeaveStatusEnum.id) */
private Integer status;
private String statusName;
private String remarks;
/** 附件 */
private List<UpFileVo> files = new ArrayList<>();
/** 创建时间 yyyy-MM-dd HH:mm:ss */
private String createTime;
}
@@ -0,0 +1,16 @@
package com.yunhai.homeleave.model.leave;
import lombok.Data;
/**
* 教师任教班级 VO(请假登记/审批选择班级用)。
*/
@Data
public class TeachingClassVo {
/** 班级ID(bschclass sch_class.class_id) */
private Integer classId;
/** 班级名称 */
private String className;
}
@@ -0,0 +1,18 @@
package com.yunhai.homeleave.repository;
import com.yunhai.homeleave.entity.LeaveReportBack;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface LeaveReportBackRepository extends JpaRepository<LeaveReportBack, Integer> {
List<LeaveReportBack> findByLeaveIdOrderByStartDateAsc(Integer leaveId);
List<LeaveReportBack> findByLeaveIdIn(List<Integer> leaveIds);
/** 学生的全部待审核销时间段(新请假时间冲突校验用) */
List<LeaveReportBack> findByStuId(Integer stuId);
void deleteByLeaveId(Integer leaveId);
}
@@ -0,0 +1,21 @@
package com.yunhai.homeleave.repository;
import com.yunhai.homeleave.entity.Leave;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
/**
* 请假主表仓储。
* 状态更新走实体 load-modify-save(@PreUpdate 审计填充生效),不用批量 JPQL
* (bulk update 绕过 EntityListeners,update_by/update_time 会丢失)。
*/
public interface LeaveRepository extends JpaRepository<Leave, Integer>, JpaSpecificationExecutor<Leave> {
/** 班级内待审核记录(一键通过用) */
List<Leave> findBySchIdAndClassIdAndStatus(Integer schId, Integer classId, Integer status);
/** 指定 ID 集合内待审核记录(批量通过按 ID 过滤用) */
List<Leave> findByIdInAndStatus(List<Integer> ids, Integer status);
}
@@ -0,0 +1,18 @@
package com.yunhai.homeleave.repository;
import com.yunhai.homeleave.entity.LeaveTime;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface LeaveTimeRepository extends JpaRepository<LeaveTime, Integer> {
List<LeaveTime> findByLeaveIdOrderByStartDateAsc(Integer leaveId);
List<LeaveTime> findByLeaveIdInOrderByStartDateAsc(List<Integer> leaveIds);
/** 学生的全部请假时间段(新请假时间冲突校验用;已撤销主表由服务层过滤) */
List<LeaveTime> findByStuId(Integer stuId);
void deleteByLeaveId(Integer leaveId);
}
@@ -0,0 +1,45 @@
package com.yunhai.homeleave.repository;
import com.yunhai.homeleave.entity.SysDict;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface SysDictRepository extends JpaRepository<SysDict, Integer> {
// ---------------- 存量/无学校归属(schId 为空时不过滤,同原作业模块多租户口径) ----------------
/** 全量(含禁用),用于管理列表与重名/序号计算 */
List<SysDict> findByDictTypeOrderBySortOrderAsc(String dictType);
/** 启用项(下拉框用) */
List<SysDict> findByDictTypeAndEnableFlagOrderBySortOrderAsc(String dictType, Integer enableFlag);
/** 按类型+key 取单条字典 */
Optional<SysDict> findByDictTypeAndDictKey(String dictType, String dictKey);
// ---------------- 多租户:本校 + 全局(sch_id 为 NULL 的存量视为全局,全校可见) ----------------
/** 可见全量(含禁用):本校 + 全局 */
@Query("select d from SysDict d where d.dictType = :dictType " +
"and (d.schId = :schId or d.schId is null) order by d.sortOrder asc")
List<SysDict> findVisibleBySchIdAndDictType(@Param("schId") Integer schId, @Param("dictType") String dictType);
/** 可见启用项:本校 + 全局(下拉框用) */
@Query("select d from SysDict d where d.dictType = :dictType and d.enableFlag = :enableFlag " +
"and (d.schId = :schId or d.schId is null) order by d.sortOrder asc")
List<SysDict> findVisibleEnabledBySchIdAndDictType(@Param("schId") Integer schId,
@Param("dictType") String dictType,
@Param("enableFlag") Integer enableFlag);
/** 可见单条:本校优先,其次全局;同 key 多条时按本校优先 + id 升序,服务侧取首条 */
@Query("select d from SysDict d where d.dictType = :dictType and d.dictKey = :dictKey " +
"and (d.schId = :schId or d.schId is null) " +
"order by case when d.schId = :schId then 0 else 1 end asc, d.id asc")
List<SysDict> findVisibleBySchIdAndDictTypeAndDictKey(@Param("schId") Integer schId,
@Param("dictType") String dictType,
@Param("dictKey") String dictKey);
}
@@ -0,0 +1,9 @@
package com.yunhai.homeleave.repository;
import com.yunhai.homeleave.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,26 @@
package com.yunhai.homeleave.repository;
import com.yunhai.homeleave.entity.UpFile;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 附件文件仓储。
*/
public interface UpFileRepository extends JpaRepository<UpFile, Integer> {
/**
* 按主题类型和主题ID查询附件列表。
*/
List<UpFile> findByTopicTypeAndTopicId(String topicType, Integer topicId);
/**
* 按主题类型和多个主题ID批量查询附件(消除 N+1)。
*/
List<UpFile> findByTopicTypeAndTopicIdIn(String topicType, List<Integer> topicIds);
/**
* 按主题类型和主题ID删除附件(作业删除时级联清理)。
*/
void deleteByTopicTypeAndTopicId(String topicType, Integer topicId);
}
@@ -0,0 +1,123 @@
package com.yunhai.homeleave.service;
import com.yunhai.homeleave.model.SchTeaList;
import com.yunhai.homeleave.model.bsch.SchClassDto;
import com.yunhai.homeleave.model.bsch.SchGradeDto;
import com.yunhai.homeleave.model.bsch.SchInfoDto;
import com.yunhai.homeleave.model.bsch.SchStudentDto;
import com.yunhai.homeleave.model.bsch.SchSystemDto;
import com.yunhai.homeleave.model.bsch.SchTreeNodeDto;
import com.yunhai.homeleave.model.SchTeaList;
import com.yunhai.homeleave.model.bsch.*;
import java.util.List;
/**
* 班级管理(bschclass)开放接口客户端门面。
* <p>基址取配置 {@code yh.open.bschclass}(/oapi/** 无需应用签名,独立 RestTemplate 直连)。
* 推送范围树、圈人、身份快照统一经本门面取数,不散落 RestTemplate 调用(改造方案 2.0)。
*/
public interface IClassService {
/**
* 获取指定学校全部教师(含所属组 GroupIds)。
* 对应 bschclass 开放接口:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}}
*
* @param schId 学校ID(即用户当前身份的 schId)
*/
List<SchTeaList> getSchTeaList(int schId);
/**
* 用户中心学校ID → bschclass 学校信息(SchSerId / 学校名)。
* 对应:GET {@code {bschclass}/oapi/SchInfo/GetUserSchFirst?UserSchID={schId}}
*/
SchInfoDto getSchFirst(int schId);
/**
* 学校学段(学制)列表:小学部 / 初中部…。
* 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenlist?SchSerID=}
*/
List<SchSystemDto> getSchLenList(String schSerId);
/**
* 学校年级列表(Id=gradeId、Name=gradeNickname、OwnerId=学段id)。
* 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenLevellist?SchSerID=}
*/
List<SchGradeDto> getSchGradeList(String schSerId);
/**
* 学校全部在读班级(ClassID/ClassName/Levell=gradeId),ScopeTree 一次取数客户端组装。
* 对应:GET {@code {bschclass}/oapi/VSchClass/GetVClassList?SchSerID=}
*/
List<SchClassDto> getSchClassList(String schSerId);
/**
* 按校全部学生(圈人:全校范围展开)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetSchStulist?schserid=}
*/
List<SchStudentDto> getSchStuList(String schSerId);
/**
* 按年级直查学生(圈人:年级范围免逐班聚合;bschclass 侧补缺接口)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetGradeStulist?SchSerID=&GradeId=}
*/
List<SchStudentDto> getGradeStuList(String schSerId, Integer gradeId);
/**
* 按学段直查学生(圈人:学部范围一键展开;bschclass 侧补缺接口)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetSubStulist?SchSerID=&SchSystemId=}
*/
List<SchStudentDto> getSubStuList(String schSerId, Integer schSystemId);
/**
* 按班级批量查学生(圈人:班级范围一次多班)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetClaArrStulist?ClassIDs=1,2,3}
*/
List<SchStudentDto> getClaArrStuList(List<Integer> classIds);
/**
* 按学生ID批量查(圈人:指定学员范围)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetStuIdslist?stuids=1,2,3}
*/
List<SchStudentDto> getStuIdsList(List<Integer> stuIds);
/**
* 学校学生树:学校→学段→年级→班级→学生(单元素列表,根为学校节点)。
* 节点契约:组节点 type=1、isParent=true、children 非 null;学生叶子 type=2、isParent=false、children=null。
* 对应:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?SchSerID=}
*/
List<SchTreeNodeDto> getTeaTree(Integer schId, boolean toUser);
List<SchTreeNodeDto> getStuTree(Integer schId,boolean toUser);
/**
* 学校学科列表(含学科编码、名称、排序号)。
* 对应:GET {@code {bschclass}/oapi/SubInfo/GetSchSublist?SchID=}
*/
List<SchSubjectDto> getSchSubList(String schSerId);
/**
* 指定教师的全部任教(校验教师归属该校;SchID 过滤学校、UserTeaSerID 过滤教师)。
* 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchTeachlist?SchID=&UserTeaSerID=}
*/
List<SchTeachDto> getSchTeachList(String schSerId, Integer teaId);
/**
* 指定班级的全部任教(班级维度:该班所有教师与学科)。
* 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchClaTeachlist?ClassID=}
*/
List<SchTeachDto> getSchClaTeachList(Integer classId);
/**
* 学校全部任教(全校所有教师的班级任教关系)。
* 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchTeachlist?SchID=}
*/
List<SchTeachDto> getSchAllTeachList(String schSerId);
/**
* 学校全部班级(含班主任 firstTeacherId / 副班主任 secondTeacherId,不含毕业班)。
* 请假模块用于识别教师所带班级(班主任 = 审批/登记数据范围)。
* 对应:GET {@code {bschclass}/oapi/sch/getSchClassList?schId=}(schId 为用户中心学校ID)
*/
List<com.yunhai.homeleave.model.bsch.SchClassMasterDto> getSchClassMasterList(Integer schId);
}
@@ -0,0 +1,93 @@
package com.yunhai.homeleave.service;
import java.util.List;
import java.util.Map;
/**
* 系统字典业务接口。
* 对应前端 Base 组接口:GetSysDic/GetDictMangeList/AddDeptDic/UpdateDeptDic/DelDept/OrdbyDicIds/GetDeptSync/GetSchModuleIsSms。
*/
public interface IDictService {
/**
* 获取系统字典(下拉框用)。
* C# GetSysDic: Type=1 部门类别, 其余类型略。
*
* @param type 字典类型编号(1=部门类别)
* @return List&lt;Map&gt; 每项 {k, v}
*/
List<Map<String, Object>> getSysDic(Integer type);
/**
* 获取系统字典管理列表(含 SerID/CodeID/Name/Des/Enable)。
*
* @param type 字典类型编号(0=部门类别)
* @return List&lt;Map&gt;
*/
List<Map<String, Object>> getDictMangeList(String type);
/**
* 添加发文单位。
*
* @param name 单位名称
*/
void addDeptDic(String name);
/**
* 更新发文单位。
*
* @param serID 记录ID
* @param name 名称
* @param enable 启用状态
*/
void updateDeptDic(Integer serID, String name, Integer enable);
/**
* 删除发文单位。
*
* @param serID 记录ID
*/
void delDept(Integer serID);
/**
* 发文单位排序。
*
* @param ids 逗号分隔的 SerID 列表(按前端顺序)
*/
void ordbyDicIds(String ids);
/**
* 按发文部门字典 key(CodeID)解析部门名称;未命中返回 null。
* 用于请假列表/详情展示 DeptName(对应 C# GetDeptName)。
*
* @param deptId 字典 key(存的是 sys_dict.dict_key)
* @param schId 所属学校ID(展示场景传业务行 schId,写入场景传当前登录人学校;
* 多租户下优先本校字典、其次全局字典,不会命中其他学校同名 key;
* 为 null 时退化为全局匹配,兼容存量数据)
* @return 部门名称;无匹配时返回 null
*/
String resolveDeptName(String deptId, Integer schId);
// ============================ 请假类型字典(dict_type=leave_type,应用设置可配置) ============================
/** 请假类型管理列表:{SerID, CodeID, Name, Des, Enable}(含禁用) */
List<Map<String, Object>> getLeaveTypeManageList();
/** 请假类型下拉项(启用):{k, v},k 为数字型字典 key 字符串 */
List<Map<String, Object>> getLeaveTypeOptions();
/** 新增请假类型(key 自动取最大数字 key + 1),返回新记录 SerID */
Integer addLeaveType(String name);
/** 更新请假类型(名称/启用) */
void updateLeaveType(Integer serID, String name, Integer enable);
/** 删除请假类型(已被请假记录引用时禁止删除,提示停用) */
void deleteLeaveType(Integer serID);
/** 请假类型排序:ids 按前端顺序(升序)重排 sortOrder */
void sortLeaveTypes(String ids);
/** 按学校解析请假类型名称(本校优先、其次全局;未命中回退 null,调用方可再退枚举名) */
String resolveLeaveTypeName(Integer leaveType, Integer schId);
}
@@ -0,0 +1,94 @@
package com.yunhai.homeleave.service;
import com.yunhai.homeleave.entity.UpFile;
import com.yunhai.homeleave.model.UpFileResultVo;
import com.yunhai.homeleave.model.UpFileVo;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 文件上传/下载/删除业务接口。
*/
public interface IFileService {
/**
* 上传单个文件。
*
* @param file 上传的文件
* @param appType 应用类型(如 "notice")
* @param topicId 关联主题ID(可为空,通知保存后再绑定)
* @return 文件视图对象
*/
UpFileVo upload(MultipartFile file, String appType, Integer topicId);
/**
* 将已上传但未绑定主题的附件绑定到指定通知。
*
* @param fileIds 文件记录ID列表
* @param topicId 通知ID
*/
void bindToTopic(List<Integer> fileIds, Integer topicId);
/**
* 查询某主题下的附件列表。
*
* @param appType 应用类型
* @param topicId 主题ID
* @return 附件列表
*/
List<UpFileVo> listByTopic(String appType, Integer topicId);
/**
* 下载文件。
*
* @param fileId 文件记录ID
* @param response HTTP 响应
*/
void download(Integer fileId, HttpServletResponse response);
/**
* 在线预览文件(Content-Disposition: inline,按扩展名输出真实 Content-Type,
* 供前端 open-file-viewer 取 Blob / video 播放)。
*
* @param fileId 文件记录ID
* @param response HTTP 响应
*/
void preview(Integer fileId, HttpServletResponse response);
/**
* 删除文件(同时删除物理文件和数据库记录)。
*
* @param fileId 文件记录ID
*/
void delete(Integer fileId);
/**
* 打包下载多个附件为 ZIP。
*
* @param files 附件记录列表(调用方按 fileIds 查好)
* @param zipName 压缩包显示名(自动追加 .zip)
* @param response HTTP 响应
*/
void downloadZip(List<UpFile> files, String zipName, HttpServletResponse response);
/**
* 按主题类型和主题ID打包下载附件为 ZIP(Controller 便捷方法,内部自行查附件列表)。
*
* @param topicType 主题类型(如 "notice")
* @param topicId 主题ID
* @param zipName 压缩包显示名
* @param response HTTP 响应
*/
void downloadZipByTopic(String topicType, Integer topicId, String zipName, HttpServletResponse response);
/**
* 批量上传图片(循环上传,返回每个文件的视图对象列表)。
*/
List<UpFileVo> uploadImages(MultipartFile[] files, String appType);
/**
* 上传单个文件并返回前端兼容的结果 VO(含 fileid/name/fileurl/host/type/size)。
*/
UpFileResultVo uploadToResult(MultipartFile file, String appType);
}
@@ -0,0 +1,69 @@
package com.yunhai.homeleave.service;
import com.yunhai.homeleave.model.BasePageResult;
import com.yunhai.homeleave.model.leave.ClassStudentVo;
import com.yunhai.homeleave.model.leave.LeaveAuditRequest;
import com.yunhai.homeleave.model.leave.LeaveBatchPassRequest;
import com.yunhai.homeleave.model.leave.LeaveDetailVo;
import com.yunhai.homeleave.model.leave.LeaveQuery;
import com.yunhai.homeleave.model.leave.LeaveReportBackRequest;
import com.yunhai.homeleave.model.leave.LeaveRequest;
import com.yunhai.homeleave.model.leave.LeaveStatsVo;
import com.yunhai.homeleave.model.leave.LeaveVo;
import com.yunhai.homeleave.model.leave.TeachingClassVo;
import java.util.List;
/**
* 学生请假业务接口(学生申请、教师审批、教师登记、撤销、销假、统计)。
* <p>身份:当前登录人ID 实时匹配 bschclass 学生名册(RosterService.mine)判定学生,
* 其余视为教师;教师数据范围 = 本人担任班主任的班级(getSchClassList firstTeacherId),
* 校管理员(BoaUser.type=2)全校。学生请假需班主任通过或拒绝(教师登记免审直接生效)。</p>
*/
public interface ILeaveService {
/**
* 提交请假:学生申请(type=2 → 待审核);教师登记(type=1 → 免审直接通过)。
*
* @return 新请假ID
*/
Integer saveLeave(LeaveRequest request);
/** 分页列表(学生本人 / 教师任教班级 / 校管理员全校,按入参二次过滤) */
BasePageResult<LeaveVo> getLeaveList(LeaveQuery query);
/** 详情(含时间段、销时间段、附件、按钮状态) */
LeaveDetailVo getLeaveDetails(Integer id);
/**
* 教师审批:审核(0→1 通过 / 0→8 拒绝)、撤销审批(2→3/4)、销假审批(5→6/7)。
*/
void auditLeave(LeaveAuditRequest request);
/** 批量通过(按班级或 ID 列表),返回实际通过条数 */
int batchPass(LeaveBatchPassRequest request);
/**
* 撤销请假。学生:待审核直接撤销(→3),已通过/撤销拒绝转待撤销(→2);
* 教师:直接作废生效中/流程中的记录(→3),无需学生申请。
*/
void withdrawLeave(Integer id);
/**
* 教师删除请假记录(连带时间段、销假记录、附件)。仅已结束状态可删除:
* 已撤销 3 / 已销假 6 / 已拒绝 8;其余状态需先撤销。
*/
void deleteLeave(Integer id);
/** 销假:学生申请销假(已通过→待销假5,落销时间段);教师直接销假(→已销假6,回写时间) */
void saveReportBackLeave(LeaveReportBackRequest request);
/** 请假统计(范围同列表权限) */
LeaveStatsVo stats(String startDate, String endDate, Integer classId, Integer leaveType);
/** 教师任教班级(校管理员=全校班级) */
List<TeachingClassVo> myClasses();
/** 班级学生名单(教师登记请假选择学生用;校验班级在数据范围内) */
List<ClassStudentVo> classStudents(Integer classId);
}
@@ -0,0 +1,21 @@
package com.yunhai.homeleave.service;
import com.yunhai.homeleave.model.BasePageResult;
import com.yunhai.homeleave.entity.SysOperationLog;
/**
* 系统审计日志业务接口(参考 UserclassService ISysOperationLogService,精简重试能力)。
*/
public interface ISysOperationLogService {
/** 切面 finally 调用:异步落库(auditLogExecutor + REQUIRES_NEW),失败仅记错误日志 */
void saveAsync(SysOperationLog entity);
/** 分页查询(timeRange 起止 yyyy-MM-dd,按 create_time 过滤;不含响应体) */
BasePageResult<SysOperationLog> pageQuery(Integer schId, String uri, String method,
Integer statusCode, Integer logLevel, String userName,
String startDate, String endDate, int pageIndex, int pageSize);
/** 解压某条响应原文 */
String getResponseData(Long id);
}
@@ -0,0 +1,51 @@
package com.yunhai.homeleave.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);
}
@@ -0,0 +1,224 @@
package com.yunhai.homeleave.service.impl;
import com.yunhai.homeleave.model.SchTeaList;
import com.yunhai.homeleave.model.bsch.SchClassDto;
import com.yunhai.homeleave.model.bsch.SchGradeDto;
import com.yunhai.homeleave.model.bsch.SchInfoDto;
import com.yunhai.homeleave.model.bsch.SchStudentDto;
import com.yunhai.homeleave.model.bsch.SchSystemDto;
import com.yunhai.homeleave.model.bsch.SchTreeNodeDto;
import com.yunhai.homeleave.service.IClassService;
import com.yunhai.homeleave.model.SchTeaList;
import com.yunhai.homeleave.model.bsch.*;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* 班级管理(bschclass)开放接口实现。
* bschclass 为开放接口(无需应用签名),使用独立 RestTemplate 直连。
* <p>推送范围树/圈人/身份快照统一经本门面(改造方案 2.0):
* 学段 GetSchLenlist、年级 GetSchLenLevellist、班级 GetVClassList、
* 学生 GetSchStulist / GetGradeStulist(补缺) / GetSubStulist(补缺) / GetClaArrStulist / GetStuIdslist、
* 学生树 SchGroup/GetStuTree。
*/
@Service
public class ClassService implements IClassService {
private static final Logger log = LoggerFactory.getLogger(ClassService.class);
/** 班级管理服务基址,如 http://base.yunhaixingtong.com/bschclass */
@Value("${yh.open.bschclass:}")
private String bschClassHost;
private final RestTemplate restTemplate;
public ClassService() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000);
factory.setReadTimeout(10000);
this.restTemplate = new RestTemplate(factory);
}
@Override
public List<SchTeaList> getSchTeaList(int schId) {
return exchangeList(bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId,
new ParameterizedTypeReference<List<SchTeaList>>() {
}, "获取教师数据", "schId=" + schId);
}
@Override
public SchInfoDto getSchFirst(int schId) {
requireHost();
String url = bschClassHost + "/oapi/SchInfo/GetUserSchFirst?UserSchID=" + schId;
try {
ResponseEntity<SchInfoDto> resp = restTemplate.getForEntity(URI.create(url), SchInfoDto.class);
return resp.getBody();
} catch (RestClientException e) {
log.error("[getSchFirst] 请求班级管理接口失败, url={}", url, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取学校信息失败");
}
}
@Override
public List<SchSystemDto> getSchLenList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenlist?SchSerID=" + schSerId,
new ParameterizedTypeReference<List<SchSystemDto>>() {
}, "获取学段列表", "schSerId=" + schSerId);
}
@Override
public List<SchGradeDto> getSchGradeList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenLevellist?SchSerID=" + schSerId,
new ParameterizedTypeReference<List<SchGradeDto>>() {
}, "获取年级列表", "schSerId=" + schSerId);
}
@Override
public List<SchClassDto> getSchClassList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/VSchClass/GetVClassList?SchSerID=" + schSerId,
new ParameterizedTypeReference<List<SchClassDto>>() {
}, "获取班级列表", "schSerId=" + schSerId);
}
@Override
public List<SchStudentDto> getSchStuList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/OAStu/GetSchStulist?schserid=" + schSerId,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取学校学生", "schSerId=" + schSerId);
}
@Override
public List<SchStudentDto> getGradeStuList(String schSerId, Integer gradeId) {
return exchangeList(bschClassHost + "/oapi/OAStu/GetGradeStulist?SchSerID=" + schSerId + "&GradeId=" + gradeId,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取年级学生", "schSerId=" + schSerId + ", gradeId=" + gradeId);
}
@Override
public List<SchStudentDto> getSubStuList(String schSerId, Integer schSystemId) {
return exchangeList(bschClassHost + "/oapi/OAStu/GetSubStulist?SchSerID=" + schSerId + "&SchSystemId=" + schSystemId,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取学段学生", "schSerId=" + schSerId + ", schSystemId=" + schSystemId);
}
@Override
public List<SchStudentDto> getClaArrStuList(List<Integer> classIds) {
String csv = joinIds(classIds);
return exchangeList(bschClassHost + "/oapi/OAStu/GetClaArrStulist?ClassIDs=" + csv,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取班级学生", "classIds=" + csv);
}
@Override
public List<SchStudentDto> getStuIdsList(List<Integer> stuIds) {
String csv = joinIds(stuIds);
return exchangeList(bschClassHost + "/oapi/OAStu/GetStuIdslist?stuids=" + csv,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取指定学生", "stuIds=" + csv);
}
@Override
public List<SchTreeNodeDto> getStuTree(Integer schId,boolean toUser) {
return exchangeList(bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId+"&toUser="+toUser,
new ParameterizedTypeReference<List<SchTreeNodeDto>>() {
}, "获取学生树", "schId=" + schId);
}
@Override
public List<SchTreeNodeDto> getTeaTree(Integer schId,boolean toUser) {
return exchangeList(bschClassHost + "/oapi/SchGroup/GetTeaTree?schId=" + schId+"&toUser="+toUser,
new ParameterizedTypeReference<List<SchTreeNodeDto>>() {
}, "获取教师树", "schId=" + schId);
}
@Override
public List<SchSubjectDto> getSchSubList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/SubInfo/GetSchSublist?SchID=" + schSerId,
new ParameterizedTypeReference<List<SchSubjectDto>>() {
}, "获取学校学科", "schSerId=" + schSerId);
}
@Override
public List<SchTeachDto> getSchTeachList(String schSerId, Integer teaId) {
return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchTeachlist?SchID=" + schSerId + "&UserTeaSerID=" + teaId,
new ParameterizedTypeReference<List<SchTeachDto>>() {
}, "获取教师任教", "schSerId=" + schSerId + ", teaId=" + teaId);
}
@Override
public List<SchTeachDto> getSchClaTeachList(Integer classId) {
return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchClaTeachlist?ClassID=" + classId,
new ParameterizedTypeReference<List<SchTeachDto>>() {
}, "获取班级任教", "classId=" + classId);
}
@Override
public List<SchTeachDto> getSchAllTeachList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchTeachlist?SchID=" + schSerId,
new ParameterizedTypeReference<List<SchTeachDto>>() {
}, "获取学校全部任教", "schSerId=" + schSerId);
}
@Override
public List<com.yunhai.homeleave.model.bsch.SchClassMasterDto> getSchClassMasterList(Integer schId) {
return exchangeList(bschClassHost + "/oapi/sch/getSchClassList?schId=" + schId,
new ParameterizedTypeReference<List<com.yunhai.homeleave.model.bsch.SchClassMasterDto>>() {
}, "获取学校班级(含班主任)", "schId=" + schId);
}
// ============================ 通用调用 ============================
private <T> List<T> exchangeList(String url, ParameterizedTypeReference<List<T>> type,
String action, String arg) {
requireHost();
try {
// GET + Accept: application/json,保持与既有 GetSchteaList 调用一致
HttpHeaders headers = new HttpHeaders();
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
ResponseEntity<List<T>> resp = restTemplate.exchange(
url, HttpMethod.GET, new HttpEntity<>(headers), type);
List<T> list = resp.getBody();
log.info("[bschclass] {} 完成, {} 返回 {} 条", action, arg, list == null ? 0 : list.size());
return list != null ? list : Collections.emptyList();
} catch (RestClientException e) {
log.error("[bschclass] {} 失败, {}, url={}", action, arg, url, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, action + "失败");
}
}
private void requireHost() {
if (!StringUtils.hasText(bschClassHost)) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)");
}
}
private String joinIds(List<Integer> ids) {
if (ids == null || ids.isEmpty()) {
return "";
}
return ids.stream().map(String::valueOf).collect(Collectors.joining(","));
}
}
@@ -0,0 +1,431 @@
package com.yunhai.homeleave.service.impl;
import com.yunhai.homeleave.entity.SysDict;
import com.yunhai.homeleave.repository.SysDictRepository;
import com.yunhai.homeleave.service.IDictService;
import com.yunhai.homeleave.utils.UserContext;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.*;
import java.util.stream.Collectors;
/**
* 系统字典服务实现。
* 对应 C# CommonController + GetSysDict.cs 中的字典管理逻辑。
*/
@Service
public class DictService implements IDictService {
private static final Logger log = LoggerFactory.getLogger(DictService.class);
/** 字典类型常量:发文部门 */
private static final String DICT_TYPE_DEPT = "dept";
/** 字典类型常量:请假类型(应用设置可配置) */
private static final String DICT_TYPE_LEAVE = "leave_type";
@Autowired
private SysDictRepository sysDictRepository;
@Autowired
private com.yunhai.homeleave.repository.LeaveRepository leaveRepository;
// ============================ GetSysDic ============================
@Override
public List<Map<String, Object>> getSysDic(Integer type) {
// C# 中 Type=1 为部门类别(DEPT),其余类型(文印、请假等)本系统暂不涉及
String dictType = mapDictType(type);
if (dictType == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
}
List<SysDict> list = visibleEnabledList(dictType);
if (list.isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "尚未添加发文部门,请联系管理员添加!");
}
// 按 sortOrder 升序返回 {k, v}
return list.stream()
.sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? Integer.MAX_VALUE : d.getSortOrder()))
.map(d -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("k", d.getDictKey());
item.put("v", d.getDictValue());
return item;
})
.collect(Collectors.toList());
}
// ============================ GetDictMangeList ============================
@Override
public List<Map<String, Object>> getDictMangeList(String type) {
// type="dept" 表示部门类别(DEPT)
String dictType = "dept".equalsIgnoreCase(type) ? DICT_TYPE_DEPT : null;
if (dictType == null) {
// 非 DEPT 类型需超管权限,本系统暂只支持 DEPT
throw new BizException(ErrorCode.FORBIDDEN, "非超管不得操作!");
}
List<SysDict> list = visibleList(dictType);
return list.stream()
.sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? 999 : d.getSortOrder()))
.map(d -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("SerID", d.getId());
item.put("CodeID", d.getDictKey());
item.put("Name", d.getDictValue());
item.put("Des", d.getSortOrder() != null ? d.getSortOrder().toString() : "");
item.put("Enable", d.getEnableFlag());
return item;
})
.collect(Collectors.toList());
}
// ============================ AddDeptDic ============================
@Override
@Transactional
public void addDeptDic(String name) {
if (!StringUtils.hasText(name)) {
throw new BizException(ErrorCode.PARAM_ERROR, "输入字符串格式不正确!");
}
Integer schId = UserContext.getSchId();
// 重名校验范围 = 本校 + 全局可见项,避免与存量全局字典重名
List<SysDict> existing = visibleList(DICT_TYPE_DEPT);
boolean duplicate = existing.stream().anyMatch(d ->
DICT_TYPE_DEPT.equals(d.getDictType()) && name.equals(d.getDictValue()) && d.getEnableFlag() != 3);
if (duplicate) {
throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!");
}
// 计算 sortOrder = max + 1(同样基于本校 + 全局可见项)
int maxSort = existing.stream()
.filter(d -> d.getEnableFlag() != 3)
.mapToInt(d -> d.getSortOrder() != null ? d.getSortOrder() : 0)
.max().orElse(0);
SysDict dict = new SysDict();
// 多租户:归属当前登录人学校;无学校归属(区域级)时为 NULL,即全局字典
dict.setSchId(schId);
dict.setDictType(DICT_TYPE_DEPT);
dict.setDictValue(name);
dict.setSortOrder(maxSort + 1);
dict.setDictKey(DICT_TYPE_DEPT + (maxSort + 1));
dict.setEnableFlag(1);
sysDictRepository.save(dict);
}
// ============================ UpdateDeptDic ============================
@Override
@Transactional
public void updateDeptDic(Integer serID, String name, Integer enable) {
if (serID == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!");
}
SysDict dict = sysDictRepository.findById(serID)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在"));
checkSchoolScope(dict);
// 校验重名(排除自身,范围 = 本校 + 全局可见项)
List<SysDict> existing = visibleList(DICT_TYPE_DEPT);
boolean duplicate = existing.stream().anyMatch(d ->
!d.getId().equals(serID) && name.equals(d.getDictValue()) && d.getEnableFlag() != 3);
if (duplicate) {
throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!");
}
if (StringUtils.hasText(name)) {
dict.setDictValue(name);
}
if (enable != null) {
dict.setEnableFlag(enable);
}
sysDictRepository.save(dict);
}
// ============================ DelDept ============================
@Override
@Transactional
public void delDept(Integer serID) {
SysDict dict = sysDictRepository.findById(serID)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在"));
checkSchoolScope(dict);
sysDictRepository.delete(dict);
}
// ============================ OrdbyDicIds ============================
@Override
@Transactional
public void ordbyDicIds(String ids) {
if (!StringUtils.hasText(ids)) {
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
}
List<SysDict> list = visibleList(DICT_TYPE_DEPT);
if (list.isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "字典类型错误,未找到该字典类型");
}
// C# 逻辑:取当前最大 Sort 为起始序号,逆序遍历 ids 递减赋值 Des
int[] idArr = Arrays.stream(ids.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.mapToInt(Integer::parseInt)
.toArray();
int maxSort = list.stream()
.mapToInt(d -> d.getSortOrder() != null ? d.getSortOrder() : idArr.length)
.max().orElse(idArr.length);
// 仅本校可见字典可参与排序(前端 ids 即来自本校管理列表)
Set<Integer> visibleIds = list.stream().map(SysDict::getId).collect(Collectors.toSet());
// lambda 中不能修改局部变量,用数组包装
final int[] indexOrd = { maxSort };
// 逆序遍历,从最大序号递减
for (int i = idArr.length - 1; i >= 0; i--) {
int id = idArr[i];
if (!visibleIds.contains(id)) {
continue;
}
sysDictRepository.findById(id).ifPresent(dict -> {
dict.setSortOrder(indexOrd[0]);
sysDictRepository.save(dict);
indexOrd[0]--;
});
}
}
// ============================ 辅助方法 ============================
/**
* 按发文部门字典 key 解析部门名称(对应 C# GetDeptName)。
* 多租户:schId 非空时优先本校字典、其次全局(sch_id 为 NULL)字典,
* 不会命中其他学校的同名 key;schId 为空时退化为全局匹配(兼容存量调用)。
*/
@Override
public String resolveDeptName(String deptId, Integer schId) {
if (!StringUtils.hasText(deptId)) {
return null;
}
String key = deptId.trim();
if (schId != null) {
List<SysDict> scoped = sysDictRepository.findVisibleBySchIdAndDictTypeAndDictKey(schId, DICT_TYPE_DEPT, key);
if (!scoped.isEmpty()) {
return scoped.get(0).getDictValue();
}
return null;
}
return sysDictRepository.findByDictTypeAndDictKey(DICT_TYPE_DEPT, key)
.map(SysDict::getDictValue)
.orElse(null);
}
/** 当前登录人可见的全量字典(本校 + 全局;无学校归属时不过滤) */
private List<SysDict> visibleList(String dictType) {
Integer schId = UserContext.getSchId();
return schId != null
? sysDictRepository.findVisibleBySchIdAndDictType(schId, dictType)
: sysDictRepository.findByDictTypeOrderBySortOrderAsc(dictType);
}
// ============================ 请假类型字典 ============================
@Override
public List<Map<String, Object>> getLeaveTypeManageList() {
return visibleList(DICT_TYPE_LEAVE).stream()
.sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? 999 : d.getSortOrder()))
.map(d -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("SerID", d.getId());
item.put("CodeID", d.getDictKey());
item.put("Name", d.getDictValue());
item.put("Des", d.getSortOrder() != null ? d.getSortOrder().toString() : "");
item.put("Enable", d.getEnableFlag());
return item;
})
.collect(Collectors.toList());
}
@Override
public List<Map<String, Object>> getLeaveTypeOptions() {
return visibleEnabledList(DICT_TYPE_LEAVE).stream()
.sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? Integer.MAX_VALUE : d.getSortOrder()))
.map(d -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("k", d.getDictKey());
item.put("v", d.getDictValue());
return item;
})
.collect(Collectors.toList());
}
@Override
@Transactional
public Integer addLeaveType(String name) {
if (!StringUtils.hasText(name)) {
throw new BizException(ErrorCode.PARAM_ERROR, "请输入类型名称!");
}
final String trimmed = name.trim();
Integer schId = UserContext.getSchId();
List<SysDict> existing = visibleList(DICT_TYPE_LEAVE);
boolean duplicate = existing.stream().anyMatch(d ->
trimmed.equals(d.getDictValue()) && d.getEnableFlag() != 3);
if (duplicate) {
throw new BizException(ErrorCode.FORBIDDEN, "已存在该类型,请重新填写!");
}
// key = 最大数字 key + 1(leave.leave_type 存数字 key)
int maxKey = existing.stream()
.map(SysDict::getDictKey)
.filter(k -> k != null && k.matches("\\d+"))
.mapToInt(Integer::parseInt)
.max().orElse(0);
int maxSort = existing.stream()
.filter(d -> d.getEnableFlag() != 3)
.mapToInt(d -> d.getSortOrder() != null ? d.getSortOrder() : 0)
.max().orElse(0);
SysDict dict = new SysDict();
dict.setSchId(schId);
dict.setDictType(DICT_TYPE_LEAVE);
dict.setDictKey(String.valueOf(maxKey + 1));
dict.setDictValue(trimmed);
dict.setSortOrder(maxSort + 1);
dict.setEnableFlag(1);
sysDictRepository.save(dict);
return dict.getId();
}
@Override
@Transactional
public void updateLeaveType(Integer serID, String name, Integer enable) {
if (serID == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!");
}
SysDict dict = sysDictRepository.findById(serID)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在"));
if (!DICT_TYPE_LEAVE.equals(dict.getDictType())) {
throw new BizException(ErrorCode.PARAM_ERROR, "非请假类型字典记录");
}
checkSchoolScope(dict);
if (StringUtils.hasText(name)) {
final String trimmed = name.trim();
boolean duplicate = visibleList(DICT_TYPE_LEAVE).stream()
.anyMatch(d -> !d.getId().equals(serID) && trimmed.equals(d.getDictValue()) && d.getEnableFlag() != 3);
if (duplicate) {
throw new BizException(ErrorCode.FORBIDDEN, "已存在该类型,请重新填写!");
}
dict.setDictValue(trimmed);
}
if (enable != null) {
dict.setEnableFlag(enable);
}
sysDictRepository.save(dict);
}
@Override
@Transactional
public void deleteLeaveType(Integer serID) {
SysDict dict = sysDictRepository.findById(serID)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在"));
if (!DICT_TYPE_LEAVE.equals(dict.getDictType())) {
throw new BizException(ErrorCode.PARAM_ERROR, "非请假类型字典记录");
}
checkSchoolScope(dict);
// 已被请假记录引用的类型禁止删除(历史记录名称解析依赖),提示改用停用
if (StringUtils.hasText(dict.getDictKey()) && dict.getDictKey().matches("\\d+")) {
long used = leaveRepository.count(
(root, query, cb) -> cb.equal(root.get("leaveType"), Integer.valueOf(dict.getDictKey())));
if (used > 0) {
throw new BizException(ErrorCode.FORBIDDEN, "该类型已有请假记录使用,不可删除,可改为停用");
}
}
sysDictRepository.delete(dict);
}
@Override
@Transactional
public void sortLeaveTypes(String ids) {
if (!StringUtils.hasText(ids)) {
throw new BizException(ErrorCode.PARAM_ERROR, "参数错误");
}
List<SysDict> visible = visibleList(DICT_TYPE_LEAVE);
Map<Integer, SysDict> byId = visible.stream()
.collect(Collectors.toMap(SysDict::getId, d -> d, (a, b) -> a));
String[] idArr = ids.split(",");
int order = 1;
boolean touched = false;
for (String raw : idArr) {
String s = raw.trim();
if (!s.matches("\\d+")) {
continue;
}
SysDict dict = byId.get(Integer.valueOf(s));
if (dict != null) {
dict.setSortOrder(order++);
sysDictRepository.save(dict);
touched = true;
}
}
if (!touched) {
throw new BizException(ErrorCode.PARAM_ERROR, "未找到可排序的请假类型");
}
}
@Override
public String resolveLeaveTypeName(Integer leaveType, Integer schId) {
if (leaveType == null) {
return null;
}
String key = String.valueOf(leaveType);
if (schId != null) {
List<SysDict> scoped = sysDictRepository
.findVisibleBySchIdAndDictTypeAndDictKey(schId, DICT_TYPE_LEAVE, key);
if (!scoped.isEmpty()) {
return scoped.get(0).getDictValue();
}
return null;
}
return sysDictRepository.findByDictTypeAndDictKey(DICT_TYPE_LEAVE, key)
.map(SysDict::getDictValue)
.orElse(null);
}
/** 当前登录人可见的启用字典(本校 + 全局;无学校归属时不过滤) */
private List<SysDict> visibleEnabledList(String dictType) {
Integer schId = UserContext.getSchId();
return schId != null
? sysDictRepository.findVisibleEnabledBySchIdAndDictType(schId, dictType, 1)
: sysDictRepository.findByDictTypeAndEnableFlagOrderBySortOrderAsc(dictType, 1);
}
/** 多租户写保护:禁止操作其他学校的字典(全局字典与无学校归属用户不受限) */
private void checkSchoolScope(SysDict dict) {
Integer schId = UserContext.getSchId();
if (dict.getSchId() != null && schId != null && !dict.getSchId().equals(schId)) {
throw new BizException(ErrorCode.FORBIDDEN, "无权操作其他学校的字典!");
}
}
/**
* 将前端 Type 参数映射到字典类型字符串。
* C# 中使用枚举,Java 版仅支持 DEPT(1)。
*/
private String mapDictType(Integer type) {
if (type == null) return null;
switch (type) {
case 1: return DICT_TYPE_DEPT;
// 其余类型(文印/请假/物品等)本系统不涉及
default: return null;
}
}
}
@@ -0,0 +1,309 @@
package com.yunhai.homeleave.service.impl;
import com.yunhai.homeleave.entity.UpFile;
import com.yunhai.homeleave.model.UpFileResultVo;
import com.yunhai.homeleave.model.UpFileVo;
import com.yunhai.homeleave.repository.UpFileRepository;
import com.yunhai.homeleave.service.IFileService;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.io.FileInputStream;
import java.io.OutputStream;
import java.net.URLConnection;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* 文件上传/下载/删除业务实现。
* <p>
* 存储路径规则:{uploadPath}/{appType}/{yyyyMMdd}/{uuid}.{ext}
* 与 C# UpLoadFile.FileSave 逻辑等价,但去除分片上传和 Office 转 PDF(后续按需补充)。
*/
@Service
public class FileService implements IFileService {
private static final Logger log = LoggerFactory.getLogger(FileService.class);
@Autowired
private UpFileRepository upFileRepository;
@Value("${FILE_PATH}")
private String uploadPath;
// ============================ 上传 ============================
@Override
@Transactional
public UpFileVo upload(MultipartFile file, String appType, Integer topicId) {
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不得为空");
}
String originalName = file.getOriginalFilename();
if (!StringUtils.hasText(originalName)) {
throw new BizException(ErrorCode.PARAM_ERROR, "文件名为空");
}
// 提取后缀
String ext = "";
int dotIdx = originalName.lastIndexOf('.');
if (dotIdx >= 0) {
ext = originalName.substring(dotIdx + 1).toLowerCase();
}
String savedName = UUID.randomUUID().toString().replace("-", "") + (ext.isEmpty() ? "" : "." + ext);
// 物理写入
Path fullPath = Paths.get(uploadPath, savedName);
try {
Files.createDirectories(fullPath.getParent());
file.transferTo(fullPath.toFile());
} catch (Exception e) {
log.error("[upload] 文件保存失败: {}", fullPath, e);
throw new BizException(ErrorCode.ERROR, "文件保存失败", e);
}
// 入库
UpFile entity = new UpFile();
entity.setTopicType(appType);
entity.setTopicId(topicId);
entity.setFileTrueName(originalName);
entity.setFileName(savedName);
entity.setFileSize((int) file.getSize());
entity.setFileType(ext);
upFileRepository.save(entity);
return toVo(entity);
}
// ============================ 绑定主题 ============================
@Override
@Transactional
public void bindToTopic(List<Integer> fileIds, Integer topicId) {
if (fileIds == null || fileIds.isEmpty() || topicId == null) {
return;
}
for (Integer fileId : fileIds) {
upFileRepository.findById(fileId).ifPresent(f -> {
f.setTopicId(topicId);
upFileRepository.save(f);
});
}
}
// ============================ 查询附件列表 ============================
@Override
public List<UpFileVo> listByTopic(String appType, Integer topicId) {
return upFileRepository.findByTopicTypeAndTopicId(appType, topicId).stream()
.map(this::toVo)
.collect(Collectors.toList());
}
// ============================ 下载 ============================
@Override
public void download(Integer fileId, HttpServletResponse response) {
writeFile(fileId, response, false);
}
@Override
public void preview(Integer fileId, HttpServletResponse response) {
writeFile(fileId, response, true);
}
/**
* 文件输出。inline=true 时 Content-Disposition 用 inline 并按扩展名输出真实 Content-Type
* (浏览器/前端组件可直接渲染),否则 attachment 强制下载。
*/
private void writeFile(Integer fileId, HttpServletResponse response, boolean inline) {
UpFile file = upFileRepository.findById(fileId)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId));
Path fullPath = Paths.get(uploadPath, file.getFileName());
if (!Files.exists(fullPath)) {
throw new BizException(ErrorCode.FORBIDDEN, "物理文件不存在: " + file.getFileName());
}
try (FileInputStream fis = new FileInputStream(fullPath.toFile());
OutputStream os = response.getOutputStream()) {
String encodedName = URLEncoder.encode(file.getFileTrueName(), "UTF-8")
.replaceAll("\\+", "%20");
if (inline) {
response.setContentType(resolveContentType(file.getFileName()));
response.setHeader("Content-Disposition", "inline; filename=\"" + encodedName + "\"");
} else {
response.setContentType("application/octet-stream");
response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedName + "\"");
}
response.setContentLengthLong(file.getFileSize());
byte[] buffer = new byte[4096];
int len;
while ((len = fis.read(buffer)) != -1) {
os.write(buffer, 0, len);
}
os.flush();
} catch (Exception e) {
log.error("[writeFile] 文件输出失败: {}", file.getFileName(), e);
throw new BizException(ErrorCode.ERROR, "文件下载失败", e);
}
}
/**
* 按扩展名推断 Content-Type(JDK 自带表 + 常见办公/图片格式兜底),未知类型回退流格式。
*/
private String resolveContentType(String fileName) {
String name = String.valueOf(fileName).toLowerCase();
Map<String, String> table = new HashMap<>();
table.put("pdf", "application/pdf");
table.put("png", "image/png");
table.put("jpg", "image/jpeg");
table.put("jpeg", "image/jpeg");
table.put("gif", "image/gif");
table.put("bmp", "image/bmp");
table.put("webp", "image/webp");
table.put("svg", "image/svg+xml");
table.put("txt", "text/plain;charset=UTF-8");
table.put("mp4", "video/mp4");
table.put("webm", "video/webm");
table.put("ogg", "video/ogg");
table.put("doc", "application/msword");
table.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
table.put("xls", "application/vnd.ms-excel");
table.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
table.put("ppt", "application/vnd.ms-powerpoint");
table.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation");
String type = table.get(name.substring(name.lastIndexOf('.') + 1));
if (type != null) {
return type;
}
String guessed = URLConnection.guessContentTypeFromName(name);
return guessed != null ? guessed : "application/octet-stream";
}
// ============================ 删除 ============================
@Override
@Transactional
public void delete(Integer fileId) {
UpFile file = upFileRepository.findById(fileId)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId));
// 删物理文件
Path fullPath = Paths.get(uploadPath, file.getFileName());
try {
Files.deleteIfExists(fullPath);
} catch (Exception e) {
log.warn("[delete] 物理文件删除失败: {}(数据库记录仍会删除)", fullPath, e);
}
// 删数据库记录
upFileRepository.delete(file);
}
// ============================ 打包下载 ZIP ============================
@Override
public void downloadZip(List<UpFile> files, String zipName, HttpServletResponse response) {
if (files == null || files.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载");
}
try {
response.setContentType("application/zip");
String name = URLEncoder.encode(zipName == null ? "附件" : zipName, "UTF-8")
.replaceAll("\\+", "%20");
response.setHeader("Content-Disposition", "attachment; filename=\"" + name + ".zip\"");
try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) {
Set<String> used = new HashSet<>();
for (UpFile f : files) {
Path p = Paths.get(uploadPath, f.getFileName());
if (!Files.exists(p)) {
log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName());
continue;
}
String entryName = sanitizeEntryName(f.getFileTrueName());
if (used.contains(entryName)) {
entryName = f.getId() + "_" + entryName;
}
used.add(entryName);
zos.putNextEntry(new ZipEntry(entryName));
Files.copy(p, zos);
zos.closeEntry();
}
}
} catch (Exception e) {
log.error("[downloadZip] 打包下载失败", e);
throw new BizException(ErrorCode.ERROR, "打包下载失败", e);
}
}
/**
* 净化 ZIP 条目名:去除路径分隔符等非法字符,防止 Zip Slip。
*/
private String sanitizeEntryName(String name) {
if (name == null || name.trim().isEmpty()) {
return "file";
}
return name.replaceAll("[\\\\/:*?\"<>|]", "_");
}
// ============================ 按主题打包下载 ============================
@Override
public void downloadZipByTopic(String topicType, Integer topicId, String zipName,
HttpServletResponse response) {
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicId(topicType, topicId);
downloadZip(files, zipName, response);
}
// ============================ 批量上传图片 ============================
@Override
@Transactional
public List<UpFileVo> uploadImages(MultipartFile[] files, String appType) {
List<UpFileVo> results = new ArrayList<>();
if (files == null || files.length == 0) {
return results;
}
for (MultipartFile file : files) {
results.add(upload(file, appType, null));
}
return results;
}
// ============================ 上传(返回前端兼容 VO) ============================
@Override
@Transactional
public UpFileResultVo uploadToResult(MultipartFile file, String appType) {
UpFileVo vo = upload(file, appType, null);
UpFileResultVo result = new UpFileResultVo();
result.setFileid(vo.getId());
result.setName(vo.getFileTrueName());
result.setFileurl(vo.getFileName());
result.setHost("");
result.setType(vo.getFileType());
result.setSize(vo.getFileSize());
return result;
}
// ============================ 转换 ============================
private UpFileVo toVo(UpFile entity) {
UpFileVo vo = new UpFileVo();
BeanUtils.copyProperties(entity, vo);
return vo;
}
}
@@ -0,0 +1,49 @@
package com.yunhai.homeleave.service.impl;
import com.yunhai.homeleave.entity.Leave;
import com.yunhai.homeleave.enums.LeaveStatusEnum;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* 请假消息通知占位服务。
* <p>当前版本暂不接入微信/企微推送(计划待确认事项 5 的决策):
* 各业务节点先保留调用位置,接入时在对应方法内补充推送实现即可,
* 调用方无需改动。所有方法自身吞异常,绝不阻断请假主流程。</p>
*/
@Service
public class LeaveNotifyService {
private static final Logger log = LoggerFactory.getLogger(LeaveNotifyService.class);
/** 学生提交申请 / 教师登记后:通知审批人(班主任) */
public void onSubmitted(Leave leave) {
// TODO 接入微信/企微推送:向该班班主任推送「有待审核请假」消息
log.info("[notify:placeholder] 请假提交提醒占位: id={}, stu={}, class={}, status={}",
leave.getId(), leave.getStuName(), leave.getClassName(),
LeaveStatusEnum.nameById(leave.getStatus()));
}
/** 教师审批后(审核/撤销审批/销假审批):通知学生(及其家长) */
public void onAudited(Leave leave, Integer targetStatus) {
// TODO 接入微信/企微推送:向学生/家长推送审批结果
log.info("[notify:placeholder] 请假审批结果占位: id={}, stu={}, target={}",
leave.getId(), leave.getStuName(), LeaveStatusEnum.nameById(targetStatus));
}
/** 学生撤销(直接撤销或转待撤销)后:通知班主任 */
public void onWithdraw(Leave leave) {
// TODO 接入微信/企微推送:向班主任推送撤销申请
log.info("[notify:placeholder] 请假撤销占位: id={}, stu={}, status={}",
leave.getId(), leave.getStuName(), LeaveStatusEnum.nameById(leave.getStatus()));
}
/** 销假动作后(学生申请销假 / 教师销假):通知对端 */
public void onReportBack(Leave leave, boolean byStudent) {
// TODO 接入微信/企微推送:销假申请/结果通知
log.info("[notify:placeholder] 销假占位: id={}, stu={}, byStudent={}, status={}",
leave.getId(), leave.getStuName(), byStudent,
LeaveStatusEnum.nameById(leave.getStatus()));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,279 @@
package com.yunhai.homeleave.service.impl;
import com.yunhai.homeleave.model.bsch.SchClassDto;
import com.yunhai.homeleave.model.bsch.SchGradeDto;
import com.yunhai.homeleave.model.bsch.SchInfoDto;
import com.yunhai.homeleave.model.bsch.SchStudentDto;
import com.yunhai.homeleave.service.IClassService;
import com.yunhai.homeleave.utils.UserContext;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 实时名册门面(bschclass 直连,无本地快照):
* 学生名单 GetClaArrStulist(按班级集合)、全校学生 GetSchStulist(学生端身份)、
* 班级名 GetVClassList + 年级名 GetSchLenLevellist 映射、学校 GUID GetUserSchFirst。
* 班级ID/学生ID 均为用户中心数字ID 域(classId 即 bschclass sch_class.class_id)。
* <p>班级显示名统一为「年级名+班级名」(如「高一1班」),下拉/列表/统计展示均走本口径。</p>
*/
@Service
public class RosterService {
private static final Logger log = LoggerFactory.getLogger(RosterService.class);
private final IClassService classService;
public RosterService(IClassService classService) {
this.classService = classService;
}
/**
* 名册条目:进度聚合/学生矩阵/提醒名单共用的 班级×学生 维度。
*/
public static class RosterEntry {
public Integer studentId;
public String studentName;
public Integer classId;
public String className;
}
/**
* 指定班级集合的在班学生(实时 bschclass GetClaArrStulist,一次批量外呼)。
* 同班同学生去重;班级名经 {@link #classDisplayNames(Collection)} 映射为「年级名+班级名」,缺失回退班级ID。
*/
public List<RosterEntry> roster(List<Integer> classIds) {
if (classIds == null || classIds.isEmpty()) {
return new ArrayList<>();
}
Map<Integer, String> names = classDisplayNames(classIds);
Map<String, RosterEntry> distinct = new LinkedHashMap<>();
for (SchStudentDto s : classService.getClaArrStuList(classIds)) {
if (s == null || s.getStuId() == null || s.getClassId() == null || !classIds.contains(s.getClassId())) {
continue;
}
RosterEntry e = new RosterEntry();
e.studentId = s.getStuId();
e.studentName = s.getStuName();
e.classId = s.getClassId();
e.className = names.getOrDefault(s.getClassId(), String.valueOf(s.getClassId()));
distinct.putIfAbsent(s.getClassId() + ":" + s.getStuId(), e);
}
return new ArrayList<>(distinct.values());
}
/**
* 指定班级的在班学生数(一次批量外呼按班级分组计数;传入班级无学生计 0)。
*/
public Map<Integer, Integer> studentCounts(List<Integer> classIds) {
Map<Integer, Integer> counts = new LinkedHashMap<>();
for (Integer classId : classIds == null ? Collections.<Integer>emptyList() : classIds) {
counts.put(classId, 0);
}
for (RosterEntry e : roster(classIds)) {
counts.merge(e.classId, 1, Integer::sum);
}
return counts;
}
/**
* 班级ID → 班级显示名(「年级名+班级名」,如「高一1班」):bschclass 班级表 GetVClassList +
* 年级表 GetSchLenLevellist。年级名缺失时退回纯班级名;仅正常班级有名字(上游已排除毕业班/已删除班级)。
* 远端不可用或无学校身份时降级为空映射,由调用方回退落库名或班级ID展示,不阻断列表本身。
*/
public Map<Integer, String> classDisplayNames(Collection<Integer> classIds) {
Map<Integer, String> display = new LinkedHashMap<>();
if (classIds == null || classIds.isEmpty()) {
return display;
}
Map<Integer, SchClassDto> table = classTable(false);
Map<Integer, String> grades = gradeNames();
for (Integer classId : classIds) {
SchClassDto c = table.get(classId);
if (c == null) {
continue;
}
display.put(classId, displayName(grades.get(c.getGradeId()), rawClassName(c)));
}
return display;
}
/**
* 年级名 + 班级名拼接(如「高一」+「1班」=「高一1班」);年级名缺失时退回纯班级名。
*/
public static String displayName(String gradeName, String className) {
if (!StringUtils.hasText(className)) {
return className;
}
return StringUtils.hasText(gradeName) ? gradeName + className : className;
}
/**
* 学校正常班级(classId → 班级名,不含年级名):bschclass GetVClassList,上游已排除毕业班与已删除班级。
* 口径与 UserclassService 的 normalFiltered 一致:正常班级 = 存在于学校班级表且未毕业。
*
* @throws BizException 无学校身份或班级列表取数失败(调用方决定降级展示还是阻断提交)
*/
public Map<Integer, String> normalClasses() {
Map<Integer, String> normal = new LinkedHashMap<>();
for (Map.Entry<Integer, SchClassDto> e : classTable(true).entrySet()) {
normal.put(e.getKey(), rawClassName(e.getValue()));
}
return normal;
}
/** 班级ID → 班级显示名(单班便捷方法,缺失回退班级ID) */
public String className(Integer classId) {
if (classId == null) {
return null;
}
String name = classDisplayNames(Collections.singletonList(classId)).get(classId);
return StringUtils.hasText(name) ? name : String.valueOf(classId);
}
/**
* 当前登录学生所在班级条目(实时 bschclass 全校学生按登录人用户ID过滤)。
* 学生ID = 登录人用户中心数字ID;一人多班各一条。含历史遗留班级,正常班级过滤见 {@link #myNormalRoster()}。
* <p>身份判定({@code isStudent()})走本方法,属每请求热路径,故只查班级表、不查年级表:
* 班级名不含年级,展示用名(带年级)由 {@link #classDisplayNames(Collection)} / {@link #myNormalRoster()} 提供。</p>
*
* @throws BizException 未登录 / 无学校身份 / 未匹配到学生名册
*/
public List<RosterEntry> mine() {
List<RosterEntry> mine = mineRaw();
Map<Integer, SchClassDto> table = classTable(false);
for (RosterEntry e : mine) {
SchClassDto c = table.get(e.classId);
e.className = c == null ? String.valueOf(e.classId) : rawClassName(c);
}
return mine;
}
/**
* 当前登录学生的正常班级条目(学生名册 ∩ 学校正常班级),班级名带年级(如「高一1班」)。
* 学生名册可能残留已毕业或已删除的旧班级(如已不存在的班级ID 90),请假申请等入口不得使用。
*
* @throws BizException 未登录 / 无学校身份 / 未匹配到学生名册 /
* 名册中的班级均非正常班级 / 正常班级列表取数失败
*/
public List<RosterEntry> myNormalRoster() {
List<RosterEntry> mine = mineRaw();
Map<Integer, SchClassDto> normal = classTable(true);
Map<Integer, String> grades = gradeNames();
List<RosterEntry> out = new ArrayList<>();
for (RosterEntry e : mine) {
SchClassDto c = normal.get(e.classId);
if (c == null) {
continue;
}
e.className = displayName(grades.get(c.getGradeId()), rawClassName(c));
out.add(e);
}
if (out.isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN,
"您所在班级不在学校正常班级列表中,请联系管理员确认班级名册");
}
return out;
}
/** 名册原始条目(未映射班级名):身份判定与正常班级过滤共用同一份取数 */
private List<RosterEntry> mineRaw() {
Integer userId = UserContext.getUserId();
if (userId == null) {
throw new BizException(ErrorCode.FORBIDDEN, "未获取到登录身份");
}
String schSerId = currentSchSerId();
if (schSerId == null) {
throw new BizException(ErrorCode.FORBIDDEN, "未获取到学校身份");
}
List<RosterEntry> mine = new ArrayList<>();
for (SchStudentDto s : classService.getSchStuList(schSerId)) {
if (s != null && userId.equals(s.getStuId()) && s.getClassId() != null) {
RosterEntry e = new RosterEntry();
e.studentId = s.getStuId();
e.studentName = s.getStuName();
e.classId = s.getClassId();
mine.add(e);
}
}
if (mine.isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "当前用户未匹配到学生名册,请联系管理员确认班级名册");
}
return mine;
}
/** 当前登录人学校的 bschclass GUID(无学校身份或上游未映射时返回 null) */
private String currentSchSerId() {
Integer schId = UserContext.getSchId();
if (schId == null) {
return null;
}
SchInfoDto sch = classService.getSchFirst(schId);
return sch == null ? null : sch.getSchSerId();
}
/**
* 学校班级表(classId → 班级条目,bschclass GetVClassList;上游已排除毕业班与已删除班级)。
*
* @param strict true=无学校身份或取数失败抛 BizException(写入口用,不得放行);
* false=降级空映射并记日志(展示用,不阻断列表)
*/
private Map<Integer, SchClassDto> classTable(boolean strict) {
Map<Integer, SchClassDto> table = new LinkedHashMap<>();
String schSerId = currentSchSerId();
if (schSerId == null) {
if (strict) {
throw new BizException(ErrorCode.FORBIDDEN, "未获取到学校身份");
}
log.warn("[roster] 未获取到学校身份, 班级名降级为落库名/班级ID");
return table;
}
try {
for (SchClassDto c : classService.getSchClassList(schSerId)) {
if (c != null && c.getClassId() != null) {
table.put(c.getClassId(), c);
}
}
} catch (BizException e) {
if (strict) {
throw e;
}
log.warn("[roster] 班级表取数失败, 班级名降级为落库名/班级ID: {}", e.getMessage());
}
return table;
}
/** 年级ID → 年级名(bschclass GetSchLenLevellist);取数失败降级空映射,显示名退回纯班级名 */
private Map<Integer, String> gradeNames() {
Map<Integer, String> grades = new LinkedHashMap<>();
String schSerId = currentSchSerId();
if (schSerId == null) {
return grades;
}
try {
for (SchGradeDto g : classService.getSchGradeList(schSerId)) {
if (g != null && g.getId() != null) {
grades.put(g.getId(), g.getName());
}
}
} catch (BizException e) {
log.warn("[roster] 年级名取数失败, 班级名退回纯班级名: {}", e.getMessage());
}
return grades;
}
/** 班级名(上游缺失回退班级ID) */
private static String rawClassName(SchClassDto c) {
return StringUtils.hasText(c.getClassName()) ? c.getClassName() : String.valueOf(c.getClassId());
}
}
@@ -0,0 +1,115 @@
package com.yunhai.homeleave.service.impl;
import com.yunhai.homeleave.entity.SysOperationLog;
import com.yunhai.homeleave.model.BasePageResult;
import com.yunhai.homeleave.repository.SysOperationLogRepository;
import com.yunhai.homeleave.service.ISysOperationLogService;
import com.yunhai.homeleave.utils.LogGzipUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.persistence.criteria.Predicate;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* 系统审计日志服务(参考 UserclassService SysOperationLogServiceImpl,精简重试能力)。
*/
@Service
public class SysOperationLogServiceImpl implements ISysOperationLogService {
private static final Logger log = LoggerFactory.getLogger(SysOperationLogServiceImpl.class);
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyy-MM-dd");
@Autowired
private SysOperationLogRepository repository;
@Async("auditLogExecutor")
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAsync(SysOperationLog entity) {
try {
repository.save(entity);
} catch (Exception e) {
log.error("审计日志异步落库失败, uri={}, method={}",
entity.getRequestUri(), entity.getRequestMethod(), e);
}
}
@Override
public BasePageResult<SysOperationLog> pageQuery(Integer schId, String uri, String method,
Integer statusCode, Integer logLevel, String userName,
String startDate, String endDate, int pageIndex, int pageSize) {
final LocalDateTime start = parseDay(startDate, false);
final LocalDateTime end = parseDay(endDate, true);
Specification<SysOperationLog> spec = (root, query, cb) -> {
List<Predicate> ps = new ArrayList<>();
if (schId != null) {
ps.add(cb.equal(root.get("schId"), schId));
}
if (StringUtils.hasText(uri)) {
ps.add(cb.like(root.get("requestUri"), "%" + uri.trim() + "%"));
}
if (StringUtils.hasText(method)) {
ps.add(cb.equal(root.get("requestMethod"), method.trim().toUpperCase()));
}
if (statusCode != null) {
ps.add(cb.equal(root.get("statusCode"), statusCode));
}
if (logLevel != null) {
ps.add(cb.equal(root.get("logLevel"), logLevel));
}
if (StringUtils.hasText(userName)) {
ps.add(cb.like(root.get("userName"), "%" + userName.trim() + "%"));
}
if (start != null) {
ps.add(cb.greaterThanOrEqualTo(root.get("createTime"), start));
}
if (end != null) {
ps.add(cb.lessThanOrEqualTo(root.get("createTime"), end));
}
return cb.and(ps.toArray(new Predicate[0]));
};
Pageable pageable = PageRequest.of(Math.max(pageIndex - 1, 0), Math.max(pageSize, 1),
Sort.by(Sort.Direction.DESC, "createTime"));
Page<SysOperationLog> page = repository.findAll(spec, pageable);
// 响应体默认不随列表下发(大字段),需要原文走 /Response 接口
page.getContent().forEach(e -> e.setResponseData(null));
return new BasePageResult<>(page.getContent(), (int) page.getTotalElements(),
page.getNumber() + 1, page.getSize(), page.getTotalPages(), page.hasNext());
}
@Override
public String getResponseData(Long id) {
return repository.findById(id)
.map(e -> LogGzipUtils.decompress(e.getResponseData()))
.orElse(null);
}
private LocalDateTime parseDay(String raw, boolean end) {
if (!StringUtils.hasText(raw)) {
return null;
}
try {
LocalDate d = LocalDate.parse(raw.trim().substring(0, 10), DAY);
return end ? d.atTime(23, 59, 59) : d.atStartOfDay();
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,296 @@
package com.yunhai.homeleave.service.impl;
import com.yunhai.homeleave.model.SchTeaList;
import com.yunhai.homeleave.service.IClassService;
import com.yunhai.homeleave.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()) {
// 平台菜单树尚未给本应用分配模块时,回退默认请假菜单,
// modId 契约与前端 routes.ts / menu.ts 一致;平台配置后以用户中心返回为准
return defaultLeaveModuleTree();
}
// 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望
return buildModuleTree(modules);
}
/**
* 默认请假模块菜单(平台未配置时的兜底)。
* modId 契约:2=请假申请(学生) 3=我的请假(学生) 4=请假审批 5=请假登记 6=请假统计 7=应用设置。
*/
private List<Map<String, Object>> defaultLeaveModuleTree() {
int[] ids = {4, 5, 6, 2, 3, 7};
String[] names = {"请假审批", "请假登记", "请假统计", "请假申请", "我的请假", "应用设置"};
String[] pages = {"/audit", "/register", "/stats", "/apply", "/my", "/setting"};
List<Map<String, Object>> list = new ArrayList<>();
for (int i = 0; i < ids.length; i++) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", ids[i]);
node.put("modId", ids[i]);
node.put("name", names[i]);
node.put("parentId", 0);
node.put("page", pages[i]);
node.put("level", 1);
node.put("icon", "");
node.put("orderNumber", i + 1);
node.put("usable", true);
node.put("children", new ArrayList<>());
list.add(node);
}
return list;
}
/**
* 将扁平模块列表组装为树(模块树组装)。
* 顶层取 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,43 @@
package com.yunhai.homeleave.utils;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 请假时间展示口径(与前端 src/utils/leaveTime.ts 一致):
* 一律精确到分钟,同一天折叠为「2026-09-29 08:00 ~ 18:00」。
* 用于时间冲突等提示文案,避免把秒级库值直接抛给用户。
*/
public final class LeaveTimeText {
/** yyyy-MM-dd HH:mm(秒与时区后缀丢弃) */
private static final Pattern MINUTE = Pattern.compile("(\\d{4}-\\d{2}-\\d{2})[ T](\\d{2}:\\d{2})");
private LeaveTimeText() {
}
/** 截到分钟:yyyy-MM-dd HH:mm;null 返回空串 */
public static String toMinute(String value) {
if (value == null) {
return "";
}
Matcher m = MINUTE.matcher(value.trim());
return m.find() ? m.group(1) + " " + m.group(2) : value.trim();
}
/** 区间展示:同一天后半段只留时分 */
public static String range(String start, String end) {
String s = toMinute(start);
String e = toMinute(end);
if (s.isEmpty()) {
return e;
}
if (e.isEmpty()) {
return s;
}
if (s.length() >= 16 && e.length() >= 16 && s.regionMatches(0, e, 0, 10)) {
return s + " ~ " + e.substring(11);
}
return s + " ~ " + e;
}
}
@@ -0,0 +1,48 @@
package com.yunhai.homeleave.utils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
/**
* 审计日志响应体 GZIP 压缩工具(对齐 UserclassService GzipUtil 用途)。
*/
public final class LogGzipUtils {
private LogGzipUtils() {
}
public static byte[] compress(String text) {
if (text == null) {
return null;
}
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
try (GZIPOutputStream gzip = new GZIPOutputStream(bos)) {
gzip.write(text.getBytes(StandardCharsets.UTF_8));
}
return bos.toByteArray();
} catch (Exception e) {
return null;
}
}
public static String decompress(byte[] data) {
if (data == null || data.length == 0) {
return null;
}
try (ByteArrayInputStream bis = new ByteArrayInputStream(data);
GZIPInputStream gzip = new GZIPInputStream(bis);
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] buf = new byte[4096];
int n;
while ((n = gzip.read(buf)) > 0) {
bos.write(buf, 0, n);
}
return new String(bos.toByteArray(), StandardCharsets.UTF_8);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,24 @@
package com.yunhai.homeleave.utils;
import java.util.regex.Pattern;
/**
* 审计日志敏感信息脱敏(对齐 UserclassService SensitiveDataUtil 用途):
* 将 password / secret / token 类字段值替换为 ***。
*/
public final class LogMaskUtils {
private static final Pattern SENSITIVE = Pattern.compile(
"(\"(?:password|passwd|secret|token|appSecret|app-secret)\"\\s*:\\s*\")([^\"]*)(\")",
Pattern.CASE_INSENSITIVE);
private LogMaskUtils() {
}
public static String mask(String text) {
if (text == null || text.isEmpty()) {
return text;
}
return SENSITIVE.matcher(text).replaceAll("$1***$3");
}
}
@@ -0,0 +1,37 @@
package com.yunhai.homeleave.utils;
import com.yunhai.homeleave.model.BasePageQuery;
import com.yunhai.homeleave.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,251 @@
package com.yunhai.homeleave.utils;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
import com.yunhai.platform.auth.spring.CurrentBoaUser;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.StringUtils;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 当前登录人上下文(ThreadLocal)。结构对齐 UserclassService 同名工具:
* 人员标识统一为用户中心数字ID({@code Integer},{@code BoaUser.id},与 bschclass 的
* {@code SchTeacher.teaId} / {@code SchStudent.stuId} 同一 ID 空间),姓名为真实姓名,
* 学校ID取当前激活身份({@code BoaUser.currentRole.schId})。
* <p>
* 三个核心取值(userId/userName/schId)在首次访问时从平台登录态
* ({@link UserContextHolder} + {@link CurrentBoaUser})惰性解析并在本请求内缓存;
* 线程复用清理由 {@code UserContextConfig} 注册的拦截器在 afterCompletion 统一执行。
*/
public class UserContext {
private static final Logger log = LoggerFactory.getLogger(UserContext.class);
private static final ThreadLocal<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<>();
/** 平台 BoaUser 供应器(Spring 启动时注入,见 UserContextConfig) */
private static CurrentBoaUser currentBoaUser;
/** 账号→真实姓名缓存:姓名属低频变更数据,进程内缓存避免同账号反复外呼用户中心 */
private static final Map<String, String> ACCOUNT_NAME_CACHE = new ConcurrentHashMap<>();
/** 注入平台 BoaUser 供应器(启动期调用一次) */
public static void init(CurrentBoaUser boaUserSupplier) {
currentBoaUser = boaUserSupplier;
}
public static void setUserId(Integer userId) {
USER_ID.set(userId);
}
/**
* 当前登录人ID(用户中心数字ID,即教师 teaId / 学生 stuId 同域标识)。
* {@code LoginUser.userId} 为数字时直取;非数字(平台默认 DefaultLoginUserProvider
* 会把登录名放进 userId)时回退用户中心按登录名反查。无登录态或解析不到为 null。
*/
public static Integer getUserId() {
Integer userId = USER_ID.get();
if (userId == null) {
userId = resolveUserId();
if (userId != null) {
USER_ID.set(userId);
}
}
return userId;
}
public static void setUserName(String userName) {
USER_NAME.set(userName);
}
/**
* 当前登录人姓名:会话 displayName 有效且不等于登录名时直取;
* 缺失或等于登录名(平台默认 Provider 的历史行为)时回退用户中心真实姓名,解析失败保持登录名。
*/
public static String getUserName() {
String userName = USER_NAME.get();
if (userName == null) {
userName = resolveUserName();
if (userName != null) {
USER_NAME.set(userName);
}
}
return userName;
}
public static void setRoleId(Integer roleId) {
ROLE_ID.set(roleId);
}
public static Integer getRoleId() {
return ROLE_ID.get();
}
public static void setSchId(Integer schId) {
SCH_ID.set(schId);
}
/** 当前登录人学校ID(多租户隔离:发布时填充,查询时过滤),无身份时为 null。 */
public static Integer getSchId() {
Integer schId = SCH_ID.get();
if (schId == null) {
BoaUser boaUser = currentBoaUser == null ? null : currentBoaUser.get();
if (boaUser != null && boaUser.getCurrentRole() != null) {
schId = boaUser.getCurrentRole().getSchId();
if (schId != null) {
SCH_ID.set(schId);
}
}
}
return schId;
}
public static void setIp(String ip) {
IP.set(ip);
}
public static String getIp() {
return IP.get();
}
public static void setType(Integer type) {
TYPE.set(type);
}
public static Integer getType() {
return TYPE.get();
}
@Value
public static class UserInfo {
Integer userId;
String userName;
Integer roleId;
Integer schId;
String ip;
Integer type;
}
public static UserInfo getCurrentUser() {
return new UserInfo(
getUserId(),
getUserName(),
ROLE_ID.get(),
getSchId(),
IP.get(),
TYPE.get()
);
}
public static void setUserInfo(UserInfo info) {
if (info == null) return; // 或抛异常
USER_ID.set(info.getUserId());
USER_NAME.set(info.getUserName());
ROLE_ID.set(info.getRoleId());
SCH_ID.set(info.getSchId());
IP.set(info.getIp());
TYPE.set(info.getType());
}
public static Runnable wrap(Runnable task) {
UserInfo info = getCurrentUser();
return () -> {
try {
setUserInfo(info);
task.run();
} finally {
clear();
}
};
}
/** 清理线程上下文(请求结束后必须调用,避免线程复用串号) */
public static void clear() {
USER_ID.remove();
USER_NAME.remove();
ROLE_ID.remove();
SCH_ID.remove();
IP.remove();
TYPE.remove();
}
private static Integer resolveUserId() {
LoginUser user = UserContextHolder.get();
if (user == null || !StringUtils.hasText(user.getUserId())) {
return null;
}
String raw = user.getUserId().trim();
try {
return Integer.valueOf(raw);
} catch (NumberFormatException ignored) {
// 平台默认 Provider 把登录名放进 userId(非数字):回退用户中心按账号反查数字ID
}
BoaUser boa = userCenterByAccount(raw);
return boa == null ? null : boa.getId();
}
private static String resolveUserName() {
LoginUser user = UserContextHolder.get();
if (user == null) {
return null;
}
String loginName = user.getLoginName();
String display = user.getDisplayName();
if (!StringUtils.hasText(display) || display.equals(loginName)) {
return cachedUserName(loginName);
}
return display;
}
/** 按登录账号取用户中心资料;数字型账号(历史落库的教师ID形态)改按ID查询 */
private static BoaUser userCenterByAccount(String account) {
try {
BoaUser boa = YschBaseOpenApi.getUserInfo(account);
if ((boa == null || !StringUtils.hasText(boa.getName())) && isNumericId(account)) {
boa = YschBaseOpenApi.getUserInfo(Integer.valueOf(account));
}
return boa;
} catch (Exception e) {
log.warn("[UserContext] 用户中心按账号反查失败, account={}", account);
return null;
}
}
/** 按账号取用户中心真实姓名,查不到回退账号本身(结果含回退值一并缓存) */
private static String cachedUserName(String account) {
if (!StringUtils.hasText(account)) {
return account;
}
if (ACCOUNT_NAME_CACHE.size() > 2000) {
ACCOUNT_NAME_CACHE.clear();
}
return ACCOUNT_NAME_CACHE.computeIfAbsent(account, acc -> {
BoaUser boa = userCenterByAccount(acc);
return boa != null && StringUtils.hasText(boa.getName()) ? boa.getName() : acc;
});
}
/** 值是否为纯数字(用于区分「教师/学生ID」与「登录账号」两种形态) */
private static boolean isNumericId(String value) {
if (!StringUtils.hasText(value)) {
return false;
}
for (int i = 0; i < value.length(); i++) {
if (!Character.isDigit(value.charAt(i))) {
return false;
}
}
return true;
}
}
+71
View File
@@ -0,0 +1,71 @@
server:
port: 8106
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/home_leave?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
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh:
env-config:
source:
# 前端运行时配置(JSON 字符串),线上由 Nacos/projman 维护;空则前端走同域推导兜底。
# 端点 /api/env-config 由平台 yh-env-config-spring-boot-starter 提供(免登录 SSO 旁路)。
front: ""
sso:
server-url-prefix: http://sso.yunhaixingtong.com
client-host-url: http://localhost:8106
allow-headers: all
allow-origins: all
url-pattern: /api/*
mobi-api-pattern: /mobiapi/*
app-auth:
enabled: true
# 本应用自身身份(客户端签名用)
app-key: 100013
app-secret: 95e86ef8e8214104b1229eb62981d120
open:
enabled: true
base-host: http://base.yunhaixingtong.com
user-center-url: http://base.yunhaixingtong.com/userbase
# 班级管理(bschclass)服务基址
bschclass: http://base.yunhaixingtong.com/userclass
FILE_PATH: D:/data/upload
+33
View File
@@ -0,0 +1,33 @@
spring:
application:
name: homeleave
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},即 homeleave.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>
@@ -0,0 +1,138 @@
package com.yunhai.homeleave.service;
import com.yunhai.homeleave.entity.Leave;
import com.yunhai.homeleave.enums.LeaveStatusEnum;
import com.yunhai.homeleave.model.leave.LeaveRequest;
import com.yunhai.homeleave.model.leave.LeaveTimeItem;
import com.yunhai.homeleave.model.leave.TeachingClassVo;
import com.yunhai.homeleave.repository.LeaveRepository;
import com.yunhai.homeleave.repository.LeaveReportBackRepository;
import com.yunhai.homeleave.repository.LeaveTimeRepository;
import com.yunhai.homeleave.service.impl.RosterService;
import com.yunhai.homeleave.service.impl.RosterService.RosterEntry;
import com.yunhai.homeleave.utils.UserContext;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 正常班级校验的端到端验证:真实 Spring 上下文 + 真实 MySQL + 真实 bschclass 上游。
* <p>用假日期写入一条学生请假(学号 10061=测试学生1,名册残留旧班级 90),断言落库班级为正常班级;
* 结束时删除测试数据,不留脏记录。类名不含 Test 后缀,默认 {@code mvn test} 不执行;显式运行:
* {@code mvn -o test -Dtest=RosterServiceE2EIT}。</p>
*/
@SpringBootTest
class RosterServiceE2EIT {
/** 云海中学联调学生:测试学生1(名册 90 + 12/1班) */
private static final Integer STU_ID = 10061;
private static final Integer SCH_ID = 3;
private static final Integer NORMAL_CLASS_ID = 12;
private static final Integer STALE_CLASS_ID = 90;
@Autowired
private ILeaveService leaveService;
@Autowired
private RosterService rosterService;
@Autowired
private LeaveRepository leaveRepository;
@Autowired
private LeaveTimeRepository leaveTimeRepository;
@Autowired
private LeaveReportBackRepository leaveReportBackRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@BeforeAll
static void initContext() {
UserContext.setUserId(STU_ID);
UserContext.setSchId(SCH_ID);
}
@AfterAll
static void clearContext() {
UserContext.clear();
}
@Test
@DisplayName("E2E:MyClasses 只下发正常班级,不含旧班级 90")
void myClassesEndToEnd() {
List<TeachingClassVo> classes = leaveService.myClasses();
List<Integer> ids = classes.stream().map(TeachingClassVo::getClassId).collect(Collectors.toList());
System.out.println("[e2e] MyClasses=" + ids + " 名称="
+ classes.stream().map(TeachingClassVo::getClassName).collect(Collectors.toList()));
assertTrue(ids.contains(NORMAL_CLASS_ID), "应含正常班级 12");
assertTrue(!ids.contains(STALE_CLASS_ID), "不应含已删除班级 90");
}
@Test
@DisplayName("E2E:学生传旧班级 90 提交被拒绝")
void submitStaleClassRejectedEndToEnd() {
BizException e = assertThrows(BizException.class, () -> leaveService.saveLeave(request(STALE_CLASS_ID)));
System.out.println("[e2e] 旧班级提交被拒: " + e.getMessage());
assertTrue(e.getMessage().contains("正常班级"), "实际提示: " + e.getMessage());
}
@Test
@DisplayName("E2E:学生提交正常班级落库(结束后清理)")
void submitNormalClassEndToEnd() {
List<RosterEntry> normal = rosterService.myNormalRoster();
assertTrue(!normal.isEmpty(), "正常班级名册不应为空");
Integer leaveId = leaveService.saveLeave(request(NORMAL_CLASS_ID));
assertNotNull(leaveId, "应返回请假ID");
try {
Leave saved = leaveRepository.findById(leaveId).orElse(null);
assertNotNull(saved, "请假记录应落库");
assertEquals(NORMAL_CLASS_ID, saved.getClassId());
// 落库班级名取自正常班级名册(带年级,如「高一1班」)
String expectedName = normal.stream().filter(e -> NORMAL_CLASS_ID.equals(e.classId))
.findFirst().orElseThrow(() -> new AssertionError("名册中未找到班级 " + NORMAL_CLASS_ID)).className;
assertEquals(expectedName, saved.getClassName());
assertTrue(expectedName.contains("班"), "班级名应含班级名部分: " + expectedName);
assertEquals(STU_ID, saved.getStuId());
assertEquals(LeaveStatusEnum.PENDING.getId(), saved.getStatus());
System.out.println("[e2e] 落库班级=" + saved.getClassId() + "/" + saved.getClassName()
+ " 状态=" + saved.getStatus());
} finally {
// 清理测试数据(时间段 + 销假 + 主表):删除需事务,独立开一个避免与业务事务纠缠
new TransactionTemplate(transactionManager).executeWithoutResult(status -> {
leaveTimeRepository.deleteByLeaveId(leaveId);
leaveReportBackRepository.deleteByLeaveId(leaveId);
leaveRepository.deleteById(leaveId);
});
System.out.println("[e2e] 测试数据已清理 leaveId=" + leaveId);
}
}
private static LeaveRequest request(Integer classId) {
LeaveRequest r = new LeaveRequest();
r.setClassId(classId);
r.setType(2); // 学生申请
r.setLeaveType(1);
r.setRemarks("E2E 正常班级校验测试");
r.setStartDate("2027-01-05 08:00:00");
r.setEndDate("2027-01-05 12:00:00");
r.setContinuousLeave(0);
LeaveTimeItem item = new LeaveTimeItem();
item.setStartDate("2027-01-05 08:00:00");
item.setEndDate("2027-01-05 12:00:00");
r.setContinuousLeaveTime(Collections.singletonList(item));
return r;
}
}
@@ -0,0 +1,257 @@
package com.yunhai.homeleave.service.impl;
import com.yunhai.homeleave.entity.Leave;
import com.yunhai.homeleave.enums.LeaveRegisterTypeEnum;
import com.yunhai.homeleave.enums.LeaveStatusEnum;
import com.yunhai.homeleave.model.bsch.SchClassMasterDto;
import com.yunhai.homeleave.model.leave.LeaveRequest;
import com.yunhai.homeleave.model.leave.LeaveTimeItem;
import com.yunhai.homeleave.model.leave.TeachingClassVo;
import com.yunhai.homeleave.repository.LeaveReportBackRepository;
import com.yunhai.homeleave.repository.LeaveRepository;
import com.yunhai.homeleave.repository.LeaveTimeRepository;
import com.yunhai.homeleave.service.IClassService;
import com.yunhai.homeleave.service.IDictService;
import com.yunhai.homeleave.service.IFileService;
import com.yunhai.homeleave.utils.UserContext;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.auth.spring.CurrentBoaUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 请假申请的正常班级校验:
* 学生端 myClasses 只给正常班级,提交时班级必须是正常班级;
* 教师登记同理(防手工传 classId 落到已毕业/已删除班级)。
*/
class LeaveServiceImplClassCheckTest {
private static final Integer STU_USER_ID = 10009;
private static final Integer TEACHER_USER_ID = 10008;
private static final Integer SCH_ID = 3;
/** 正常班级:高一1班 */
private static final Integer NORMAL_CLASS_ID = 12;
/** 学校班级表中已不存在的旧班级(学生名册残留) */
private static final Integer STALE_CLASS_ID = 90;
private LeaveRepository leaveRepository;
private LeaveTimeRepository leaveTimeRepository;
private LeaveReportBackRepository leaveReportBackRepository;
private IFileService fileService;
private IDictService dictService;
private LeaveNotifyService notifyService;
private IClassService classService;
private RosterService rosterService;
private LeaveServiceImpl leaveService;
@BeforeEach
void setUp() {
leaveRepository = mock(LeaveRepository.class);
leaveTimeRepository = mock(LeaveTimeRepository.class);
leaveReportBackRepository = mock(LeaveReportBackRepository.class);
fileService = mock(IFileService.class);
dictService = mock(IDictService.class);
notifyService = mock(LeaveNotifyService.class);
classService = mock(IClassService.class);
rosterService = mock(RosterService.class);
CurrentBoaUser currentBoaUser = mock(CurrentBoaUser.class);
when(currentBoaUser.get()).thenReturn(null); // 非校管理员
leaveService = new LeaveServiceImpl();
ReflectionTestUtils.setField(leaveService, "leaveRepository", leaveRepository);
ReflectionTestUtils.setField(leaveService, "leaveTimeRepository", leaveTimeRepository);
ReflectionTestUtils.setField(leaveService, "leaveReportBackRepository", leaveReportBackRepository);
ReflectionTestUtils.setField(leaveService, "fileService", fileService);
ReflectionTestUtils.setField(leaveService, "dictService", dictService);
ReflectionTestUtils.setField(leaveService, "notifyService", notifyService);
ReflectionTestUtils.setField(leaveService, "classService", classService);
ReflectionTestUtils.setField(leaveService, "rosterService", rosterService);
ReflectionTestUtils.setField(leaveService, "currentBoaUser", currentBoaUser);
UserContext.setUserId(STU_USER_ID);
UserContext.setSchId(SCH_ID);
}
@AfterEach
void tearDown() {
UserContext.clear();
}
// ============================ 学生端 ============================
@Test
@DisplayName("myClasses() 学生端只返回正常班级,班级名带年级(旧班级 90 不下发)")
void myClassesForStudentOnlyNormal() {
when(rosterService.mine()).thenReturn(Arrays.asList(
entry(NORMAL_CLASS_ID, "1班"), entry(STALE_CLASS_ID, "90")));
when(rosterService.myNormalRoster()).thenReturn(
Collections.singletonList(entry(NORMAL_CLASS_ID, "高一1班")));
List<TeachingClassVo> classes = leaveService.myClasses();
assertEquals(1, classes.size());
assertEquals(NORMAL_CLASS_ID, classes.get(0).getClassId());
assertEquals("高一1班", classes.get(0).getClassName());
}
@Test
@DisplayName("myClasses() 教师端班级名带年级:跨年级同名班可区分,且只下发本人班主任班级")
void myClassesForTeacherIncludesGrade() {
UserContext.setUserId(TEACHER_USER_ID);
when(rosterService.mine()).thenThrow(new BizException(
com.yunhai.platform.utils.exception.ErrorCode.FORBIDDEN, "当前用户未匹配到学生名册"));
SchClassMasterDto other = new SchClassMasterDto();
other.setClassId(380);
other.setName("1班");
other.setGradeName("高二");
other.setFirstTeacherId(10099);
SchClassMasterDto mineClass = new SchClassMasterDto();
mineClass.setClassId(NORMAL_CLASS_ID);
mineClass.setName("1班");
mineClass.setGradeName("高一");
mineClass.setFirstTeacherId(TEACHER_USER_ID);
when(classService.getSchClassMasterList(SCH_ID)).thenReturn(Arrays.asList(other, mineClass));
List<TeachingClassVo> classes = leaveService.myClasses();
assertEquals(1, classes.size(), "只下发本人担任班主任的班级");
assertEquals(NORMAL_CLASS_ID, classes.get(0).getClassId());
assertEquals("高一1班", classes.get(0).getClassName());
}
@Test
@DisplayName("学生提交:手工传旧班级 90 被拒绝,不回退到其它班级")
void studentSubmitRejectsStaleClass() {
when(rosterService.mine()).thenReturn(Arrays.asList(
entry(NORMAL_CLASS_ID, "1班"), entry(STALE_CLASS_ID, "90")));
when(rosterService.myNormalRoster()).thenReturn(
Collections.singletonList(entry(NORMAL_CLASS_ID, "1班")));
BizException e = assertThrows(BizException.class, () -> leaveService.saveLeave(request(STALE_CLASS_ID, 2)));
assertTrue(e.getMessage().contains("正常班级"), "实际提示: " + e.getMessage());
}
@Test
@DisplayName("学生提交:正常班级落库为本人名册班级(班级名带年级)")
void studentSubmitUsesNormalClass() {
when(rosterService.mine()).thenReturn(Collections.singletonList(entry(NORMAL_CLASS_ID, "高一1班")));
when(rosterService.myNormalRoster()).thenReturn(
Collections.singletonList(entry(NORMAL_CLASS_ID, "高一1班")));
when(leaveRepository.save(any(Leave.class))).thenAnswer(inv -> {
Leave l = inv.getArgument(0);
l.setId(1);
return l;
});
Integer id = leaveService.saveLeave(request(null, 2));
assertEquals(1, id);
ArgumentCaptor<Leave> captor = ArgumentCaptor.forClass(Leave.class);
verify(leaveRepository).save(captor.capture());
assertEquals(NORMAL_CLASS_ID, captor.getValue().getClassId());
assertEquals("高一1班", captor.getValue().getClassName());
assertEquals(LeaveStatusEnum.PENDING.getId(), captor.getValue().getStatus());
}
// ============================ 教师端 ============================
@Test
@DisplayName("教师登记:班主任范围内但已删除班级 90 被拒绝")
void teacherRegisterRejectsStaleClass() {
prepareTeacher(STALE_CLASS_ID, "90");
when(rosterService.normalClasses()).thenReturn(normalClassMap(NORMAL_CLASS_ID, "1班"));
BizException e = assertThrows(BizException.class, () -> leaveService.saveLeave(request(STALE_CLASS_ID, 1)));
assertTrue(e.getMessage().contains("正常班级"), "实际提示: " + e.getMessage());
}
@Test
@DisplayName("教师登记:正常班级可登记(免审直接通过)")
void teacherRegisterAcceptsNormalClass() {
prepareTeacher(NORMAL_CLASS_ID, "1班");
when(rosterService.normalClasses()).thenReturn(normalClassMap(NORMAL_CLASS_ID, "1班"));
when(rosterService.roster(anyList())).thenReturn(
Collections.singletonList(entry(NORMAL_CLASS_ID, "1班")));
when(leaveRepository.save(any(Leave.class))).thenAnswer(inv -> {
Leave l = inv.getArgument(0);
l.setId(2);
return l;
});
Integer id = leaveService.saveLeave(request(NORMAL_CLASS_ID, 1));
assertEquals(2, id);
ArgumentCaptor<Leave> captor = ArgumentCaptor.forClass(Leave.class);
verify(leaveRepository).save(captor.capture());
assertEquals(LeaveStatusEnum.APPROVED.getId(), captor.getValue().getStatus());
assertEquals(NORMAL_CLASS_ID, captor.getValue().getClassId());
}
/** 教师身份:非学生(名册匹配不到)+ 本人为指定班级班主任(验证正常班级校验不被范围校验掩盖) */
private void prepareTeacher(Integer masterClassId, String masterClassName) {
UserContext.setUserId(TEACHER_USER_ID);
when(rosterService.mine()).thenThrow(new BizException(
com.yunhai.platform.utils.exception.ErrorCode.FORBIDDEN, "当前用户未匹配到学生名册"));
SchClassMasterDto master = new SchClassMasterDto();
master.setClassId(masterClassId);
master.setName(masterClassName);
master.setFirstTeacherId(TEACHER_USER_ID);
when(classService.getSchClassMasterList(SCH_ID)).thenReturn(Collections.singletonList(master));
}
private static Map<Integer, String> normalClassMap(Integer classId, String name) {
Map<Integer, String> map = new LinkedHashMap<>();
map.put(classId, name);
return map;
}
private static RosterService.RosterEntry entry(Integer classId, String className) {
RosterService.RosterEntry e = new RosterService.RosterEntry();
e.studentId = STU_USER_ID;
e.studentName = "克格莫";
e.classId = classId;
e.className = className;
return e;
}
private static LeaveRequest request(Integer classId, Integer type) {
LeaveRequest r = new LeaveRequest();
r.setClassId(classId);
r.setType(type);
r.setLeaveType(1);
r.setStartDate("2026-09-23 08:00:00");
r.setEndDate("2026-09-23 12:00:00");
r.setRemarks("测试请假");
LeaveTimeItem item = new LeaveTimeItem();
item.setStartDate("2026-09-23 08:00:00");
item.setEndDate("2026-09-23 12:00:00");
r.setContinuousLeave(0);
r.setContinuousLeaveTime(Collections.singletonList(item));
if (type != null && type == LeaveRegisterTypeEnum.TEACHER.getId()) {
r.setStuId(STU_USER_ID);
}
return r;
}
}
@@ -0,0 +1,105 @@
package com.yunhai.homeleave.service.impl;
import com.yunhai.homeleave.entity.Leave;
import com.yunhai.homeleave.entity.LeaveTime;
import com.yunhai.homeleave.enums.LeaveStatusEnum;
import com.yunhai.homeleave.model.leave.LeaveTimeItem;
import com.yunhai.homeleave.repository.LeaveReportBackRepository;
import com.yunhai.homeleave.repository.LeaveRepository;
import com.yunhai.homeleave.repository.LeaveTimeRepository;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.ArrayList;
import java.util.Collections;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 时间冲突提示的时间文案:精确到分钟、同一天折叠(与前端展示口径一致)。
*/
class LeaveServiceImplConflictTextTest {
private static final Integer STU_ID = 10009;
private LeaveRepository leaveRepository;
private LeaveTimeRepository leaveTimeRepository;
private LeaveServiceImpl leaveService;
@BeforeEach
void setUp() {
leaveRepository = mock(LeaveRepository.class);
leaveTimeRepository = mock(LeaveTimeRepository.class);
leaveService = new LeaveServiceImpl();
ReflectionTestUtils.setField(leaveService, "leaveRepository", leaveRepository);
ReflectionTestUtils.setField(leaveService, "leaveTimeRepository", leaveTimeRepository);
ReflectionTestUtils.setField(leaveService, "leaveReportBackRepository", mock(LeaveReportBackRepository.class));
}
private void givenExistingTime(String start, String end) {
LeaveTime existing = new LeaveTime();
existing.setLeaveId(7);
existing.setStuId(STU_ID);
existing.setStartDate(start);
existing.setEndDate(end);
when(leaveTimeRepository.findByStuId(STU_ID))
.thenReturn(new ArrayList<>(Collections.singletonList(existing)));
Leave owner = new Leave();
owner.setId(7);
owner.setStatus(LeaveStatusEnum.APPROVED.getId());
when(leaveRepository.findAllById(anyList())).thenReturn(Collections.singletonList(owner));
}
private LeaveTimeItem item(String start, String end) {
LeaveTimeItem item = new LeaveTimeItem();
item.setStartDate(start);
item.setEndDate(end);
return item;
}
@Test
@DisplayName("同一天冲突:提示折叠为一个日期,且不带秒")
void sameDayConflictFoldsAndDropsSeconds() {
givenExistingTime("2026-09-29 08:00:00", "2026-09-29 18:00:00");
BizException ex = assertThrows(BizException.class, () -> ReflectionTestUtils.invokeMethod(
leaveService, "checkTimeConflict", STU_ID,
Collections.singletonList(item("2026-09-29 09:00:00", "2026-09-29 10:00:00"))));
assertEquals("该请假已存在,2026-09-29 08:00 ~ 18:00", ex.getMessage());
}
@Test
@DisplayName("跨天冲突:两端都带日期,秒被丢弃")
void crossDayConflictKeepsBothDates() {
givenExistingTime("2026-09-29 08:00:00", "2026-09-30 18:00:00");
BizException ex = assertThrows(BizException.class, () -> ReflectionTestUtils.invokeMethod(
leaveService, "checkTimeConflict", STU_ID,
Collections.singletonList(item("2026-09-30 09:00:00", "2026-09-30 10:00:00"))));
assertTrue(ex.getMessage().contains("2026-09-29 08:00 ~ 2026-09-30 18:00"), ex.getMessage());
}
@Test
@DisplayName("已撤销记录不参与冲突校验")
void withdrawnIgnored() {
givenExistingTime("2026-09-29 08:00:00", "2026-09-29 18:00:00");
Leave owner = new Leave();
owner.setId(7);
owner.setStatus(LeaveStatusEnum.WITHDRAWN.getId());
when(leaveRepository.findAllById(anyList())).thenReturn(Collections.singletonList(owner));
ReflectionTestUtils.invokeMethod(leaveService, "checkTimeConflict", STU_ID,
Collections.singletonList(item("2026-09-29 09:00:00", "2026-09-29 10:00:00")));
}
}
@@ -0,0 +1,163 @@
package com.yunhai.homeleave.service.impl;
import com.yunhai.homeleave.entity.Leave;
import com.yunhai.homeleave.model.BasePageResult;
import com.yunhai.homeleave.model.leave.LeaveQuery;
import com.yunhai.homeleave.model.leave.LeaveStatsVo;
import com.yunhai.homeleave.model.leave.LeaveVo;
import com.yunhai.homeleave.repository.LeaveReportBackRepository;
import com.yunhai.homeleave.repository.LeaveRepository;
import com.yunhai.homeleave.repository.LeaveTimeRepository;
import com.yunhai.homeleave.service.IClassService;
import com.yunhai.homeleave.service.IDictService;
import com.yunhai.homeleave.service.IFileService;
import com.yunhai.homeleave.utils.UserContext;
import com.yunhai.platform.auth.spring.CurrentBoaUser;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 统计/列表的班级名口径:展示统一为「年级名+班级名」。
* 历史记录落库的是纯班级名(如「1班」),跨年级重名无法区分,读取时按班级表回填年级名。
*/
class LeaveServiceImplStatsTest {
private static final Integer STU_USER_ID = 10009;
private static final Integer SCH_ID = 3;
/** 高一1班 */
private static final Integer G1_CLASS_ID = 12;
/** 高二1班(与高一1班同班名,靠年级区分) */
private static final Integer G2_CLASS_ID = 380;
private LeaveRepository leaveRepository;
private LeaveTimeRepository leaveTimeRepository;
private RosterService rosterService;
private LeaveServiceImpl leaveService;
@BeforeEach
void setUp() {
leaveRepository = mock(LeaveRepository.class);
leaveTimeRepository = mock(LeaveTimeRepository.class);
LeaveReportBackRepository leaveReportBackRepository = mock(LeaveReportBackRepository.class);
IFileService fileService = mock(IFileService.class);
IDictService dictService = mock(IDictService.class);
LeaveNotifyService notifyService = mock(LeaveNotifyService.class);
IClassService classService = mock(IClassService.class);
rosterService = mock(RosterService.class);
CurrentBoaUser currentBoaUser = mock(CurrentBoaUser.class);
when(currentBoaUser.get()).thenReturn(null);
leaveService = new LeaveServiceImpl();
ReflectionTestUtils.setField(leaveService, "leaveRepository", leaveRepository);
ReflectionTestUtils.setField(leaveService, "leaveTimeRepository", leaveTimeRepository);
ReflectionTestUtils.setField(leaveService, "leaveReportBackRepository", leaveReportBackRepository);
ReflectionTestUtils.setField(leaveService, "fileService", fileService);
ReflectionTestUtils.setField(leaveService, "dictService", dictService);
ReflectionTestUtils.setField(leaveService, "notifyService", notifyService);
ReflectionTestUtils.setField(leaveService, "classService", classService);
ReflectionTestUtils.setField(leaveService, "rosterService", rosterService);
ReflectionTestUtils.setField(leaveService, "currentBoaUser", currentBoaUser);
UserContext.setUserId(STU_USER_ID);
UserContext.setSchId(SCH_ID);
// 学生身份(名册匹配到本人)+ 无时间段行
when(rosterService.mine()).thenReturn(Collections.singletonList(entry(G1_CLASS_ID, "高一1班")));
when(leaveTimeRepository.findByLeaveIdOrderByStartDateAsc(anyInt()))
.thenReturn(Collections.emptyList());
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("stats() 按班级统计的分组名带年级(历史落库为纯班级名)")
void statsClassNamesIncludeGrade() {
when(leaveRepository.findAll(any(Specification.class))).thenReturn(Arrays.asList(
leave(G1_CLASS_ID, "1班", 1, 0), leave(G1_CLASS_ID, "1班", 2, 1),
leave(G2_CLASS_ID, "1班", 1, 0)));
stubDisplayNames();
LeaveStatsVo vo = leaveService.stats(null, null, null, null);
assertEquals(2, vo.getByClass().size(), "两个年级的同名班应各占一行");
assertEquals("高一1班", vo.getByClass().get(0).getClassName());
assertEquals("高二1班", vo.getByClass().get(1).getClassName());
}
@Test
@DisplayName("getLeaveList() 列表班级名带年级(历史落库为纯班级名)")
void listClassNamesIncludeGrade() {
Page<Leave> page = new PageImpl<>(Collections.singletonList(leave(G1_CLASS_ID, "1班", 1, 0)),
PageRequest.of(0, 10), 1);
when(leaveRepository.findAll(any(Specification.class), any(Pageable.class))).thenReturn(page);
stubDisplayNames();
BasePageResult<LeaveVo> result = leaveService.getLeaveList(new LeaveQuery());
assertEquals("高一1班", result.getContent().get(0).getClassName());
}
@Test
@DisplayName("班级表取不到名时保留落库班级名(不显示为空)")
void keepsStoredNameWhenClassTableUnavailable() {
when(leaveRepository.findAll(any(Specification.class))).thenReturn(
Collections.singletonList(leave(G1_CLASS_ID, "1班", 1, 0)));
when(rosterService.classDisplayNames(any(Collection.class))).thenReturn(new LinkedHashMap<>());
LeaveStatsVo vo = leaveService.stats(null, null, null, null);
assertEquals("1班", vo.getByClass().get(0).getClassName());
}
private void stubDisplayNames() {
Map<Integer, String> names = new LinkedHashMap<>();
names.put(G1_CLASS_ID, "高一1班");
names.put(G2_CLASS_ID, "高二1班");
when(rosterService.classDisplayNames(any(Collection.class))).thenReturn(names);
}
private static Leave leave(Integer classId, String className, Integer stuId, Integer status) {
Leave l = new Leave();
l.setId(classId * 100 + stuId);
l.setSchId(SCH_ID);
l.setClassId(classId);
l.setClassName(className);
l.setStuId(stuId);
l.setStuName("学生" + stuId);
l.setType(2);
l.setLeaveType(1);
l.setStatus(status);
return l;
}
private static RosterService.RosterEntry entry(Integer classId, String className) {
RosterService.RosterEntry e = new RosterService.RosterEntry();
e.studentId = STU_USER_ID;
e.studentName = "克格莫";
e.classId = classId;
e.className = className;
return e;
}
}
@@ -0,0 +1,88 @@
package com.yunhai.homeleave.service.impl;
import com.yunhai.homeleave.model.bsch.SchClassDto;
import com.yunhai.homeleave.model.bsch.SchStudentDto;
import com.yunhai.homeleave.service.impl.RosterService.RosterEntry;
import com.yunhai.homeleave.utils.UserContext;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 正常班级过滤的活体验证(直连真实 bschclass oapi,需外网)。
* <p>类名不含 Test 后缀,默认 {@code mvn test} 不执行;显式运行:
* {@code mvn -o test -Dtest=RosterServiceLiveIT}。用于改动后确认真实上游数据下的取数口径。</p>
*/
class RosterServiceLiveIT {
private static final Integer SCH_ID = 3;
/** 云海中学联调学生:测试学生1,名册同时挂已删除班级 90 与正常班级 12(高一1班) */
private static final Integer STU_ID = 10061;
private static ClassService classService;
private static RosterService rosterService;
@BeforeAll
static void setUp() {
classService = new ClassService();
ReflectionTestUtils.setField(classService, "bschClassHost", "http://base.yunhaixingtong.com/userclass");
rosterService = new RosterService(classService);
UserContext.setUserId(STU_ID);
UserContext.setSchId(SCH_ID);
}
@AfterAll
static void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("真实上游:学校正常班级列表不含班级 90,正常班级 12 在列表内")
void liveNormalClasses() {
Map<Integer, String> normal = rosterService.normalClasses();
assertFalse(normal.isEmpty(), "正常班级列表不应为空");
assertFalse(normal.containsKey(90), "已删除班级 90 不应出现在正常班级列表");
assertTrue(normal.containsKey(12), "高一1班(12) 应为正常班级");
System.out.println("[live] 正常班级数=" + normal.size() + ", 12=" + normal.get(12));
}
@Test
@DisplayName("真实上游:学生名册含旧班级 90,myNormalRoster 只留 1班")
void liveMyNormalRoster() {
List<RosterEntry> all = rosterService.mine();
List<Integer> allIds = all.stream().map(e -> e.classId).collect(Collectors.toList());
System.out.println("[live] 名册全部班级=" + allIds + " 名称="
+ all.stream().map(e -> e.className).collect(Collectors.toList()));
assertTrue(allIds.contains(90), "名册应含历史遗留班级 90(验证过滤前状态)");
List<RosterEntry> mine = rosterService.myNormalRoster();
assertFalse(mine.isEmpty(), "正常班级名册不应为空");
assertTrue(mine.stream().noneMatch(e -> e.classId == 90), "旧班级 90 必须被过滤");
assertTrue(mine.stream().anyMatch(e -> e.classId == 12 && "1班".equals(e.className)),
"应保留正常班级 12/1班");
System.out.println("[live] 正常班级名册="
+ mine.stream().map(e -> e.classId + "/" + e.className).collect(Collectors.toList()));
}
@Test
@DisplayName("真实上游:名册中学生所在的旧班级确实不在学校班级表")
void liveStaleClassAbsentFromSchoolClasses() {
List<SchClassDto> classes = classService.getSchClassList(classService.getSchFirst(SCH_ID).getSchSerId());
assertNotNull(classes);
List<Integer> ids = classes.stream().map(SchClassDto::getClassId).collect(Collectors.toList());
assertFalse(ids.contains(90), "学校班级表不应含 90");
List<SchStudentDto> students = classService.getSchStuList(classService.getSchFirst(SCH_ID).getSchSerId());
assertTrue(students.stream().anyMatch(s -> s.getClassId() != null && s.getClassId() == 90),
"学生名册仍残留 90(上游数据脏点,本次修复的根因)");
}
}
@@ -0,0 +1,189 @@
package com.yunhai.homeleave.service.impl;
import com.yunhai.homeleave.model.bsch.SchClassDto;
import com.yunhai.homeleave.model.bsch.SchGradeDto;
import com.yunhai.homeleave.model.bsch.SchInfoDto;
import com.yunhai.homeleave.model.bsch.SchStudentDto;
import com.yunhai.homeleave.service.IClassService;
import com.yunhai.homeleave.utils.UserContext;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 正常班级过滤(学生名册 ∩ 学校班级表):名册残留旧班级不得进入请假入口。
* 数据取自真实环境形态:学生 10009 同时在已删除班级 90 与正常班级 12(高一1班)。
*/
class RosterServiceTest {
private static final Integer STU_ID = 10009;
private static final Integer SCH_ID = 3;
private static final String SCH_SER_ID = "8d920c30-0f9b-4d8c-8a9f-2f9682899635";
private IClassService classService;
private RosterService rosterService;
@BeforeEach
void setUp() {
classService = mock(IClassService.class);
rosterService = new RosterService(classService);
UserContext.setUserId(STU_ID);
UserContext.setSchId(SCH_ID);
SchInfoDto sch = new SchInfoDto();
sch.setSchSerId(SCH_SER_ID);
when(classService.getSchFirst(SCH_ID)).thenReturn(sch);
when(classService.getSchStuList(SCH_SER_ID)).thenReturn(Arrays.asList(
student(STU_ID, "克格莫", 12),
student(STU_ID, "克格莫", 90),
student(10086, "测试学生25", 12)));
// 学校班级表:含正常班级 12,不含已删除班级 90
when(classService.getSchClassList(SCH_SER_ID)).thenReturn(Arrays.asList(
clazz(12, "1班", 10),
clazz(371, "2班", 10)));
// 年级表:班级 Levell=10 → 高一(班级显示名带年级)
when(classService.getSchGradeList(SCH_SER_ID)).thenReturn(
Collections.singletonList(grade(10, "高一")));
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("mine() 保留名册全部条目(历史记录展示不受过滤影响);热路径不查年级表")
void mineKeepsStaleClasses() {
List<RosterService.RosterEntry> mine = rosterService.mine();
assertEquals(2, mine.size());
List<Integer> classIds = mine.stream().map(e -> e.classId).collect(Collectors.toList());
assertTrue(classIds.contains(12));
assertTrue(classIds.contains(90), "历史遗留班级仍应可见,避免既有记录显示异常");
// 正常班级映射到班级名,旧班级回退班级ID
assertEquals("1班", mine.stream().filter(e -> e.classId == 12).findFirst().get().className);
assertEquals("90", mine.stream().filter(e -> e.classId == 90).findFirst().get().className);
verify(classService, never()).getSchGradeList(anyString());
}
@Test
@DisplayName("myNormalRoster() 过滤已删除班级,只留正常班级(班级名带年级)")
void myNormalRosterFiltersStaleClasses() {
List<RosterService.RosterEntry> mine = rosterService.myNormalRoster();
assertEquals(1, mine.size());
assertEquals(12, mine.get(0).classId);
assertEquals("高一1班", mine.get(0).className);
}
@Test
@DisplayName("跨年级同名班可区分:高一1班 / 高二1班")
void sameClassNameAcrossGradesDistinguished() {
when(classService.getSchClassList(SCH_SER_ID)).thenReturn(Arrays.asList(
clazz(12, "1班", 10), clazz(380, "1班", 11)));
when(classService.getSchGradeList(SCH_SER_ID)).thenReturn(Arrays.asList(
grade(10, "高一"), grade(11, "高二")));
Map<Integer, String> names = rosterService.classDisplayNames(Arrays.asList(12, 380));
assertEquals("高一1班", names.get(12));
assertEquals("高二1班", names.get(380));
}
@Test
@DisplayName("年级名取数失败时退回纯班级名(不阻断展示)")
void fallsBackToPlainClassNameWhenGradeUnavailable() {
when(classService.getSchGradeList(SCH_SER_ID))
.thenThrow(new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取年级列表失败"));
assertEquals("1班", rosterService.className(12));
}
@Test
@DisplayName("displayName 拼接口径:年级名缺失退回班级名,班级名缺失返回原值")
void displayNameJoinsGradeAndClass() {
assertEquals("高一1班", RosterService.displayName("高一", "1班"));
assertEquals("1班", RosterService.displayName(null, "1班"));
assertEquals("1班", RosterService.displayName("", "1班"));
assertNull(RosterService.displayName("高一", null));
}
@Test
@DisplayName("名册中班级全部非正常时抛出提示(不是静默放行)")
void myNormalRosterRejectsWhenNoNormalClass() {
when(classService.getSchStuList(SCH_SER_ID)).thenReturn(
Collections.singletonList(student(STU_ID, "克格莫", 90)));
BizException e = assertThrows(BizException.class, () -> rosterService.myNormalRoster());
assertTrue(e.getMessage().contains("正常班级"), "实际提示: " + e.getMessage());
}
@Test
@DisplayName("normalClasses() 返回学校班级表全量(纯班级名,不含年级)")
void normalClassesMapsIdToName() {
Map<Integer, String> normal = rosterService.normalClasses();
assertEquals(2, normal.size());
assertEquals("1班", normal.get(12));
assertEquals("2班", normal.get(371));
assertFalse(normal.containsKey(90));
}
@Test
@DisplayName("无学校身份时 normalClasses() 抛出,展示名映射降级为空")
void normalClassesRequiresSchoolIdentity() {
UserContext.clear();
UserContext.setUserId(STU_ID);
when(classService.getSchFirst(SCH_ID)).thenReturn(null);
when(classService.getSchFirst(anyInt())).thenReturn(null);
assertThrows(BizException.class, () -> rosterService.normalClasses());
// 名称映射失败不阻断名册本身(沿用降级口径)
assertTrue(rosterService.classDisplayNames(Collections.singletonList(12)).isEmpty());
}
private static SchStudentDto student(Integer stuId, String name, Integer classId) {
SchStudentDto s = new SchStudentDto();
s.setStuId(stuId);
s.setStuName(name);
s.setClassId(classId);
return s;
}
private static SchClassDto clazz(Integer classId, String name, Integer gradeId) {
SchClassDto c = new SchClassDto();
c.setClassId(classId);
c.setClassName(name);
c.setGradeId(gradeId);
return c;
}
private static SchGradeDto grade(Integer gradeId, String name) {
SchGradeDto g = new SchGradeDto();
g.setId(gradeId);
g.setName(name);
return g;
}
}
@@ -0,0 +1,43 @@
package com.yunhai.homeleave.utils;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 请假时间展示口径:精确到分钟,同一天折叠(与前端 utils/leaveTime.ts 对齐)。
*/
class LeaveTimeTextTest {
@Test
@DisplayName("同一天:只保留一个日期,后半段显示时分")
void sameDayFolds() {
assertEquals("2026-09-29 08:00 ~ 18:00",
LeaveTimeText.range("2026-09-29 08:00:00", "2026-09-29 18:00:00"));
}
@Test
@DisplayName("跨天:两端都带日期")
void crossDayKeepsBothDates() {
assertEquals("2026-09-29 08:00 ~ 2026-09-30 18:00",
LeaveTimeText.range("2026-09-29 08:00:00", "2026-09-30 18:00:00"));
}
@Test
@DisplayName("秒被丢弃;缺秒/ISO 分隔符也能解析")
void secondsDropped() {
assertEquals("2026-09-29 08:05", LeaveTimeText.toMinute("2026-09-29 08:05:59"));
assertEquals("2026-09-29 08:05", LeaveTimeText.toMinute("2026-09-29T08:05:00"));
assertEquals("2026-09-29 08:05", LeaveTimeText.toMinute("2026-09-29 08:05"));
}
@Test
@DisplayName("空值与无法识别的值:不抛异常")
void toleratesBadInput() {
assertEquals("", LeaveTimeText.toMinute(null));
assertEquals("", LeaveTimeText.range(null, null));
assertEquals("2026-09-29 08:00", LeaveTimeText.range("2026-09-29 08:00:00", null));
assertEquals("abc", LeaveTimeText.toMinute("abc"));
}
}