@@ -0,0 +1,5 @@
|
||||
target/
|
||||
.idea/
|
||||
*.iml
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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<String,Object> 返回)。
|
||||
* 使用 @JsonProperty 保持旧字段名兼容前端渲染。
|
||||
*/
|
||||
@Data
|
||||
public class UpFileResultVo {
|
||||
|
||||
@JsonProperty("fileid")
|
||||
private Integer fileid;
|
||||
|
||||
@JsonProperty("name")
|
||||
private String name;
|
||||
|
||||
@JsonProperty("fileurl")
|
||||
private String fileurl;
|
||||
|
||||
@JsonProperty("host")
|
||||
private String host;
|
||||
|
||||
@JsonProperty("type")
|
||||
private String type;
|
||||
|
||||
@JsonProperty("size")
|
||||
private Integer size;
|
||||
}
|
||||
@@ -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<Map> 每项 {k, v}
|
||||
*/
|
||||
List<Map<String, Object>> getSysDic(Integer type);
|
||||
|
||||
/**
|
||||
* 获取系统字典管理列表(含 SerID/CodeID/Name/Des/Enable)。
|
||||
*
|
||||
* @param type 字典类型编号(0=部门类别)
|
||||
* @return List<Map>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+105
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user