diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a46b5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +.idea/ +*.iml +*.log +.DS_Store diff --git a/docker/application-prod.yml b/docker/application-prod.yml new file mode 100644 index 0000000..a2afb67 --- /dev/null +++ b/docker/application-prod.yml @@ -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 diff --git a/docker/run-app.sh b/docker/run-app.sh new file mode 100644 index 0000000..ec06a58 --- /dev/null +++ b/docker/run-app.sh @@ -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 diff --git a/err84.txt b/err84.txt new file mode 100644 index 0000000..d1087b5 --- /dev/null +++ b/err84.txt @@ -0,0 +1 @@ +could not extract ResultSet; SQL [n/a]; nested exception is org.hibernate.exception.SQLGrammarException: could not extract ResultSet \norg.springframework.dao.InvalidDataAccessResourceUsageException: could not extract ResultSet; SQL [n/a]; nested exception is org.hibernate.exception.SQLGrammarException: could not extract ResultSet \n\tat org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:259) \n\tat org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:233) \n\tat org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:551) \n\tat org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:61) \n\tat org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:243) \n\tat org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:152) \n\tat org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) \n\tat org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:145) \n\tat org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) \n\tat org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) \n\tat org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) \n\tat org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:241) \n\tat com.sun.proxy.$Proxy161.findVisibleBySchIdAndDictTypeAndDictKey(Unknown Source) \n\tat com.yunhai.homeleave.service.impl.DictService.resolveLeaveTypeName(DictService.java:392) \n\tat com.yunhai.homeleave.service.impl.DictService$$FastClassBySpringCGLIB$$cbbddc83.invoke() \n\tat org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218) \n\tat org.springframework.aop.framework.CglibAopProxy.invokeMethod(CglibAopProxy.java:386) \n\tat org.springframework.aop.framework.CglibAopProxy.access$000(CglibAopProxy.java:85) \n\tat org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:703) \n\tat com.yunhai.homeleave.service.impl.DictService$$EnhancerBySpringCGLIB$$c5544de6.resolveLeaveTypeName() \n\tat com.yunhai.homeleave.service.impl.LeaveServiceImpl.leaveTypeName(LeaveServiceImpl.java:828) \n\tat com.yunhai.homeleave.service.impl.LeaveServiceImpl.copyVo(LeaveServiceImpl.java:966) \n\tat com.yunhai.homeleave.service.impl.LeaveServiceImpl.toVo(LeaveServiceImpl.java:952) \n\tat java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:193) \n\tat java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1384) \n\tat java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:482) \n\tat java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:472) \n\tat java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:708) \n\tat java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234) \n\tat java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:499) \n\tat com.yunhai.homeleave.service.impl.LeaveServiceImpl.getLeaveList(LeaveServiceImpl.java:253) \n\tat com.yunhai.homeleave.service.impl.LeaveServiceImpl$$FastClassBySpringCGLIB$$ea3be75a.invoke() \n\tat org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218) \n\tat org.springframework.aop.framework.CglibAopProxy.invokeMethod(CglibAopProxy.java:386) \n\tat org.springframework.aop.framework.CglibAopProxy.access$000(CglibAopProxy.java:85) \n\tat org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:703) \n\tat com.yunhai.homeleave.service.impl.LeaveServiceImpl$$EnhancerBySpringCGLIB$$aa846ccb.getLeaveList() \n\tat com.yunhai.homeleave.controller.api.LeaveController.list(LeaveController.java:55) \n\tat com.yunhai.homeleave.controller.api.LeaveController$$FastClassBySpringCGLIB$$7a2118e6.invoke() \n\tat org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218) \n\tat org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:792) \n\tat org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163) \n\tat org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:762) \n\tat org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint.proceed(MethodInvocationProceedingJoinPoint.java:89) \n\tat com.yunhai.homeleave.common.log.OperationLogAspect.around(OperationLogAspect.java:74) \n\tat sun.reflect.GeneratedMethodAccessor111.invoke(Unknown Source) \n\tat sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) \n\tat java.lang.reflect.Method.invoke(Method.java:498) \n\tat org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:634) \n\tat org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethod(AbstractAspectJAdvice.java:624) \n\tat org.springframework.aop.aspectj.AspectJAroundAdvice.invoke(AspectJAroundAdvice.java:72) \n\tat org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) \n\tat org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:762) \n\tat org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97) \n\tat org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) \n\tat org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:762) \n\tat org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:707) \n\tat com.yunhai.homeleave.controller.api.LeaveController$$EnhancerBySpringCGLIB$$e1ab03f8.list() \n\tat sun.reflect.GeneratedMethodAccessor151.invoke(Unknown Source) \n\tat sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) \n\tat java.lang.reflect.Method.invoke(Method.java:498) \n\tat org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:205) \n\tat org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:150) \n\tat org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117) \n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895) \n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808) \n\tat org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) \n\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1072) \n\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:965) \n\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) \n\tat org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) \n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:529) \n\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883) \n\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:623) \n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:209) \n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(Applica diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..22b5e1b --- /dev/null +++ b/pom.xml @@ -0,0 +1,198 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + homeleave + 0.0.1 + HomeLeave + 请假子应用 + + 1.8 + + + + + + + com.yunhai.platform + yh-platform-dependencies + 1.0.0-SNAPSHOT + pom + import + + + + com.alibaba.cloud + spring-cloud-alibaba-dependencies + 2021.0.5.0 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.security + spring-security-crypto + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + org.springframework.cloud + spring-cloud-starter-bootstrap + 3.1.8 + + + + com.mysql + mysql-connector-j + runtime + + + + org.projectlombok + lombok + true + + + + + com.alibaba + fastjson + 1.2.80 + + + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + + + + + com.yunhai.platform + yh-spring-boot-starter + + + + + org.jasig.cas.client + cas-client-core + 3.5.1 + + + + + com.alibaba + easyexcel + 3.3.2 + + + + + + + + homeleave + + + org.apache.maven.plugins + maven-compiler-plugin + + + false + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.homeleave.HomeLeaveApplication + + + + + repackage + + + + + + + + + + + nexus-public + bsch/nexus public + http://nexus.yunhaixingtong.com/repository/maven-public/ + true + true + + + + + + ci + + C:/homeleave-build/homeleave + + + + + diff --git a/src/main/java/com/yunhai/homeleave/HomeLeaveApplication.java b/src/main/java/com/yunhai/homeleave/HomeLeaveApplication.java new file mode 100644 index 0000000..fc6cbe3 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/HomeLeaveApplication.java @@ -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); + } + +} diff --git a/src/main/java/com/yunhai/homeleave/common/listener/EntityAuditListener.java b/src/main/java/com/yunhai/homeleave/common/listener/EntityAuditListener.java new file mode 100644 index 0000000..deee920 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/common/listener/EntityAuditListener.java @@ -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); + } + } + +} diff --git a/src/main/java/com/yunhai/homeleave/common/log/OperationLogAspect.java b/src/main/java/com/yunhai/homeleave/common/log/OperationLogAspect.java new file mode 100644 index 0000000..2ce5d4f --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/common/log/OperationLogAspect.java @@ -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)。 + *
    + *
  • 全量拦截 controller 包所有方法,不依赖注解;
  • + *
  • 排除日志查询控制器自身(防递归刷屏);
  • + *
  • 用户信息取 {@link UserContext}(平台登录态惰性解析);
  • + *
  • 响应体 GZIP 压缩落库,参数脱敏;异步落库,失败仅记错误日志。
  • + *
+ */ +@Aspect +@Component +@Order(3) +public class OperationLogAspect { + + /** 排除的控制器(审计日志查询自身,防递归) */ + private static final List 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 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); + } +} diff --git a/src/main/java/com/yunhai/homeleave/config/AsyncConfig.java b/src/main/java/com/yunhai/homeleave/config/AsyncConfig.java new file mode 100644 index 0000000..be31a0a --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/config/AsyncConfig.java @@ -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; + } +} diff --git a/src/main/java/com/yunhai/homeleave/config/Config.java b/src/main/java/com/yunhai/homeleave/config/Config.java new file mode 100644 index 0000000..7240b89 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/config/Config.java @@ -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"; +} diff --git a/src/main/java/com/yunhai/homeleave/config/JacksonConfig.java b/src/main/java/com/yunhai/homeleave/config/JacksonConfig.java new file mode 100644 index 0000000..943a4b1 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/config/JacksonConfig.java @@ -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)); + } +} diff --git a/src/main/java/com/yunhai/homeleave/config/SchedulingConfig.java b/src/main/java/com/yunhai/homeleave/config/SchedulingConfig.java new file mode 100644 index 0000000..af7d536 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/config/SchedulingConfig.java @@ -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 { +} diff --git a/src/main/java/com/yunhai/homeleave/config/SwaggerConfig.java b/src/main/java/com/yunhai/homeleave/config/SwaggerConfig.java new file mode 100644 index 0000000..8acff1c --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/config/SwaggerConfig.java @@ -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")); + } +} diff --git a/src/main/java/com/yunhai/homeleave/config/UserContextConfig.java b/src/main/java/com/yunhai/homeleave/config/UserContextConfig.java new file mode 100644 index 0000000..fd8f76e --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/config/UserContextConfig.java @@ -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("/**"); + } + }; + } +} diff --git a/src/main/java/com/yunhai/homeleave/controller/api/BaseApiController.java b/src/main/java/com/yunhai/homeleave/controller/api/BaseApiController.java new file mode 100644 index 0000000..6e66398 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/controller/api/BaseApiController.java @@ -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/)。 + *

路径前缀:{@code Config.API_PATH + "/base/"}(原硬编码已修正)。 + * 承载平台代理接口:GetUserInfo / GetUserMod / GetSchoolAll / GetGroupUser / Logout, + * 业务逻辑已下沉 {@link IUserCenterService},本控制器仅做"取参 → 调 Service → 返回"。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/base/") +public class BaseApiController { + + private static final Logger log = LoggerFactory.getLogger(BaseApiController.class); + + @Autowired + private IUserCenterService userCenterService; + + @Autowired + private IFileService fileService; + + // ============================ FileUpload ============================ + + /** + * 附件上传(单文件)。 + * 前端: POST base/FileUpload (form-data: file) + * 返回: { fileid, host, fileurl, name, type, size } + */ + @PostMapping("FileUpload") + public UpFileResultVo fileUpload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, "leave"); + } + + // ============================ GetUserInfo ============================ + + /** + * 获取当前登录用户信息(CAS 会话注入)。 + */ + @GetMapping("GetUserInfo") + public BoaUser getUserInfo(BoaUser authUserWithDept) { + return authUserWithDept; + } + + // ============================ GetUserMod ============================ + + /** + * 获取当前用户已授权的模块列表(左侧菜单树)。 + * 模块树组装已下沉 IUserCenterService。 + */ + @GetMapping("GetUserMod") + public List> getUserMod(BoaUser authUserWithDept) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId); + } + + // ============================ GetSchoolAll ============================ + + /** + * 获取当前用户学校组及用户(树形结构)。 + * 组树组装/教师挂组/递归建树逻辑已下沉 IUserCenterService。 + */ + @GetMapping("GetSchoolAll") + public List> getSchoolAll(BoaUser authUserWithDept) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + return userCenterService.getSchoolAllTree(schId); + } + + // ============================ GetGroupUser ============================ + + /** + * 获取指定组内用户(教师 + 管理员)。 + * 空桩已改为显式报错(避免前端误以为组内无用户),由 IUserCenterService 统一处理。 + */ + @GetMapping("GetGroupUser") + public List> getGroupUser( + @RequestParam("gid") Integer gid, + @RequestParam(value = "role", defaultValue = "2") Integer role) { + return userCenterService.getGroupUser(gid, role); + } + + // ============================ Logout ============================ + + /** + * 退出登录。 + * 认证由平台网关处理,此处仅返回成功。 + */ + @PostMapping("Logout") + public void logout() { + LoginUser user = UserContextHolder.get(); + log.info("[logout] 用户退出登录: {}", user == null ? null : user.getLoginName()); + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/homeleave/controller/api/DictController.java b/src/main/java/com/yunhai/homeleave/controller/api/DictController.java new file mode 100644 index 0000000..1afbad3 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/controller/api/DictController.java @@ -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; + +/** + * 系统字典接口(发文部门域)。 + *

路径前缀:{@code Config.API_PATH + "/dict/"}(原 base/ 前缀已修正)。 + * 写操作一律 POST(原 DelDept/OrdbyDicIds 为 GET 写操作,已 POST 化)。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/dict/") +public class DictController { + + @Autowired + private IDictService dictService; + + /** + * 获取系统字典(下拉框用)。 + */ + @GetMapping("List") + public List> list(@RequestParam(value = "type", defaultValue = "0") Integer type) { + return dictService.getSysDic(type); + } + + /** + * 获取系统字典管理列表。 + */ + @GetMapping("ManageList") + public List> manageList(@RequestParam(value = "type", defaultValue = "dept") String type) { + return dictService.getDictMangeList(type); + } + + /** + * 添加发文单位。 + */ + @PostMapping("Add") + public void add(@RequestParam("name") String name) { + dictService.addDeptDic(name); + } + + /** + * 更新发文单位。 + */ + @PostMapping("Update") + public void update( + @RequestParam("SerID") Integer serID, + @RequestParam("Name") String name, + @RequestParam(value = "Enable", required = false, defaultValue = "1") Integer enable) { + dictService.updateDeptDic(serID, name, enable); + } + + /** + * 删除发文单位(原 GET DelDept,已 POST 化)。 + */ + @PostMapping("Delete") + public void delete(@RequestParam("serid") Integer serID) { + dictService.delDept(serID); + } + + /** + * 发文单位排序(原 GET OrdbyDicIds,已 POST 化)。 + */ + @PostMapping("Sort") + public void sort(@RequestParam("ids") String ids) { + dictService.ordbyDicIds(ids); + } + + // ============================ 请假类型字典(应用设置) ============================ + + /** 请假类型下拉项(启用):[{k, v}],k 为数字型 key 字符串 */ + @GetMapping("LeaveTypeOptions") + public List> leaveTypeOptions() { + return dictService.getLeaveTypeOptions(); + } + + /** 请假类型管理列表(含禁用):[{SerID, CodeID, Name, Des, Enable}] */ + @GetMapping("LeaveTypeList") + public List> 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); + } +} diff --git a/src/main/java/com/yunhai/homeleave/controller/api/FileController.java b/src/main/java/com/yunhai/homeleave/controller/api/FileController.java new file mode 100644 index 0000000..c1e1849 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/controller/api/FileController.java @@ -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/)。 + *

路径前缀:{@code Config.API_PATH + "/file/"}(原 base/ 前缀已修正为独立文件域)。 + * 方法名统一 PascalCase;写操作一律 POST。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/file/") +public class FileController { + + @Autowired + private IFileService fileService; + + /** + * 附件上传(单文件)。 + * 前端: POST file/Upload (form-data) + * 返回: { fileid, host, fileurl, name, type, size } + */ + @PostMapping("Upload") + public UpFileResultVo upload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, "leave"); + } + + /** + * 图片上传(多文件,返回数组)。 + * 前端: POST file/UploadImage (form-data, accept=image/*) + */ + @PostMapping("UploadImage") + public List 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 list(@RequestParam("topicId") Integer topicId) { + return fileService.listByTopic("leave", topicId); + } + + /** + * 批量绑定附件到作业。 + */ + @PostMapping("Bind") + public void bind(@RequestParam("fileIds") List fileIds, + @RequestParam("topicId") Integer topicId) { + fileService.bindToTopic(fileIds, topicId); + } + +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/homeleave/controller/api/LeaveController.java b/src/main/java/com/yunhai/homeleave/controller/api/LeaveController.java new file mode 100644 index 0000000..b21d068 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/controller/api/LeaveController.java @@ -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; + +/** + * 学生请假接口(学生端 + 教师端)。 + *

路径前缀:{@code Config.API_PATH + "/leave/"};方法名 PascalCase,写操作一律 POST。 + * 身份与数据范围由服务层经 UserContext 收敛:学生=名册本人,教师=班主任班级,校管理员全校; + * 接口不收账号参数。返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

+ */ +@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 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 myClasses() { + return leaveService.myClasses(); + } + + /** 班级学生名单(教师登记请假选择学生) */ + @GetMapping("ClassStudents") + public List classStudents(@RequestParam("classId") Integer classId) { + return leaveService.classStudents(classId); + } + + /** 状态字典(前端状态标签渲染,值与 LeaveStatusEnum 对齐) */ + @GetMapping("StatusDict") + public List> statusDict() { + return java.util.Arrays.stream(com.yunhai.homeleave.enums.LeaveStatusEnum.values()) + .map(e -> { + java.util.Map m = new java.util.LinkedHashMap<>(); + m.put("id", e.getId()); + m.put("name", e.getName()); + return m; + }).collect(java.util.stream.Collectors.toList()); + } +} diff --git a/src/main/java/com/yunhai/homeleave/controller/api/LeaveStatsController.java b/src/main/java/com/yunhai/homeleave/controller/api/LeaveStatsController.java new file mode 100644 index 0000000..29410ae --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/controller/api/LeaveStatsController.java @@ -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); + } +} diff --git a/src/main/java/com/yunhai/homeleave/controller/api/SysOperationLogController.java b/src/main/java/com/yunhai/homeleave/controller/api/SysOperationLogController.java new file mode 100644 index 0000000..32b4a6c --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/controller/api/SysOperationLogController.java @@ -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 response(@RequestParam("id") Long id) { + String data = sysOperationLogService.getResponseData(id); + if (data == null) { + throw new BizException(ErrorCode.FORBIDDEN, "日志记录不存在或无响应体"); + } + Map m = new LinkedHashMap<>(); + m.put("id", id); + m.put("response", data); + return m; + } +} diff --git a/src/main/java/com/yunhai/homeleave/entity/BaseEntity.java b/src/main/java/com/yunhai/homeleave/entity/BaseEntity.java new file mode 100644 index 0000000..e042d28 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/entity/BaseEntity.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/entity/Leave.java b/src/main/java/com/yunhai/homeleave/entity/Leave.java new file mode 100644 index 0000000..35672d2 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/entity/Leave.java @@ -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; + +/** + * 请假主表(学生请假/教师登记)。 + *

表名不用 {@code leave}:MySQL 保留字,JPA 生成 SQL 需处处反引号,易踩坑。

+ *

人员只存 id(stuId 为用户中心数字ID,与 bschclass 学生ID同域), + * 创建人/时间由 {@link BaseEntity}(EntityAuditListener)自动填充。

+ */ +@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; +} diff --git a/src/main/java/com/yunhai/homeleave/entity/LeaveReportBack.java b/src/main/java/com/yunhai/homeleave/entity/LeaveReportBack.java new file mode 100644 index 0000000..e71e636 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/entity/LeaveReportBack.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/entity/LeaveTime.java b/src/main/java/com/yunhai/homeleave/entity/LeaveTime.java new file mode 100644 index 0000000..d528a66 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/entity/LeaveTime.java @@ -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)。 + *

不连续请假也落一行(与主表起止时间一致);连续请假每天一行。

+ */ +@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; +} diff --git a/src/main/java/com/yunhai/homeleave/entity/SysDict.java b/src/main/java/com/yunhai/homeleave/entity/SysDict.java new file mode 100644 index 0000000..34fe82d --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/entity/SysDict.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/entity/SysOperationLog.java b/src/main/java/com/yunhai/homeleave/entity/SysOperationLog.java new file mode 100644 index 0000000..12384b3 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/entity/SysOperationLog.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/entity/UpFile.java b/src/main/java/com/yunhai/homeleave/entity/UpFile.java new file mode 100644 index 0000000..d0b35c5 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/entity/UpFile.java @@ -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 表。 + *

+ * 存储文件元信息(真实文件名、存储路径、大小、类型),物理文件存储在 + * 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; +} diff --git a/src/main/java/com/yunhai/homeleave/enums/LeaveRegisterTypeEnum.java b/src/main/java/com/yunhai/homeleave/enums/LeaveRegisterTypeEnum.java new file mode 100644 index 0000000..8946f7b --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/enums/LeaveRegisterTypeEnum.java @@ -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; + } +} diff --git a/src/main/java/com/yunhai/homeleave/enums/LeaveStatusEnum.java b/src/main/java/com/yunhai/homeleave/enums/LeaveStatusEnum.java new file mode 100644 index 0000000..85c2794 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/enums/LeaveStatusEnum.java @@ -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 list; + + private static List list() { + if (list != null) { + return list; + } + list = new ArrayList<>(); + for (LeaveStatusEnum status : EnumSet.allOf(LeaveStatusEnum.class)) { + list.add(status); + } + return list; + } +} diff --git a/src/main/java/com/yunhai/homeleave/enums/LeaveTypeEnum.java b/src/main/java/com/yunhai/homeleave/enums/LeaveTypeEnum.java new file mode 100644 index 0000000..b9b0925 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/enums/LeaveTypeEnum.java @@ -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 list; + + private static List list() { + if (list != null) { + return list; + } + list = new ArrayList<>(); + for (LeaveTypeEnum type : EnumSet.allOf(LeaveTypeEnum.class)) { + list.add(type); + } + return list; + } +} diff --git a/src/main/java/com/yunhai/homeleave/model/BasePageQuery.java b/src/main/java/com/yunhai/homeleave/model/BasePageQuery.java new file mode 100644 index 0000000..cd11375 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/BasePageQuery.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/model/BasePageResult.java b/src/main/java/com/yunhai/homeleave/model/BasePageResult.java new file mode 100644 index 0000000..d4c09c0 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/BasePageResult.java @@ -0,0 +1,33 @@ +package com.yunhai.homeleave.model; + +import lombok.Data; + +import java.util.List; + +/** + * 统一分页响应外壳。 + */ +@Data +public class BasePageResult { + /** 总记录数 */ + private int total; + /** 当前页数据 */ + private List content; + /** 当前页码(从 1 起) */ + private int pageIndex; + /** 每页条数 */ + private int pageSize; + /** 总页数 */ + private int totalPages; + /** 是否还有下一页 */ + private boolean hasNext; + + public BasePageResult(List content, int total, int pageIndex, int pageSize, int totalPages, boolean hasNext) { + this.content = content; + this.total = total; + this.pageIndex = pageIndex; + this.pageSize = pageSize; + this.totalPages = totalPages; + this.hasNext = hasNext; + } +} diff --git a/src/main/java/com/yunhai/homeleave/model/SchTeaList.java b/src/main/java/com/yunhai/homeleave/model/SchTeaList.java new file mode 100644 index 0000000..8a42ca5 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/SchTeaList.java @@ -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 groupIds; + + /** 教师所属组ID(接口现行字段 GroupID,单个) */ + @JsonProperty("GroupID") + private Integer groupId; + + public Integer getGroupId() { + return groupId; + } + + public void setGroupId(Integer groupId) { + this.groupId = groupId; + } + + public String getSchSerID() { + return schSerID; + } + + public void setSchSerID(String schSerID) { + this.schSerID = schSerID; + } + + public String getTeaTel() { + return teaTel; + } + + public void setTeaTel(String teaTel) { + this.teaTel = teaTel; + } + + public String getTeaName() { + return teaName; + } + + public void setTeaName(String teaName) { + this.teaName = teaName; + } + + public Integer getUserTeaSerID() { + return userTeaSerID; + } + + public void setUserTeaSerID(Integer userTeaSerID) { + this.userTeaSerID = userTeaSerID; + } + + public String getTeaIDCard() { + return teaIDCard; + } + + public void setTeaIDCard(String teaIDCard) { + this.teaIDCard = teaIDCard; + } + + public String getTeaNo() { + return teaNo; + } + + public void setTeaNo(String teaNo) { + this.teaNo = teaNo; + } + + public List getGroupIds() { + return groupIds; + } + + public void setGroupIds(List groupIds) { + this.groupIds = groupIds; + } +} diff --git a/src/main/java/com/yunhai/homeleave/model/UpFileResultVo.java b/src/main/java/com/yunhai/homeleave/model/UpFileResultVo.java new file mode 100644 index 0000000..60f68d6 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/UpFileResultVo.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/model/UpFileVo.java b/src/main/java/com/yunhai/homeleave/model/UpFileVo.java new file mode 100644 index 0000000..afc075a --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/UpFileVo.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/model/bsch/SchClassDto.java b/src/main/java/com/yunhai/homeleave/model/bsch/SchClassDto.java new file mode 100644 index 0000000..292d807 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/bsch/SchClassDto.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/model/bsch/SchClassMasterDto.java b/src/main/java/com/yunhai/homeleave/model/bsch/SchClassMasterDto.java new file mode 100644 index 0000000..5a57160 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/bsch/SchClassMasterDto.java @@ -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; + } +} diff --git a/src/main/java/com/yunhai/homeleave/model/bsch/SchGradeDto.java b/src/main/java/com/yunhai/homeleave/model/bsch/SchGradeDto.java new file mode 100644 index 0000000..6cced63 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/bsch/SchGradeDto.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/model/bsch/SchInfoDto.java b/src/main/java/com/yunhai/homeleave/model/bsch/SchInfoDto.java new file mode 100644 index 0000000..6314f69 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/bsch/SchInfoDto.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/model/bsch/SchStudentDto.java b/src/main/java/com/yunhai/homeleave/model/bsch/SchStudentDto.java new file mode 100644 index 0000000..ad54247 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/bsch/SchStudentDto.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/model/bsch/SchSubjectDto.java b/src/main/java/com/yunhai/homeleave/model/bsch/SchSubjectDto.java new file mode 100644 index 0000000..14fa0ed --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/bsch/SchSubjectDto.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/model/bsch/SchSystemDto.java b/src/main/java/com/yunhai/homeleave/model/bsch/SchSystemDto.java new file mode 100644 index 0000000..47b8b84 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/bsch/SchSystemDto.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/model/bsch/SchTeachDto.java b/src/main/java/com/yunhai/homeleave/model/bsch/SchTeachDto.java new file mode 100644 index 0000000..69e7924 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/bsch/SchTeachDto.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/model/bsch/SchTreeNodeDto.java b/src/main/java/com/yunhai/homeleave/model/bsch/SchTreeNodeDto.java new file mode 100644 index 0000000..bc5f845 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/bsch/SchTreeNodeDto.java @@ -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 children; +} diff --git a/src/main/java/com/yunhai/homeleave/model/leave/ClassStudentVo.java b/src/main/java/com/yunhai/homeleave/model/leave/ClassStudentVo.java new file mode 100644 index 0000000..e539aad --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/leave/ClassStudentVo.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/model/leave/LeaveAuditRequest.java b/src/main/java/com/yunhai/homeleave/model/leave/LeaveAuditRequest.java new file mode 100644 index 0000000..d01b272 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/leave/LeaveAuditRequest.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/model/leave/LeaveBatchPassRequest.java b/src/main/java/com/yunhai/homeleave/model/leave/LeaveBatchPassRequest.java new file mode 100644 index 0000000..da6117c --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/leave/LeaveBatchPassRequest.java @@ -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 ids; +} diff --git a/src/main/java/com/yunhai/homeleave/model/leave/LeaveDetailVo.java b/src/main/java/com/yunhai/homeleave/model/leave/LeaveDetailVo.java new file mode 100644 index 0000000..2d471d2 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/leave/LeaveDetailVo.java @@ -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:列表项 + 销假时间段 + 按钮状态。 + *

btnStatus 按钮状态:0 不显示;1 审核(通过/拒绝);2 销假;3 撤销/销假审批(通过/拒绝)。

+ *
+ * 状态       学生可见              教师可见
+ * 0 待审核   2 销假(=撤销申请)    1 审核
+ * 1 已通过   2 销假/申请撤销        type=1→2 销假(教师登记)
+ * 2 待撤销   —                    3 撤销审批
+ * 5 待销假   —                    3 销假审批
+ * 4/6/7/8   4/7 可再次申请销假     —
+ * 
+ */ +@Data +@EqualsAndHashCode(callSuper = false) +public class LeaveDetailVo extends LeaveVo { + + /** 销假申请时间段(status=5 待销假时有值) */ + private List reportBackLeaveTime = new ArrayList<>(); + + /** 按钮状态:0 不显示;1 审核;2 销假;3 撤销/销假审批 */ + private Integer btnStatus; + + /** 当前登录人是否学生(前端按身份渲染按钮) */ + private Boolean viewerIsStudent; +} diff --git a/src/main/java/com/yunhai/homeleave/model/leave/LeaveQuery.java b/src/main/java/com/yunhai/homeleave/model/leave/LeaveQuery.java new file mode 100644 index 0000000..47d9954 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/leave/LeaveQuery.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/model/leave/LeaveReportBackRequest.java b/src/main/java/com/yunhai/homeleave/model/leave/LeaveReportBackRequest.java new file mode 100644 index 0000000..7706436 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/leave/LeaveReportBackRequest.java @@ -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 continuousLeaveTime; +} diff --git a/src/main/java/com/yunhai/homeleave/model/leave/LeaveRequest.java b/src/main/java/com/yunhai/homeleave/model/leave/LeaveRequest.java new file mode 100644 index 0000000..028953f --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/leave/LeaveRequest.java @@ -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 continuousLeaveTime; + + /** 请假事由 */ + @NotBlank(message = "请假事由不能为空") + @Size(max = 1000, message = "请假事由不能超过1000字") + private String remarks; + + /** 佐证附件文件ID列表(file/Upload 上传后返回的 fileid) */ + private List fileIds; +} diff --git a/src/main/java/com/yunhai/homeleave/model/leave/LeaveStatsVo.java b/src/main/java/com/yunhai/homeleave/model/leave/LeaveStatsVo.java new file mode 100644 index 0000000..52dda41 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/leave/LeaveStatsVo.java @@ -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 byLeaveType = new ArrayList<>(); + + /** 按班级分布 */ + private List byClass = new ArrayList<>(); + + /** 按日期分布(请假时间段覆盖的每天,按 start_date 计) */ + private List 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; + } +} diff --git a/src/main/java/com/yunhai/homeleave/model/leave/LeaveTimeItem.java b/src/main/java/com/yunhai/homeleave/model/leave/LeaveTimeItem.java new file mode 100644 index 0000000..14d92f9 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/leave/LeaveTimeItem.java @@ -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); + } +} diff --git a/src/main/java/com/yunhai/homeleave/model/leave/LeaveVo.java b/src/main/java/com/yunhai/homeleave/model/leave/LeaveVo.java new file mode 100644 index 0000000..6ea0847 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/leave/LeaveVo.java @@ -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 continuousLeaveTime = new ArrayList<>(); + + /** 状态(LeaveStatusEnum.id) */ + private Integer status; + + private String statusName; + + private String remarks; + + /** 附件 */ + private List files = new ArrayList<>(); + + /** 创建时间 yyyy-MM-dd HH:mm:ss */ + private String createTime; +} diff --git a/src/main/java/com/yunhai/homeleave/model/leave/TeachingClassVo.java b/src/main/java/com/yunhai/homeleave/model/leave/TeachingClassVo.java new file mode 100644 index 0000000..ab5793b --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/model/leave/TeachingClassVo.java @@ -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; +} diff --git a/src/main/java/com/yunhai/homeleave/repository/LeaveReportBackRepository.java b/src/main/java/com/yunhai/homeleave/repository/LeaveReportBackRepository.java new file mode 100644 index 0000000..59b8636 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/repository/LeaveReportBackRepository.java @@ -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 { + + List findByLeaveIdOrderByStartDateAsc(Integer leaveId); + + List findByLeaveIdIn(List leaveIds); + + /** 学生的全部待审核销时间段(新请假时间冲突校验用) */ + List findByStuId(Integer stuId); + + void deleteByLeaveId(Integer leaveId); +} diff --git a/src/main/java/com/yunhai/homeleave/repository/LeaveRepository.java b/src/main/java/com/yunhai/homeleave/repository/LeaveRepository.java new file mode 100644 index 0000000..caf8861 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/repository/LeaveRepository.java @@ -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, JpaSpecificationExecutor { + + /** 班级内待审核记录(一键通过用) */ + List findBySchIdAndClassIdAndStatus(Integer schId, Integer classId, Integer status); + + /** 指定 ID 集合内待审核记录(批量通过按 ID 过滤用) */ + List findByIdInAndStatus(List ids, Integer status); +} diff --git a/src/main/java/com/yunhai/homeleave/repository/LeaveTimeRepository.java b/src/main/java/com/yunhai/homeleave/repository/LeaveTimeRepository.java new file mode 100644 index 0000000..c66657f --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/repository/LeaveTimeRepository.java @@ -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 { + + List findByLeaveIdOrderByStartDateAsc(Integer leaveId); + + List findByLeaveIdInOrderByStartDateAsc(List leaveIds); + + /** 学生的全部请假时间段(新请假时间冲突校验用;已撤销主表由服务层过滤) */ + List findByStuId(Integer stuId); + + void deleteByLeaveId(Integer leaveId); +} diff --git a/src/main/java/com/yunhai/homeleave/repository/SysDictRepository.java b/src/main/java/com/yunhai/homeleave/repository/SysDictRepository.java new file mode 100644 index 0000000..dc20609 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/repository/SysDictRepository.java @@ -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 { + + // ---------------- 存量/无学校归属(schId 为空时不过滤,同原作业模块多租户口径) ---------------- + + /** 全量(含禁用),用于管理列表与重名/序号计算 */ + List findByDictTypeOrderBySortOrderAsc(String dictType); + + /** 启用项(下拉框用) */ + List findByDictTypeAndEnableFlagOrderBySortOrderAsc(String dictType, Integer enableFlag); + + /** 按类型+key 取单条字典 */ + Optional 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 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 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 findVisibleBySchIdAndDictTypeAndDictKey(@Param("schId") Integer schId, + @Param("dictType") String dictType, + @Param("dictKey") String dictKey); +} diff --git a/src/main/java/com/yunhai/homeleave/repository/SysOperationLogRepository.java b/src/main/java/com/yunhai/homeleave/repository/SysOperationLogRepository.java new file mode 100644 index 0000000..014e5e4 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/repository/SysOperationLogRepository.java @@ -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, JpaSpecificationExecutor { +} diff --git a/src/main/java/com/yunhai/homeleave/repository/UpFileRepository.java b/src/main/java/com/yunhai/homeleave/repository/UpFileRepository.java new file mode 100644 index 0000000..e243958 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/repository/UpFileRepository.java @@ -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 { + + /** + * 按主题类型和主题ID查询附件列表。 + */ + List findByTopicTypeAndTopicId(String topicType, Integer topicId); + + /** + * 按主题类型和多个主题ID批量查询附件(消除 N+1)。 + */ + List findByTopicTypeAndTopicIdIn(String topicType, List topicIds); + + /** + * 按主题类型和主题ID删除附件(作业删除时级联清理)。 + */ + void deleteByTopicTypeAndTopicId(String topicType, Integer topicId); +} diff --git a/src/main/java/com/yunhai/homeleave/service/IClassService.java b/src/main/java/com/yunhai/homeleave/service/IClassService.java new file mode 100644 index 0000000..ccf0190 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/service/IClassService.java @@ -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)开放接口客户端门面。 + *

基址取配置 {@code yh.open.bschclass}(/oapi/** 无需应用签名,独立 RestTemplate 直连)。 + * 推送范围树、圈人、身份快照统一经本门面取数,不散落 RestTemplate 调用(改造方案 2.0)。 + */ +public interface IClassService { + + /** + * 获取指定学校全部教师(含所属组 GroupIds)。 + * 对应 bschclass 开放接口:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}} + * + * @param schId 学校ID(即用户当前身份的 schId) + */ + List getSchTeaList(int schId); + + /** + * 用户中心学校ID → bschclass 学校信息(SchSerId / 学校名)。 + * 对应:GET {@code {bschclass}/oapi/SchInfo/GetUserSchFirst?UserSchID={schId}} + */ + SchInfoDto getSchFirst(int schId); + + /** + * 学校学段(学制)列表:小学部 / 初中部…。 + * 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenlist?SchSerID=} + */ + List getSchLenList(String schSerId); + + /** + * 学校年级列表(Id=gradeId、Name=gradeNickname、OwnerId=学段id)。 + * 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenLevellist?SchSerID=} + */ + List getSchGradeList(String schSerId); + + /** + * 学校全部在读班级(ClassID/ClassName/Levell=gradeId),ScopeTree 一次取数客户端组装。 + * 对应:GET {@code {bschclass}/oapi/VSchClass/GetVClassList?SchSerID=} + */ + List getSchClassList(String schSerId); + + /** + * 按校全部学生(圈人:全校范围展开)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetSchStulist?schserid=} + */ + List getSchStuList(String schSerId); + + /** + * 按年级直查学生(圈人:年级范围免逐班聚合;bschclass 侧补缺接口)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetGradeStulist?SchSerID=&GradeId=} + */ + List getGradeStuList(String schSerId, Integer gradeId); + + /** + * 按学段直查学生(圈人:学部范围一键展开;bschclass 侧补缺接口)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetSubStulist?SchSerID=&SchSystemId=} + */ + List getSubStuList(String schSerId, Integer schSystemId); + + /** + * 按班级批量查学生(圈人:班级范围一次多班)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetClaArrStulist?ClassIDs=1,2,3} + */ + List getClaArrStuList(List classIds); + + /** + * 按学生ID批量查(圈人:指定学员范围)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetStuIdslist?stuids=1,2,3} + */ + List getStuIdsList(List stuIds); + + /** + * 学校学生树:学校→学段→年级→班级→学生(单元素列表,根为学校节点)。 + * 节点契约:组节点 type=1、isParent=true、children 非 null;学生叶子 type=2、isParent=false、children=null。 + * 对应:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?SchSerID=} + */ + List getTeaTree(Integer schId, boolean toUser); + + List getStuTree(Integer schId,boolean toUser); + + /** + * 学校学科列表(含学科编码、名称、排序号)。 + * 对应:GET {@code {bschclass}/oapi/SubInfo/GetSchSublist?SchID=} + */ + List getSchSubList(String schSerId); + + /** + * 指定教师的全部任教(校验教师归属该校;SchID 过滤学校、UserTeaSerID 过滤教师)。 + * 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchTeachlist?SchID=&UserTeaSerID=} + */ + List getSchTeachList(String schSerId, Integer teaId); + + /** + * 指定班级的全部任教(班级维度:该班所有教师与学科)。 + * 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchClaTeachlist?ClassID=} + */ + List getSchClaTeachList(Integer classId); + + /** + * 学校全部任教(全校所有教师的班级任教关系)。 + * 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchTeachlist?SchID=} + */ + List getSchAllTeachList(String schSerId); + + /** + * 学校全部班级(含班主任 firstTeacherId / 副班主任 secondTeacherId,不含毕业班)。 + * 请假模块用于识别教师所带班级(班主任 = 审批/登记数据范围)。 + * 对应:GET {@code {bschclass}/oapi/sch/getSchClassList?schId=}(schId 为用户中心学校ID) + */ + List getSchClassMasterList(Integer schId); +} diff --git a/src/main/java/com/yunhai/homeleave/service/IDictService.java b/src/main/java/com/yunhai/homeleave/service/IDictService.java new file mode 100644 index 0000000..3675751 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/service/IDictService.java @@ -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> getSysDic(Integer type); + + /** + * 获取系统字典管理列表(含 SerID/CodeID/Name/Des/Enable)。 + * + * @param type 字典类型编号(0=部门类别) + * @return List<Map> + */ + List> getDictMangeList(String type); + + /** + * 添加发文单位。 + * + * @param name 单位名称 + */ + void addDeptDic(String name); + + /** + * 更新发文单位。 + * + * @param serID 记录ID + * @param name 名称 + * @param enable 启用状态 + */ + void updateDeptDic(Integer serID, String name, Integer enable); + + /** + * 删除发文单位。 + * + * @param serID 记录ID + */ + void delDept(Integer serID); + + /** + * 发文单位排序。 + * + * @param ids 逗号分隔的 SerID 列表(按前端顺序) + */ + void ordbyDicIds(String ids); + + /** + * 按发文部门字典 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> getLeaveTypeManageList(); + + /** 请假类型下拉项(启用):{k, v},k 为数字型字典 key 字符串 */ + List> 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); +} diff --git a/src/main/java/com/yunhai/homeleave/service/IFileService.java b/src/main/java/com/yunhai/homeleave/service/IFileService.java new file mode 100644 index 0000000..011bf6f --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/service/IFileService.java @@ -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 fileIds, Integer topicId); + + /** + * 查询某主题下的附件列表。 + * + * @param appType 应用类型 + * @param topicId 主题ID + * @return 附件列表 + */ + List listByTopic(String appType, Integer topicId); + + /** + * 下载文件。 + * + * @param fileId 文件记录ID + * @param response HTTP 响应 + */ + void download(Integer fileId, HttpServletResponse response); + + /** + * 在线预览文件(Content-Disposition: inline,按扩展名输出真实 Content-Type, + * 供前端 open-file-viewer 取 Blob / video 播放)。 + * + * @param fileId 文件记录ID + * @param response HTTP 响应 + */ + void preview(Integer fileId, HttpServletResponse response); + + /** + * 删除文件(同时删除物理文件和数据库记录)。 + * + * @param fileId 文件记录ID + */ + void delete(Integer fileId); + + /** + * 打包下载多个附件为 ZIP。 + * + * @param files 附件记录列表(调用方按 fileIds 查好) + * @param zipName 压缩包显示名(自动追加 .zip) + * @param response HTTP 响应 + */ + void downloadZip(List files, String zipName, HttpServletResponse response); + + /** + * 按主题类型和主题ID打包下载附件为 ZIP(Controller 便捷方法,内部自行查附件列表)。 + * + * @param topicType 主题类型(如 "notice") + * @param topicId 主题ID + * @param zipName 压缩包显示名 + * @param response HTTP 响应 + */ + void downloadZipByTopic(String topicType, Integer topicId, String zipName, HttpServletResponse response); + + /** + * 批量上传图片(循环上传,返回每个文件的视图对象列表)。 + */ + List uploadImages(MultipartFile[] files, String appType); + + /** + * 上传单个文件并返回前端兼容的结果 VO(含 fileid/name/fileurl/host/type/size)。 + */ + UpFileResultVo uploadToResult(MultipartFile file, String appType); +} diff --git a/src/main/java/com/yunhai/homeleave/service/ILeaveService.java b/src/main/java/com/yunhai/homeleave/service/ILeaveService.java new file mode 100644 index 0000000..f64a8fe --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/service/ILeaveService.java @@ -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; + +/** + * 学生请假业务接口(学生申请、教师审批、教师登记、撤销、销假、统计)。 + *

身份:当前登录人ID 实时匹配 bschclass 学生名册(RosterService.mine)判定学生, + * 其余视为教师;教师数据范围 = 本人担任班主任的班级(getSchClassList firstTeacherId), + * 校管理员(BoaUser.type=2)全校。学生请假需班主任通过或拒绝(教师登记免审直接生效)。

+ */ +public interface ILeaveService { + + /** + * 提交请假:学生申请(type=2 → 待审核);教师登记(type=1 → 免审直接通过)。 + * + * @return 新请假ID + */ + Integer saveLeave(LeaveRequest request); + + /** 分页列表(学生本人 / 教师任教班级 / 校管理员全校,按入参二次过滤) */ + BasePageResult 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 myClasses(); + + /** 班级学生名单(教师登记请假选择学生用;校验班级在数据范围内) */ + List classStudents(Integer classId); +} diff --git a/src/main/java/com/yunhai/homeleave/service/ISysOperationLogService.java b/src/main/java/com/yunhai/homeleave/service/ISysOperationLogService.java new file mode 100644 index 0000000..fcd399d --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/service/ISysOperationLogService.java @@ -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 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); +} diff --git a/src/main/java/com/yunhai/homeleave/service/IUserCenterService.java b/src/main/java/com/yunhai/homeleave/service/IUserCenterService.java new file mode 100644 index 0000000..aa80851 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/service/IUserCenterService.java @@ -0,0 +1,51 @@ +package com.yunhai.homeleave.service; + +import java.util.List; +import java.util.Map; + +/** + * 用户中心门面服务(对齐 UserBase {@code UserCenterService} 模式)。 + *

封装 {@code YschBaseOpenApi} / {@code OpenApiUserClient} 静态门面调用, + * 把原本散落在 Controller 的组树组装、教师挂组、成员解析等业务逻辑下沉到 Service 层, + * Controller 只保留"取参 → 调 Service → 返回"的薄壳。

+ */ +public interface IUserCenterService { + + /** + * 获取当前用户学校组及用户(树形结构)。 + *

组装规则(从原 BaseApiController.getSchoolAll 下沉):

+ *
    + *
  1. userbase 读取当前学校整棵组树(getGroupById(schId, withSub=true)),剔除学生组(type=8)及其子组;
  2. + *
  3. bschclass 获取该校全部教师(/oapi/DbClass/GetSchteaList?Group=schId),按 GroupIds 挂到对应组下, + * 无有效组(如所属组全被剔除)的教师兜底挂学校根节点;
  4. + *
  5. 扁平节点按 pid 递归组装为树(空组剔除,同 C# FormatUserTree)。
  6. + *
+ * + * @param schId 学校ID(用户当前角色所属学校) + * @return 用户树形结构(组节点 type=1 / 教师节点 type=2) + */ + List> getSchoolAllTree(Integer schId); + + /** + * 获取指定组内用户(教师 + 管理员)。 + *

对应前端 base/GetGroupUser。原实现为空桩返回空列表,现按 C# 语义 + * (ITeainfoBLL.GetGroupTeainfo + IAdminfoBLL.GetGroupAdminfo)接入用户中心。 + * 若用户中心不可用或未配置对应开放接口,则抛出带语义的异常(显式报错而非静默失能)。

+ * + * @param gid 组ID + * @param role 角色(默认 2=教师;1=管理员等) + * @return 组内用户列表 + */ + List> getGroupUser(Integer gid, Integer role); + + /** + * 获取当前用户可访问的模块树(左侧菜单)。 + *

从基础平台拉取当前用户模块列表(含学校上下文),组装为前端 layout 期望的树结构 + * ({@code modId/page/children}),对应前端 GetUserMod 接口。

+ * + * @param userId 用户ID + * @param schId 学校ID(可为 null) + * @return 模块树(顶层 List,含 children 嵌套) + */ + List> getUserModuleTree(Integer userId, Integer schId); +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/homeleave/service/impl/ClassService.java b/src/main/java/com/yunhai/homeleave/service/impl/ClassService.java new file mode 100644 index 0000000..105fe23 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/service/impl/ClassService.java @@ -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 直连。 + *

推送范围树/圈人/身份快照统一经本门面(改造方案 2.0): + * 学段 GetSchLenlist、年级 GetSchLenLevellist、班级 GetVClassList、 + * 学生 GetSchStulist / GetGradeStulist(补缺) / GetSubStulist(补缺) / GetClaArrStulist / GetStuIdslist、 + * 学生树 SchGroup/GetStuTree。 + */ +@Service +public class ClassService implements IClassService { + + private static final Logger log = LoggerFactory.getLogger(ClassService.class); + + /** 班级管理服务基址,如 http://base.yunhaixingtong.com/bschclass */ + @Value("${yh.open.bschclass:}") + private String bschClassHost; + + private final RestTemplate restTemplate; + + public ClassService() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(10000); + this.restTemplate = new RestTemplate(factory); + } + + @Override + public List getSchTeaList(int schId) { + return exchangeList(bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId, + new ParameterizedTypeReference>() { + }, "获取教师数据", "schId=" + schId); + } + + @Override + public SchInfoDto getSchFirst(int schId) { + requireHost(); + String url = bschClassHost + "/oapi/SchInfo/GetUserSchFirst?UserSchID=" + schId; + try { + ResponseEntity resp = restTemplate.getForEntity(URI.create(url), SchInfoDto.class); + return resp.getBody(); + } catch (RestClientException e) { + log.error("[getSchFirst] 请求班级管理接口失败, url={}", url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取学校信息失败"); + } + } + + @Override + public List getSchLenList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenlist?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学段列表", "schSerId=" + schSerId); + } + + @Override + public List getSchGradeList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenLevellist?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取年级列表", "schSerId=" + schSerId); + } + + @Override + public List getSchClassList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/VSchClass/GetVClassList?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取班级列表", "schSerId=" + schSerId); + } + + @Override + public List getSchStuList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/OAStu/GetSchStulist?schserid=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学校学生", "schSerId=" + schSerId); + } + + @Override + public List getGradeStuList(String schSerId, Integer gradeId) { + return exchangeList(bschClassHost + "/oapi/OAStu/GetGradeStulist?SchSerID=" + schSerId + "&GradeId=" + gradeId, + new ParameterizedTypeReference>() { + }, "获取年级学生", "schSerId=" + schSerId + ", gradeId=" + gradeId); + } + + @Override + public List getSubStuList(String schSerId, Integer schSystemId) { + return exchangeList(bschClassHost + "/oapi/OAStu/GetSubStulist?SchSerID=" + schSerId + "&SchSystemId=" + schSystemId, + new ParameterizedTypeReference>() { + }, "获取学段学生", "schSerId=" + schSerId + ", schSystemId=" + schSystemId); + } + + @Override + public List getClaArrStuList(List classIds) { + String csv = joinIds(classIds); + return exchangeList(bschClassHost + "/oapi/OAStu/GetClaArrStulist?ClassIDs=" + csv, + new ParameterizedTypeReference>() { + }, "获取班级学生", "classIds=" + csv); + } + + @Override + public List getStuIdsList(List stuIds) { + String csv = joinIds(stuIds); + return exchangeList(bschClassHost + "/oapi/OAStu/GetStuIdslist?stuids=" + csv, + new ParameterizedTypeReference>() { + }, "获取指定学生", "stuIds=" + csv); + } + + @Override + public List getStuTree(Integer schId,boolean toUser) { + return exchangeList(bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId+"&toUser="+toUser, + new ParameterizedTypeReference>() { + }, "获取学生树", "schId=" + schId); + } + + @Override + public List getTeaTree(Integer schId,boolean toUser) { + return exchangeList(bschClassHost + "/oapi/SchGroup/GetTeaTree?schId=" + schId+"&toUser="+toUser, + new ParameterizedTypeReference>() { + }, "获取教师树", "schId=" + schId); + } + + @Override + public List getSchSubList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/SubInfo/GetSchSublist?SchID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学校学科", "schSerId=" + schSerId); + } + + @Override + public List getSchTeachList(String schSerId, Integer teaId) { + return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchTeachlist?SchID=" + schSerId + "&UserTeaSerID=" + teaId, + new ParameterizedTypeReference>() { + }, "获取教师任教", "schSerId=" + schSerId + ", teaId=" + teaId); + } + + @Override + public List getSchClaTeachList(Integer classId) { + return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchClaTeachlist?ClassID=" + classId, + new ParameterizedTypeReference>() { + }, "获取班级任教", "classId=" + classId); + } + + @Override + public List getSchAllTeachList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchTeachlist?SchID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学校全部任教", "schSerId=" + schSerId); + } + + @Override + public List getSchClassMasterList(Integer schId) { + return exchangeList(bschClassHost + "/oapi/sch/getSchClassList?schId=" + schId, + new ParameterizedTypeReference>() { + }, "获取学校班级(含班主任)", "schId=" + schId); + } + + + + + + // ============================ 通用调用 ============================ + + private List exchangeList(String url, ParameterizedTypeReference> type, + String action, String arg) { + requireHost(); + try { + // GET + Accept: application/json,保持与既有 GetSchteaList 调用一致 + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + ResponseEntity> resp = restTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(headers), type); + List list = resp.getBody(); + log.info("[bschclass] {} 完成, {} 返回 {} 条", action, arg, list == null ? 0 : list.size()); + return list != null ? list : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[bschclass] {} 失败, {}, url={}", action, arg, url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, action + "失败"); + } + } + + private void requireHost() { + if (!StringUtils.hasText(bschClassHost)) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)"); + } + } + + private String joinIds(List ids) { + if (ids == null || ids.isEmpty()) { + return ""; + } + return ids.stream().map(String::valueOf).collect(Collectors.joining(",")); + } +} diff --git a/src/main/java/com/yunhai/homeleave/service/impl/DictService.java b/src/main/java/com/yunhai/homeleave/service/impl/DictService.java new file mode 100644 index 0000000..9b9827a --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/service/impl/DictService.java @@ -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> getSysDic(Integer type) { + // C# 中 Type=1 为部门类别(DEPT),其余类型(文印、请假等)本系统暂不涉及 + String dictType = mapDictType(type); + if (dictType == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); + } + List 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 item = new LinkedHashMap<>(); + item.put("k", d.getDictKey()); + item.put("v", d.getDictValue()); + return item; + }) + .collect(Collectors.toList()); + } + + // ============================ GetDictMangeList ============================ + + @Override + public List> getDictMangeList(String type) { + // type="dept" 表示部门类别(DEPT) + String dictType = "dept".equalsIgnoreCase(type) ? DICT_TYPE_DEPT : null; + if (dictType == null) { + // 非 DEPT 类型需超管权限,本系统暂只支持 DEPT + throw new BizException(ErrorCode.FORBIDDEN, "非超管不得操作!"); + } + List list = visibleList(dictType); + return list.stream() + .sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? 999 : d.getSortOrder())) + .map(d -> { + Map item = new LinkedHashMap<>(); + item.put("SerID", d.getId()); + item.put("CodeID", d.getDictKey()); + item.put("Name", d.getDictValue()); + item.put("Des", d.getSortOrder() != null ? d.getSortOrder().toString() : ""); + item.put("Enable", d.getEnableFlag()); + return item; + }) + .collect(Collectors.toList()); + } + + // ============================ AddDeptDic ============================ + + @Override + @Transactional + public void addDeptDic(String name) { + if (!StringUtils.hasText(name)) { + throw new BizException(ErrorCode.PARAM_ERROR, "输入字符串格式不正确!"); + } + Integer schId = UserContext.getSchId(); + // 重名校验范围 = 本校 + 全局可见项,避免与存量全局字典重名 + List 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 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 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 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 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 visibleList(String dictType) { + Integer schId = UserContext.getSchId(); + return schId != null + ? sysDictRepository.findVisibleBySchIdAndDictType(schId, dictType) + : sysDictRepository.findByDictTypeOrderBySortOrderAsc(dictType); + } + + // ============================ 请假类型字典 ============================ + + @Override + public List> getLeaveTypeManageList() { + return visibleList(DICT_TYPE_LEAVE).stream() + .sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? 999 : d.getSortOrder())) + .map(d -> { + Map item = new LinkedHashMap<>(); + item.put("SerID", d.getId()); + item.put("CodeID", d.getDictKey()); + item.put("Name", d.getDictValue()); + item.put("Des", d.getSortOrder() != null ? d.getSortOrder().toString() : ""); + item.put("Enable", d.getEnableFlag()); + return item; + }) + .collect(Collectors.toList()); + } + + @Override + public List> getLeaveTypeOptions() { + return visibleEnabledList(DICT_TYPE_LEAVE).stream() + .sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? Integer.MAX_VALUE : d.getSortOrder())) + .map(d -> { + Map item = new LinkedHashMap<>(); + item.put("k", d.getDictKey()); + item.put("v", d.getDictValue()); + return item; + }) + .collect(Collectors.toList()); + } + + @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 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 visible = visibleList(DICT_TYPE_LEAVE); + Map 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 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 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; + } + } +} diff --git a/src/main/java/com/yunhai/homeleave/service/impl/FileService.java b/src/main/java/com/yunhai/homeleave/service/impl/FileService.java new file mode 100644 index 0000000..3cd856e --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/service/impl/FileService.java @@ -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; + +/** + * 文件上传/下载/删除业务实现。 + *

+ * 存储路径规则:{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 fileIds, Integer topicId) { + if (fileIds == null || fileIds.isEmpty() || topicId == null) { + return; + } + for (Integer fileId : fileIds) { + upFileRepository.findById(fileId).ifPresent(f -> { + f.setTopicId(topicId); + upFileRepository.save(f); + }); + } + } + + // ============================ 查询附件列表 ============================ + + @Override + public List listByTopic(String appType, Integer topicId) { + 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 table = new HashMap<>(); + table.put("pdf", "application/pdf"); + table.put("png", "image/png"); + table.put("jpg", "image/jpeg"); + table.put("jpeg", "image/jpeg"); + table.put("gif", "image/gif"); + table.put("bmp", "image/bmp"); + table.put("webp", "image/webp"); + table.put("svg", "image/svg+xml"); + table.put("txt", "text/plain;charset=UTF-8"); + table.put("mp4", "video/mp4"); + table.put("webm", "video/webm"); + table.put("ogg", "video/ogg"); + table.put("doc", "application/msword"); + table.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + table.put("xls", "application/vnd.ms-excel"); + table.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + table.put("ppt", "application/vnd.ms-powerpoint"); + table.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"); + String type = table.get(name.substring(name.lastIndexOf('.') + 1)); + if (type != null) { + return type; + } + String guessed = URLConnection.guessContentTypeFromName(name); + return guessed != null ? guessed : "application/octet-stream"; + } + + // ============================ 删除 ============================ + + @Override + @Transactional + public void delete(Integer fileId) { + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId)); + // 删物理文件 + Path fullPath = Paths.get(uploadPath, file.getFileName()); + try { + Files.deleteIfExists(fullPath); + } catch (Exception e) { + log.warn("[delete] 物理文件删除失败: {}(数据库记录仍会删除)", fullPath, e); + } + // 删数据库记录 + upFileRepository.delete(file); + } + + // ============================ 打包下载 ZIP ============================ + + @Override + public void downloadZip(List files, String zipName, HttpServletResponse response) { + if (files == null || files.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载"); + } + try { + response.setContentType("application/zip"); + String name = URLEncoder.encode(zipName == null ? "附件" : zipName, "UTF-8") + .replaceAll("\\+", "%20"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + name + ".zip\""); + try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) { + Set used = new HashSet<>(); + for (UpFile f : files) { + Path p = Paths.get(uploadPath, f.getFileName()); + if (!Files.exists(p)) { + log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName()); + continue; + } + String entryName = sanitizeEntryName(f.getFileTrueName()); + if (used.contains(entryName)) { + entryName = f.getId() + "_" + entryName; + } + used.add(entryName); + zos.putNextEntry(new ZipEntry(entryName)); + Files.copy(p, zos); + zos.closeEntry(); + } + } + } catch (Exception e) { + log.error("[downloadZip] 打包下载失败", e); + throw new BizException(ErrorCode.ERROR, "打包下载失败", e); + } + } + + /** + * 净化 ZIP 条目名:去除路径分隔符等非法字符,防止 Zip Slip。 + */ + private String sanitizeEntryName(String name) { + if (name == null || name.trim().isEmpty()) { + return "file"; + } + return name.replaceAll("[\\\\/:*?\"<>|]", "_"); + } + + // ============================ 按主题打包下载 ============================ + + @Override + public void downloadZipByTopic(String topicType, Integer topicId, String zipName, + HttpServletResponse response) { + List files = upFileRepository.findByTopicTypeAndTopicId(topicType, topicId); + downloadZip(files, zipName, response); + } + + // ============================ 批量上传图片 ============================ + + @Override + @Transactional + public List uploadImages(MultipartFile[] files, String appType) { + List results = new ArrayList<>(); + if (files == null || files.length == 0) { + return results; + } + 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; + } +} diff --git a/src/main/java/com/yunhai/homeleave/service/impl/LeaveNotifyService.java b/src/main/java/com/yunhai/homeleave/service/impl/LeaveNotifyService.java new file mode 100644 index 0000000..fdb54b8 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/service/impl/LeaveNotifyService.java @@ -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; + +/** + * 请假消息通知占位服务。 + *

当前版本暂不接入微信/企微推送(计划待确认事项 5 的决策): + * 各业务节点先保留调用位置,接入时在对应方法内补充推送实现即可, + * 调用方无需改动。所有方法自身吞异常,绝不阻断请假主流程。

+ */ +@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())); + } +} diff --git a/src/main/java/com/yunhai/homeleave/service/impl/LeaveServiceImpl.java b/src/main/java/com/yunhai/homeleave/service/impl/LeaveServiceImpl.java new file mode 100644 index 0000000..e7e8ff6 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/service/impl/LeaveServiceImpl.java @@ -0,0 +1,1160 @@ +package com.yunhai.homeleave.service.impl; + +import com.yunhai.homeleave.entity.Leave; +import com.yunhai.homeleave.entity.LeaveReportBack; +import com.yunhai.homeleave.entity.LeaveTime; +import com.yunhai.homeleave.enums.LeaveRegisterTypeEnum; +import com.yunhai.homeleave.enums.LeaveStatusEnum; +import com.yunhai.homeleave.enums.LeaveTypeEnum; +import com.yunhai.homeleave.model.BasePageResult; +import com.yunhai.homeleave.model.UpFileVo; +import com.yunhai.homeleave.model.bsch.SchClassDto; +import com.yunhai.homeleave.model.bsch.SchClassMasterDto; +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.LeaveTimeItem; +import com.yunhai.homeleave.model.leave.LeaveVo; +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.IFileService; +import com.yunhai.homeleave.service.ILeaveService; +import com.yunhai.homeleave.service.impl.RosterService.RosterEntry; +import com.yunhai.homeleave.utils.LeaveTimeText; +import com.yunhai.homeleave.utils.PageUtils; +import com.yunhai.homeleave.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.auth.spring.CurrentBoaUser; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import javax.persistence.criteria.Predicate; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.yunhai.homeleave.enums.LeaveStatusEnum.APPROVED; +import static com.yunhai.homeleave.enums.LeaveStatusEnum.PENDING; +import static com.yunhai.homeleave.enums.LeaveStatusEnum.REJECTED; +import static com.yunhai.homeleave.enums.LeaveStatusEnum.REPORT_BACKED; +import static com.yunhai.homeleave.enums.LeaveStatusEnum.REPORT_BACK_REJECTED; +import static com.yunhai.homeleave.enums.LeaveStatusEnum.REPORT_BACKING; +import static com.yunhai.homeleave.enums.LeaveStatusEnum.WITHDRAWN; +import static com.yunhai.homeleave.enums.LeaveStatusEnum.WITHDRAWING; +import static com.yunhai.homeleave.enums.LeaveStatusEnum.WITHDRAW_REJECTED; + +/** + * 学生请假业务实现(参考 HomeSchLeave.LeaveService,去除微信/人脸/待办推送)。 + *

教师登记免审直接通过(历史口径沿用);请假日期范围 ≤30 天; + * 时间冲突校验沿用历史区间重叠公式(排除已撤销记录)。

+ */ +@Service +public class LeaveServiceImpl implements ILeaveService { + + private static final Logger log = LoggerFactory.getLogger(LeaveServiceImpl.class); + + /** 请假附件在 up_file.topic_type 中的标识 */ + private static final String TOPIC_TYPE_LEAVE = "leave"; + + /** 平台用户类型:2 = 学校管理员(数据范围放开到全校) */ + private static final Integer USER_TYPE_SCHOOL_ADMIN = 2; + + private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter DATETIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** + * 教师可直接撤销的状态:待审核 / 已通过 / 待撤销 / 撤销拒绝 / 待销假 / 销假拒绝。 + * 前四类为流程中,撤销拒绝与销假拒绝后请假仍然生效,均属于"可作废"范围。 + */ + private static final Set TEACHER_WITHDRAWABLE = new HashSet<>(Arrays.asList( + PENDING.getId(), APPROVED.getId(), WITHDRAWING.getId(), WITHDRAW_REJECTED.getId(), + REPORT_BACKING.getId(), REPORT_BACK_REJECTED.getId())); + + /** 可删除的状态:已结束的单据(已撤销 / 已销假 / 已拒绝),与 TEACHER_WITHDRAWABLE 互补 */ + private static final Set DELETABLE = new HashSet<>(Arrays.asList( + WITHDRAWN.getId(), REPORT_BACKED.getId(), REJECTED.getId())); + + @Autowired + private LeaveRepository leaveRepository; + + @Autowired + private LeaveTimeRepository leaveTimeRepository; + + @Autowired + private LeaveReportBackRepository leaveReportBackRepository; + + @Autowired + private RosterService rosterService; + + @Autowired + private IClassService classService; + + @Autowired + private IFileService fileService; + + @Autowired + private com.yunhai.homeleave.service.IDictService dictService; + + @Autowired + private LeaveNotifyService notifyService; + + @Autowired + private CurrentBoaUser currentBoaUser; + + // ============================ 提交请假 ============================ + + @Override + @Transactional + public Integer saveLeave(LeaveRequest request) { + Integer userId = requireUserId(); + Integer schId = requireSchId(); + + boolean isStudent = isStudent(); + Leave leave = new Leave(); + leave.setSchId(schId); + + List timeItems; + if (isStudent) { + // 学生申请:目标学生=本人,班级取名册(仅正常班级),状态=待审核 + List mine = myNormalRoster(); + RosterEntry entry = mine.stream() + .filter(e -> request.getClassId() == null || request.getClassId().equals(e.classId)) + .findFirst() + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, + "班级不在您的正常班级范围内,请联系管理员确认班级名册")); + leave.setStuId(entry.studentId); + leave.setStuName(entry.studentName); + leave.setClassId(entry.classId); + leave.setClassName(entry.className); + leave.setType(LeaveRegisterTypeEnum.STUDENT.getId()); + leave.setStatus(PENDING.getId()); + } else { + // 教师登记/代申请:指定学生,状态=已通过(免审,历史口径) + if (request.getStuId() == null || request.getClassId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "教师登记请指定班级和学生"); + } + if (!inScope(request.getClassId())) { + throw new BizException(ErrorCode.FORBIDDEN, "该班级不在您的数据范围内"); + } + requireNormalClass(request.getClassId()); + RosterEntry stu = rosterEntry(request.getClassId(), request.getStuId()); + if (stu == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "班级名册中未找到该学生"); + } + leave.setStuId(stu.studentId); + leave.setStuName(stu.studentName); + leave.setClassId(stu.classId); + leave.setClassName(stu.className); + leave.setType(request.getType()); + leave.setStatus(APPROVED.getId()); + } + + if (request.getType() != null + && LeaveRegisterTypeEnum.nameById(request.getType()) == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "登记类型错误"); + } + if (!isValidLeaveType(request.getLeaveType())) { + throw new BizException(ErrorCode.PARAM_ERROR, "请假类型错误"); + } + leave.setLeaveType(request.getLeaveType()); + leave.setRemarks(request.getRemarks()); + + // 时间规范化 + 基础校验 + String start = normalizeDateTime(request.getStartDate(), false); + String end = normalizeDateTime(request.getEndDate(), true); + validateRange(start, end); + + int continuous = Objects.equals(request.getContinuousLeave(), 1) ? 1 : 0; + if (continuous == 1) { + // 历史口径(WechatStudentLeaveRequest):连续请假 = 基准时段 × 连续天数, + // 时间段按天递增跨天(如 9-22 08:00~12:00 起连续 3 天 → 22/23/24 各一段); + // 主表起止为第一天基准时段(须同一天),每段内部起止须同一天 + if (request.getContinuousLeaveNum() == null || request.getContinuousLeaveNum() <= 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "请填写连续请假天数"); + } + timeItems = request.getContinuousLeaveTime(); + if (timeItems == null || timeItems.size() != request.getContinuousLeaveNum()) { + throw new BizException(ErrorCode.PARAM_ERROR, "连续请假时间段与天数不一致"); + } + for (LeaveTimeItem item : timeItems) { + String s = normalizeDateTime(item.getStartDate(), false); + String e = normalizeDateTime(item.getEndDate(), true); + validateRange(s, e); + if (!s.substring(0, 10).equals(e.substring(0, 10))) { + throw new BizException(ErrorCode.PARAM_ERROR, "连续请假每个时间段的起止须为同一天"); + } + item.setStartDate(s); + item.setEndDate(e); + } + if (!start.substring(0, 10).equals(end.substring(0, 10))) { + throw new BizException(ErrorCode.PARAM_ERROR, "连续请假时,请假时间(第一天)须为同一天"); + } + } else { + timeItems = new ArrayList<>(); + LeaveTimeItem single = new LeaveTimeItem(); + single.setStartDate(start); + single.setEndDate(end); + timeItems.add(single); + } + + leave.setStartDate(start); + leave.setEndDate(end); + leave.setContinuousLeave(continuous); + leave.setContinuousLeaveNum(continuous == 1 ? request.getContinuousLeaveNum() : null); + + // 时间冲突校验(沿用历史重叠公式;已撤销可覆盖) + checkTimeConflict(leave.getStuId(), timeItems); + + leaveRepository.save(leave); + + List timeRows = new ArrayList<>(); + for (LeaveTimeItem item : timeItems) { + LeaveTime row = new LeaveTime(); + row.setLeaveId(leave.getId()); + row.setStuId(leave.getStuId()); + row.setStartDate(item.getStartDate()); + row.setEndDate(item.getEndDate()); + timeRows.add(row); + } + leaveTimeRepository.saveAll(timeRows); + + // 附件绑定(file/Upload 上传时 topicType 已是 leave,此处只挂 topicId) + fileService.bindToTopic(request.getFileIds(), leave.getId()); + + // 消息通知占位(微信/企微推送接入点,见 LeaveNotifyService) + notifyService.onSubmitted(leave); + + log.info("[saveLeave] {} 请假 id={}, stuId={}, classId={}, {} ~ {}", + LeaveRegisterTypeEnum.nameById(leave.getType()), leave.getId(), leave.getStuId(), + leave.getClassId(), leave.getStartDate(), leave.getEndDate()); + return leave.getId(); + } + + // ============================ 列表 / 详情 ============================ + + @Override + public BasePageResult getLeaveList(LeaveQuery query) { + Pageable pageable = PageUtils.toPageable(query, + Sort.by(Sort.Order.desc("createTime"), Sort.Order.desc("id"))); + Page page = leaveRepository.findAll(listSpec(query), pageable); + List content = page.getContent().stream() + .map(this::toVo) + .collect(Collectors.toList()); + fillTimeItems(content); + fillClassNames(content); + return new BasePageResult<>(content, (int) page.getTotalElements(), + pageable.getPageNumber() + 1, pageable.getPageSize(), + page.getTotalPages(), page.hasNext()); + } + + /** + * 列表班级名补齐为「年级名+班级名」:历史记录落库的是纯班级名(如「1班」), + * 跨年级重名无法区分;正常班级表取数失败或班级已删除时保留落库名。 + */ + private void fillClassNames(List vos) { + Set classIds = vos.stream().map(LeaveVo::getClassId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + if (classIds.isEmpty()) { + return; + } + Map names = rosterService.classDisplayNames(classIds); + for (LeaveVo vo : vos) { + String name = vo.getClassId() == null ? null : names.get(vo.getClassId()); + if (StringUtils.hasText(name)) { + vo.setClassName(name); + } + } + } + + /** + * 列表时间段批量填充(连续请假在列表展示"连续 N 段"依赖此字段; + * 一次 in 查询按 leaveId 分组,避免逐行查询)。 + */ + private void fillTimeItems(List vos) { + if (vos.isEmpty()) { + return; + } + List ids = vos.stream().map(LeaveVo::getId).collect(Collectors.toList()); + Map> byLeave = leaveTimeRepository + .findByLeaveIdInOrderByStartDateAsc(ids).stream() + .collect(Collectors.groupingBy(LeaveTime::getLeaveId)); + for (LeaveVo vo : vos) { + List rows = byLeave.get(vo.getId()); + if (rows != null && !rows.isEmpty()) { + vo.setContinuousLeaveTime(timeItems(rows)); + } + } + } + + @Override + public LeaveDetailVo getLeaveDetails(Integer id) { + Leave leave = leaveRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "请假记录不存在")); + boolean isStudent = isStudent(); + authorize(leave, isStudent); + + LeaveDetailVo vo = new LeaveDetailVo(); + copyVo(leave, vo); + fillClassNames(Collections.singletonList(vo)); + vo.setContinuousLeaveTime(timeItems(leaveTimeRepository.findByLeaveIdOrderByStartDateAsc(id))); + vo.setReportBackLeaveTime( + leaveReportBackRepository.findByLeaveIdOrderByStartDateAsc(id).stream() + .map(r -> { + LeaveTimeItem item = new LeaveTimeItem(); + item.setId(r.getLeaveTimeId()); + item.setStartDate(r.getStartDate()); + item.setEndDate(r.getEndDate()); + return item; + }).collect(Collectors.toList())); + vo.setFiles(fileService.listByTopic(TOPIC_TYPE_LEAVE, id)); + vo.setBtnStatus(btnStatus(isStudent, leave)); + vo.setViewerIsStudent(isStudent); + return vo; + } + + // ============================ 审批 / 撤销 / 销假 ============================ + + @Override + @Transactional + public void auditLeave(LeaveAuditRequest request) { + boolean isStudent = isStudent(); + if (isStudent) { + throw new BizException(ErrorCode.FORBIDDEN, "学生无审批权限"); + } + Leave leave = leaveRepository.findById(request.getId()) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "请假记录不存在")); + authorize(leave, false); + + Integer current = leave.getStatus(); + Integer target = request.getStatus(); + if (PENDING.getId().equals(current)) { + if (!APPROVED.getId().equals(target) && !REJECTED.getId().equals(target)) { + throw new BizException(ErrorCode.PARAM_ERROR, "待审核状态仅允许通过或拒绝"); + } + leave.setStatus(target); + } else if (WITHDRAWING.getId().equals(current)) { + if (!WITHDRAWN.getId().equals(target) && !WITHDRAW_REJECTED.getId().equals(target)) { + throw new BizException(ErrorCode.PARAM_ERROR, "待撤销状态仅允许通过撤销或拒绝撤销"); + } + leave.setStatus(target); + } else if (REPORT_BACKING.getId().equals(current)) { + if (REPORT_BACKED.getId().equals(target)) { + passReportBack(leave); + } else if (REPORT_BACK_REJECTED.getId().equals(target)) { + refuseReportBack(leave); + } else { + throw new BizException(ErrorCode.PARAM_ERROR, "待销假状态仅允许通过销假或拒绝销假"); + } + } else { + throw new BizException(ErrorCode.PARAM_ERROR, "当前状态无需审批"); + } + leaveRepository.save(leave); + notifyService.onAudited(leave, target); + } + + @Override + @Transactional + public int batchPass(LeaveBatchPassRequest request) { + if (isStudent()) { + throw new BizException(ErrorCode.FORBIDDEN, "学生无审批权限"); + } + Integer schId = requireSchId(); + List pending; + if (request.getClassId() != null) { + if (!inScope(request.getClassId())) { + throw new BizException(ErrorCode.FORBIDDEN, "该班级不在您的数据范围内"); + } + pending = leaveRepository.findBySchIdAndClassIdAndStatus(schId, request.getClassId(), PENDING.getId()); + } else if (request.getIds() != null && !request.getIds().isEmpty()) { + pending = leaveRepository.findByIdInAndStatus(request.getIds(), PENDING.getId()).stream() + .filter(l -> inScope(l.getClassId())) + .collect(Collectors.toList()); + } else { + throw new BizException(ErrorCode.PARAM_ERROR, "请指定班级或请假记录"); + } + for (Leave leave : pending) { + leave.setStatus(APPROVED.getId()); + } + leaveRepository.saveAll(pending); + return pending.size(); + } + + @Override + @Transactional + public void withdrawLeave(Integer id) { + Leave leave = leaveRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "请假记录不存在")); + boolean isStudent = isStudent(); + authorize(leave, isStudent); + + if (isStudent) { + Integer current = leave.getStatus(); + if (PENDING.getId().equals(current)) { + leave.setStatus(WITHDRAWN.getId()); // 待审核直接撤销 + } else if (APPROVED.getId().equals(current) || WITHDRAW_REJECTED.getId().equals(current)) { + leave.setStatus(WITHDRAWING.getId()); // 已通过/曾被拒 → 转待撤销 + } else { + throw new BizException(ErrorCode.PARAM_ERROR, "当前状态不可撤销"); + } + } else { + // 教师直接撤销:无需学生申请,生效中/流程中的记录直接作废; + // 已结束状态(已撤销/已销假/已拒绝)不可撤销,只能删除 + if (!TEACHER_WITHDRAWABLE.contains(leave.getStatus())) { + throw new BizException(ErrorCode.PARAM_ERROR, "当前状态不可撤销"); + } + leave.setStatus(WITHDRAWN.getId()); + // 未决销假记录一并清理(否则已撤销记录仍挂着待审销假时间段) + leaveReportBackRepository.deleteByLeaveId(leave.getId()); + } + leaveRepository.save(leave); + notifyService.onWithdraw(leave); + } + + @Override + @Transactional + public void deleteLeave(Integer id) { + if (isStudent()) { + throw new BizException(ErrorCode.FORBIDDEN, "学生无权删除请假记录"); + } + Leave leave = leaveRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "请假记录不存在")); + authorize(leave, false); + + // 仅已结束状态可删除;生效中/流程中的记录请先撤销,避免误删有效请假 + if (!DELETABLE.contains(leave.getStatus())) { + throw new BizException(ErrorCode.PARAM_ERROR, "仅已撤销、已销假、已拒绝的请假可删除"); + } + leaveTimeRepository.deleteByLeaveId(leave.getId()); + leaveReportBackRepository.deleteByLeaveId(leave.getId()); + // 附件级联清理(与项目内其他模块删除主题时的口径一致) + for (UpFileVo file : fileService.listByTopic(TOPIC_TYPE_LEAVE, leave.getId())) { + fileService.delete(file.getId()); + } + leaveRepository.delete(leave); + log.info("[deleteLeave] 删除请假 id={}, stuId={}, classId={}, status={}", + leave.getId(), leave.getStuId(), leave.getClassId(), leave.getStatus()); + } + + @Override + @Transactional + public void saveReportBackLeave(LeaveReportBackRequest request) { + Leave leave = leaveRepository.findById(request.getId()) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "请假记录不存在")); + boolean isStudent = isStudent(); + authorize(leave, isStudent); + + boolean continuous = Objects.equals(request.getContinuousLeave(), 1); + List items = normalizedReportBackItems(request, continuous); + + if (isStudent) { + // 学生申请销假:已通过/销假被拒 → 待销假,落销时间段 + if (!APPROVED.getId().equals(leave.getStatus()) + && !REPORT_BACK_REJECTED.getId().equals(leave.getStatus())) { + throw new BizException(ErrorCode.PARAM_ERROR, "当前状态不可申请销假"); + } + List timeRows = leaveTimeRepository.findByLeaveIdOrderByStartDateAsc(leave.getId()); + List backs = new ArrayList<>(); + if (continuous) { + Map byId = timeRows.stream() + .collect(Collectors.toMap(LeaveTime::getId, t -> t, (a, b) -> a)); + for (LeaveTimeItem item : items) { + if (item.getId() == null || !byId.containsKey(item.getId())) { + throw new BizException(ErrorCode.PARAM_ERROR, "销假时间段与请假时间段不匹配"); + } + backs.add(backOf(leave, item)); + } + } else { + // 不连续请假只有一条时间段:缺 id 时自动对位首行 + LeaveTimeItem single = items.get(0); + if (single.getId() == null && !timeRows.isEmpty()) { + single.setId(timeRows.get(0).getId()); + } + backs.add(backOf(leave, single)); + } + leaveReportBackRepository.deleteByLeaveId(leave.getId()); + leaveReportBackRepository.saveAll(backs); + leave.setStatus(REPORT_BACKING.getId()); + leaveRepository.save(leave); + } else { + // 教师直接销假:回写时间,状态=已销假 + applyReportBackTimes(leave, items, continuous); + leave.setStatus(REPORT_BACKED.getId()); + leaveReportBackRepository.deleteByLeaveId(leave.getId()); + leaveRepository.save(leave); + } + notifyService.onReportBack(leave, isStudent); + } + + // ============================ 统计 / 班级学生 ============================ + + @Override + public LeaveStatsVo stats(String startDate, String endDate, Integer classId, Integer leaveType) { + LeaveQuery query = new LeaveQuery(); + query.setClassId(classId); + query.setLeaveType(leaveType); + query.setStartDate(startDate); + query.setEndDate(endDate); + List leaves = leaveRepository.findAll(listSpec(query)).stream() + .filter(l -> !WITHDRAWN.getId().equals(l.getStatus())) + .collect(Collectors.toList()); + + LeaveStatsVo vo = new LeaveStatsVo(); + vo.setTotalCount(leaves.size()); + Set students = new HashSet<>(); + Map byClass = new LinkedHashMap<>(); + Map byType = new LinkedHashMap<>(); + Map byDate = new LinkedHashMap<>(); + Map dateCount = new LinkedHashMap<>(); + + for (Leave leave : leaves) { + students.add(leave.getStuId()); + if (PENDING.getId().equals(leave.getStatus())) { + vo.setPendingCount(vo.getPendingCount() + 1); + } + LeaveStatsVo.ClassDist cd = byClass.computeIfAbsent(leave.getClassId(), k -> { + LeaveStatsVo.ClassDist d = new LeaveStatsVo.ClassDist(); + d.setClassId(k); + d.setClassName(leave.getClassName()); + return d; + }); + cd.setCount(cd.getCount() + 1); + LeaveStatsVo.TypeDist td = byType.computeIfAbsent(leave.getLeaveType(), k -> { + LeaveStatsVo.TypeDist d = new LeaveStatsVo.TypeDist(); + d.setLeaveType(k); + d.setLeaveTypeName(leaveTypeName(leave)); + return d; + }); + td.setCount(td.getCount() + 1); + + // 日期分布按请假时间段覆盖天数展开(单次请假 ≤30 天,安全) + for (LeaveTime row : leaveTimeRepository.findByLeaveIdOrderByStartDateAsc(leave.getId())) { + LocalDate s = parseDay(row.getStartDate()); + LocalDate e = parseDay(row.getEndDate()); + for (LocalDate d = s; d != null && e != null && !d.isAfter(e); d = d.plusDays(1)) { + dateCount.merge(d, 1, Integer::sum); + } + } + } + // 班级名统一「年级名+班级名」:历史记录落库为纯班级名,跨年级重名无法区分 + Map classNames = rosterService.classDisplayNames( + byClass.keySet().stream().filter(Objects::nonNull).collect(Collectors.toSet())); + byClass.values().forEach(cd -> { + String name = classNames.get(cd.getClassId()); + if (!StringUtils.hasText(name)) { + name = StringUtils.hasText(cd.getClassName()) + ? cd.getClassName() : String.valueOf(cd.getClassId()); + } + cd.setClassName(name); + }); + + // 班级去重学生数 + Map> classStudents = new LinkedHashMap<>(); + for (Leave leave : leaves) { + classStudents.computeIfAbsent(leave.getClassId(), k -> new HashSet<>()).add(leave.getStuId()); + } + byClass.values().forEach(cd -> { + Set set = classStudents.get(cd.getClassId()); + cd.setStudentCount(set == null ? 0 : set.size()); + }); + + vo.setStudentCount(students.size()); + vo.setByClass(new ArrayList<>(byClass.values())); + vo.setByLeaveType(new ArrayList<>(byType.values())); + dateCount.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(e -> { + LeaveStatsVo.DateDist dd = new LeaveStatsVo.DateDist(); + dd.setDate(e.getKey().format(DAY)); + dd.setCount(e.getValue()); + vo.getByDate().add(dd); + }); + return vo; + } + + /** + * 可选班级(下拉):班级名统一带年级(如「高一1班」),避免多个年级的「1班」重名难辨。 + * 学生=本人正常班级;校管理员=全校班级;教师=本人担任班主任的班级。 + */ + @Override + public List myClasses() { + if (isStudent()) { + // 仅正常班级:学生名册可能残留已毕业/已删除班级(下拉显示班级ID),不得出现在申请入口 + return myNormalRoster().stream() + .map(e -> classVo(e.classId, e.className)) + .collect(Collectors.toList()); + } + Integer schId = requireSchId(); + Integer userId = requireUserId(); + if (isSchoolAdmin()) { + // 校管理员:全校班级 + List all = classService.getSchClassMasterList(schId); + if (!all.isEmpty()) { + return all.stream() + .filter(c -> c != null && c.getClassId() != null) + .map(c -> classVo(c.getClassId(), RosterService.displayName(c.getGradeName(), c.className()))) + .collect(Collectors.toList()); + } + // 兼容上游无班级(含班主任)接口时的降级:全校班级列表(无班主任信息) + List classes = classService.getSchClassList(schSerId(schId)).stream() + .filter(c -> c != null && c.getClassId() != null) + .collect(Collectors.toList()); + Map names = rosterService.classDisplayNames( + classes.stream().map(SchClassDto::getClassId).collect(Collectors.toSet())); + return classes.stream() + .map(c -> classVo(c.getClassId(), names.getOrDefault(c.getClassId(), c.getClassName()))) + .collect(Collectors.toList()); + } + // 教师:本人担任班主任的班级(与数据范围一致) + return classService.getSchClassMasterList(schId).stream() + .filter(c -> c != null && c.getClassId() != null && userId.equals(c.getFirstTeacherId())) + .map(c -> classVo(c.getClassId(), RosterService.displayName(c.getGradeName(), c.className()))) + .collect(Collectors.toList()); + } + + /** 班级下拉项:班级名缺失回退班级ID */ + private static TeachingClassVo classVo(Integer classId, String className) { + TeachingClassVo vo = new TeachingClassVo(); + vo.setClassId(classId); + vo.setClassName(StringUtils.hasText(className) ? className : String.valueOf(classId)); + return vo; + } + + @Override + public List classStudents(Integer classId) { + if (classId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "班级不能为空"); + } + if (isStudent()) { + throw new BizException(ErrorCode.FORBIDDEN, "学生无权查看班级名单"); + } + if (!inScope(classId)) { + throw new BizException(ErrorCode.FORBIDDEN, "该班级不在您的数据范围内"); + } + return rosterService.roster(Collections.singletonList(classId)).stream().map(e -> { + ClassStudentVo vo = new ClassStudentVo(); + vo.setStuId(e.studentId); + vo.setStuName(e.studentName); + vo.setClassId(e.classId); + vo.setClassName(e.className); + return vo; + }).collect(Collectors.toList()); + } + + // ============================ 内部:身份与数据范围 ============================ + + /** 当前登录人是否学生(bschclass 学生名册实时匹配;上游异常按教师处理,由范围兜底拦截) */ + private boolean isStudent() { + return !myRosterOrNull().isEmpty(); + } + + /** + * 正常班级名册条目(学生名册 ∩ 学校正常班级 = 未毕业且仍存在的班级)。 + * 申请等写入口一律用本方法,避免落到历史遗留班级(下拉只会显示班级ID)。 + */ + private List myNormalRoster() { + return rosterService.myNormalRoster(); + } + + private List myRosterOrNull() { + try { + return rosterService.mine(); + } catch (BizException e) { + return Collections.emptyList(); + } + } + + /** 班级须为学校正常班级(未毕业且仍存在);上游取数失败时放行,避免外呼异常阻断业务 */ + private void requireNormalClass(Integer classId) { + Map normal; + try { + normal = rosterService.normalClasses(); + } catch (BizException e) { + log.warn("[requireNormalClass] 正常班级列表取数失败, 放行校验: classId={}, {}", classId, e.getMessage()); + return; + } + if (!normal.containsKey(classId)) { + throw new BizException(ErrorCode.PARAM_ERROR, "所选班级不是正常班级(已毕业或已删除),请重新选择"); + } + } + + /** + * 教师数据范围班级ID集合(班主任制):null=不受限(校管理员);否则为本人担任 + * 班主任的班级(bschclass /oapi/sch/getSchClassList 的 firstTeacherId 实时匹配)。 + * 学生请假的审批/登记/查看均限定班主任(计划决策:学生请假需班主任通过或拒绝)。 + */ + private Set scopeClassIds() { + if (isSchoolAdmin()) { + return null; + } + Integer userId = requireUserId(); + Integer schId = requireSchId(); + Set classIds = new HashSet<>(); + try { + for (SchClassMasterDto c : classService.getSchClassMasterList(schId)) { + if (c != null && c.getClassId() != null && userId.equals(c.getFirstTeacherId())) { + classIds.add(c.getClassId()); + } + } + } catch (Exception e) { + log.warn("[scopeClassIds] 班主任班级拉取失败, 退化为空范围: {}", e.getMessage()); + } + return classIds; + } + + private boolean isSchoolAdmin() { + try { + BoaUser user = currentBoaUser == null ? null : currentBoaUser.get(); + return user != null && USER_TYPE_SCHOOL_ADMIN.equals(user.getType()); + } catch (Exception e) { + return false; + } + } + + /** 班级是否在当前登录人数据范围内(校管理员不限范围,见 {@link #scopeClassIds()}) */ + private boolean inScope(Integer classId) { + Set scope = scopeClassIds(); + return scope == null || scope.contains(classId); + } + + private void authorize(Leave leave, boolean isStudent) { + if (isStudent) { + if (!Objects.equals(leave.getStuId(), UserContext.getUserId())) { + throw new BizException(ErrorCode.FORBIDDEN, "只能查看本人的请假记录"); + } + return; + } + if (!inScope(leave.getClassId())) { + throw new BizException(ErrorCode.FORBIDDEN, "该请假不在您的数据范围内"); + } + } + + private Integer requireUserId() { + Integer userId = UserContext.getUserId(); + if (userId == null) { + throw new BizException(ErrorCode.FORBIDDEN, "未获取到登录身份"); + } + return userId; + } + + private Integer requireSchId() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + throw new BizException(ErrorCode.FORBIDDEN, "未获取到学校身份"); + } + return schId; + } + + private String schSerId(Integer schId) { + return classService.getSchFirst(schId).getSchSerId(); + } + + /** 班级内指定学生名册条目 */ + private RosterEntry rosterEntry(Integer classId, Integer stuId) { + for (RosterEntry e : rosterService.roster(Collections.singletonList(classId))) { + if (Objects.equals(e.studentId, stuId)) { + return e; + } + } + return null; + } + + // ============================ 内部:校验与工具 ============================ + + /** 列表/统计共用查询条件(含身份范围收敛) */ + private Specification listSpec(LeaveQuery query) { + Integer schId = requireSchId(); + boolean isStudent = isStudent(); + Set scope = isStudent ? null : scopeClassIds(); + String overlapStart = StringUtils.hasText(query.getStartDate()) + ? normalizeDay(query.getStartDate(), false) : null; + String overlapEnd = StringUtils.hasText(query.getEndDate()) + ? normalizeDay(query.getEndDate(), true) : null; + String keyword = StringUtils.hasText(query.getKeyword()) + ? query.getKeyword().trim() : null; + + return (root, q, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("schId"), schId)); + if (isStudent) { + predicates.add(cb.equal(root.get("stuId"), UserContext.getUserId())); + } else if (scope != null) { + if (scope.isEmpty()) { + // 无任教班级:恒false,返回空页 + predicates.add(cb.disjunction()); + } else if (query.getClassId() == null) { + predicates.add(root.get("classId").in(scope)); + } + } + if (query.getClassId() != null) { + predicates.add(cb.equal(root.get("classId"), query.getClassId())); + } + if (query.getStuId() != null) { + predicates.add(cb.equal(root.get("stuId"), query.getStuId())); + } + if (query.getStatus() != null) { + predicates.add(cb.equal(root.get("status"), query.getStatus())); + } + if (query.getLeaveType() != null) { + predicates.add(cb.equal(root.get("leaveType"), query.getLeaveType())); + } + if (query.getType() != null) { + predicates.add(cb.equal(root.get("type"), query.getType())); + } + if (overlapStart != null && overlapEnd != null) { + // 时间段重叠:leave.end >= 范围起 && leave.start <= 范围止(固定格式字符串比较) + predicates.add(cb.greaterThanOrEqualTo(root.get("endDate"), overlapStart)); + predicates.add(cb.lessThanOrEqualTo(root.get("startDate"), overlapEnd)); + } + if (keyword != null) { + String like = "%" + keyword + "%"; + predicates.add(cb.or( + cb.like(root.get("stuName"), like), + cb.like(root.get("remarks"), like))); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + } + + /** + * 时间冲突校验(历史公式:(s>=x.s || e>x.s) && (s items) { + List exists = leaveTimeRepository.findByStuId(stuId); + if (!exists.isEmpty()) { + Map leaveById = leaveRepository.findAllById( + exists.stream().map(LeaveTime::getLeaveId).collect(Collectors.toList())) + .stream().collect(Collectors.toMap(Leave::getId, l -> l, (a, b) -> a)); + exists.removeIf(t -> { + Leave owner = leaveById.get(t.getLeaveId()); + return owner == null || WITHDRAWN.getId().equals(owner.getStatus()); + }); + } + List backs = leaveReportBackRepository.findByStuId(stuId); + + List conflicts = new ArrayList<>(); + for (LeaveTimeItem item : items) { + for (LeaveTime t : exists) { + if (overlaps(item.getStartDate(), item.getEndDate(), t.getStartDate(), t.getEndDate())) { + conflicts.add(LeaveTimeText.range(t.getStartDate(), t.getEndDate())); + } + } + for (LeaveReportBack b : backs) { + if (overlaps(item.getStartDate(), item.getEndDate(), b.getStartDate(), b.getEndDate())) { + conflicts.add(LeaveTimeText.range(b.getStartDate(), b.getEndDate())); + } + } + } + if (!conflicts.isEmpty()) { + List distinct = conflicts.stream().distinct().collect(Collectors.toList()); + throw new BizException(ErrorCode.PARAM_ERROR, "该请假已存在," + String.join(",", distinct)); + } + } + + private boolean overlaps(String s, String e, String xs, String xe) { + return (s.compareTo(xs) >= 0 || e.compareTo(xs) > 0) && (s.compareTo(xe) < 0 || e.compareTo(xe) <= 0); + } + + /** 起止基础校验:开始 < 结束、跨度 ≤30 天 */ + private void validateRange(String start, String end) { + if (start.compareTo(end) >= 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "请假开始时间不能大于或等于结束时间"); + } + LocalDate s = parseDay(start); + LocalDate e = parseDay(end); + if (s == null || e == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "时间格式错误"); + } + if (ChronoUnit.DAYS.between(s, e) > 30) { + throw new BizException(ErrorCode.PARAM_ERROR, "请假日期范围不能超过30天"); + } + } + + /** 日期时间规范化:仅日期补 00:00:00(起)/23:59:59(止);缺秒补全 */ + private String normalizeDateTime(String raw, boolean end) { + if (!StringUtils.hasText(raw)) { + throw new BizException(ErrorCode.PARAM_ERROR, "时间不能为空"); + } + String s = raw.trim().replace("T", " "); + if (s.length() == 10) { + return s + (end ? " 23:59:59" : " 00:00:00"); + } + if (s.length() == 16) { + return s + (end ? ":59" : ":00"); + } + return s; + } + + /** 日期规范化为 19 位定长(列表重叠比较用) */ + private String normalizeDay(String raw, boolean end) { + return normalizeDateTime(raw.length() == 10 ? raw : raw.substring(0, 10), end); + } + + private LocalDate parseDay(String dateTime) { + try { + return LocalDate.parse(dateTime.substring(0, 10), DAY); + } catch (Exception e) { + return null; + } + } + + /** + * 请假类型校验:sys_dict(leave_type) 启用项;字典完全未配置时回退固定枚举(1/2)。 + */ + /** 取异常链最底层的真实原因文本(MySQL 错误在链尾) */ + private static String rootMessage(Throwable e) { + Throwable r = e; + while (r.getCause() != null && r.getCause() != r) { + r = r.getCause(); + } + return r.getClass().getSimpleName() + ": " + r.getMessage(); + } + + private boolean isValidLeaveType(Integer leaveType) { + if (leaveType == null) { + return false; + } + try { + List> options = dictService.getLeaveTypeOptions(); + if (!options.isEmpty()) { + String key = String.valueOf(leaveType); + return options.stream().anyMatch(o -> key.equals(String.valueOf(o.get("k")))); + } + } catch (Exception e) { + // 字典查询异常不阻断提交:回退固定枚举,并把最底层原因打出来便于排查 + log.error("[isValidLeaveType] 请假类型字典查询失败, 回退枚举校验. cause={}", rootMessage(e), e); + } + return LeaveTypeEnum.isValid(leaveType); + } + + /** 请假类型名称:sys_dict 优先,未命中回退固定枚举名(字典查询异常同样回退,不阻断列表) */ + private String leaveTypeName(Leave leave) { + try { + String name = dictService.resolveLeaveTypeName(leave.getLeaveType(), leave.getSchId()); + if (name != null) { + return name; + } + } catch (Exception e) { + log.error("[leaveTypeName] 请假类型字典查询失败, 回退枚举名. leaveType={}, cause={}", + leave.getLeaveType(), rootMessage(e), e); + } + return LeaveTypeEnum.nameById(leave.getLeaveType()); + } + + /** 销假时间段回传规范化(不连续合成单条;连续要求与请假时间段一一对应) */ + private List normalizedReportBackItems(LeaveReportBackRequest request, boolean continuous) { + List items = new ArrayList<>(); + if (continuous) { + if (request.getContinuousLeaveTime() == null || request.getContinuousLeaveTime().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请填写销假时间段"); + } + for (LeaveTimeItem item : request.getContinuousLeaveTime()) { + LeaveTimeItem n = new LeaveTimeItem(); + n.setId(item.getId()); + n.setStartDate(normalizeDateTime(item.getStartDate(), false)); + n.setEndDate(normalizeDateTime(item.getEndDate(), true)); + items.add(n); + } + } else { + LeaveTimeItem n = new LeaveTimeItem(); + n.setStartDate(normalizeDateTime(request.getStartDate(), false)); + n.setEndDate(normalizeDateTime(request.getEndDate(), true)); + items.add(n); + } + return items; + } + + private LeaveReportBack backOf(Leave leave, LeaveTimeItem item) { + LeaveReportBack back = new LeaveReportBack(); + back.setLeaveId(leave.getId()); + back.setLeaveTimeId(item.getId()); + back.setStuId(leave.getStuId()); + back.setStartDate(item.getStartDate()); + back.setEndDate(item.getEndDate()); + return back; + } + + /** 教师直接销假 / 审批通过销假:把销假时间回写主表与时间段表 */ + /** + * 销假回写实际时间(历史口径,支持部分销假): + * 连续请假时仅回写提交的时间段,未提交(提前返校)的段从 leave_time 删除, + * 主表起止更新为剩余段的最早/最晚时间,连续次数同步为剩余段数;至少保留一段。 + */ + private void applyReportBackTimes(Leave leave, List items, boolean continuous) { + if (items == null || items.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "销假时间段不能为空"); + } + List timeRows = leaveTimeRepository.findByLeaveIdOrderByStartDateAsc(leave.getId()); + if (!continuous) { + LeaveTimeItem single = items.get(0); + leave.setStartDate(single.getStartDate()); + leave.setEndDate(single.getEndDate()); + if (!timeRows.isEmpty()) { + timeRows.get(0).setStartDate(single.getStartDate()); + timeRows.get(0).setEndDate(single.getEndDate()); + leaveTimeRepository.save(timeRows.get(0)); + } + } else { + Map byId = timeRows.stream() + .collect(Collectors.toMap(LeaveTime::getId, t -> t, (a, b) -> a)); + Set keptIds = new HashSet<>(); + String minStart = null; + String maxEnd = null; + for (LeaveTimeItem item : items) { + LeaveTime row = item.getId() == null ? null : byId.get(item.getId()); + if (row == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "销假时间段与请假时间段不匹配"); + } + row.setStartDate(item.getStartDate()); + row.setEndDate(item.getEndDate()); + leaveTimeRepository.save(row); + keptIds.add(row.getId()); + if (minStart == null || item.getStartDate().compareTo(minStart) < 0) { + minStart = item.getStartDate(); + } + if (maxEnd == null || item.getEndDate().compareTo(maxEnd) > 0) { + maxEnd = item.getEndDate(); + } + } + // 部分销假:删除未提交(提前返校)的时间段 + List removeIds = timeRows.stream() + .map(LeaveTime::getId) + .filter(id -> !keptIds.contains(id)) + .collect(Collectors.toList()); + if (!removeIds.isEmpty()) { + if (keptIds.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "销假不能删除全部时间段,至少保留一段"); + } + leaveTimeRepository.deleteAllById(removeIds); + } + leave.setStartDate(minStart); + leave.setEndDate(maxEnd); + leave.setContinuousLeaveNum(keptIds.size()); + } + } + + /** 通过销假:回写时间 → 已销假,清销假记录 */ + private void passReportBack(Leave leave) { + List backs = leaveReportBackRepository.findByLeaveIdOrderByStartDateAsc(leave.getId()); + if (backs.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "该请假无销假申请"); + } + List items = backs.stream().map(b -> { + LeaveTimeItem item = new LeaveTimeItem(); + item.setId(b.getLeaveTimeId()); + item.setStartDate(b.getStartDate()); + item.setEndDate(b.getEndDate()); + return item; + }).collect(Collectors.toList()); + applyReportBackTimes(leave, items, Objects.equals(leave.getContinuousLeave(), 1)); + leave.setStatus(REPORT_BACKED.getId()); + leaveReportBackRepository.deleteByLeaveId(leave.getId()); + } + + /** 拒绝销假:清销假记录 → 销假拒绝 */ + private void refuseReportBack(Leave leave) { + leaveReportBackRepository.deleteByLeaveId(leave.getId()); + leave.setStatus(REPORT_BACK_REJECTED.getId()); + } + + // ============================ 内部:VO 组装 ============================ + + private LeaveVo toVo(Leave leave) { + LeaveVo vo = new LeaveVo(); + copyVo(leave, vo); + return vo; + } + + private void copyVo(Leave leave, LeaveVo vo) { + vo.setId(leave.getId()); + vo.setClassId(leave.getClassId()); + vo.setClassName(leave.getClassName()); + vo.setStuId(leave.getStuId()); + vo.setStuName(leave.getStuName()); + vo.setStuPhoto(leave.getStuPhoto()); + vo.setType(leave.getType()); + vo.setTypeName(LeaveRegisterTypeEnum.nameById(leave.getType())); + vo.setLeaveType(leave.getLeaveType()); + vo.setLeaveTypeName(leaveTypeName(leave)); + vo.setStartDate(leave.getStartDate()); + vo.setEndDate(leave.getEndDate()); + vo.setContinuousLeave(leave.getContinuousLeave()); + vo.setContinuousLeaveNum(leave.getContinuousLeaveNum()); + vo.setStatus(leave.getStatus()); + vo.setStatusName(LeaveStatusEnum.nameById(leave.getStatus())); + vo.setRemarks(leave.getRemarks()); + vo.setCreateTime(leave.getCreateTime() == null ? null : leave.getCreateTime().format(DATETIME)); + } + + private List timeItems(List rows) { + return rows.stream().map(t -> { + LeaveTimeItem item = new LeaveTimeItem(); + item.setId(t.getId()); + item.setStartDate(t.getStartDate()); + item.setEndDate(t.getEndDate()); + return item; + }).collect(Collectors.toList()); + } + + /** + * 按钮状态:0 不显示;1 审核;2 销假/撤销;3 撤销/销假审批。 + * 学生:0/1/4/7 → 2(销假或撤销入口);教师:type=1&1 → 2,type=2:0→1,2/5→3。 + */ + private Integer btnStatus(boolean isStudent, Leave leave) { + if (isStudent) { + switch (leave.getStatus()) { + case 0: + case 1: + case 4: + case 7: + return 2; + default: + return 0; + } + } + if (Objects.equals(leave.getType(), LeaveRegisterTypeEnum.TEACHER.getId()) + && APPROVED.getId().equals(leave.getStatus())) { + return 2; + } + if (Objects.equals(leave.getType(), LeaveRegisterTypeEnum.STUDENT.getId())) { + switch (leave.getStatus()) { + case 0: + return 1; + case 2: + case 5: + return 3; + default: + return 0; + } + } + return 0; + } +} diff --git a/src/main/java/com/yunhai/homeleave/service/impl/RosterService.java b/src/main/java/com/yunhai/homeleave/service/impl/RosterService.java new file mode 100644 index 0000000..5fd8750 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/service/impl/RosterService.java @@ -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)。 + *

班级显示名统一为「年级名+班级名」(如「高一1班」),下拉/列表/统计展示均走本口径。

+ */ +@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 roster(List classIds) { + if (classIds == null || classIds.isEmpty()) { + return new ArrayList<>(); + } + Map names = classDisplayNames(classIds); + Map 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 studentCounts(List classIds) { + Map counts = new LinkedHashMap<>(); + for (Integer classId : classIds == null ? Collections.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 classDisplayNames(Collection classIds) { + Map display = new LinkedHashMap<>(); + if (classIds == null || classIds.isEmpty()) { + return display; + } + Map table = classTable(false); + Map 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 normalClasses() { + Map normal = new LinkedHashMap<>(); + for (Map.Entry 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()}。 + *

身份判定({@code isStudent()})走本方法,属每请求热路径,故只查班级表、不查年级表: + * 班级名不含年级,展示用名(带年级)由 {@link #classDisplayNames(Collection)} / {@link #myNormalRoster()} 提供。

+ * + * @throws BizException 未登录 / 无学校身份 / 未匹配到学生名册 + */ + public List mine() { + List mine = mineRaw(); + Map 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 myNormalRoster() { + List mine = mineRaw(); + Map normal = classTable(true); + Map grades = gradeNames(); + List 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 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 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 classTable(boolean strict) { + Map 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 gradeNames() { + Map 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()); + } +} diff --git a/src/main/java/com/yunhai/homeleave/service/impl/SysOperationLogServiceImpl.java b/src/main/java/com/yunhai/homeleave/service/impl/SysOperationLogServiceImpl.java new file mode 100644 index 0000000..13c94d7 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/service/impl/SysOperationLogServiceImpl.java @@ -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 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 spec = (root, query, cb) -> { + List 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 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; + } + } +} diff --git a/src/main/java/com/yunhai/homeleave/service/impl/UserCenterService.java b/src/main/java/com/yunhai/homeleave/service/impl/UserCenterService.java new file mode 100644 index 0000000..2424b18 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/service/impl/UserCenterService.java @@ -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; + +/** + * 用户中心门面服务实现。 + *

原 BaseApiController.getSchoolAll 的组树组装、教师挂组、递归建树、排序逻辑整体下沉至此; + * GetGroupUser 空桩改为显式报错(未接入用户中心对应开放接口时提示,而非静默返回空列表)。 + * 跨仓调用统一收敛到 {@link YschBaseOpenApi} 门面,Controller 不再直接静态调用。

+ */ +@Service +public class UserCenterService implements IUserCenterService { + + private static final Logger log = LoggerFactory.getLogger(UserCenterService.class); + + /** 学生组(UserBase GroupTypeEnum.STUDENT_GROUP),组树中剔除 */ + private static final int GROUP_TYPE_STUDENT = 8; + + @Autowired + private IClassService classService; + + // ============================ 学校组织树 ============================ + + @Override + public List> getSchoolAllTree(Integer schId) { + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + + // 1. userbase 组树(整棵学校子树),剔除学生组 + List> flatNodes = new ArrayList<>(); + BoaGroup root = YschBaseOpenApi.getGroupById(schId, true); + if (root == null) { + log.warn("[getSchoolAll] 用户中心未找到学校组, schId={}", schId); + return Collections.emptyList(); + } + flattenGroup(root, 0, flatNodes); + + // 2. bschclass 全部教师,按 GroupIds 挂到对应组下;无有效组的兜底挂学校根节点 + Set treeGroupIds = new HashSet<>(); + for (Map node : flatNodes) { + treeGroupIds.add((Integer) node.get("id")); + } + Map teacherMap = new LinkedHashMap<>(); + Map> teacherPids = new LinkedHashMap<>(); + List teachers = classService.getSchTeaList(schId); + for (SchTeaList tea : teachers) { + if (tea.getUserTeaSerID() == null) { + continue; + } + // 同一教师多行(多组/接口重复数据)合并,按 (教师, 组) 去重 + teacherMap.putIfAbsent(tea.getUserTeaSerID(), tea); + // 接口现行字段为单个 GroupID,兼容旧字段 GroupIds + List teaGroupIds = tea.getGroupIds(); + if ((teaGroupIds == null || teaGroupIds.isEmpty()) && tea.getGroupId() != null) { + teaGroupIds = Collections.singletonList(tea.getGroupId()); + } + if (teaGroupIds != null) { + for (Integer gid : teaGroupIds) { + if (gid != null && treeGroupIds.contains(gid)) { + teacherPids.computeIfAbsent(tea.getUserTeaSerID(), k -> new HashSet<>()).add(gid); + } + } + } + } + for (Map.Entry entry : teacherMap.entrySet()) { + int teaId = entry.getKey(); + SchTeaList tea = entry.getValue(); + Set pids = teacherPids.get(teaId); + if (pids == null || pids.isEmpty()) { + // 教师所属组均被剔除(如学生组)或无组信息,挂学校根节点 + pids = new HashSet<>(Collections.singletonList(treeGroupIds.contains(schId) ? schId : 0)); + } + for (Integer pid : pids) { + Map node = new LinkedHashMap<>(); + node.put("id", teaId); + node.put("name", tea.getTeaName() != null ? tea.getTeaName() : ""); + node.put("pid", pid); + node.put("no", tea.getTeaNo() != null ? tea.getTeaNo() : 0); + node.put("type", 2); + node.put("isParent", false); + node.put("photo", ""); + flatNodes.add(node); + } + } + + // 3. 扁平节点递归组装为树 + return formatUserTree(flatNodes, 0); + } + + /** + * 将 BoaGroup 子树扁平化为组节点列表(type=1);学生组(type=8)整棵剔除。 + * isParent=true 标识组节点,前端 ztree 据此渲染为文件夹。 + */ + private void flattenGroup(BoaGroup group, int pid, List> nodes) { + if (group == null || group.getId() == null) { + return; + } + if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) { + return; + } + Map node = new LinkedHashMap<>(); + node.put("id", group.getId()); + node.put("name", group.getName() != null ? group.getName() : ""); + node.put("pid", pid); + node.put("no", group.getSort() != null ? group.getSort() : 0); + node.put("type", 1); + node.put("isParent", true); + node.put("photo", ""); + nodes.add(node); + + if (group.getChildren() != null) { + for (BoaGroup child : group.getChildren()) { + flattenGroup(child, group.getId(), nodes); + } + } + } + + /** + * 递归构建用户树(对应 C# HttpUserApi.FormatUserTree)。 + * 根层取 pid=0 或父节点不在列表中的节点;type=1 且无子节点的空组剔除。 + */ + private List> formatUserTree(List> treeList, int pid) { + List> data; + if (pid == 0) { + Set allIds = new HashSet<>(); + for (Map n : treeList) { + allIds.add((Integer) n.get("id")); + } + data = new ArrayList<>(); + for (Map n : treeList) { + int p = (Integer) n.get("pid"); + if (p == 0 || !allIds.contains(p)) { + data.add(n); + } + } + } else { + data = new ArrayList<>(); + for (Map n : treeList) { + if ((Integer) n.get("pid") == pid) { + data.add(n); + } + } + } + + List> result = new ArrayList<>(); + for (Map item : data) { + int itemId = (Integer) item.get("id"); + int itemType = (Integer) item.get("type"); + + List> children = formatUserTree(treeList, itemId); + + // type=1(组) 且无子节点 → 不加入(C# 逻辑) + if (itemType == 1 && children.isEmpty()) { + continue; + } + + Map node = new LinkedHashMap<>(item); + // 仅组节点携带 children;叶子不带该字段,避免 ztree 误判 isParent + if (itemType == 1) { + node.put("children", children); + } + result.add(node); + } + + // 按 no 排序(数字优先,无法解析的排最后) + result.sort(Comparator.comparingInt(n -> { + Object no = n.get("no"); + if (no instanceof Integer) { + return (Integer) no; + } + if (no instanceof String) { + try { + return Integer.parseInt((String) no); + } catch (NumberFormatException e) { + return 9999; + } + } + return 9999; + })); + + return result; + } + + // ============================ 组内用户 ============================ + + @Override + public List> getGroupUser(Integer gid, Integer role) { + if (gid == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "组ID不得为空"); + } + log.info("[getGroupUser] gid={}, role={}, 用户中心组内用户开放接口尚未接入", gid, role); + // 显式报错而非静默返回空列表:原空桩会让前端误以为组内无用户 + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "组内用户查询接口暂未接入用户中心,请联系管理员配置后重试"); + } + + // ============================ 用户模块树 ============================ + + @Override + public List> getUserModuleTree(Integer userId, Integer schId) { + if (userId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空"); + } + List modules = YschBaseOpenApi.getUserModule(userId, schId); + if (modules == null || modules.isEmpty()) { + // 平台菜单树尚未给本应用分配模块时,回退默认请假菜单, + // modId 契约与前端 routes.ts / menu.ts 一致;平台配置后以用户中心返回为准 + return defaultLeaveModuleTree(); + } + // 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望 + return buildModuleTree(modules); + } + + /** + * 默认请假模块菜单(平台未配置时的兜底)。 + * modId 契约:2=请假申请(学生) 3=我的请假(学生) 4=请假审批 5=请假登记 6=请假统计 7=应用设置。 + */ + private List> defaultLeaveModuleTree() { + int[] ids = {4, 5, 6, 2, 3, 7}; + String[] names = {"请假审批", "请假登记", "请假统计", "请假申请", "我的请假", "应用设置"}; + String[] pages = {"/audit", "/register", "/stats", "/apply", "/my", "/setting"}; + List> list = new ArrayList<>(); + for (int i = 0; i < ids.length; i++) { + Map 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> buildModuleTree(List modules) { + Map moduleMap = modules.stream() + .collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a)); + + // 每个模块节点(含 children 占位) + Map> nodeMap = new LinkedHashMap<>(); + for (BoaModule m : modules) { + Map node = new LinkedHashMap<>(); + node.put("id", m.getId()); + node.put("modId", m.getId()); + node.put("name", m.getName()); + node.put("parentId", m.getParentId() != null ? m.getParentId() : 0); + node.put("page", m.getPath() != null ? m.getPath() : ""); + node.put("level", m.getLevel() != null ? m.getLevel() : 0); + node.put("icon", m.getIcon() != null ? m.getIcon() : ""); + node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0); + node.put("usable", true); + node.put("children", new ArrayList<>()); + nodeMap.put(m.getId(), node); + } + + // 挂父子关系 + List> roots = new ArrayList<>(); + for (BoaModule m : modules) { + Map node = nodeMap.get(m.getId()); + int parentId = m.getParentId() != null ? m.getParentId() : 0; + if (parentId == 0 || !moduleMap.containsKey(parentId)) { + roots.add(node); + } else { + @SuppressWarnings("unchecked") + List> children = (List>) nodeMap.get(parentId).get("children"); + children.add(node); + } + } + + // 保持 SDK(用户中心)返回顺序,不再按 orderNumber 重排 + return roots; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/homeleave/utils/LeaveTimeText.java b/src/main/java/com/yunhai/homeleave/utils/LeaveTimeText.java new file mode 100644 index 0000000..05feb9d --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/utils/LeaveTimeText.java @@ -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; + } +} diff --git a/src/main/java/com/yunhai/homeleave/utils/LogGzipUtils.java b/src/main/java/com/yunhai/homeleave/utils/LogGzipUtils.java new file mode 100644 index 0000000..c764d8c --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/utils/LogGzipUtils.java @@ -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; + } + } +} diff --git a/src/main/java/com/yunhai/homeleave/utils/LogMaskUtils.java b/src/main/java/com/yunhai/homeleave/utils/LogMaskUtils.java new file mode 100644 index 0000000..2ce6586 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/utils/LogMaskUtils.java @@ -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"); + } +} diff --git a/src/main/java/com/yunhai/homeleave/utils/PageUtils.java b/src/main/java/com/yunhai/homeleave/utils/PageUtils.java new file mode 100644 index 0000000..6896475 --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/utils/PageUtils.java @@ -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 BasePageResult toResult(Page page) { + return new BasePageResult( + page.getContent(), + (int) page.getTotalElements(), + page.getNumber() + 1, + page.getSize(), + page.getTotalPages(), + page.hasNext() + ); + } +} diff --git a/src/main/java/com/yunhai/homeleave/utils/UserContext.java b/src/main/java/com/yunhai/homeleave/utils/UserContext.java new file mode 100644 index 0000000..0354cbc --- /dev/null +++ b/src/main/java/com/yunhai/homeleave/utils/UserContext.java @@ -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})。 + *

+ * 三个核心取值(userId/userName/schId)在首次访问时从平台登录态 + * ({@link UserContextHolder} + {@link CurrentBoaUser})惰性解析并在本请求内缓存; + * 线程复用清理由 {@code UserContextConfig} 注册的拦截器在 afterCompletion 统一执行。 + */ +public class UserContext { + + private static final Logger log = LoggerFactory.getLogger(UserContext.class); + + private static final ThreadLocal USER_ID = new ThreadLocal<>(); + private static final ThreadLocal USER_NAME = new ThreadLocal<>(); + private static final ThreadLocal ROLE_ID = new ThreadLocal<>(); + private static final ThreadLocal SCH_ID = new ThreadLocal<>(); + private static final ThreadLocal IP = new ThreadLocal<>(); + private static final ThreadLocal TYPE = new ThreadLocal<>(); + + /** 平台 BoaUser 供应器(Spring 启动时注入,见 UserContextConfig) */ + private static CurrentBoaUser currentBoaUser; + + /** 账号→真实姓名缓存:姓名属低频变更数据,进程内缓存避免同账号反复外呼用户中心 */ + private static final Map ACCOUNT_NAME_CACHE = new ConcurrentHashMap<>(); + + /** 注入平台 BoaUser 供应器(启动期调用一次) */ + public static void init(CurrentBoaUser boaUserSupplier) { + currentBoaUser = boaUserSupplier; + } + + public static void setUserId(Integer userId) { + USER_ID.set(userId); + } + + /** + * 当前登录人ID(用户中心数字ID,即教师 teaId / 学生 stuId 同域标识)。 + * {@code LoginUser.userId} 为数字时直取;非数字(平台默认 DefaultLoginUserProvider + * 会把登录名放进 userId)时回退用户中心按登录名反查。无登录态或解析不到为 null。 + */ + public static Integer getUserId() { + Integer userId = USER_ID.get(); + if (userId == null) { + userId = resolveUserId(); + if (userId != null) { + USER_ID.set(userId); + } + } + return userId; + } + + public static void setUserName(String userName) { + USER_NAME.set(userName); + } + + /** + * 当前登录人姓名:会话 displayName 有效且不等于登录名时直取; + * 缺失或等于登录名(平台默认 Provider 的历史行为)时回退用户中心真实姓名,解析失败保持登录名。 + */ + public static String getUserName() { + String userName = USER_NAME.get(); + if (userName == null) { + userName = resolveUserName(); + if (userName != null) { + USER_NAME.set(userName); + } + } + return userName; + } + + public static void setRoleId(Integer roleId) { + ROLE_ID.set(roleId); + } + + public static Integer getRoleId() { + return ROLE_ID.get(); + } + + public static void setSchId(Integer schId) { + SCH_ID.set(schId); + } + + /** 当前登录人学校ID(多租户隔离:发布时填充,查询时过滤),无身份时为 null。 */ + public static Integer getSchId() { + Integer schId = SCH_ID.get(); + if (schId == null) { + BoaUser boaUser = currentBoaUser == null ? null : currentBoaUser.get(); + if (boaUser != null && boaUser.getCurrentRole() != null) { + schId = boaUser.getCurrentRole().getSchId(); + if (schId != null) { + SCH_ID.set(schId); + } + } + } + return schId; + } + + public static void setIp(String ip) { + IP.set(ip); + } + + public static String getIp() { + return IP.get(); + } + + public static void setType(Integer type) { + TYPE.set(type); + } + + public static Integer getType() { + return TYPE.get(); + } + + @Value + public static class UserInfo { + Integer userId; + String userName; + Integer roleId; + Integer schId; + String ip; + Integer type; + } + + public static UserInfo getCurrentUser() { + return new UserInfo( + getUserId(), + getUserName(), + ROLE_ID.get(), + getSchId(), + IP.get(), + TYPE.get() + ); + } + + public static void setUserInfo(UserInfo info) { + if (info == null) return; // 或抛异常 + USER_ID.set(info.getUserId()); + USER_NAME.set(info.getUserName()); + ROLE_ID.set(info.getRoleId()); + SCH_ID.set(info.getSchId()); + IP.set(info.getIp()); + TYPE.set(info.getType()); + } + + public static Runnable wrap(Runnable task) { + UserInfo info = getCurrentUser(); + return () -> { + try { + setUserInfo(info); + task.run(); + } finally { + clear(); + } + }; + } + + /** 清理线程上下文(请求结束后必须调用,避免线程复用串号) */ + public static void clear() { + USER_ID.remove(); + USER_NAME.remove(); + ROLE_ID.remove(); + SCH_ID.remove(); + IP.remove(); + TYPE.remove(); + } + + private static Integer resolveUserId() { + LoginUser user = UserContextHolder.get(); + if (user == null || !StringUtils.hasText(user.getUserId())) { + return null; + } + String raw = user.getUserId().trim(); + try { + return Integer.valueOf(raw); + } catch (NumberFormatException ignored) { + // 平台默认 Provider 把登录名放进 userId(非数字):回退用户中心按账号反查数字ID + } + BoaUser boa = userCenterByAccount(raw); + return boa == null ? null : boa.getId(); + } + + private static String resolveUserName() { + LoginUser user = UserContextHolder.get(); + if (user == null) { + return null; + } + String loginName = user.getLoginName(); + String display = user.getDisplayName(); + if (!StringUtils.hasText(display) || display.equals(loginName)) { + return cachedUserName(loginName); + } + return display; + } + + /** 按登录账号取用户中心资料;数字型账号(历史落库的教师ID形态)改按ID查询 */ + private static BoaUser userCenterByAccount(String account) { + try { + BoaUser boa = YschBaseOpenApi.getUserInfo(account); + if ((boa == null || !StringUtils.hasText(boa.getName())) && isNumericId(account)) { + boa = YschBaseOpenApi.getUserInfo(Integer.valueOf(account)); + } + return boa; + } catch (Exception e) { + log.warn("[UserContext] 用户中心按账号反查失败, account={}", account); + return null; + } + } + + /** 按账号取用户中心真实姓名,查不到回退账号本身(结果含回退值一并缓存) */ + private static String cachedUserName(String account) { + if (!StringUtils.hasText(account)) { + return account; + } + if (ACCOUNT_NAME_CACHE.size() > 2000) { + ACCOUNT_NAME_CACHE.clear(); + } + return ACCOUNT_NAME_CACHE.computeIfAbsent(account, acc -> { + BoaUser boa = userCenterByAccount(acc); + return boa != null && StringUtils.hasText(boa.getName()) ? boa.getName() : acc; + }); + } + + /** 值是否为纯数字(用于区分「教师/学生ID」与「登录账号」两种形态) */ + private static boolean isNumericId(String value) { + if (!StringUtils.hasText(value)) { + return false; + } + for (int i = 0; i < value.length(); i++) { + if (!Character.isDigit(value.charAt(i))) { + return false; + } + } + return true; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..da41758 --- /dev/null +++ b/src/main/resources/application.yml @@ -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 \ No newline at end of file diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..da13714 --- /dev/null +++ b/src/main/resources/bootstrap.yml @@ -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 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..20612de --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,35 @@ + + + + + + + + + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET} + + + + + + + /app/logs/app.log + + /app/logs/app.%d{yyyy-MM-dd}.log + 30 + 5GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/com/yunhai/homeleave/service/RosterServiceE2EIT.java b/src/test/java/com/yunhai/homeleave/service/RosterServiceE2EIT.java new file mode 100644 index 0000000..24fb0b7 --- /dev/null +++ b/src/test/java/com/yunhai/homeleave/service/RosterServiceE2EIT.java @@ -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 上游。 + *

用假日期写入一条学生请假(学号 10061=测试学生1,名册残留旧班级 90),断言落库班级为正常班级; + * 结束时删除测试数据,不留脏记录。类名不含 Test 后缀,默认 {@code mvn test} 不执行;显式运行: + * {@code mvn -o test -Dtest=RosterServiceE2EIT}。

+ */ +@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 classes = leaveService.myClasses(); + List 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 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; + } +} diff --git a/src/test/java/com/yunhai/homeleave/service/impl/LeaveServiceImplClassCheckTest.java b/src/test/java/com/yunhai/homeleave/service/impl/LeaveServiceImplClassCheckTest.java new file mode 100644 index 0000000..07eee7a --- /dev/null +++ b/src/test/java/com/yunhai/homeleave/service/impl/LeaveServiceImplClassCheckTest.java @@ -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 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 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 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 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 normalClassMap(Integer classId, String name) { + Map 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; + } +} diff --git a/src/test/java/com/yunhai/homeleave/service/impl/LeaveServiceImplConflictTextTest.java b/src/test/java/com/yunhai/homeleave/service/impl/LeaveServiceImplConflictTextTest.java new file mode 100644 index 0000000..ec72587 --- /dev/null +++ b/src/test/java/com/yunhai/homeleave/service/impl/LeaveServiceImplConflictTextTest.java @@ -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"))); + } +} diff --git a/src/test/java/com/yunhai/homeleave/service/impl/LeaveServiceImplStatsTest.java b/src/test/java/com/yunhai/homeleave/service/impl/LeaveServiceImplStatsTest.java new file mode 100644 index 0000000..43e8092 --- /dev/null +++ b/src/test/java/com/yunhai/homeleave/service/impl/LeaveServiceImplStatsTest.java @@ -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 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 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 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; + } +} diff --git a/src/test/java/com/yunhai/homeleave/service/impl/RosterServiceLiveIT.java b/src/test/java/com/yunhai/homeleave/service/impl/RosterServiceLiveIT.java new file mode 100644 index 0000000..c8c3746 --- /dev/null +++ b/src/test/java/com/yunhai/homeleave/service/impl/RosterServiceLiveIT.java @@ -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,需外网)。 + *

类名不含 Test 后缀,默认 {@code mvn test} 不执行;显式运行: + * {@code mvn -o test -Dtest=RosterServiceLiveIT}。用于改动后确认真实上游数据下的取数口径。

+ */ +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 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 all = rosterService.mine(); + List 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 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 classes = classService.getSchClassList(classService.getSchFirst(SCH_ID).getSchSerId()); + assertNotNull(classes); + List ids = classes.stream().map(SchClassDto::getClassId).collect(Collectors.toList()); + assertFalse(ids.contains(90), "学校班级表不应含 90"); + List students = classService.getSchStuList(classService.getSchFirst(SCH_ID).getSchSerId()); + assertTrue(students.stream().anyMatch(s -> s.getClassId() != null && s.getClassId() == 90), + "学生名册仍残留 90(上游数据脏点,本次修复的根因)"); + } +} diff --git a/src/test/java/com/yunhai/homeleave/service/impl/RosterServiceTest.java b/src/test/java/com/yunhai/homeleave/service/impl/RosterServiceTest.java new file mode 100644 index 0000000..86e2cff --- /dev/null +++ b/src/test/java/com/yunhai/homeleave/service/impl/RosterServiceTest.java @@ -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 mine = rosterService.mine(); + + assertEquals(2, mine.size()); + List 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 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 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 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; + } +} diff --git a/src/test/java/com/yunhai/homeleave/utils/LeaveTimeTextTest.java b/src/test/java/com/yunhai/homeleave/utils/LeaveTimeTextTest.java new file mode 100644 index 0000000..93308c0 --- /dev/null +++ b/src/test/java/com/yunhai/homeleave/utils/LeaveTimeTextTest.java @@ -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")); + } +}