@@ -0,0 +1,5 @@
|
||||
target/
|
||||
.idea/
|
||||
*.iml
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,52 @@
|
||||
# WorkWeekPlan 生产配置(线上以 nacos 的 work-week-plan.yaml 为准,本文件是 nacos 不可达时的兜底)
|
||||
# 真正上线请在 Nacos 新建 dataId=work-week-plan.yaml、group=OA_APP 的配置覆盖以下项
|
||||
# (尤其 datasource 的库名/账号、yh.sso.client-host-url)。
|
||||
server:
|
||||
port: 8070
|
||||
servlet:
|
||||
# 对外经 caddy 暴露为 http://base.yunhaixingtong.com/work-week-plan
|
||||
context-path: /work-week-plan
|
||||
|
||||
spring:
|
||||
mvc:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
jackson:
|
||||
time-zone: GMT+8
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
datasource:
|
||||
# 与 UserBase 共用同一台 mysql8;库名为 bsch_work_week_plan(需先在 mysql8 上建库)
|
||||
url: jdbc:mysql://mysql8:3306/bsch_work_week_plan?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: 'CVf^x$sW*b6CGHxh'
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
min-idle: 10
|
||||
max-active: 30
|
||||
jpa:
|
||||
database: MySQL
|
||||
show-sql: false
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
|
||||
properties:
|
||||
hibernate.order_by.default_null_ordering: last
|
||||
open-in-view: true
|
||||
|
||||
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
|
||||
yh:
|
||||
sso:
|
||||
server-url-prefix: http://sso.yunhaixingtong.com
|
||||
client-host-url: http://base.yunhaixingtong.com/work-week-plan
|
||||
allow-headers: all
|
||||
allow-origins: all
|
||||
url-pattern: /api/*
|
||||
app-auth:
|
||||
enabled: true
|
||||
# 本应用自身身份(客户端签名用)
|
||||
app-key: 100617
|
||||
app-secret: 2a3b4c5d6e7f8091a2b3c4d5e6f70819
|
||||
# 受信应用(服务端验签用):appKey -> appSecret
|
||||
trusted-apps:
|
||||
100617: 2a3b4c5d6e7f8091a2b3c4d5e6f70819
|
||||
|
||||
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
|
||||
FILE_PATH: /data/upload
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
# WorkWeekPlan 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml)
|
||||
# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到
|
||||
# 镜像内烤好的 application-prod.yml 兜底默认。
|
||||
set -e
|
||||
|
||||
echo "[run-app] starting WorkWeekPlan (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..."
|
||||
exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/app.jar \
|
||||
--spring.profiles.active=prod
|
||||
@@ -0,0 +1,204 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>2.7.18</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
|
||||
<groupId>com.yunhai</groupId>
|
||||
<artifactId>work-week-plan</artifactId>
|
||||
<version>0.0.1</version>
|
||||
<name>WorkWeekPlan</name>
|
||||
<description>一周安排(周次工作安排)子应用</description>
|
||||
<properties>
|
||||
<java.version>1.8</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<!-- 基础平台版本管理(含 yh-* 架包与 spring-boot-dependencies 对齐) -->
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-platform-dependencies</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<!-- Spring Cloud Alibaba(Nacos 配置中心,对齐 Boot 2.7) -->
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
|
||||
<version>2021.0.5.0</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 仅引入 Spring Security 的加密模块(BCryptPasswordEncoder),不引入整套安全框架 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-crypto</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- AOP(UserContext 填充切面 + 审计日志切面) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
||||
</dependency>
|
||||
<!-- Boot 2.4+ 默认不再处理 bootstrap.yml,需显式引入以继续从 Nacos 拉取配置 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-bootstrap</artifactId>
|
||||
<version>3.1.8</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- JSON 序列化(fastjson,主类开启安全模式) -->
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>fastjson</artifactId>
|
||||
<version>1.2.80</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Swagger / OpenAPI 文档(springdoc,兼容 Boot 2.7) -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-ui</artifactId>
|
||||
<version>1.7.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 基础平台全家桶(聚合 starter):认证(CAS/会话/SSO)+ 应用间签名鉴权 + 用户中心静态门面
|
||||
+ 统一响应 + actuator 健康探活,一行带齐(模块构成见 BasePlatform/后端项目知识库.md)。
|
||||
约定:标准业务服务一律引聚合 starter;平台能力提供方(如 UserBase)按需单引。 -->
|
||||
<dependency>
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-spring-boot-starter</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。
|
||||
版本 3.5.1 已在本地 .m2 仓库。 -->
|
||||
<dependency>
|
||||
<groupId>org.jasig.cas.client</groupId>
|
||||
<artifactId>cas-client-core</artifactId>
|
||||
<version>3.5.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Excel 导入导出(EasyExcel 3.x,注解驱动、流式写入内存友好) -->
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
<artifactId>easyexcel</artifactId>
|
||||
<version>3.3.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 不引 spring-boot-starter-data-redis / cache:代码零使用,data-redis 会自动装配
|
||||
RedisHealthIndicator,连不上 redis 会把 /actuator/health 整体拉成 DOWN(503)。 -->
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>work-week-plan</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- 关闭增量编译,避免 Windows 下 maven-status 状态文件被锁导致
|
||||
"Error while storing the mojo status / 拒绝访问" -->
|
||||
<useIncrementalCompilation>false</useIncrementalCompilation>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<!-- 打可执行 fat jar(Docker 镜像直接 java -jar 启动) -->
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<mainClass>com.yunhai.workweekplan.WorkWeekPlanApplication</mainClass>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>repackage</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>nexus-public</id>
|
||||
<name>bsch/nexus public</name>
|
||||
<url>http://nexus.yunhaixingtong.com/repository/maven-public/</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>true</enabled></snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
<!--
|
||||
ci 构建档案:将构建产物重定向到 IDE(IntelliJ/Eclipse)索引目录之外,
|
||||
避免 IDE 后台进程持续锁定 target/maven-status/.../createdFiles.lst 导致
|
||||
"Error while storing the mojo status / 拒绝访问"。
|
||||
输出目录需从未被 IDE 打开过。常规本地构建无需激活此 profile。
|
||||
-->
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>ci</id>
|
||||
<build>
|
||||
<directory>C:/aca-build/work-week-plan</directory>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.yunhai.workweekplan;
|
||||
|
||||
import com.alibaba.fastjson.parser.ParserConfig;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
|
||||
@SpringBootApplication(scanBasePackages = "com.yunhai.workweekplan")
|
||||
@EnableTransactionManagement
|
||||
@EnableAsync
|
||||
public class WorkWeekPlanApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
// 开启 fastjson 安全模式
|
||||
ParserConfig.getGlobalInstance().setSafeMode(true);
|
||||
SpringApplication.run(WorkWeekPlanApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.yunhai.workweekplan.aspect;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.workweekplan.enums.UserType;
|
||||
import com.yunhai.workweekplan.model.UserSysRole;
|
||||
import com.yunhai.workweekplan.utils.IpUtils;
|
||||
import com.yunhai.workweekplan.utils.UserContext;
|
||||
import com.yunhai.platform.auth.BoaUserContext;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 当前用户上下文填充切面(移植自 UserclassService ControllerUserContextAspect)。
|
||||
* <p>@Order(1) 先于审计日志切面(Order 3)执行,保证日志能取到 UserContext。
|
||||
* 取用户优先级:方法参数 BoaUser → BoaUserContext 静态持有者 → request attribute;
|
||||
* 角色切换信息从 currentrole(Header/Query,URL 编码 JSON)解析。</p>
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
@Order(1)
|
||||
public class ControllerUserContextAspect {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ControllerUserContextAspect.class);
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
/** 外部认证框架把解析好的 BoaUser 放进 request attribute 的候选 key */
|
||||
private static final List<String> BOA_USER_ATTR_KEYS = Arrays.asList(
|
||||
"boaUser", "boauser", "currentUser", "user", "authUser", "loginUser"
|
||||
);
|
||||
|
||||
@Around("execution(* com.yunhai.workweekplan.controller..*.*(..))")
|
||||
public Object setUserContext(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
Object[] args = joinPoint.getArgs();
|
||||
BoaUser boaUser = null;
|
||||
UserSysRole userSysRole = null;
|
||||
|
||||
for (Object arg : args) {
|
||||
if (arg instanceof BoaUser) {
|
||||
boaUser = (BoaUser) arg;
|
||||
} else if (arg instanceof UserSysRole) {
|
||||
userSysRole = (UserSysRole) arg;
|
||||
}
|
||||
}
|
||||
|
||||
if (boaUser == null) {
|
||||
try {
|
||||
boaUser = BoaUserContext.get();
|
||||
} catch (Throwable ignore) {
|
||||
boaUser = null;
|
||||
}
|
||||
}
|
||||
HttpServletRequest request = getCurrentHttpRequest();
|
||||
if (request != null) {
|
||||
if (boaUser == null) {
|
||||
boaUser = resolveBoaUserFromRequest(request);
|
||||
}
|
||||
if (userSysRole == null) {
|
||||
userSysRole = resolveUserSysRoleFromRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
populateUserContext(boaUser, userSysRole);
|
||||
|
||||
try {
|
||||
return joinPoint.proceed();
|
||||
} finally {
|
||||
UserContext.clear();
|
||||
log.debug("UserContext cleared for {}", joinPoint.getSignature().toShortString());
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 currentrole(URL 编码,Header 或 Query 参数)解析 UserSysRole */
|
||||
private UserSysRole resolveUserSysRoleFromRequest(HttpServletRequest request) {
|
||||
String raw = request.getHeader("currentrole");
|
||||
boolean fromQuery = false;
|
||||
if (raw == null) {
|
||||
raw = request.getParameter("currentrole");
|
||||
fromQuery = true;
|
||||
}
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String decoded = fromQuery ? raw : URLDecoder.decode(raw, StandardCharsets.UTF_8.name());
|
||||
return MAPPER.readValue(decoded, UserSysRole.class);
|
||||
} catch (Exception e) {
|
||||
log.warn("解析 currentrole 失败: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 request attribute(外部框架注入的 BoaUser)回退解析 */
|
||||
private BoaUser resolveBoaUserFromRequest(HttpServletRequest request) {
|
||||
for (String key : BOA_USER_ATTR_KEYS) {
|
||||
Object attr = request.getAttribute(key);
|
||||
if (attr instanceof BoaUser) {
|
||||
return (BoaUser) attr;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private HttpServletRequest getCurrentHttpRequest() {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
return attributes != null ? attributes.getRequest() : null;
|
||||
}
|
||||
|
||||
private void populateUserContext(BoaUser boaUser, UserSysRole userSysRole) {
|
||||
if (boaUser != null) {
|
||||
UserContext.setUserId(boaUser.getId());
|
||||
UserContext.setUserName(boaUser.getName() != null ? boaUser.getName() : "unknown");
|
||||
Integer schId = boaUser.getSchool() == null ? null : boaUser.getSchool().getId();
|
||||
if (schId == null && userSysRole != null) {
|
||||
schId = userSysRole.getSchId();
|
||||
}
|
||||
UserContext.setSchId(schId);
|
||||
} else {
|
||||
UserContext.setUserName("anonymous");
|
||||
if (userSysRole != null && userSysRole.getSchId() != null) {
|
||||
UserContext.setSchId(userSysRole.getSchId());
|
||||
}
|
||||
log.warn("No BoaUser found, using anonymous");
|
||||
}
|
||||
|
||||
if (userSysRole != null) {
|
||||
UserContext.setRoleId(userSysRole.getRoleId());
|
||||
UserContext.setChildId(userSysRole.getChildId());
|
||||
// currentrole 携带 userId 时兜底填充(无 BoaUser 的场景:内部通道/调试直调)
|
||||
if (boaUser == null && userSysRole.getUserId() != null) {
|
||||
UserContext.setUserId(userSysRole.getUserId());
|
||||
}
|
||||
}
|
||||
|
||||
UserContext.setType(boaUser != null ? boaUser.getType() : null);
|
||||
|
||||
// 超级管理员(TYPE=1)不绑定具体学校,schId 统一置 0
|
||||
if (UserType.SUPER_ADMINISTRATOR.getId().equals(UserContext.getType())) {
|
||||
UserContext.setSchId(0);
|
||||
}
|
||||
|
||||
UserContext.setIp(IpUtils.getClientIp());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package com.yunhai.workweekplan.aspect;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.yunhai.workweekplan.entity.SysOperationLog;
|
||||
import com.yunhai.workweekplan.service.ISysOperationLogService;
|
||||
import com.yunhai.workweekplan.utils.GzipUtil;
|
||||
import com.yunhai.workweekplan.utils.IpUtils;
|
||||
import com.yunhai.workweekplan.utils.UserContext;
|
||||
import com.yunhai.platform.apistd.ApiResponse;
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.exception.IErrorCode;
|
||||
import com.yunhai.platform.utils.http.ApiResult;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 系统审计日志切面(移植自 UserclassService SysOperationLogAspect)。
|
||||
* <p>全量拦截 controller 包所有方法,不依赖注解;排除日志查询控制器自身(防递归);
|
||||
* 用户信息来自 {@link ControllerUserContextAspect}(Order 1)填充的 UserContext;
|
||||
* 响应体 GZIP 压缩落库,落库走 @Async 不阻塞请求。</p>
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
@Order(3)
|
||||
public class SysOperationLogAspect {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SysOperationLogAspect.class);
|
||||
|
||||
/** 排除的控制器(日志查询自身,防递归刷屏) */
|
||||
private static final List<String> EXCLUDE_CONTROLLERS = java.util.Arrays.asList(
|
||||
"com.yunhai.workweekplan.controller.api.LogController"
|
||||
);
|
||||
private static final int DEFAULT_MAX_RESPONSE_SIZE = 10240;
|
||||
private static final int MAX_ERROR_MSG = 8000;
|
||||
private static final int MAX_PARAMS = 2000;
|
||||
|
||||
@Autowired
|
||||
private ISysOperationLogService sysOperationLogService;
|
||||
|
||||
@Around("execution(* com.yunhai.workweekplan.controller..*.*(..))")
|
||||
public Object around(ProceedingJoinPoint point) throws Throwable {
|
||||
String declType = point.getSignature().getDeclaringTypeName();
|
||||
if (EXCLUDE_CONTROLLERS.contains(declType)) {
|
||||
return point.proceed();
|
||||
}
|
||||
|
||||
HttpServletRequest request = getRequest();
|
||||
String httpMethod = request != null ? request.getMethod() : "";
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
SysOperationLog entity = buildEntity(point, request, httpMethod);
|
||||
|
||||
Object result;
|
||||
try {
|
||||
result = point.proceed();
|
||||
entity.setStatusCode(resolveSuccessCode(result));
|
||||
fillResponse(entity, result, DEFAULT_MAX_RESPONSE_SIZE);
|
||||
return result;
|
||||
} catch (Throwable e) {
|
||||
entity.setStatusCode(resolveErrorCode(e));
|
||||
entity.setErrorMsg(buildErrorMsg(e));
|
||||
throw e;
|
||||
} finally {
|
||||
entity.setCostTime(System.currentTimeMillis() - start);
|
||||
sysOperationLogService.saveAsync(entity);
|
||||
}
|
||||
}
|
||||
|
||||
private SysOperationLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request, String httpMethod) {
|
||||
SysOperationLog e = new SysOperationLog();
|
||||
e.setCreateTime(LocalDateTime.now());
|
||||
|
||||
// 用户信息(ControllerUserContextAspect Order 1 已填充)
|
||||
e.setUserId(UserContext.getUserId());
|
||||
e.setUserName(StringUtils.hasText(UserContext.getUserName()) ? UserContext.getUserName() : "anonymous");
|
||||
e.setSchId(UserContext.getSchId());
|
||||
e.setRequestIp(StringUtils.hasText(UserContext.getIp()) ? UserContext.getIp() : IpUtils.getClientIp());
|
||||
|
||||
String uri = request != null ? request.getRequestURI() : "";
|
||||
String methodName = point.getSignature().getName();
|
||||
e.setRequestUri(uri);
|
||||
e.setRequestMethod(httpMethod);
|
||||
e.setClassMethod(point.getTarget().getClass().getName() + "." + methodName);
|
||||
e.setLogLevel(resolveLevel(httpMethod));
|
||||
e.setOperationType(resolveOperationType(httpMethod, methodName));
|
||||
if (request != null) {
|
||||
e.setUserAgent(request.getHeader("User-Agent"));
|
||||
}
|
||||
e.setRequestParams(truncate(buildRequestParams(point, request), MAX_PARAMS));
|
||||
return e;
|
||||
}
|
||||
|
||||
private String buildRequestParams(ProceedingJoinPoint point, HttpServletRequest request) {
|
||||
try {
|
||||
if (request != null && "GET".equalsIgnoreCase(request.getMethod())) {
|
||||
String qs = request.getQueryString();
|
||||
if (StringUtils.hasText(qs)) {
|
||||
return qs;
|
||||
}
|
||||
}
|
||||
Object[] args = point.getArgs();
|
||||
if (args == null || args.length == 0) {
|
||||
return "";
|
||||
}
|
||||
List<Object> filtered = new ArrayList<>();
|
||||
for (Object arg : args) {
|
||||
if (arg == null) {
|
||||
continue;
|
||||
}
|
||||
if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse
|
||||
|| arg instanceof HttpSession || arg instanceof MultipartFile
|
||||
|| arg instanceof MultipartFile[]) {
|
||||
continue;
|
||||
}
|
||||
// BoaUser 等平台对象跳过序列化(无 toString 意义且可能循环引用)
|
||||
if (arg.getClass().getName().startsWith("com.yunhai.platform.")) {
|
||||
continue;
|
||||
}
|
||||
filtered.add(arg);
|
||||
}
|
||||
return filtered.isEmpty() ? "" : JSON.toJSONString(filtered);
|
||||
} catch (Exception ex) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private void fillResponse(SysOperationLog e, Object result, int max) {
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String resp = JSON.toJSONString(result);
|
||||
if (resp.length() > max) {
|
||||
resp = resp.substring(0, max) + "...[截断]";
|
||||
}
|
||||
e.setResponseData(GzipUtil.compress(resp));
|
||||
} catch (Exception ex) {
|
||||
log.warn("审计日志响应序列化失败, uri={}", e.getRequestUri());
|
||||
}
|
||||
}
|
||||
|
||||
/** 成功路径:Controller 返回 ApiResponse/ApiResult 时取其 code,其余视为 SUCCESS(0) */
|
||||
private int resolveSuccessCode(Object result) {
|
||||
if (result instanceof ApiResponse) {
|
||||
return ((ApiResponse<?>) result).getCode();
|
||||
}
|
||||
if (result instanceof ApiResult) {
|
||||
return ((ApiResult<?>) result).getCode();
|
||||
}
|
||||
return ErrorCode.SUCCESS.getCode();
|
||||
}
|
||||
|
||||
/** 异常路径:从业务异常提取错误码,兜底 INTERNAL_SERVER_ERROR */
|
||||
private int resolveErrorCode(Throwable e) {
|
||||
if (e instanceof DException) {
|
||||
IErrorCode ec = ((DException) e).getCode();
|
||||
if (ec != null) {
|
||||
return ec.getCode();
|
||||
}
|
||||
}
|
||||
return ErrorCode.INTERNAL_SERVER_ERROR.getCode();
|
||||
}
|
||||
|
||||
private HttpServletRequest getRequest() {
|
||||
ServletRequestAttributes a = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
return a != null ? a.getRequest() : null;
|
||||
}
|
||||
|
||||
private String buildErrorMsg(Throwable e) {
|
||||
String msg = e.getMessage();
|
||||
String head = (msg == null || msg.trim().isEmpty()) ? e.getClass().getName() : msg;
|
||||
return truncate(head + System.lineSeparator() + ExceptionUtils.getStackTrace(e), MAX_ERROR_MSG);
|
||||
}
|
||||
|
||||
private String truncate(String s, int max) {
|
||||
return s != null && s.length() > max ? s.substring(0, max) : s;
|
||||
}
|
||||
|
||||
/** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */
|
||||
private int resolveLevel(String httpMethod) {
|
||||
if ("DELETE".equalsIgnoreCase(httpMethod)) {
|
||||
return 1;
|
||||
}
|
||||
if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod)) {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
private String resolveOperationType(String httpMethod, String methodName) {
|
||||
String lower = methodName == null ? "" : methodName.toLowerCase();
|
||||
if ("DELETE".equalsIgnoreCase(httpMethod) || lower.startsWith("delete") || lower.startsWith("del")) {
|
||||
return "DELETE";
|
||||
}
|
||||
if (lower.startsWith("create") || lower.startsWith("add")) {
|
||||
return "CREATE";
|
||||
}
|
||||
if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod)
|
||||
|| lower.startsWith("edit") || lower.startsWith("update") || lower.startsWith("set")) {
|
||||
return "UPDATE";
|
||||
}
|
||||
return "QUERY";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.yunhai.workweekplan.common.listener;
|
||||
|
||||
import com.yunhai.workweekplan.entity.BaseEntity;
|
||||
import com.yunhai.platform.auth.LoginUser;
|
||||
import com.yunhai.platform.auth.UserContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.persistence.PrePersist;
|
||||
import javax.persistence.PreUpdate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(平台当前登录人)与时间戳。
|
||||
* 对应 {@link BaseEntity} 的 createBy / updateBy / createTime / updateTime 字段。
|
||||
* 当前登录人取自基础平台 {@link UserContextHolder}。
|
||||
*/
|
||||
@Component
|
||||
public class BaseAuditListener {
|
||||
|
||||
@PrePersist
|
||||
public void prePersist(BaseEntity entity) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
entity.setCreateTime(now);
|
||||
entity.setUpdateTime(now);
|
||||
String account = currentAccount();
|
||||
if (account != null) {
|
||||
if (entity.getCreateBy() == null) {
|
||||
entity.setCreateBy(account);
|
||||
}
|
||||
entity.setUpdateBy(account);
|
||||
}
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
public void preUpdate(BaseEntity entity) {
|
||||
entity.setUpdateTime(LocalDateTime.now());
|
||||
String account = currentAccount();
|
||||
if (account != null) {
|
||||
entity.setUpdateBy(account);
|
||||
}
|
||||
}
|
||||
|
||||
private String currentAccount() {
|
||||
LoginUser user = UserContextHolder.get();
|
||||
return user == null ? null : user.getLoginName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.yunhai.workweekplan.common.validation;
|
||||
|
||||
/**
|
||||
* JSR-303 分组校验标记接口(对齐 UserBase 规范)。
|
||||
* Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。
|
||||
*/
|
||||
public interface ValidationGroups {
|
||||
interface Create {}
|
||||
interface Update {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yunhai.workweekplan.config;
|
||||
|
||||
/**
|
||||
* 全局路径常量。与 UserBase 一致:API 统一挂在 /{platform}/ 之下(platform = api / manage / mobiapi)。
|
||||
*/
|
||||
public enum Config {
|
||||
Inst;
|
||||
|
||||
public static final String API_PATH = "/{platform}"; // manage / api / mobiapi,前面有/后面无/,拼接子路径时用"/"分隔
|
||||
public static final String OPEN_API_PATH = "/oapi";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yunhai.workweekplan.config;
|
||||
|
||||
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* Jackson 时间序列化全局约定(对齐旧 .NET 版 "yyyy-MM-dd HH:mm:ss"):
|
||||
* 接口出入参中的 LocalDateTime/LocalDate 统一使用该格式,避免 ISO 的 "T" 分隔符
|
||||
* (如 2026-09-01T16:38:40)直接暴露给前端表格。
|
||||
*/
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
@Bean
|
||||
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
|
||||
return builder -> builder
|
||||
.serializers(
|
||||
new LocalDateTimeSerializer(DATE_TIME),
|
||||
new LocalDateSerializer(DATE))
|
||||
.deserializers(
|
||||
new LocalDateTimeDeserializer(DATE_TIME),
|
||||
new LocalDateDeserializer(DATE));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yunhai.workweekplan.config;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 接口文档(springdoc-openapi,替代旧 Springfox)。
|
||||
*/
|
||||
@Configuration
|
||||
public class SwaggerConfig {
|
||||
|
||||
@Bean
|
||||
public OpenAPI noticeOpenAPI() {
|
||||
return new OpenAPI()
|
||||
.info(new Info()
|
||||
.title("一周安排接口文档")
|
||||
.description("通知公告子应用:发通知、列表、删除、编辑、管理员查看、统计(springdoc-openapi)")
|
||||
.version("1.0"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.yunhai.workweekplan.controller.api;
|
||||
|
||||
import com.yunhai.workweekplan.config.Config;
|
||||
import com.yunhai.workweekplan.model.base.SetRoleRequest;
|
||||
import com.yunhai.workweekplan.model.base.SetRoleResult;
|
||||
import com.yunhai.workweekplan.service.IUserCenterService;
|
||||
import com.yunhai.workweekplan.utils.UserContext;
|
||||
import com.yunhai.platform.auth.LoginUser;
|
||||
import com.yunhai.platform.auth.UserContextHolder;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 平台代理域接口(base/)。
|
||||
* <p>承载:GetUserInfo / GetUserMod / SetRole / Logout。
|
||||
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/base/")
|
||||
public class BaseApiController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BaseApiController.class);
|
||||
|
||||
@Autowired
|
||||
private IUserCenterService userCenterService;
|
||||
|
||||
// ============================ GetUserInfo ============================
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息。
|
||||
* 返回 {id, name, role: teacher|parent, profilePicture, school: {id, name}, currentRole}
|
||||
*/
|
||||
@GetMapping("GetUserInfo")
|
||||
public BoaUser getUserInfo(BoaUser authUserWithDept) {
|
||||
return authUserWithDept;
|
||||
}
|
||||
|
||||
// ============================ GetUserMod ============================
|
||||
|
||||
/**
|
||||
* 获取当前用户已授权的模块列表(左侧菜单树)。
|
||||
*/
|
||||
@GetMapping("GetUserMod")
|
||||
public List<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
|
||||
Integer schId = schIdOf(authUserWithDept);
|
||||
return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId);
|
||||
}
|
||||
|
||||
// ============================ SetRole ============================
|
||||
|
||||
/**
|
||||
* 角色切换(teacher / parent)。会话身份由平台 CAS 管理,此处回显确认。
|
||||
*/
|
||||
@PostMapping("SetRole")
|
||||
public SetRoleResult setRole(@RequestBody SetRoleRequest request) {
|
||||
String role = request != null && "parent".equalsIgnoreCase(request.getAs()) ? "parent" : "teacher";
|
||||
return new SetRoleResult(role);
|
||||
}
|
||||
|
||||
// ============================ Logout ============================
|
||||
|
||||
/**
|
||||
* 退出登录。认证由平台网关处理,此处仅返回成功。
|
||||
*/
|
||||
@PostMapping("Logout")
|
||||
public void logout() {
|
||||
LoginUser user = UserContextHolder.get();
|
||||
log.info("[logout] 用户退出登录: {}", user == null ? null : user.getLoginName());
|
||||
}
|
||||
|
||||
// ============================ 内部方法 ============================
|
||||
|
||||
private Integer schIdOf(BoaUser user) {
|
||||
if (user.getSchool() != null) {
|
||||
return user.getSchool().getId();
|
||||
}
|
||||
return UserContext.getSchId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yunhai.workweekplan.controller.api;
|
||||
|
||||
import com.yunhai.workweekplan.config.Config;
|
||||
import com.yunhai.workweekplan.entity.SysOperationLog;
|
||||
import com.yunhai.workweekplan.model.BasePageQuery;
|
||||
import com.yunhai.workweekplan.model.BasePageResult;
|
||||
import com.yunhai.workweekplan.service.ISysOperationLogService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 审计日志查询接口(log/)。
|
||||
* <p>本控制器被 SysOperationLogAspect 排除,避免日志查询自身再产生日志(防递归)。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/log/")
|
||||
public class LogController {
|
||||
|
||||
@Autowired
|
||||
private ISysOperationLogService logService;
|
||||
|
||||
/** 分页查询审计日志(responseData 不返回) */
|
||||
@GetMapping("Page")
|
||||
public BasePageResult<SysOperationLog> page(BasePageQuery query,
|
||||
@RequestParam(value = "operationType", required = false) String operationType,
|
||||
@RequestParam(value = "statusCode", required = false) Integer statusCode) {
|
||||
return logService.page(query, operationType, statusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.yunhai.workweekplan.controller.api;
|
||||
|
||||
import com.yunhai.workweekplan.config.Config;
|
||||
import com.yunhai.workweekplan.model.BasePageResult;
|
||||
import com.yunhai.workweekplan.model.workplan.AllPlanItemVo;
|
||||
import com.yunhai.workweekplan.model.workplan.AllPlanQuery;
|
||||
import com.yunhai.workweekplan.model.workplan.FirstDayVo;
|
||||
import com.yunhai.workweekplan.model.workplan.ImportResultVo;
|
||||
import com.yunhai.workweekplan.model.workplan.OneWeekPlanVo;
|
||||
import com.yunhai.workweekplan.model.workplan.RemoveRequest;
|
||||
import com.yunhai.workweekplan.model.workplan.WeekPlanSaveRequest;
|
||||
import com.yunhai.workweekplan.model.workplan.WeekRemarkRequest;
|
||||
import com.yunhai.workweekplan.model.workplan.WeekTimeVo;
|
||||
import com.yunhai.workweekplan.model.workplan.WorkPlanItemVo;
|
||||
import com.yunhai.workweekplan.service.IWorkPlanService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一周安排接口(workplan/)。
|
||||
* <p>动作名对齐历史 ASP.NET 接口(GetYear/GetTerm/Weektime/OneWeekPlan/GetOneWeekPlan/
|
||||
* AddOneWeekPlan/UpdateWeekRemark/DeleteWeekPlan/GetFirstWeekDay/SetFirstWeekDay/
|
||||
* WeekPlanExcelImport/GetWorkPlan),便于前后端对照迁移;新增 Remove(单条删除)。
|
||||
* 全部动作自动被 SysOperationLogAspect 记录审计日志。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/workplan/")
|
||||
public class WorkPlanController {
|
||||
|
||||
@Autowired
|
||||
private IWorkPlanService workPlanService;
|
||||
|
||||
/** 学年下拉(当前学年在最前) */
|
||||
@GetMapping("GetYear")
|
||||
public List<Integer> getYear() {
|
||||
return workPlanService.getYears();
|
||||
}
|
||||
|
||||
/** 当前学期(1/2) */
|
||||
@GetMapping("GetTerm")
|
||||
public Integer getTerm() {
|
||||
return workPlanService.getCurrentTerm();
|
||||
}
|
||||
|
||||
/** 周次下拉 */
|
||||
@GetMapping("Weektime")
|
||||
public List<WeekTimeVo> weektime(@RequestParam("year") Integer year,
|
||||
@RequestParam("term") Integer term) {
|
||||
return workPlanService.getWeektime(year, term);
|
||||
}
|
||||
|
||||
/** 周视图(7 天安排 + 备注 + 权限标记) */
|
||||
@GetMapping("OneWeekPlan")
|
||||
public OneWeekPlanVo oneWeekPlan(@RequestParam("year") Integer year,
|
||||
@RequestParam("term") Integer term,
|
||||
@RequestParam("week") Integer week) {
|
||||
return workPlanService.getOneWeekPlan(year, term, week);
|
||||
}
|
||||
|
||||
/** 编辑视图(本周安排行列表,含 editable 标记) */
|
||||
@GetMapping("GetOneWeekPlan")
|
||||
public List<WorkPlanItemVo> getOneWeekPlan(@RequestParam("year") Integer year,
|
||||
@RequestParam("term") Integer term,
|
||||
@RequestParam("week") Integer week) {
|
||||
return workPlanService.getEditList(year, term, week);
|
||||
}
|
||||
|
||||
/** 保存一周安排(差集删除 + 权限过滤;新增行保存当前教师 id) */
|
||||
@PostMapping("AddOneWeekPlan")
|
||||
public void addOneWeekPlan(@Valid @RequestBody WeekPlanSaveRequest request) {
|
||||
workPlanService.saveWeekPlan(request);
|
||||
}
|
||||
|
||||
/** 修改周备注 */
|
||||
@PostMapping("UpdateWeekRemark")
|
||||
public void updateWeekRemark(@Valid @RequestBody WeekRemarkRequest request) {
|
||||
workPlanService.updateRemark(request);
|
||||
}
|
||||
|
||||
/** 删除整周安排(含备注),仅管理员/校领导 */
|
||||
@GetMapping("DeleteWeekPlan")
|
||||
public void deleteWeekPlan(@RequestParam("year") Integer year,
|
||||
@RequestParam("term") Integer term,
|
||||
@RequestParam("week") Integer week) {
|
||||
workPlanService.deleteWeekPlan(year, term, week);
|
||||
}
|
||||
|
||||
/** 删除单条安排(全部安排页) */
|
||||
@PostMapping("Remove")
|
||||
public void remove(@Valid @RequestBody RemoveRequest request) {
|
||||
workPlanService.removeRecord(request.getId());
|
||||
}
|
||||
|
||||
/** 全部安排分页查询 */
|
||||
@GetMapping("GetWorkPlan")
|
||||
public BasePageResult<AllPlanItemVo> getWorkPlan(AllPlanQuery query) {
|
||||
return workPlanService.pageAllPlan(query);
|
||||
}
|
||||
|
||||
/** 查询学期首周周一(未设置时返回默认推算值) */
|
||||
@GetMapping("GetFirstWeekDay")
|
||||
public FirstDayVo getFirstWeekDay(@RequestParam("year") Integer year,
|
||||
@RequestParam("term") Integer term) {
|
||||
return workPlanService.getFirstDay(year, term);
|
||||
}
|
||||
|
||||
/** 设置学期首周周一 */
|
||||
@GetMapping("SetFirstWeekDay")
|
||||
public void setFirstWeekDay(@RequestParam("year") Integer year,
|
||||
@RequestParam("term") Integer term,
|
||||
@RequestParam("dateTime") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTime) {
|
||||
workPlanService.setFirstDay(year, term, dateTime);
|
||||
}
|
||||
|
||||
/** Excel 导入本周安排(追加式) */
|
||||
@PostMapping("WeekPlanExcelImport")
|
||||
public ImportResultVo weekPlanExcelImport(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("year") Integer year,
|
||||
@RequestParam("term") Integer term,
|
||||
@RequestParam("week") Integer week) {
|
||||
return workPlanService.importExcel(file, year, term, week);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.yunhai.workweekplan.controller.oapi;
|
||||
|
||||
import com.yunhai.workweekplan.config.Config;
|
||||
import com.yunhai.workweekplan.model.workplan.OapiWeekPlanVo;
|
||||
import com.yunhai.workweekplan.service.IWorkPlanService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 一周安排开放接口(参考 AcaSyllabu /oapi/timetable/Get,本项目作为提供方开放 /oapi/**)。
|
||||
* <p>强制验签由 yh-app-auth starter 提供(yml {@code yh.app-auth.server.enforce-patterns: [/oapi/*]}):
|
||||
* 浏览器/匿名直访 401,注册应用带 X-App-Key/X-Timestamp/X-Signature 三件套直调通过
|
||||
* (本地无 Redis 走 yml trusted-apps 白名单,接入新调用方时追加其 appKey/密钥)。</p>
|
||||
* <p>身份由调用方显式传入(userid/schId,不依赖登录态);"本周"(学年/学期/周次)
|
||||
* 由服务端推算。周安排为全校共享数据,无角色概念,返回内容不因调用方不同
|
||||
* (不含站内的 canEditAll/canDelete 权限标记);写操作不开放。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(Config.OPEN_API_PATH + "/workplan")
|
||||
public class OapiWorkPlanController {
|
||||
|
||||
@Autowired
|
||||
private IWorkPlanService workPlanService;
|
||||
|
||||
/**
|
||||
* 获取本周安排(当前学年学期当前周,全校数据)。
|
||||
* 返回结构:标题 + 周一~周日 7 天安排 + 周备注 + hasData。
|
||||
*/
|
||||
@GetMapping("/Get")
|
||||
public OapiWeekPlanVo get(@RequestParam("userid") Integer userId,
|
||||
@RequestParam("schId") Integer schId) {
|
||||
return workPlanService.oapiOneWeekPlan(userId, schId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.yunhai.workweekplan.entity;
|
||||
|
||||
import com.yunhai.workweekplan.common.listener.BaseAuditListener;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link BaseAuditListener} 自动填充)。
|
||||
*/
|
||||
@MappedSuperclass
|
||||
@Getter
|
||||
@Setter
|
||||
@EntityListeners(BaseAuditListener.class)
|
||||
public abstract class BaseEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
/**
|
||||
* 创建人登录账号
|
||||
*/
|
||||
@Column(name = "create_by", updatable = false)
|
||||
private String createBy;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@Column(name = "create_time", updatable = false)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新人登录账号
|
||||
*/
|
||||
@Column(name = "update_by")
|
||||
private String updateBy;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.yunhai.workweekplan.entity;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.GeneratedValue;
|
||||
import javax.persistence.GenerationType;
|
||||
import javax.persistence.Id;
|
||||
import javax.persistence.Table;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 系统审计日志实体(移植自 UserclassService SysOperationLog)。
|
||||
* 全量记录 controller 请求:用户、URI、参数、响应(GZIP)、耗时、异常。
|
||||
* 索引由 resources/sql/sys_operation_log.sql 创建,本实体不含 @Table(indexes),
|
||||
* 避免 ddl-auto=update 启动时重复建索引告警。
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@Entity
|
||||
@Table(name = "sys_operation_log")
|
||||
public class SysOperationLog implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */
|
||||
@Column(name = "log_level", nullable = false, columnDefinition = "TINYINT")
|
||||
private Integer logLevel;
|
||||
|
||||
@Column(name = "operation_type", nullable = false, length = 16)
|
||||
private String operationType;
|
||||
|
||||
@Column(name = "user_id")
|
||||
private Integer userId;
|
||||
|
||||
@Column(name = "user_name", length = 64)
|
||||
private String userName;
|
||||
|
||||
@Column(name = "sch_id")
|
||||
private Integer schId;
|
||||
|
||||
@Column(name = "request_ip", nullable = false, length = 64)
|
||||
private String requestIp;
|
||||
|
||||
@Column(name = "request_uri", nullable = false, length = 512)
|
||||
private String requestUri;
|
||||
|
||||
@Column(name = "request_method", nullable = false, length = 16)
|
||||
private String requestMethod;
|
||||
|
||||
@Column(name = "class_method", nullable = false, length = 512)
|
||||
private String classMethod;
|
||||
|
||||
@Column(name = "request_params", columnDefinition = "TEXT")
|
||||
private String requestParams;
|
||||
|
||||
/** GZIP 压缩后的响应 JSON(不参与 JSON 序列化) */
|
||||
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
|
||||
private byte[] responseData;
|
||||
|
||||
@Column(name = "status_code", nullable = false)
|
||||
private Integer statusCode;
|
||||
|
||||
/** 耗时 ms */
|
||||
@Column(name = "cost_time", nullable = false)
|
||||
private Long costTime;
|
||||
|
||||
@Column(name = "user_agent", length = 512)
|
||||
private String userAgent;
|
||||
|
||||
@Column(name = "error_msg", columnDefinition = "TEXT")
|
||||
private String errorMsg;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "create_time", nullable = false, updatable = false, columnDefinition = "DATETIME(3)")
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.yunhai.workweekplan.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 学期第一周周一设置实体(对应历史 SchSet_Firstday)。
|
||||
* <p>每校每学年每学期一条,作为周次换算锚点;未设置时按默认规则推算。</p>
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "work_plan_first_day", uniqueConstraints = @UniqueConstraint(name = "uk_wpfd_sch_year_term", columnNames = {"sch_id", "year", "term"}))
|
||||
public class WorkPlanFirstDay extends BaseEntity {
|
||||
|
||||
/** 学校ID(多租户隔离) */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Integer schId;
|
||||
|
||||
/** 学年(如 2026 表示 2026-2027 学年) */
|
||||
@Column(name = "year", nullable = false)
|
||||
private Integer year;
|
||||
|
||||
/** 学期(1第一学期 2第二学期) */
|
||||
@Column(name = "term", nullable = false)
|
||||
private Integer term;
|
||||
|
||||
/** 第一周周一日期 */
|
||||
@Column(name = "first_day", nullable = false)
|
||||
private LocalDate firstDay;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.yunhai.workweekplan.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 节假日缓存实体(对应历史 YearHoliday/{年}.txt 文件缓存)。
|
||||
* <p>全国法定节假日 + 调休补班日,按公历年一次性同步(数据源:timor.tech / holiday-cn),
|
||||
* 与学校无关(无 sch_id)。off_day=false 表示调休补班日(周末变工作日)。</p>
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "work_plan_holiday",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_wph_date", columnNames = "date"),
|
||||
indexes = @Index(name = "idx_wph_year", columnList = "year"))
|
||||
public class WorkPlanHoliday extends BaseEntity {
|
||||
|
||||
/** 日期所属公历年(用于按年判断是否已同步) */
|
||||
@Column(name = "year", nullable = false)
|
||||
private Integer year;
|
||||
|
||||
/** 日期 */
|
||||
@Column(name = "date", nullable = false)
|
||||
private LocalDate date;
|
||||
|
||||
/** 节日名称(如 国庆节;补班日同名) */
|
||||
@Column(name = "name", length = 50, nullable = false)
|
||||
private String name;
|
||||
|
||||
/** true=放假日;false=调休补班日 */
|
||||
@Column(name = "off_day", nullable = false)
|
||||
private Boolean offDay;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.yunhai.workweekplan.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 一周安排明细实体(对应历史 Plan_Record)。
|
||||
* <p>每条为一项具体安排:起止日期(单日安排两者相等)、时间、工作内容、负责人、地点、出席对象。
|
||||
* 创建人只存教师/平台用户 id(user_id),不存账号。</p>
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "work_plan_record", indexes = @Index(name = "idx_wpr_sch_start", columnList = "sch_id, start_date"))
|
||||
public class WorkPlanRecord extends BaseEntity {
|
||||
|
||||
/** 学校ID(多租户隔离) */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Integer schId;
|
||||
|
||||
/** 开始日期(单日安排与 end_date 相等) */
|
||||
@Column(name = "start_date", nullable = false)
|
||||
private LocalDate startDate;
|
||||
|
||||
/** 结束日期(单日安排与 start_date 相等) */
|
||||
@Column(name = "end_date", nullable = false)
|
||||
private LocalDate endDate;
|
||||
|
||||
/** 时间(如 10:00 或 全天) */
|
||||
@Column(name = "times", length = 50)
|
||||
private String times;
|
||||
|
||||
/** 工作内容 */
|
||||
@Column(name = "contents", nullable = false, length = 200)
|
||||
private String contents;
|
||||
|
||||
/** 负责人(部门) */
|
||||
@Column(name = "principal", length = 200)
|
||||
private String principal;
|
||||
|
||||
/** 地点 */
|
||||
@Column(name = "place", length = 200)
|
||||
private String place;
|
||||
|
||||
/** 出席对象 */
|
||||
@Column(name = "participant", length = 200)
|
||||
private String participant;
|
||||
|
||||
/** 是否公开(预留,历史恒为 true) */
|
||||
@Column(name = "is_open")
|
||||
private Boolean isOpen;
|
||||
|
||||
/** 创建教师/平台用户 id(不存账号) */
|
||||
@Column(name = "user_id")
|
||||
private Integer userId;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yunhai.workweekplan.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 一周安排周备注实体(对应历史 Plan_WeekRemark)。
|
||||
* <p>每周一条,week_date 为该周周一日期。</p>
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
@Entity
|
||||
@Table(name = "work_plan_week_remark", uniqueConstraints = @UniqueConstraint(name = "uk_wpwr_sch_week", columnNames = {"sch_id", "week_date"}))
|
||||
public class WorkPlanWeekRemark extends BaseEntity {
|
||||
|
||||
/** 学校ID(多租户隔离) */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Integer schId;
|
||||
|
||||
/** 周一日期(该周锚点) */
|
||||
@Column(name = "week_date", nullable = false)
|
||||
private LocalDate weekDate;
|
||||
|
||||
/** 周备注 */
|
||||
@Column(name = "remark", length = 2000)
|
||||
private String remark;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.yunhai.workweekplan.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 用户类型(与基础平台 BoaUser.type 对齐)。
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public enum UserType {
|
||||
SUPER_ADMINISTRATOR(1, "超级管理员"),
|
||||
SCHOOL_ADMIN(2, "学校管理员"),
|
||||
REGULAR_USER(3, "普通用户");
|
||||
|
||||
private Integer id;
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.yunhai.workweekplan.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 统一分页查询入参。pageIndex 从 1 起。
|
||||
*/
|
||||
@Data
|
||||
public class BasePageQuery {
|
||||
private int pageIndex = 1;
|
||||
private int pageSize = 10;
|
||||
/** 通用模糊搜索关键词 */
|
||||
private String keyword;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.yunhai.workweekplan.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 统一分页响应外壳。
|
||||
*/
|
||||
@Data
|
||||
public class BasePageResult<T> {
|
||||
/** 总记录数 */
|
||||
private int total;
|
||||
/** 当前页数据 */
|
||||
private List<T> content;
|
||||
/** 当前页码(从 1 起) */
|
||||
private int pageIndex;
|
||||
/** 每页条数 */
|
||||
private int pageSize;
|
||||
/** 总页数 */
|
||||
private int totalPages;
|
||||
/** 是否还有下一页 */
|
||||
private boolean hasNext;
|
||||
|
||||
public BasePageResult(List<T> content, int total, int pageIndex, int pageSize, int totalPages, boolean hasNext) {
|
||||
this.content = content;
|
||||
this.total = total;
|
||||
this.pageIndex = pageIndex;
|
||||
this.pageSize = pageSize;
|
||||
this.totalPages = totalPages;
|
||||
this.hasNext = hasNext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.workweekplan.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 用户系统角色上下文(来自请求头 currentrole,URL 编码 JSON:{childId, roleId, schId, userId})。
|
||||
* 家长端 childId 即当前孩子(学生)ID,0 代表主账号操作。
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class UserSysRole {
|
||||
|
||||
/** 子账号/孩子ID,0=主账号 */
|
||||
private Integer childId;
|
||||
|
||||
/** 角色ID */
|
||||
private Integer roleId;
|
||||
|
||||
/** 学校ID(数据隔离关键字段) */
|
||||
private Integer schId;
|
||||
|
||||
/** 用户ID(部分平台版本携带) */
|
||||
private Integer userId;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yunhai.workweekplan.model.base;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 角色切换入参(对齐前端 baseApi.setRole)。
|
||||
*/
|
||||
@Data
|
||||
public class SetRoleRequest {
|
||||
/** "teacher" 或 "parent" */
|
||||
private String as;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.yunhai.workweekplan.model.base;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 角色切换结果。
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SetRoleResult {
|
||||
private String role;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.yunhai.workweekplan.model.workplan;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 全部安排列表行(对应历史 GetWorkPlan 返回的 Plan_Record 列表)。
|
||||
* <p>year/term/week 为换算结果,供前端"编辑"跳回本周安排页。</p>
|
||||
*/
|
||||
@Data
|
||||
public class AllPlanItemVo {
|
||||
|
||||
private Integer id;
|
||||
|
||||
/** 安排日期 */
|
||||
private LocalDate date;
|
||||
|
||||
private String times;
|
||||
|
||||
private String contents;
|
||||
|
||||
private String principal;
|
||||
|
||||
private String place;
|
||||
|
||||
private String participant;
|
||||
|
||||
/** 所属学年 */
|
||||
private Integer year;
|
||||
|
||||
/** 所属学期(1/2) */
|
||||
private Integer term;
|
||||
|
||||
/** 所属周次 */
|
||||
private Integer week;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.yunhai.workweekplan.model.workplan;
|
||||
|
||||
import com.yunhai.workweekplan.model.BasePageQuery;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 全部安排分页查询(对应历史 GetWorkPlan:startDt/endDt/name/PageIndex/PageSize)。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AllPlanQuery extends BasePageQuery {
|
||||
|
||||
/** 起始日期(与安排有交集:endDate >= startDt) */
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
private LocalDate startDt;
|
||||
|
||||
/** 截止日期(与安排有交集:startDate <= endDt) */
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
private LocalDate endDt;
|
||||
|
||||
/** 关键字(匹配 内容/负责人/地点/出席对象) */
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.yunhai.workweekplan.model.workplan;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 学期首周周一(对应历史 GetFirstWeekDay/SetFirstWeekDay)。
|
||||
*/
|
||||
@Data
|
||||
public class FirstDayVo {
|
||||
|
||||
/** 首周周一日期(yyyy-MM-dd;未设置时返回按默认规则推算的值) */
|
||||
private String firstDay;
|
||||
|
||||
/** 是否为默认推算(true=尚未手工设置) */
|
||||
private boolean usingDefault;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.yunhai.workweekplan.model.workplan;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Excel 导入结果(对应历史 WeekPlanExcelImport 返回的错误明细表)。
|
||||
*/
|
||||
@Data
|
||||
public class ImportResultVo {
|
||||
|
||||
/** 是否导入成功(无 rank=1 错误且写入条数 > 0) */
|
||||
private boolean success;
|
||||
|
||||
/** 结果消息 */
|
||||
private String message;
|
||||
|
||||
/** 导入条数 */
|
||||
private int count;
|
||||
|
||||
/** 明细:rank 1=错误(红色) 2=警告(灰色) */
|
||||
private List<ErrorVo> errors = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
public static class ErrorVo {
|
||||
/** Excel 行号(1 起,含表头) */
|
||||
private int rowIndex;
|
||||
/** 行标识+列名,如 "周一 时间" */
|
||||
private String rowsName;
|
||||
/** 错误详情 */
|
||||
private String message;
|
||||
/** 1=错误(该行未导入) 2=警告(已跳过/提示) */
|
||||
private int rank;
|
||||
|
||||
public ErrorVo() {
|
||||
}
|
||||
|
||||
public ErrorVo(int rowIndex, String rowsName, String message, int rank) {
|
||||
this.rowIndex = rowIndex;
|
||||
this.rowsName = rowsName;
|
||||
this.message = message;
|
||||
this.rank = rank;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.yunhai.workweekplan.model.workplan;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 开放接口"获取本周安排"返回体(/oapi/workplan/Get)。
|
||||
* <p>全校共享数据,无角色概念:不含站内 OneWeekPlanVo 的 canEditAll/canDelete 权限标记,
|
||||
* 天结构复用 {@link OneWeekPlanVo.DayVo}。</p>
|
||||
*/
|
||||
@Data
|
||||
public class OapiWeekPlanVo {
|
||||
|
||||
/** 标题:如 " 2026-1 第6周 学校工作安排 (10.12-10.18)" */
|
||||
private String title;
|
||||
|
||||
private Integer year;
|
||||
|
||||
private Integer term;
|
||||
|
||||
private Integer week;
|
||||
|
||||
/** 该周周一日期 */
|
||||
private LocalDate monday;
|
||||
|
||||
/** 7 天(周一~周日) */
|
||||
private List<OneWeekPlanVo.DayVo> days = new ArrayList<>();
|
||||
|
||||
/** 周备注 */
|
||||
private String remark;
|
||||
|
||||
/** 本周是否已有安排数据 */
|
||||
private boolean hasData;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.yunhai.workweekplan.model.workplan;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一周安排视图(对应历史 OneWeekPlan 返回的 Data)。
|
||||
*/
|
||||
@Data
|
||||
public class OneWeekPlanVo {
|
||||
|
||||
/** 标题:如 " 2026-1 第8周 学校工作安排 (10.13-10.19)" */
|
||||
private String title;
|
||||
|
||||
private Integer year;
|
||||
|
||||
private Integer term;
|
||||
|
||||
private Integer week;
|
||||
|
||||
/** 该周周一日期 */
|
||||
private LocalDate monday;
|
||||
|
||||
/** 7 天(周一~周日) */
|
||||
private List<DayVo> days = new ArrayList<>();
|
||||
|
||||
/** 周备注 */
|
||||
private String remark;
|
||||
|
||||
/** 本周是否已有安排数据 */
|
||||
private boolean hasData;
|
||||
|
||||
/** 当前用户是否可删除整周安排(管理员/校领导) */
|
||||
private boolean canDelete;
|
||||
|
||||
/** 当前用户是否可管理整周安排(管理员/校领导,前端控制工具栏按钮显隐) */
|
||||
private boolean canEditAll;
|
||||
|
||||
@Data
|
||||
public static class DayVo {
|
||||
/** 日期 */
|
||||
private LocalDate date;
|
||||
/** 日期文本 MM.dd */
|
||||
private String dateText;
|
||||
/** 星期中文(一~日) */
|
||||
private String weekday;
|
||||
/** 当日安排 */
|
||||
private List<WorkPlanItemVo> items = new ArrayList<>();
|
||||
/** 节假日类型:0=工作日 1=休息日 2=法定节假日 */
|
||||
private Integer dayType;
|
||||
/** 无安排时占位文案:无 / 休 / 节日名称 */
|
||||
private String dayLabel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.yunhai.workweekplan.model.workplan;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 删除单条安排请求(全部安排页,新增能力)。
|
||||
*/
|
||||
@Data
|
||||
public class RemoveRequest {
|
||||
|
||||
@NotNull(message = "记录ID不能为空")
|
||||
private Integer id;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.yunhai.workweekplan.model.workplan;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.Max;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 保存一周安排请求(对应历史 AddOneWeekPlan:JsonData + year + term + week)。
|
||||
*/
|
||||
@Data
|
||||
public class WeekPlanSaveRequest {
|
||||
|
||||
@NotNull(message = "学年不能为空")
|
||||
@Min(value = 2000, message = "学年不合法")
|
||||
@Max(value = 2100, message = "学年不合法")
|
||||
private Integer year;
|
||||
|
||||
@NotNull(message = "学期不能为空")
|
||||
@Min(value = 1, message = "学期取值 1-2")
|
||||
@Max(value = 2, message = "学期取值 1-2")
|
||||
private Integer term;
|
||||
|
||||
@NotNull(message = "周次不能为空")
|
||||
@Min(value = 1, message = "周次不合法")
|
||||
private Integer week;
|
||||
|
||||
/** 空列表表示清空本周安排 */
|
||||
@Valid
|
||||
@Size(max = 200, message = "单周安排条数过多")
|
||||
private List<WorkPlanItemVo> items;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yunhai.workweekplan.model.workplan;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.Max;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 修改周备注请求(对应历史 UpdateWeekRemark)。
|
||||
*/
|
||||
@Data
|
||||
public class WeekRemarkRequest {
|
||||
|
||||
@NotNull(message = "学年不能为空")
|
||||
@Min(value = 2000, message = "学年不合法")
|
||||
@Max(value = 2100, message = "学年不合法")
|
||||
private Integer year;
|
||||
|
||||
@NotNull(message = "学期不能为空")
|
||||
@Min(value = 1, message = "学期取值 1-2")
|
||||
@Max(value = 2, message = "学期取值 1-2")
|
||||
private Integer term;
|
||||
|
||||
@NotNull(message = "周次不能为空")
|
||||
@Min(value = 1, message = "周次不合法")
|
||||
private Integer week;
|
||||
|
||||
@Size(max = 2000, message = "备注不能超过2000字")
|
||||
private String remark;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.yunhai.workweekplan.model.workplan;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 周次下拉项(对应历史 selectframe)。
|
||||
*/
|
||||
@Data
|
||||
public class WeekTimeVo {
|
||||
|
||||
/** 周次,从 1 起 */
|
||||
private int week;
|
||||
|
||||
/** 日期范围文本,如 (10.13-10.19) */
|
||||
private String label;
|
||||
|
||||
/** 是否当前周/默认选中周 */
|
||||
private boolean current;
|
||||
|
||||
public WeekTimeVo() {
|
||||
}
|
||||
|
||||
public WeekTimeVo(int week, String label, boolean current) {
|
||||
this.week = week;
|
||||
this.label = label;
|
||||
this.current = current;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.yunhai.workweekplan.model.workplan;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.Max;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import javax.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* 一条周安排(编辑行 / 保存行,对应历史 oneweekplan)。
|
||||
* <p>editable 仅在查询响应中有意义:当前用户是否可编辑该行
|
||||
* (管理员/教职工全部可编辑;教师仅本人创建的可编辑)。</p>
|
||||
*/
|
||||
@Data
|
||||
public class WorkPlanItemVo {
|
||||
|
||||
/** 记录ID(新增行为空或 0) */
|
||||
private Integer id;
|
||||
|
||||
/** 本周星期几(1=周一 … 7=周日) */
|
||||
@NotNull(message = "星期不能为空")
|
||||
@Min(value = 1, message = "星期取值 1-7")
|
||||
@Max(value = 7, message = "星期取值 1-7")
|
||||
private Integer iweek;
|
||||
|
||||
/** 时间 */
|
||||
@Size(max = 50, message = "时间不能超过50字")
|
||||
private String times;
|
||||
|
||||
/** 工作内容 */
|
||||
@NotNull(message = "工作内容不能为空")
|
||||
@Size(min = 1, max = 200, message = "工作内容不能超过200字")
|
||||
private String contents;
|
||||
|
||||
/** 负责人(部门) */
|
||||
@Size(max = 200, message = "负责人不能超过200字")
|
||||
private String principal;
|
||||
|
||||
/** 地点 */
|
||||
@Size(max = 200, message = "地点不能超过200字")
|
||||
private String place;
|
||||
|
||||
/** 出席对象 */
|
||||
@Size(max = 200, message = "出席对象不能超过200字")
|
||||
private String participant;
|
||||
|
||||
/** 当前用户是否可编辑该行(仅查询响应有效,保存时忽略) */
|
||||
private Boolean editable;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yunhai.workweekplan.repository;
|
||||
|
||||
import com.yunhai.workweekplan.entity.SysOperationLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
/**
|
||||
* 系统审计日志仓储。
|
||||
*/
|
||||
public interface SysOperationLogRepository
|
||||
extends JpaRepository<SysOperationLog, Long>, JpaSpecificationExecutor<SysOperationLog> {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yunhai.workweekplan.repository;
|
||||
|
||||
import com.yunhai.workweekplan.entity.WorkPlanFirstDay;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface WorkPlanFirstDayRepository extends JpaRepository<WorkPlanFirstDay, Integer> {
|
||||
|
||||
Optional<WorkPlanFirstDay> findBySchIdAndYearAndTerm(Integer schId, Integer year, Integer term);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yunhai.workweekplan.repository;
|
||||
|
||||
import com.yunhai.workweekplan.entity.WorkPlanHoliday;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface WorkPlanHolidayRepository extends JpaRepository<WorkPlanHoliday, Integer> {
|
||||
|
||||
List<WorkPlanHoliday> findByYear(Integer year);
|
||||
|
||||
boolean existsByYear(Integer year);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yunhai.workweekplan.repository;
|
||||
|
||||
import com.yunhai.workweekplan.entity.WorkPlanRecord;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
public interface WorkPlanRecordRepository extends JpaRepository<WorkPlanRecord, Integer>, JpaSpecificationExecutor<WorkPlanRecord> {
|
||||
|
||||
/**
|
||||
* 查询与 [start, end] 有交集的安排(周查询:start=周一 end=周日)。
|
||||
*/
|
||||
@Query("select r from WorkPlanRecord r where r.schId = :schId and r.startDate <= :end and r.endDate >= :start order by r.startDate asc, r.id asc")
|
||||
List<WorkPlanRecord> findInWeek(@Param("schId") Integer schId, @Param("start") LocalDate start, @Param("end") LocalDate end);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yunhai.workweekplan.repository;
|
||||
|
||||
import com.yunhai.workweekplan.entity.WorkPlanWeekRemark;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface WorkPlanWeekRemarkRepository extends JpaRepository<WorkPlanWeekRemark, Integer> {
|
||||
|
||||
Optional<WorkPlanWeekRemark> findBySchIdAndWeekDate(Integer schId, LocalDate weekDate);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.yunhai.workweekplan.service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 节假日识别(对应历史 BestSchool.Common.Workday)。
|
||||
* <p>type 语义与历史一致:0=工作日 1=休息日 2=节假日。</p>
|
||||
*/
|
||||
public interface IHolidayService {
|
||||
|
||||
/** 工作日占位文案 */
|
||||
String LABEL_WORKDAY = "无";
|
||||
/** 休息日占位文案 */
|
||||
String LABEL_REST = "休";
|
||||
/** 降级提示占位文案(节假日数据不可用时仍按周末判断,正常流程不会出现) */
|
||||
String LABEL_EMPTY = "空";
|
||||
|
||||
/**
|
||||
* 识别某天类型与占位文案:法定节假日→节日名,调休补班/普通工作日→"无",周末→"休"。
|
||||
* 数据源故障时退化为仅周末判断,绝不抛异常。
|
||||
*/
|
||||
DayInfo getDayInfo(LocalDate date);
|
||||
|
||||
/** 识别结果:type 0/1/2 + 占位文案 */
|
||||
class DayInfo {
|
||||
|
||||
private final int type;
|
||||
private final String name;
|
||||
|
||||
public DayInfo(int type, String name) {
|
||||
this.type = type;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public int getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.yunhai.workweekplan.service;
|
||||
|
||||
import com.yunhai.workweekplan.entity.SysOperationLog;
|
||||
import com.yunhai.workweekplan.model.BasePageQuery;
|
||||
import com.yunhai.workweekplan.model.BasePageResult;
|
||||
|
||||
/**
|
||||
* 系统审计日志业务接口:异步落库 + 分页查询。
|
||||
*/
|
||||
public interface ISysOperationLogService {
|
||||
|
||||
/** 异步保存(失败仅记应用日志,不影响业务请求) */
|
||||
void saveAsync(SysOperationLog entity);
|
||||
|
||||
/** 分页查询(responseData 不返回) */
|
||||
BasePageResult<SysOperationLog> page(BasePageQuery query, String operationType, Integer statusCode);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.yunhai.workweekplan.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户中心门面服务(对齐 UserBase {@code UserCenterService} 模式)。
|
||||
* <p>封装 {@code YschBaseOpenApi} 静态门面调用,把模块树组装等业务逻辑下沉到 Service 层,
|
||||
* Controller 只保留"取参 → 调 Service → 返回"的薄壳。</p>
|
||||
*/
|
||||
public interface IUserCenterService {
|
||||
|
||||
/**
|
||||
* 获取当前用户可访问的模块树(左侧菜单)。
|
||||
* <p>从基础平台拉取当前用户模块列表(含学校上下文),组装为前端 layout 期望的树结构
|
||||
* ({@code modId/page/children}),对应前端 GetUserMod 接口。</p>
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param schId 学校ID(可为 null)
|
||||
* @return 模块树(顶层 List,含 children 嵌套)
|
||||
*/
|
||||
List<Map<String, Object>> getUserModuleTree(Integer userId, Integer schId);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.yunhai.workweekplan.service;
|
||||
|
||||
import com.yunhai.workweekplan.model.BasePageResult;
|
||||
import com.yunhai.workweekplan.model.workplan.AllPlanItemVo;
|
||||
import com.yunhai.workweekplan.model.workplan.AllPlanQuery;
|
||||
import com.yunhai.workweekplan.model.workplan.FirstDayVo;
|
||||
import com.yunhai.workweekplan.model.workplan.ImportResultVo;
|
||||
import com.yunhai.workweekplan.model.workplan.OneWeekPlanVo;
|
||||
import com.yunhai.workweekplan.model.workplan.OapiWeekPlanVo;
|
||||
import com.yunhai.workweekplan.model.workplan.WeekPlanSaveRequest;
|
||||
import com.yunhai.workweekplan.model.workplan.WeekRemarkRequest;
|
||||
import com.yunhai.workweekplan.model.workplan.WeekTimeVo;
|
||||
import com.yunhai.workweekplan.model.workplan.WorkPlanItemVo;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一周安排业务(移植自历史 BestSchool.WorkPlan WeekWorkPlan/PlanCommon)。
|
||||
*/
|
||||
public interface IWorkPlanService {
|
||||
|
||||
/** 学年下拉(当前学年在最前,共 7 项) */
|
||||
List<Integer> getYears();
|
||||
|
||||
/** 当前学期(1/2) */
|
||||
Integer getCurrentTerm();
|
||||
|
||||
/** 周次下拉 */
|
||||
List<WeekTimeVo> getWeektime(Integer year, Integer term);
|
||||
|
||||
/** 周视图(7 天安排 + 备注 + 权限标记) */
|
||||
OneWeekPlanVo getOneWeekPlan(Integer year, Integer term, Integer week);
|
||||
|
||||
/**
|
||||
* 开放接口:获取本周安排(当前学年学期当前周,服务端推算周次)。
|
||||
* <p>身份由调用方显式传入(不依赖登录态);周安排为全校共享数据,无角色概念,
|
||||
* 返回内容不因调用方不同(见 {@link OapiWeekPlanVo},不含权限标记)。</p>
|
||||
*/
|
||||
OapiWeekPlanVo oapiOneWeekPlan(Integer userId, Integer schId);
|
||||
|
||||
/** 编辑视图(本周安排行列表,含 editable 标记) */
|
||||
List<WorkPlanItemVo> getEditList(Integer year, Integer term, Integer week);
|
||||
|
||||
/**
|
||||
* 保存一周安排:差集删除 + 权限过滤更新;新增行写当前教师 id。
|
||||
*/
|
||||
void saveWeekPlan(WeekPlanSaveRequest request);
|
||||
|
||||
/** 修改周备注 */
|
||||
void updateRemark(WeekRemarkRequest request);
|
||||
|
||||
/** 删除整周安排(含备注),仅管理员/教职工 */
|
||||
void deleteWeekPlan(Integer year, Integer term, Integer week);
|
||||
|
||||
/** 删除单条安排(管理员/教职工或本人) */
|
||||
void removeRecord(Integer id);
|
||||
|
||||
/** 全部安排分页查询 */
|
||||
BasePageResult<AllPlanItemVo> pageAllPlan(AllPlanQuery query);
|
||||
|
||||
/** 查询学期首周周一(未设置时返回默认推算值) */
|
||||
FirstDayVo getFirstDay(Integer year, Integer term);
|
||||
|
||||
/** 设置学期首周周一 */
|
||||
void setFirstDay(Integer year, Integer term, LocalDate firstDay);
|
||||
|
||||
/** Excel 导入本周安排(追加式,同历史行为) */
|
||||
ImportResultVo importExcel(MultipartFile file, Integer year, Integer term, Integer week);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package com.yunhai.workweekplan.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.workweekplan.entity.WorkPlanHoliday;
|
||||
import com.yunhai.workweekplan.repository.WorkPlanHolidayRepository;
|
||||
import com.yunhai.workweekplan.service.IHolidayService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 节假日识别实现(移植历史 BestSchool.Common.Workday:timor.tech 按年拉取 + 本地缓存 + 异常降级)。
|
||||
* <p>与历史的差异:
|
||||
* 1) 缓存从本地文件改为 MySQL 表 work_plan_holiday(按公历年一次性同步);
|
||||
* 2) timor.tech 现有 Cloudflare 防护,请求必须带浏览器 User-Agent,且备用官方数据源
|
||||
* holiday-cn(jsdelivr CDN 镜像国务院公告);两源全挂时按周末判断降级,并做 10 分钟
|
||||
* 负缓存避免每次请求都外呼;
|
||||
* 3) 已同步年份不自动刷新(国务院全年公告一经发布不再变化),纠错需手工清表。</p>
|
||||
*/
|
||||
@Service
|
||||
public class HolidayService implements IHolidayService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(HolidayService.class);
|
||||
|
||||
/** timor.tech 防护要求浏览器 UA,Java 默认 UA 会被 Cloudflare 人机验证拦截 */
|
||||
private static final String BROWSER_UA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36";
|
||||
|
||||
private static final String TIMOR_URL = "https://timor.tech/api/holiday/year/";
|
||||
private static final String HOLIDAY_CN_URL = "https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master/";
|
||||
|
||||
/** 同步失败后的负缓存时长:期间不再外呼,直接按周末判断 */
|
||||
private static final long FAIL_RETRY_MS = 10 * 60 * 1000L;
|
||||
|
||||
private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
@Value("${workweekplan.holiday.enabled:true}")
|
||||
private boolean enabled;
|
||||
|
||||
@Autowired
|
||||
private WorkPlanHolidayRepository holidayRepository;
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private volatile RestTemplate restTemplate;
|
||||
|
||||
/** 年份 → (日期 → 节假日行) 内存缓存 */
|
||||
private final Map<Integer, Map<LocalDate, WorkPlanHoliday>> yearCache = new ConcurrentHashMap<>();
|
||||
/** 年份 → 最近一次同步失败时间戳 */
|
||||
private final Map<Integer, Long> failedYears = new ConcurrentHashMap<>();
|
||||
|
||||
@PostConstruct
|
||||
public void prefetch() {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
// 后台预取当年+次年,正常情况下用户首个周视图请求无同步等待
|
||||
Thread t = new Thread(() -> {
|
||||
int year = LocalDate.now().getYear();
|
||||
loadYear(year);
|
||||
loadYear(year + 1);
|
||||
}, "holiday-prefetch");
|
||||
t.setDaemon(true);
|
||||
t.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public DayInfo getDayInfo(LocalDate date) {
|
||||
boolean weekend = date.getDayOfWeek() == DayOfWeek.SATURDAY
|
||||
|| date.getDayOfWeek() == DayOfWeek.SUNDAY;
|
||||
if (!enabled) {
|
||||
// 开关关闭:仅按周末判断(同数据源全挂的降级路径)
|
||||
return weekend ? new DayInfo(1, LABEL_REST) : new DayInfo(0, LABEL_WORKDAY);
|
||||
}
|
||||
try {
|
||||
WorkPlanHoliday h = loadYear(date.getYear()).get(date);
|
||||
if (h != null) {
|
||||
if (Boolean.TRUE.equals(h.getOffDay())) {
|
||||
return new DayInfo(2, h.getName());
|
||||
}
|
||||
// 调休补班日:周末也按工作日处理(同历史 GetHoliday)
|
||||
return new DayInfo(0, LABEL_WORKDAY);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("节假日识别失败,按周末降级 date={}", date, e);
|
||||
}
|
||||
return weekend ? new DayInfo(1, LABEL_REST) : new DayInfo(0, LABEL_WORKDAY);
|
||||
}
|
||||
|
||||
/** 年份节假日数据:内存 → 库 → 远端同步。空表视为同步失败结果,负缓存过期后重试 */
|
||||
private Map<LocalDate, WorkPlanHoliday> loadYear(int year) {
|
||||
Map<LocalDate, WorkPlanHoliday> cached = yearCache.get(year);
|
||||
if (cached != null && !cached.isEmpty()) {
|
||||
return cached;
|
||||
}
|
||||
Long failedAt = failedYears.get(year);
|
||||
if (cached != null && failedAt != null
|
||||
&& System.currentTimeMillis() - failedAt < FAIL_RETRY_MS) {
|
||||
return cached;
|
||||
}
|
||||
synchronized (this) {
|
||||
cached = yearCache.get(year);
|
||||
if (cached != null && !cached.isEmpty()) {
|
||||
return cached;
|
||||
}
|
||||
List<WorkPlanHoliday> rows = holidayRepository.findByYear(year);
|
||||
if (rows.isEmpty()) {
|
||||
rows = syncYear(year);
|
||||
}
|
||||
Map<LocalDate, WorkPlanHoliday> map = toMap(rows);
|
||||
yearCache.put(year, map);
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
/** 远端按年同步;两源全挂返回空表并记负缓存 */
|
||||
private List<WorkPlanHoliday> syncYear(int year) {
|
||||
Long failedAt = failedYears.get(year);
|
||||
if (failedAt != null && System.currentTimeMillis() - failedAt < FAIL_RETRY_MS) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<WorkPlanHoliday> rows = fetchTimor(year);
|
||||
String source = "timor.tech";
|
||||
if (rows == null) {
|
||||
rows = fetchHolidayCn(year);
|
||||
source = "holiday-cn";
|
||||
}
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
failedYears.put(year, System.currentTimeMillis());
|
||||
log.warn("节假日数据源全部失败 year={},{}分钟内按周末判断降级", year, FAIL_RETRY_MS / 60000);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
holidayRepository.saveAll(rows);
|
||||
holidayRepository.flush();
|
||||
} catch (Exception e) {
|
||||
// 落库失败不影响本次使用(下次启动会重试同步)
|
||||
log.warn("节假日缓存落库失败 year={},仅内存生效", year, e);
|
||||
}
|
||||
failedYears.remove(year);
|
||||
log.info("节假日数据同步完成 year={} source={} 天数={}", year, source, rows.size());
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 源1:timor.tech(带浏览器 UA)。结构 {code:0, holiday:{"MM-dd":{holiday,name,date}}} */
|
||||
private List<WorkPlanHoliday> fetchTimor(int year) {
|
||||
try {
|
||||
String body = getForString(TIMOR_URL + year);
|
||||
JsonNode root = mapper.readTree(body);
|
||||
if (root.path("code").asInt(-1) != 0) {
|
||||
log.warn("timor.tech 返回异常 year={} body={}", year, abbreviate(body));
|
||||
return null;
|
||||
}
|
||||
List<WorkPlanHoliday> rows = new ArrayList<>();
|
||||
JsonNode holidayMap = root.path("holiday");
|
||||
holidayMap.fields().forEachRemaining(e -> {
|
||||
JsonNode node = e.getValue();
|
||||
LocalDate date = parseDate(year, e.getKey(), node.path("date").asText(null));
|
||||
if (date == null) {
|
||||
return;
|
||||
}
|
||||
rows.add(build(year, date, node.path("name").asText(""),
|
||||
node.path("holiday").asBoolean()));
|
||||
});
|
||||
return rows.isEmpty() ? null : sort(rows);
|
||||
} catch (Exception e) {
|
||||
log.warn("timor.tech 拉取失败 year={}: {}", year, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 源2:holiday-cn 官方数据镜像。结构 {days:[{name,date,isOffDay}]} */
|
||||
private List<WorkPlanHoliday> fetchHolidayCn(int year) {
|
||||
try {
|
||||
String body = getForString(HOLIDAY_CN_URL + year + ".json");
|
||||
JsonNode root = mapper.readTree(body);
|
||||
List<WorkPlanHoliday> rows = new ArrayList<>();
|
||||
for (JsonNode node : root.path("days")) {
|
||||
LocalDate date = parseDate(year, null, node.path("date").asText(null));
|
||||
if (date == null) {
|
||||
continue;
|
||||
}
|
||||
rows.add(build(year, date, node.path("name").asText(""),
|
||||
node.path("isOffDay").asBoolean()));
|
||||
}
|
||||
return rows.isEmpty() ? null : sort(rows);
|
||||
} catch (Exception e) {
|
||||
log.warn("holiday-cn 拉取失败 year={}: {}", year, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String getForString(String url) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("User-Agent", BROWSER_UA);
|
||||
ResponseEntity<String> resp = rest().exchange(url, HttpMethod.GET, new HttpEntity<>(headers), String.class);
|
||||
return resp.getBody();
|
||||
}
|
||||
|
||||
private RestTemplate rest() {
|
||||
if (restTemplate == null) {
|
||||
synchronized (this) {
|
||||
if (restTemplate == null) {
|
||||
SimpleClientHttpRequestFactory f = new SimpleClientHttpRequestFactory();
|
||||
f.setConnectTimeout(2000);
|
||||
f.setReadTimeout(4000);
|
||||
restTemplate = new RestTemplate(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
return restTemplate;
|
||||
}
|
||||
|
||||
private WorkPlanHoliday build(int year, LocalDate date, String name, boolean offDay) {
|
||||
WorkPlanHoliday h = new WorkPlanHoliday();
|
||||
h.setYear(year);
|
||||
h.setDate(date);
|
||||
h.setName(name);
|
||||
h.setOffDay(offDay);
|
||||
return h;
|
||||
}
|
||||
|
||||
/** 优先用响应内 date 字段,缺失时退回 "yyyy-MM-dd" 拼接 map key(timor 的 MM-dd) */
|
||||
private LocalDate parseDate(int year, String mapKey, String dateText) {
|
||||
try {
|
||||
if (dateText != null && !dateText.isEmpty()) {
|
||||
return LocalDate.parse(dateText, ISO_DATE);
|
||||
}
|
||||
if (mapKey != null && mapKey.length() == 5) {
|
||||
return LocalDate.parse(year + "-" + mapKey, ISO_DATE);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 非法日期条目直接跳过
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<LocalDate, WorkPlanHoliday> toMap(List<WorkPlanHoliday> rows) {
|
||||
Map<LocalDate, WorkPlanHoliday> map = new HashMap<>();
|
||||
for (WorkPlanHoliday r : rows) {
|
||||
map.put(r.getDate(), r);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private List<WorkPlanHoliday> sort(List<WorkPlanHoliday> rows) {
|
||||
rows.sort(Comparator.comparing(WorkPlanHoliday::getDate));
|
||||
return rows;
|
||||
}
|
||||
|
||||
private String abbreviate(String s) {
|
||||
if (s == null) {
|
||||
return "null";
|
||||
}
|
||||
return s.length() > 200 ? s.substring(0, 200) + "..." : s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.yunhai.workweekplan.service.impl;
|
||||
|
||||
import com.yunhai.workweekplan.aspect.SysOperationLogAspect;
|
||||
import com.yunhai.workweekplan.entity.SysOperationLog;
|
||||
import com.yunhai.workweekplan.model.BasePageQuery;
|
||||
import com.yunhai.workweekplan.model.BasePageResult;
|
||||
import com.yunhai.workweekplan.repository.SysOperationLogRepository;
|
||||
import com.yunhai.workweekplan.service.ISysOperationLogService;
|
||||
import com.yunhai.workweekplan.utils.PageUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 系统审计日志业务实现:@Async 异步落库,失败仅记日志不抛出。
|
||||
*/
|
||||
@Service
|
||||
public class SysOperationLogService implements ISysOperationLogService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SysOperationLogService.class);
|
||||
|
||||
@Autowired
|
||||
private SysOperationLogRepository repository;
|
||||
|
||||
@Override
|
||||
@Async
|
||||
public void saveAsync(SysOperationLog entity) {
|
||||
// 不加 @Transactional:save() 单条自原子;若外层事务在异常后提交 rollback-only
|
||||
// 会抛 UnexpectedRollbackException 逃出 try/catch,被异步线程兜底处理器当成未捕获异常
|
||||
try {
|
||||
repository.save(entity);
|
||||
} catch (Exception e) {
|
||||
log.warn("[saveAsync] 审计日志落库失败, uri={}", entity == null ? null : entity.getRequestUri());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public BasePageResult<SysOperationLog> page(BasePageQuery query, String operationType, Integer statusCode) {
|
||||
Specification<SysOperationLog> spec = (root, cq, cb) -> {
|
||||
List<Predicate> ps = new ArrayList<>();
|
||||
if (StringUtils.hasText(operationType)) {
|
||||
ps.add(cb.equal(root.get("operationType"), operationType));
|
||||
}
|
||||
if (statusCode != null) {
|
||||
ps.add(cb.equal(root.get("statusCode"), statusCode));
|
||||
}
|
||||
if (StringUtils.hasText(query.getKeyword())) {
|
||||
String kw = "%" + query.getKeyword().trim() + "%";
|
||||
Predicate uriLike = cb.like(root.get("requestUri"), kw);
|
||||
Predicate nameLike = cb.like(root.get("userName"), kw);
|
||||
Predicate methodLike = cb.like(root.get("classMethod"), kw);
|
||||
ps.add(cb.or(uriLike, nameLike, methodLike));
|
||||
}
|
||||
return cb.and(ps.toArray(new Predicate[0]));
|
||||
};
|
||||
Pageable pageable = PageUtils.toPageable(query, Sort.by(Sort.Direction.DESC, "createTime"));
|
||||
Page<SysOperationLog> page = repository.findAll(spec, pageable);
|
||||
page.getContent().forEach(e -> e.setResponseData(null));
|
||||
return PageUtils.toResult(page);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.yunhai.workweekplan.service.impl;
|
||||
|
||||
import com.yunhai.workweekplan.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.BoaModule;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 用户中心门面服务实现。
|
||||
* <p>跨仓调用统一收敛到 {@link YschBaseOpenApi} 门面,Controller 不再直接静态调用。</p>
|
||||
*/
|
||||
@Service
|
||||
public class UserCenterService implements IUserCenterService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UserCenterService.class);
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> getUserModuleTree(Integer userId, Integer schId) {
|
||||
if (userId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空");
|
||||
}
|
||||
List<BoaModule> modules = YschBaseOpenApi.getUserModule(userId, schId);
|
||||
if (modules == null || modules.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
// 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望
|
||||
return buildModuleTree(modules);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将扁平模块列表组装为树(对应原 NoticeController.buildModuleTree)。
|
||||
* 顶层取 parentId=0 或父节点不在列表中的节点,子节点按 orderNumber 排序。
|
||||
*/
|
||||
private List<Map<String, Object>> buildModuleTree(List<BoaModule> modules) {
|
||||
Map<Integer, BoaModule> moduleMap = modules.stream()
|
||||
.collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a));
|
||||
|
||||
// 每个模块节点(含 children 占位)
|
||||
Map<Integer, Map<String, Object>> nodeMap = new LinkedHashMap<>();
|
||||
for (BoaModule m : modules) {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("id", m.getId());
|
||||
node.put("modId", m.getId());
|
||||
node.put("name", m.getName());
|
||||
node.put("parentId", m.getParentId() != null ? m.getParentId() : 0);
|
||||
node.put("page", m.getPath() != null ? m.getPath() : "");
|
||||
node.put("level", m.getLevel() != null ? m.getLevel() : 0);
|
||||
node.put("icon", m.getIcon() != null ? m.getIcon() : "");
|
||||
node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0);
|
||||
node.put("usable", true);
|
||||
node.put("children", new ArrayList<>());
|
||||
nodeMap.put(m.getId(), node);
|
||||
}
|
||||
|
||||
// 挂父子关系
|
||||
List<Map<String, Object>> roots = new ArrayList<>();
|
||||
Set<Integer> moduleIds = new HashSet<>(moduleMap.keySet());
|
||||
for (BoaModule m : modules) {
|
||||
Map<String, Object> node = nodeMap.get(m.getId());
|
||||
int parentId = m.getParentId() != null ? m.getParentId() : 0;
|
||||
if (parentId == 0 || !moduleIds.contains(parentId)) {
|
||||
roots.add(node);
|
||||
} else {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> children = (List<Map<String, Object>>) nodeMap.get(parentId).get("children");
|
||||
children.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
// 保持 SDK(用户中心)返回顺序,不再按 orderNumber 重排
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.yunhai.workweekplan.service.impl;
|
||||
|
||||
import com.alibaba.excel.EasyExcel;
|
||||
import com.yunhai.workweekplan.entity.WorkPlanRecord;
|
||||
import com.yunhai.workweekplan.entity.WorkPlanWeekRemark;
|
||||
import com.yunhai.workweekplan.model.workplan.ImportResultVo;
|
||||
import com.yunhai.workweekplan.repository.WorkPlanRecordRepository;
|
||||
import com.yunhai.workweekplan.repository.WorkPlanWeekRemarkRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 一周安排 Excel 导入(移植自历史 BestSchool.WorkPlan ImportWorkPlan)。
|
||||
* <p>列契约:本周 | 时间 | 工作内容 | 负责人(部门) | 地点 | 出席对象;
|
||||
* "本周"列取 周一~周日;末尾可加"备注"行(备注文本写在"时间"列);
|
||||
* 追加式导入(不清空本周已有数据),同历史行为。</p>
|
||||
*/
|
||||
@Service
|
||||
public class WorkPlanImportService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WorkPlanImportService.class);
|
||||
|
||||
private static final String[] PLAN_COLUMNS = {"本周", "时间", "工作内容", "负责人(部门)", "地点", "出席对象"};
|
||||
private static final String[] WEEK_NUM = WorkPlanService.WEEK_NUM;
|
||||
private static final DateTimeFormatter HHMM = DateTimeFormatter.ofPattern("HH:mm");
|
||||
|
||||
@Autowired
|
||||
private WorkPlanRecordRepository recordRepository;
|
||||
|
||||
@Autowired
|
||||
private WorkPlanWeekRemarkRepository remarkRepository;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ImportResultVo importExcel(MultipartFile file, Integer schId, Integer userId, LocalDate monday) {
|
||||
List<Map<Integer, String>> rows = readRows(file);
|
||||
if (rows.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "Excel 内容为空!");
|
||||
}
|
||||
|
||||
// 表头校验(同历史:逐列精确匹配)
|
||||
Map<Integer, String> header = rows.get(0);
|
||||
for (int i = 0; i < PLAN_COLUMNS.length; i++) {
|
||||
if (!PLAN_COLUMNS[i].equals(StringUtils.trimToEmpty(header.get(i)))) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "Excel栏目不正确!");
|
||||
}
|
||||
}
|
||||
|
||||
ImportResultVo result = new ImportResultVo();
|
||||
List<ImportResultVo.ErrorVo> errors = result.getErrors();
|
||||
List<WorkPlanRecord> toSave = new ArrayList<>();
|
||||
List<String> remarks = new ArrayList<>();
|
||||
|
||||
for (int i = 1; i < rows.size(); i++) {
|
||||
Map<Integer, String> row = rows.get(i);
|
||||
int excelRow = i + 1;
|
||||
String ksrq = StringUtils.trimToEmpty(row.get(0));
|
||||
String sj = StringUtils.trimToEmpty(row.get(1));
|
||||
String gznr = StringUtils.trimToEmpty(row.get(2));
|
||||
String fzr = StringUtils.trimToEmpty(row.get(3));
|
||||
String dd = StringUtils.trimToEmpty(row.get(4));
|
||||
String cjry = StringUtils.trimToEmpty(row.get(5));
|
||||
|
||||
// 整行为空跳过
|
||||
if (StringUtils.isAllBlank(ksrq, sj, gznr, fzr, dd, cjry)) {
|
||||
continue;
|
||||
}
|
||||
String rowLabel = StringUtils.defaultIfBlank(ksrq, "第" + excelRow + "行");
|
||||
|
||||
// 备注行:备注文本写在"时间"列(同历史)
|
||||
if ("备注".equals(ksrq)) {
|
||||
remarks.add(sj);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(gznr)) {
|
||||
errors.add(new ImportResultVo.ErrorVo(excelRow, rowLabel + " " + PLAN_COLUMNS[2], "工作内容为空跳过此行不导入!", 2));
|
||||
continue;
|
||||
}
|
||||
|
||||
int weekday = weekdayIndex(ksrq);
|
||||
if (weekday < 0) {
|
||||
errors.add(new ImportResultVo.ErrorVo(excelRow, rowLabel + " " + PLAN_COLUMNS[0], "(" + ksrq + ")格式不正确!日期格式:周一至周日", 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
String times = normalizeTime(sj);
|
||||
if (times.length() > 50) {
|
||||
errors.add(new ImportResultVo.ErrorVo(excelRow, rowLabel + " " + PLAN_COLUMNS[1], "(" + times.length() + ")超出50字数!", 1));
|
||||
}
|
||||
if (gznr.length() > 200) {
|
||||
errors.add(new ImportResultVo.ErrorVo(excelRow, rowLabel + " " + PLAN_COLUMNS[2], "(" + gznr.length() + ")超出200字数!", 1));
|
||||
}
|
||||
if (fzr.length() > 200) {
|
||||
errors.add(new ImportResultVo.ErrorVo(excelRow, rowLabel + " " + PLAN_COLUMNS[3], "(" + fzr.length() + ")超出200字数!", 1));
|
||||
}
|
||||
if (dd.length() > 200) {
|
||||
errors.add(new ImportResultVo.ErrorVo(excelRow, rowLabel + " " + PLAN_COLUMNS[4], "(" + dd.length() + ")超出200字数!", 1));
|
||||
}
|
||||
if (cjry.length() > 200) {
|
||||
errors.add(new ImportResultVo.ErrorVo(excelRow, rowLabel + " " + PLAN_COLUMNS[5], "(" + cjry.length() + ")超出200字数!", 1));
|
||||
}
|
||||
|
||||
WorkPlanRecord record = new WorkPlanRecord();
|
||||
LocalDate date = monday.plusDays(weekday);
|
||||
record.setSchId(schId);
|
||||
record.setStartDate(date);
|
||||
record.setEndDate(date);
|
||||
record.setTimes(times);
|
||||
record.setContents(gznr);
|
||||
record.setPrincipal(fzr);
|
||||
record.setPlace(dd);
|
||||
record.setParticipant(cjry);
|
||||
record.setIsOpen(true);
|
||||
// 导入同样只保存教师 id(不存账号)
|
||||
record.setUserId(userId);
|
||||
toSave.add(record);
|
||||
}
|
||||
|
||||
if (errors.stream().anyMatch(e -> e.getRank() == 1)) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("数据异常!");
|
||||
return result;
|
||||
}
|
||||
if (toSave.isEmpty()) {
|
||||
result.setSuccess(false);
|
||||
result.setMessage("保存数据0条!");
|
||||
return result;
|
||||
}
|
||||
|
||||
recordRepository.saveAll(toSave);
|
||||
if (!remarks.isEmpty()) {
|
||||
String remark = String.join("\r\n", remarks);
|
||||
WorkPlanWeekRemark entity = remarkRepository.findBySchIdAndWeekDate(schId, monday).orElse(null);
|
||||
if (entity == null) {
|
||||
entity = new WorkPlanWeekRemark();
|
||||
entity.setSchId(schId);
|
||||
entity.setWeekDate(monday);
|
||||
entity.setRemark(remark);
|
||||
} else {
|
||||
entity.setRemark(remark);
|
||||
}
|
||||
remarkRepository.save(entity);
|
||||
}
|
||||
|
||||
result.setSuccess(true);
|
||||
result.setCount(toSave.size());
|
||||
result.setMessage("导入成功!共导入 " + toSave.size() + " 条");
|
||||
log.info("[WorkPlanImport] 导入成功 {} 条, schId={}, userId={}, monday={}", toSave.size(), schId, userId, monday);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 读取首个 sheet 全部单元格为字符串(含表头行,不做表头拦截) */
|
||||
private List<Map<Integer, String>> readRows(MultipartFile file) {
|
||||
try {
|
||||
List<Map<Integer, String>> rows = EasyExcel.read(file.getInputStream())
|
||||
.sheet(0)
|
||||
.headRowNumber(0)
|
||||
.doReadSync();
|
||||
if (rows == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
// easyexcel 读取时可能把空行剔除为 null 值 map,这里过滤纯空 map
|
||||
return rows.stream().filter(r -> r != null && !r.isEmpty()).collect(Collectors.toList());
|
||||
} catch (IOException e) {
|
||||
log.error("[WorkPlanImport] Excel 读取失败", e);
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "Excel 文件读取失败,请检查文件格式");
|
||||
} catch (Exception e) {
|
||||
log.error("[WorkPlanImport] Excel 解析失败", e);
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "Excel 文件解析失败,请使用导入模板填写");
|
||||
}
|
||||
}
|
||||
|
||||
/** 周一~周日 → 0~6;支持"周一/星期一/一"等写法;无法识别返回 -1 */
|
||||
private int weekdayIndex(String text) {
|
||||
String t = StringUtils.trimToEmpty(text).replace("星期", "").replace("周", "");
|
||||
int idx = Arrays.asList(WEEK_NUM).indexOf(t);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** 可解析为时间的规范化为 HH:mm,否则原样返回(同历史 DateTime.Parse 尝试语义) */
|
||||
private String normalizeTime(String text) {
|
||||
String t = StringUtils.trimToEmpty(text);
|
||||
if (t.isEmpty()) {
|
||||
return t;
|
||||
}
|
||||
for (String pattern : new String[]{"H:mm", "HH:mm", "H:mm:ss", "HH:mm:ss", "H点mm分", "H:mm分"}) {
|
||||
try {
|
||||
return LocalTime.parse(t, DateTimeFormatter.ofPattern(pattern)).format(HHMM);
|
||||
} catch (Exception ignore) {
|
||||
// 尝试下一种格式
|
||||
}
|
||||
}
|
||||
try {
|
||||
return LocalTime.parse(t).format(HHMM);
|
||||
} catch (Exception ignore) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
package com.yunhai.workweekplan.service.impl;
|
||||
|
||||
import com.yunhai.workweekplan.entity.WorkPlanFirstDay;
|
||||
import com.yunhai.workweekplan.entity.WorkPlanRecord;
|
||||
import com.yunhai.workweekplan.entity.WorkPlanWeekRemark;
|
||||
import com.yunhai.workweekplan.enums.UserType;
|
||||
import com.yunhai.workweekplan.model.BasePageResult;
|
||||
import com.yunhai.workweekplan.model.workplan.AllPlanItemVo;
|
||||
import com.yunhai.workweekplan.model.workplan.AllPlanQuery;
|
||||
import com.yunhai.workweekplan.model.workplan.FirstDayVo;
|
||||
import com.yunhai.workweekplan.model.workplan.ImportResultVo;
|
||||
import com.yunhai.workweekplan.model.workplan.OneWeekPlanVo;
|
||||
import com.yunhai.workweekplan.model.workplan.OapiWeekPlanVo;
|
||||
import com.yunhai.workweekplan.model.workplan.WeekPlanSaveRequest;
|
||||
import com.yunhai.workweekplan.model.workplan.WeekRemarkRequest;
|
||||
import com.yunhai.workweekplan.model.workplan.WeekTimeVo;
|
||||
import com.yunhai.workweekplan.model.workplan.WorkPlanItemVo;
|
||||
import com.yunhai.workweekplan.repository.WorkPlanFirstDayRepository;
|
||||
import com.yunhai.workweekplan.repository.WorkPlanRecordRepository;
|
||||
import com.yunhai.workweekplan.repository.WorkPlanWeekRemarkRepository;
|
||||
import com.yunhai.workweekplan.service.IHolidayService;
|
||||
import com.yunhai.workweekplan.service.IWorkPlanService;
|
||||
import com.yunhai.workweekplan.utils.PageUtils;
|
||||
import com.yunhai.workweekplan.utils.UserContext;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
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.web.multipart.MultipartFile;
|
||||
|
||||
import javax.persistence.criteria.Predicate;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Month;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 一周安排业务实现(移植自历史 BestSchool.WorkPlan WeekWorkPlan/PlanCommon)。
|
||||
* <p>与历史的差异:</p>
|
||||
* <ul>
|
||||
* <li>账号体系改为平台用户:记录只保存教师/平台用户 id(user_id),不存账号;</li>
|
||||
* <li>权限映射:历史 role 1=教师/2=校领导/7=管理员 → 现 roleId 2/7 可管理全部,roleId 1 及其余仅本人
|
||||
* (roleId 缺失回退账号类型 超管/校管);</li>
|
||||
* <li>差集删除同样受权限约束(教师只能删除本人记录),修复了历史实现教师可删他人记录的问题;</li>
|
||||
* <li>跨天安排不移植:保存路径恒为单日(startDate=endDate),历史数据亦然;</li>
|
||||
* <li>节假日展示移植历史 Workday.GetHoliday:无安排日占位为 节日名/无/休(见 {@link HolidayService})。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
public class WorkPlanService implements IWorkPlanService {
|
||||
|
||||
/** 星期中文(下标 0~6 对应周一~周日,对应历史 PlanCommon.WeekNum) */
|
||||
public static final String[] WEEK_NUM = {"一", "二", "三", "四", "五", "六", "日"};
|
||||
|
||||
/** 平台角色:管理员(UserclassService UserRole.AREAADMIN,同历史 role 7) */
|
||||
private static final int ROLE_ADMIN = 7;
|
||||
|
||||
/** 平台角色:校领导(UserclassService UserRole.SCH_LEADER_USER,同历史 role 2) */
|
||||
private static final int ROLE_SCH_LEADER = 2;
|
||||
|
||||
private static final DateTimeFormatter MMDD = DateTimeFormatter.ofPattern("MM.dd");
|
||||
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
|
||||
|
||||
@Autowired
|
||||
private WorkPlanRecordRepository recordRepository;
|
||||
|
||||
@Autowired
|
||||
private WorkPlanWeekRemarkRepository remarkRepository;
|
||||
|
||||
@Autowired
|
||||
private WorkPlanFirstDayRepository firstDayRepository;
|
||||
|
||||
@Autowired
|
||||
private WorkPlanImportService importService;
|
||||
|
||||
@Autowired
|
||||
private IHolidayService holidayService;
|
||||
|
||||
// ============================ 学年学期 / 周次 ============================
|
||||
|
||||
@Override
|
||||
public List<Integer> getYears() {
|
||||
int now = nowSchoolYear();
|
||||
List<Integer> years = new ArrayList<>();
|
||||
for (int y = now; y > now - 7; y--) {
|
||||
years.add(y);
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getCurrentTerm() {
|
||||
return nowSchoolTerm();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<WeekTimeVo> getWeektime(Integer year, Integer term) {
|
||||
checkYearTerm(year, term);
|
||||
LocalDate monday = resolveFirstDay(schId(), year, term);
|
||||
LocalDate termEnd = termEndDate(year, term);
|
||||
|
||||
boolean isCurrentYearTerm = year == nowSchoolYear() && nowSchoolTerm() == term;
|
||||
LocalDate today = LocalDate.now();
|
||||
|
||||
List<WeekTimeVo> list = new ArrayList<>();
|
||||
int week = 0;
|
||||
while (!monday.plusDays(week * 7L).isAfter(termEnd)) {
|
||||
LocalDate kssj = monday.plusDays(week * 7L);
|
||||
LocalDate jssj = kssj.plusDays(6);
|
||||
boolean checked = isCurrentYearTerm
|
||||
? !today.isBefore(kssj) && !today.isAfter(jssj)
|
||||
: week == 0;
|
||||
list.add(new WeekTimeVo(week + 1, "(" + MMDD.format(kssj) + "-" + MMDD.format(jssj) + ")", checked));
|
||||
week++;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
// ============================ 周视图 / 编辑视图 ============================
|
||||
|
||||
@Override
|
||||
public OneWeekPlanVo getOneWeekPlan(Integer year, Integer term, Integer week) {
|
||||
checkYearTerm(year, term);
|
||||
return buildWeekView(schId(), canEditAll(), year, term, week);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OapiWeekPlanVo oapiOneWeekPlan(Integer userId, Integer schId) {
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "schId 不能为空");
|
||||
}
|
||||
int year = nowSchoolYear();
|
||||
int term = nowSchoolTerm();
|
||||
// 当前周次:与站内周次下拉的 current 口径一致(包含今天的那一周,周一为一周之始);
|
||||
// 早于首周(如假期)按第 1 周
|
||||
LocalDate firstDay = resolveFirstDay(schId, year, term);
|
||||
int week = (int) (ChronoUnit.DAYS.between(firstDay, LocalDate.now()) / 7) + 1;
|
||||
// 全校共享数据,开放接口无角色概念:managerView 恒 false(权限标记不外发)
|
||||
OneWeekPlanVo view = buildWeekView(schId, false, year, term, Math.max(week, 1));
|
||||
|
||||
OapiWeekPlanVo vo = new OapiWeekPlanVo();
|
||||
vo.setTitle(view.getTitle());
|
||||
vo.setYear(view.getYear());
|
||||
vo.setTerm(view.getTerm());
|
||||
vo.setWeek(view.getWeek());
|
||||
vo.setMonday(view.getMonday());
|
||||
vo.setDays(view.getDays());
|
||||
vo.setRemark(view.getRemark());
|
||||
vo.setHasData(view.isHasData());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 周视图构建(站内 OneWeekPlan 与 oapi 本周安排共用;managerView 对应站内 canEditAll 口径) */
|
||||
private OneWeekPlanVo buildWeekView(Integer schId, boolean managerView, Integer year, Integer term, Integer week) {
|
||||
checkYearTerm(year, term);
|
||||
LocalDate monday = weekMonday(schId, year, term, week);
|
||||
LocalDate sunday = monday.plusDays(6);
|
||||
|
||||
List<WorkPlanRecord> records = recordRepository.findInWeek(schId, monday, sunday);
|
||||
Map<LocalDate, List<WorkPlanRecord>> byDate = records.stream()
|
||||
.collect(Collectors.groupingBy(WorkPlanRecord::getStartDate));
|
||||
|
||||
OneWeekPlanVo vo = new OneWeekPlanVo();
|
||||
vo.setYear(year);
|
||||
vo.setTerm(term);
|
||||
vo.setWeek(week);
|
||||
vo.setMonday(monday);
|
||||
vo.setHasData(!records.isEmpty());
|
||||
vo.setCanDelete(managerView);
|
||||
vo.setCanEditAll(managerView);
|
||||
vo.setTitle(" " + year + "-" + term + " 第" + week + "周 学校工作安排 ("
|
||||
+ MMDD.format(monday) + "-" + MMDD.format(sunday) + ")");
|
||||
for (int i = 0; i < 7; i++) {
|
||||
LocalDate date = monday.plusDays(i);
|
||||
OneWeekPlanVo.DayVo day = new OneWeekPlanVo.DayVo();
|
||||
day.setDate(date);
|
||||
day.setDateText(MMDD.format(date));
|
||||
day.setWeekday(WEEK_NUM[i]);
|
||||
day.setItems(byDate.getOrDefault(date, Collections.emptyList()).stream()
|
||||
.map(r -> toItemVo(r, false))
|
||||
.collect(Collectors.toList()));
|
||||
IHolidayService.DayInfo info = holidayService.getDayInfo(date);
|
||||
day.setDayType(info.getType());
|
||||
day.setDayLabel(info.getName());
|
||||
vo.getDays().add(day);
|
||||
}
|
||||
vo.setRemark(remarkRepository.findBySchIdAndWeekDate(schId, monday)
|
||||
.map(WorkPlanWeekRemark::getRemark).orElse(""));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<WorkPlanItemVo> getEditList(Integer year, Integer term, Integer week) {
|
||||
checkYearTerm(year, term);
|
||||
LocalDate monday = weekMonday(schId(), year, term, week);
|
||||
return recordRepository.findInWeek(schId(), monday, monday.plusDays(6)).stream()
|
||||
.map(r -> toItemVo(r, true))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ============================ 保存 / 删除 ============================
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void saveWeekPlan(WeekPlanSaveRequest request) {
|
||||
Integer schId = schId();
|
||||
Integer year = request.getYear();
|
||||
Integer term = request.getTerm();
|
||||
int week = request.getWeek();
|
||||
checkYearTerm(year, term);
|
||||
LocalDate monday = weekMonday(schId, year, term, week);
|
||||
|
||||
List<WorkPlanItemVo> items = request.getItems() == null ? Collections.emptyList() : request.getItems();
|
||||
// 跳过内容全空的行为(前端兜底,双保险)
|
||||
items = items.stream()
|
||||
.filter(it -> it != null && StringUtils.isNotBlank(it.getContents()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<WorkPlanRecord> existing = recordRepository.findInWeek(schId, monday, monday.plusDays(6));
|
||||
Map<Integer, WorkPlanRecord> existingById = existing.stream()
|
||||
.collect(Collectors.toMap(WorkPlanRecord::getId, r -> r, (a, b) -> a));
|
||||
|
||||
Set<Integer> submittedIds = items.stream()
|
||||
.map(WorkPlanItemVo::getId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 差集删除:库中存在、提交列表没有 → 删除(受权限约束,教师仅删本人)
|
||||
boolean admin = canEditAll();
|
||||
Integer me = UserContext.getUserId();
|
||||
List<WorkPlanRecord> toDelete = existing.stream()
|
||||
.filter(r -> !submittedIds.contains(r.getId()))
|
||||
.filter(r -> admin || isOwner(r, me))
|
||||
.collect(Collectors.toList());
|
||||
if (!toDelete.isEmpty()) {
|
||||
recordRepository.deleteAll(toDelete);
|
||||
}
|
||||
|
||||
for (WorkPlanItemVo item : items) {
|
||||
LocalDate date = monday.plusDays(item.getIweek() - 1);
|
||||
if (item.getId() != null && item.getId() > 0) {
|
||||
WorkPlanRecord record = existingById.get(item.getId());
|
||||
// 权限过滤更新范围:教师只能改本人记录;id 已失效(被他人删除)则忽略
|
||||
if (record != null && (admin || isOwner(record, me))) {
|
||||
record.setStartDate(date);
|
||||
record.setEndDate(date);
|
||||
record.setTimes(item.getTimes());
|
||||
record.setContents(item.getContents());
|
||||
record.setPrincipal(item.getPrincipal());
|
||||
record.setPlace(item.getPlace());
|
||||
record.setParticipant(item.getParticipant());
|
||||
}
|
||||
} else {
|
||||
WorkPlanRecord record = new WorkPlanRecord();
|
||||
record.setSchId(schId);
|
||||
record.setStartDate(date);
|
||||
record.setEndDate(date);
|
||||
record.setTimes(item.getTimes());
|
||||
record.setContents(item.getContents());
|
||||
record.setPrincipal(item.getPrincipal());
|
||||
record.setPlace(item.getPlace());
|
||||
record.setParticipant(item.getParticipant());
|
||||
record.setIsOpen(true);
|
||||
// 业务保存教师 id(不存账号)
|
||||
record.setUserId(me);
|
||||
recordRepository.save(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateRemark(WeekRemarkRequest request) {
|
||||
checkYearTerm(request.getYear(), request.getTerm());
|
||||
LocalDate monday = weekMonday(schId(), request.getYear(), request.getTerm(), request.getWeek());
|
||||
String remark = StringUtils.defaultString(request.getRemark());
|
||||
WorkPlanWeekRemark entity = remarkRepository.findBySchIdAndWeekDate(schId(), monday).orElse(null);
|
||||
if (entity == null) {
|
||||
entity = new WorkPlanWeekRemark();
|
||||
entity.setSchId(schId());
|
||||
entity.setWeekDate(monday);
|
||||
entity.setRemark(remark);
|
||||
} else {
|
||||
entity.setRemark(remark);
|
||||
}
|
||||
remarkRepository.save(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void deleteWeekPlan(Integer year, Integer term, Integer week) {
|
||||
if (!canEditAll()) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "仅管理员/校领导可删除本周安排");
|
||||
}
|
||||
checkYearTerm(year, term);
|
||||
Integer schId = schId();
|
||||
LocalDate monday = weekMonday(schId, year, term, week);
|
||||
List<WorkPlanRecord> records = recordRepository.findInWeek(schId, monday, monday.plusDays(6));
|
||||
if (!records.isEmpty()) {
|
||||
recordRepository.deleteAll(records);
|
||||
}
|
||||
remarkRepository.findBySchIdAndWeekDate(schId, monday).ifPresent(remarkRepository::delete);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void removeRecord(Integer id) {
|
||||
WorkPlanRecord record = recordRepository.findById(id)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "记录不存在或已被删除"));
|
||||
if (!canEditAll() && !isOwner(record, UserContext.getUserId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "无权删除他人创建的安排");
|
||||
}
|
||||
recordRepository.delete(record);
|
||||
}
|
||||
|
||||
// ============================ 全部安排 ============================
|
||||
|
||||
@Override
|
||||
public BasePageResult<AllPlanItemVo> pageAllPlan(AllPlanQuery query) {
|
||||
Integer schId = schId();
|
||||
String name = StringUtils.trimToNull(query.getName());
|
||||
|
||||
Specification<WorkPlanRecord> spec = (root, cq, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.equal(root.get("schId"), schId));
|
||||
if (query.getStartDt() != null) {
|
||||
predicates.add(cb.greaterThanOrEqualTo(root.get("endDate"), query.getStartDt()));
|
||||
}
|
||||
if (query.getEndDt() != null) {
|
||||
predicates.add(cb.lessThanOrEqualTo(root.get("startDate"), query.getEndDt()));
|
||||
}
|
||||
if (name != null) {
|
||||
String like = "%" + name + "%";
|
||||
predicates.add(cb.or(
|
||||
cb.like(root.get("contents"), like),
|
||||
cb.like(root.get("principal"), like),
|
||||
cb.like(root.get("place"), like),
|
||||
cb.like(root.get("participant"), like)
|
||||
));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
Page<WorkPlanRecord> page = recordRepository.findAll(spec,
|
||||
PageUtils.toPageable(query, Sort.by(Sort.Order.desc("startDate"), Sort.Order.desc("id"))));
|
||||
|
||||
// 学年学期首周查询缓存(同页多行同学年学期时避免重复查库)
|
||||
Map<String, LocalDate> firstDayCache = new HashMap<>();
|
||||
List<AllPlanItemVo> content = page.getContent().stream().map(r -> {
|
||||
AllPlanItemVo vo = new AllPlanItemVo();
|
||||
vo.setId(r.getId());
|
||||
vo.setDate(r.getStartDate());
|
||||
vo.setTimes(r.getTimes());
|
||||
vo.setContents(r.getContents());
|
||||
vo.setPrincipal(r.getPrincipal());
|
||||
vo.setPlace(r.getPlace());
|
||||
vo.setParticipant(r.getParticipant());
|
||||
int[] yearTerm = yearTermOf(r.getStartDate());
|
||||
vo.setYear(yearTerm[0]);
|
||||
vo.setTerm(yearTerm[1]);
|
||||
String key = yearTerm[0] + "-" + yearTerm[1];
|
||||
LocalDate firstDay = firstDayCache.computeIfAbsent(key,
|
||||
k -> resolveFirstDay(schId, yearTerm[0], yearTerm[1]));
|
||||
long gap = ChronoUnit.DAYS.between(firstDay, r.getStartDate());
|
||||
vo.setWeek(gap < 0 ? 0 : (int) (gap / 7 + 1));
|
||||
return vo;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
return new BasePageResult<>(content, (int) page.getTotalElements(),
|
||||
page.getNumber() + 1, page.getSize(), page.getTotalPages(), page.hasNext());
|
||||
}
|
||||
|
||||
// ============================ 首周设置 ============================
|
||||
|
||||
@Override
|
||||
public FirstDayVo getFirstDay(Integer year, Integer term) {
|
||||
checkYearTerm(year, term);
|
||||
FirstDayVo vo = new FirstDayVo();
|
||||
Optional<WorkPlanFirstDay> setting = firstDayRepository.findBySchIdAndYearAndTerm(schId(), year, term);
|
||||
if (setting.isPresent()) {
|
||||
vo.setFirstDay(ISO.format(setting.get().getFirstDay()));
|
||||
vo.setUsingDefault(false);
|
||||
} else {
|
||||
vo.setFirstDay(ISO.format(defaultFirstDay(year, term)));
|
||||
vo.setUsingDefault(true);
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void setFirstDay(Integer year, Integer term, LocalDate firstDay) {
|
||||
checkYearTerm(year, term);
|
||||
if (firstDay == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "日期不能为空");
|
||||
}
|
||||
WorkPlanFirstDay entity = firstDayRepository.findBySchIdAndYearAndTerm(schId(), year, term).orElse(null);
|
||||
if (entity == null) {
|
||||
entity = new WorkPlanFirstDay();
|
||||
entity.setSchId(schId());
|
||||
entity.setYear(year);
|
||||
entity.setTerm(term);
|
||||
entity.setFirstDay(firstDay);
|
||||
} else {
|
||||
entity.setFirstDay(firstDay);
|
||||
}
|
||||
firstDayRepository.save(entity);
|
||||
}
|
||||
|
||||
// ============================ Excel 导入 ============================
|
||||
|
||||
@Override
|
||||
public ImportResultVo importExcel(MultipartFile file, Integer year, Integer term, Integer week) {
|
||||
checkYearTerm(year, term);
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "请选择要导入的 Excel 文件");
|
||||
}
|
||||
LocalDate monday = weekMonday(schId(), year, term, week);
|
||||
return importService.importExcel(file, schId(), UserContext.getUserId(), monday);
|
||||
}
|
||||
|
||||
// ============================ 内部方法:日期推算(对应历史 PlanCommon) ============================
|
||||
|
||||
/** 学期首周周一:优先读学校设置,否则按默认规则推算 */
|
||||
private LocalDate resolveFirstDay(Integer schId, Integer year, Integer term) {
|
||||
return firstDayRepository.findBySchIdAndYearAndTerm(schId, year, term)
|
||||
.map(WorkPlanFirstDay::getFirstDay)
|
||||
.orElse(defaultFirstDay(year, term));
|
||||
}
|
||||
|
||||
/** 默认首周周一:第一学期取学年 9 月 1 日起的周一;第二学期取次年 3 月 1 日起的周一 */
|
||||
private LocalDate defaultFirstDay(Integer year, Integer term) {
|
||||
LocalDate anchor = term == 1 ? LocalDate.of(year, Month.SEPTEMBER, 1) : LocalDate.of(year + 1, Month.MARCH, 1);
|
||||
return anchor.with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY));
|
||||
}
|
||||
|
||||
/** 学期结束日(对应历史默认边界):第一学期次年 2 月 10 日;第二学期次年 7 月 10 日 */
|
||||
private LocalDate termEndDate(Integer year, Integer term) {
|
||||
return term == 1 ? LocalDate.of(year + 1, Month.FEBRUARY, 10) : LocalDate.of(year + 1, Month.JULY, 10);
|
||||
}
|
||||
|
||||
/** 该周周一:week<=0 取真实本周(周日归一到下周一,同历史 FirstDateOfWeek 语义) */
|
||||
private LocalDate weekMonday(Integer schId, Integer year, Integer term, Integer week) {
|
||||
if (week == null || week <= 0) {
|
||||
return LocalDate.now().with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY));
|
||||
}
|
||||
return resolveFirstDay(schId, year, term).plusDays((week - 1) * 7L);
|
||||
}
|
||||
|
||||
/** 当前学年:9-12 月 → 当年;1-8 月 → 上一年(第二学期属于上一学年) */
|
||||
private static int nowSchoolYear() {
|
||||
LocalDate now = LocalDate.now();
|
||||
int year = now.getYear();
|
||||
return now.getMonthValue() >= Month.SEPTEMBER.getValue() ? year : year - 1;
|
||||
}
|
||||
|
||||
/** 当前学期:9 月~次年 2 月 → 1;3~8 月 → 2 */
|
||||
private static int nowSchoolTerm() {
|
||||
int m = LocalDate.now().getMonthValue();
|
||||
return (m >= Month.SEPTEMBER.getValue() || m <= Month.FEBRUARY.getValue()) ? 1 : 2;
|
||||
}
|
||||
|
||||
/** 由日期反推 [学年, 学期](与 nowSchoolYear/nowSchoolTerm 同一边界) */
|
||||
private static int[] yearTermOf(LocalDate date) {
|
||||
int m = date.getMonthValue();
|
||||
if (m >= Month.SEPTEMBER.getValue()) {
|
||||
return new int[]{date.getYear(), 1};
|
||||
}
|
||||
if (m <= Month.FEBRUARY.getValue()) {
|
||||
return new int[]{date.getYear() - 1, 1};
|
||||
}
|
||||
return new int[]{date.getYear() - 1, 2};
|
||||
}
|
||||
|
||||
// ============================ 内部方法:权限 / 上下文 ============================
|
||||
|
||||
/**
|
||||
* 管理员(7)/校领导(2) 可管理整周安排(显示新增/编辑/备注/导入/删除按钮),普通教师(1)仅本人相关。
|
||||
* roleId 取自 currentrole 头(平台切换视角后跟随变化);缺失(内部/调试通道无 currentrole)
|
||||
* 时回退账号类型:超管/校管视为管理员。
|
||||
*/
|
||||
private boolean canEditAll() {
|
||||
Integer roleId = UserContext.getRoleId();
|
||||
if (roleId != null) {
|
||||
return roleId == ROLE_ADMIN || roleId == ROLE_SCH_LEADER;
|
||||
}
|
||||
Integer type = UserContext.getType();
|
||||
return UserType.SUPER_ADMINISTRATOR.getId().equals(type)
|
||||
|| UserType.SCHOOL_ADMIN.getId().equals(type);
|
||||
}
|
||||
|
||||
/** 当前用户是否为记录创建人(双方均非 null 才比较,避免空值互判为本人) */
|
||||
private static boolean isOwner(WorkPlanRecord record, Integer me) {
|
||||
return me != null && Objects.equals(record.getUserId(), me);
|
||||
}
|
||||
|
||||
private Integer schId() {
|
||||
Integer schId = UserContext.getSchId();
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "无法识别当前学校,请重新选择角色");
|
||||
}
|
||||
return schId;
|
||||
}
|
||||
|
||||
private static void checkYearTerm(Integer year, Integer term) {
|
||||
if (year == null || year < 2000 || year > 2100) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "学年不合法");
|
||||
}
|
||||
if (term == null || (term != 1 && term != 2)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "学期取值 1-2");
|
||||
}
|
||||
}
|
||||
|
||||
private WorkPlanItemVo toItemVo(WorkPlanRecord r, boolean withEditable) {
|
||||
WorkPlanItemVo vo = new WorkPlanItemVo();
|
||||
vo.setId(r.getId());
|
||||
// 周一=1 … 周日=7(同历史 startDate.DayOfWeek==0?7 映射)
|
||||
DayOfWeek dow = r.getStartDate().getDayOfWeek();
|
||||
vo.setIweek(dow == DayOfWeek.SUNDAY ? 7 : dow.getValue());
|
||||
vo.setTimes(r.getTimes());
|
||||
vo.setContents(r.getContents());
|
||||
vo.setPrincipal(r.getPrincipal());
|
||||
vo.setPlace(r.getPlace());
|
||||
vo.setParticipant(r.getParticipant());
|
||||
if (withEditable) {
|
||||
vo.setEditable(canEditAll() || isOwner(r, UserContext.getUserId()));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.yunhai.workweekplan.utils;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
/**
|
||||
* GZIP 压缩工具(审计日志响应体压缩落库用)。
|
||||
*/
|
||||
public class GzipUtil {
|
||||
|
||||
private GzipUtil() {
|
||||
}
|
||||
|
||||
public static byte[] compress(String data) {
|
||||
if (data == null || data.isEmpty()) {
|
||||
return new byte[0];
|
||||
}
|
||||
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
GZIPOutputStream gzip = new GZIPOutputStream(bos)) {
|
||||
gzip.write(data.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
gzip.finish();
|
||||
return bos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
return new byte[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.yunhai.workweekplan.utils;
|
||||
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* 客户端 IP 解析(移植自 UserclassService OtherUtil.getClientIp)。
|
||||
* 按 X-Forwarded-For 等代理头链路取真实 IP,多级代理取第一个。
|
||||
*/
|
||||
public class IpUtils {
|
||||
|
||||
private IpUtils() {
|
||||
}
|
||||
|
||||
public static String getClientIp() {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) {
|
||||
return "unknown";
|
||||
}
|
||||
HttpServletRequest request = attributes.getRequest();
|
||||
String ip = request.getHeader("X-Forwarded-For");
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("WL-Proxy-Client-IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("HTTP_CLIENT_IP");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
|
||||
}
|
||||
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
|
||||
ip = request.getRemoteAddr();
|
||||
}
|
||||
if (ip != null && ip.contains(",")) {
|
||||
ip = ip.split(",")[0].trim();
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.yunhai.workweekplan.utils;
|
||||
|
||||
import com.yunhai.workweekplan.model.BasePageQuery;
|
||||
import com.yunhai.workweekplan.model.BasePageResult;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
|
||||
/**
|
||||
* 分页辅助类:BasePageQuery <-> Pageable,Page <-> BasePageResult。
|
||||
*/
|
||||
public class PageUtils {
|
||||
|
||||
private PageUtils() {
|
||||
}
|
||||
|
||||
public static Pageable toPageable(BasePageQuery query, Sort sort) {
|
||||
int page = Math.max(query.getPageIndex() - 1, 0);
|
||||
int size = Math.max(query.getPageSize(), 1);
|
||||
if (sort == null) {
|
||||
sort = Sort.unsorted();
|
||||
}
|
||||
return PageRequest.of(page, size, sort);
|
||||
}
|
||||
|
||||
public static <T> BasePageResult<T> toResult(Page<T> page) {
|
||||
return new BasePageResult<T>(
|
||||
page.getContent(),
|
||||
(int) page.getTotalElements(),
|
||||
page.getNumber() + 1,
|
||||
page.getSize(),
|
||||
page.getTotalPages(),
|
||||
page.hasNext()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.yunhai.workweekplan.utils;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
/**
|
||||
* 当前登录用户上下文(ThreadLocal 静态持有器)。
|
||||
* <p>移植自 UserclassService(com.yunhai.bschclassmanage.utils.UserContext),
|
||||
* 由 {@link com.yunhai.workweekplan.aspect.ControllerUserContextAspect} 在每个请求进入 controller 前填充,
|
||||
* 请求结束后 clear,防止线程复用串号。</p>
|
||||
*/
|
||||
public class UserContext {
|
||||
private static final ThreadLocal<Integer> USER_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> USER_NAME = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Integer> ROLE_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Integer> SCH_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> IP = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Integer> TYPE = new ThreadLocal<>();
|
||||
|
||||
/** 当前角色切换上下文(来自 currentrole header),家长场景 childId 即学生ID */
|
||||
private static final ThreadLocal<Integer> CHILD_ID = new ThreadLocal<>();
|
||||
|
||||
public static void setUserId(Integer userId) {
|
||||
USER_ID.set(userId);
|
||||
}
|
||||
|
||||
public static Integer getUserId() {
|
||||
return USER_ID.get();
|
||||
}
|
||||
|
||||
public static void setUserName(String userName) {
|
||||
USER_NAME.set(userName);
|
||||
}
|
||||
|
||||
public static String getUserName() {
|
||||
return USER_NAME.get();
|
||||
}
|
||||
|
||||
public static void setRoleId(Integer roleId) {
|
||||
ROLE_ID.set(roleId);
|
||||
}
|
||||
|
||||
public static Integer getRoleId() {
|
||||
return ROLE_ID.get();
|
||||
}
|
||||
|
||||
public static void setSchId(Integer schId) {
|
||||
SCH_ID.set(schId);
|
||||
}
|
||||
|
||||
public static Integer getSchId() {
|
||||
return SCH_ID.get();
|
||||
}
|
||||
|
||||
public static void setIp(String ip) {
|
||||
IP.set(ip);
|
||||
}
|
||||
|
||||
public static String getIp() {
|
||||
return IP.get();
|
||||
}
|
||||
|
||||
public static void setType(Integer type) {
|
||||
TYPE.set(type);
|
||||
}
|
||||
|
||||
public static Integer getType() {
|
||||
return TYPE.get();
|
||||
}
|
||||
|
||||
public static void setChildId(Integer childId) {
|
||||
CHILD_ID.set(childId);
|
||||
}
|
||||
|
||||
/** 家长端当前孩子(学生)ID;主账号/教师为 0 或 null */
|
||||
public static Integer getChildId() {
|
||||
return CHILD_ID.get();
|
||||
}
|
||||
|
||||
/** 用户信息快照(用于异步任务传递) */
|
||||
@Value
|
||||
public static class UserInfo {
|
||||
Integer userId;
|
||||
String userName;
|
||||
Integer roleId;
|
||||
Integer schId;
|
||||
String ip;
|
||||
Integer type;
|
||||
Integer childId;
|
||||
}
|
||||
|
||||
public static UserInfo getCurrentUser() {
|
||||
return new UserInfo(
|
||||
USER_ID.get(),
|
||||
USER_NAME.get(),
|
||||
ROLE_ID.get(),
|
||||
SCH_ID.get(),
|
||||
IP.get(),
|
||||
TYPE.get(),
|
||||
CHILD_ID.get()
|
||||
);
|
||||
}
|
||||
|
||||
public static void setUserInfo(UserInfo info) {
|
||||
if (info == null) {
|
||||
return;
|
||||
}
|
||||
USER_ID.set(info.getUserId());
|
||||
USER_NAME.set(info.getUserName());
|
||||
ROLE_ID.set(info.getRoleId());
|
||||
SCH_ID.set(info.getSchId());
|
||||
IP.set(info.getIp());
|
||||
TYPE.set(info.getType());
|
||||
CHILD_ID.set(info.getChildId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 把当前用户快照带入子线程执行异步任务,执行完 clear。
|
||||
*/
|
||||
public static Runnable wrap(Runnable task) {
|
||||
UserInfo info = getCurrentUser();
|
||||
return () -> {
|
||||
try {
|
||||
setUserInfo(info);
|
||||
task.run();
|
||||
} finally {
|
||||
clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
USER_ID.remove();
|
||||
USER_NAME.remove();
|
||||
ROLE_ID.remove();
|
||||
SCH_ID.remove();
|
||||
IP.remove();
|
||||
TYPE.remove();
|
||||
CHILD_ID.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
server:
|
||||
port: 8110
|
||||
|
||||
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/work_weekplan?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai
|
||||
username: root
|
||||
password: 123123
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
min-idle: 10
|
||||
max-active: 30
|
||||
#jpa
|
||||
jpa:
|
||||
database: MySQL
|
||||
show-sql: true
|
||||
hibernate:
|
||||
# 开发期使用 update 自动建表;生产环境设置成 none
|
||||
ddl-auto: update
|
||||
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
|
||||
properties:
|
||||
hibernate.order_by.default_null_ordering: last
|
||||
open-in-view: true
|
||||
|
||||
#文件上传
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 100MB
|
||||
max-request-size: 100MB
|
||||
|
||||
#本地关闭 rabbit
|
||||
rabbitmq:
|
||||
listener:
|
||||
direct:
|
||||
auto-startup: false
|
||||
simple:
|
||||
auto-startup: false
|
||||
|
||||
# 短信模块配置
|
||||
sms:
|
||||
enabled: true
|
||||
module-id: work-week-plan
|
||||
|
||||
# 节假日识别(周视图无安排日占位:节日名/无/休)
|
||||
# 数据源:timor.tech(需浏览器 UA)→ holiday-cn(jsdelivr) 兜底,按公历年缓存进 work_plan_holiday
|
||||
# 关闭后仅按周末判断;已同步年份不自动刷新,纠错手工清表
|
||||
workweekplan:
|
||||
holiday:
|
||||
enabled: true
|
||||
|
||||
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
|
||||
yh:
|
||||
sso:
|
||||
server-url-prefix: http://sso.yunhaixingtong.com
|
||||
client-host-url: http://localhost:8110
|
||||
allow-headers: all
|
||||
allow-origins: all
|
||||
url-pattern: /api/*
|
||||
mobi-api-pattern: /mobiapi/*
|
||||
app-auth:
|
||||
enabled: true
|
||||
# 本应用自身身份(客户端签名用)
|
||||
app-key: 100023
|
||||
app-secret: a16185c61d7545bca758f0bf95b042eb
|
||||
# 提供方强制验签(参考 noticeservice /oapi 试点):/oapi/** 匿名/浏览器直访 401,
|
||||
# 注册应用带 X-App-Key/X-Timestamp/X-Signature 三件套直调通过(/oapi/timetable/Get)
|
||||
server:
|
||||
enforce-patterns: [/oapi/*]
|
||||
# 入站验签密钥:本地无 Redis 不走中央密钥链(trust-registry),用白名单兜底——
|
||||
# 先收本应用自身(自测直调),新调用方接入时在此追加其 appKey/密钥
|
||||
trusted-apps:
|
||||
"100021": "d3ee380e38c549d1978e89f364cc1164"
|
||||
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
|
||||
FILE_WEB_PATH: http://base.yunhaixingtong.com/userbase/upload/
|
||||
@@ -0,0 +1,33 @@
|
||||
spring:
|
||||
application:
|
||||
name: work-week-plan
|
||||
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},即 work-week-plan.yaml
|
||||
file-extension: yaml
|
||||
encode: UTF-8
|
||||
# nacos 不可达时不强制退出,回退到本地 application.yml 兜底
|
||||
fail-fast: false
|
||||
# 只读账号
|
||||
username: ${SPRING_CLOUD_NACOS_USERNAME:yh-reader}
|
||||
password: ${SPRING_CLOUD_NACOS_PASSWORD:123456}
|
||||
# 共享公共配置:public 空间下的 common.properties
|
||||
shared-configs:
|
||||
# ① 1 号(最低优先级):public 命名空间(namespace="" 固定写空字符串)的 application-default.yaml
|
||||
- data-id: application-default.yaml
|
||||
group: DEFAULT_GROUP
|
||||
namespace: "" # ⭐ 固定 = "",强制指向 Nacos "public" 命名空间,不读上面的全局 prod namespace
|
||||
refresh: false
|
||||
|
||||
# ② 2 号(中等优先级):prod 命名空间(不写 namespace = 继承顶部全局 prod namespace id)的 application-infra.yaml
|
||||
- data-id: application-infra.yaml
|
||||
group: DEFAULT_GROUP
|
||||
refresh: false
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<!-- 引入 Spring Boot 默认彩色日志变量 -->
|
||||
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
|
||||
|
||||
<!-- 控制台:所有环境都有,且带颜色2 -->
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
|
||||
<charset>${CONSOLE_LOG_CHARSET}</charset>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 文件日志:只有非 local 环境才启用 -->
|
||||
<springProfile name="!local">
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>/app/logs/app.log</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||
<fileNamePattern>/app/logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||
<maxHistory>30</maxHistory>
|
||||
<totalSizeCap>5GB</totalSizeCap>
|
||||
</rollingPolicy>
|
||||
<encoder>
|
||||
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
</springProfile>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE" />
|
||||
<springProfile name="!local">
|
||||
<appender-ref ref="FILE" />
|
||||
</springProfile>
|
||||
</root>
|
||||
</configuration>
|
||||
@@ -0,0 +1,31 @@
|
||||
-- ============================================================
|
||||
-- AcaSyllabu(课程表)审计日志表
|
||||
-- 与业务表物理隔离;索引在此创建,实体不含 @Table(indexes),
|
||||
-- 避免 ddl-auto=update 启动时重复建索引告警。
|
||||
-- MySQL 5.7+;幂等(不存在才建)。
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sys_operation_log` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`log_level` TINYINT NOT NULL COMMENT '1-重要(删除) 2-次要(新增/编辑) 3-一般(查询)',
|
||||
`operation_type` VARCHAR(16) NOT NULL COMMENT 'CREATE/UPDATE/DELETE/QUERY',
|
||||
`user_id` INT NULL COMMENT '操作人用户ID(UserContext)',
|
||||
`user_name` VARCHAR(64) NULL COMMENT '操作人姓名',
|
||||
`sch_id` INT NULL COMMENT '学校ID(多校隔离)',
|
||||
`request_ip` VARCHAR(64) NOT NULL COMMENT '客户端IP',
|
||||
`request_uri` VARCHAR(512) NOT NULL COMMENT '请求URI',
|
||||
`request_method` VARCHAR(16) NOT NULL COMMENT 'HTTP方法',
|
||||
`class_method` VARCHAR(512) NOT NULL COMMENT '类.方法',
|
||||
`request_params` TEXT NULL COMMENT '请求参数',
|
||||
`response_data` MEDIUMBLOB NULL COMMENT '响应JSON(GZIP 压缩)',
|
||||
`status_code` INT NOT NULL COMMENT '业务状态码:0 成功 / 非 0 失败',
|
||||
`cost_time` BIGINT NOT NULL COMMENT '耗时 ms',
|
||||
`user_agent` VARCHAR(512) NULL,
|
||||
`error_msg` TEXT NULL COMMENT '异常信息(截断 8000)',
|
||||
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sol_create_time` (`create_time`),
|
||||
KEY `idx_sol_sch_level` (`sch_id`, `log_level`),
|
||||
KEY `idx_sol_user` (`user_id`),
|
||||
KEY `idx_sol_op_type` (`operation_type`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='系统审计日志(双轨之审计轨)';
|
||||
@@ -0,0 +1,86 @@
|
||||
-- =====================================================================
|
||||
-- 一周安排(work-week-plan)业务表结构
|
||||
-- 库:work_weekplan(本地) / bsch_work_week_plan(生产)
|
||||
-- 说明:本地开发 JPA ddl-auto=update 会自动建表,本文件作为生产初始化
|
||||
-- 与表结构留档(幂等执行)。
|
||||
-- 历史对应:Plan_Record / Plan_WeekRemark / SchSet_Firstday(EduCloudWorkPlan 库)
|
||||
-- 实体约定:业务只保存教师/平台用户 id(user_id),不保存账号。
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `work_plan_record` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`sch_id` INT NOT NULL COMMENT '学校ID(多租户隔离)',
|
||||
`start_date` DATE NOT NULL COMMENT '开始日期(单日安排与 end_date 相等)',
|
||||
`end_date` DATE NOT NULL COMMENT '结束日期',
|
||||
`times` VARCHAR(50) DEFAULT NULL COMMENT '时间(如 10:00 或 全天)',
|
||||
`contents` VARCHAR(200) NOT NULL COMMENT '工作内容',
|
||||
`principal` VARCHAR(200) DEFAULT NULL COMMENT '负责人(部门)',
|
||||
`place` VARCHAR(200) DEFAULT NULL COMMENT '地点',
|
||||
`participant` VARCHAR(200) DEFAULT NULL COMMENT '出席对象',
|
||||
`is_open` TINYINT(1) DEFAULT 1 COMMENT '是否公开(预留)',
|
||||
`user_id` INT DEFAULT NULL COMMENT '创建教师/平台用户 id(不存账号)',
|
||||
`create_by` VARCHAR(255) DEFAULT NULL COMMENT '创建人(审计,平台自动填充)',
|
||||
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
|
||||
`update_by` VARCHAR(255) DEFAULT NULL COMMENT '更新人(审计)',
|
||||
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_wpr_sch_start` (`sch_id`, `start_date`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '一周安排明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `work_plan_week_remark` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`sch_id` INT NOT NULL COMMENT '学校ID',
|
||||
`week_date` DATE NOT NULL COMMENT '周一日期(该周锚点)',
|
||||
`remark` VARCHAR(2000) DEFAULT NULL COMMENT '周备注',
|
||||
`create_by` VARCHAR(255) DEFAULT NULL COMMENT '创建人(审计)',
|
||||
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
|
||||
`update_by` VARCHAR(255) DEFAULT NULL COMMENT '更新人(审计)',
|
||||
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_wpwr_sch_week` (`sch_id`, `week_date`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '一周安排周备注';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `work_plan_first_day` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`sch_id` INT NOT NULL COMMENT '学校ID',
|
||||
`year` INT NOT NULL COMMENT '学年(2026 = 2026-2027 学年)',
|
||||
`term` INT NOT NULL COMMENT '学期(1/2)',
|
||||
`first_day` DATE NOT NULL COMMENT '第一周周一日期',
|
||||
`create_by` VARCHAR(255) DEFAULT NULL COMMENT '创建人(审计)',
|
||||
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
|
||||
`update_by` VARCHAR(255) DEFAULT NULL COMMENT '更新人(审计)',
|
||||
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_wpfd_sch_year_term` (`sch_id`, `year`, `term`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '学期第一周周一设置';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 旧"课程表(acasyllabu)"模块遗留表清理(确认无数据保留价值后手工执行)
|
||||
-- ---------------------------------------------------------------------
|
||||
-- DROP TABLE IF EXISTS `timetable`;
|
||||
-- DROP TABLE IF EXISTS `timetable_slot`;
|
||||
-- DROP TABLE IF EXISTS `timetable_period_template`;
|
||||
-- DROP TABLE IF EXISTS `timetable_template_row`;
|
||||
-- DROP TABLE IF EXISTS `timetable_time_setting`;
|
||||
-- DROP TABLE IF EXISTS `timetable_grade_setting`;
|
||||
-- DROP TABLE IF EXISTS `up_file`;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 节假日缓存(历史 YearHoliday/{年}.txt 文件缓存的落库版;与学校无关,无 sch_id)
|
||||
-- 按公历年一次性同步(timor.tech → holiday-cn 兜底),已同步年份不自动刷新
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `work_plan_holiday` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`year` INT NOT NULL COMMENT '日期所属公历年(同步单位)',
|
||||
`date` DATE NOT NULL COMMENT '日期',
|
||||
`name` VARCHAR(50) NOT NULL COMMENT '节日名称',
|
||||
`off_day` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'true=放假日;false=调休补班日',
|
||||
`create_by` VARCHAR(255) DEFAULT NULL COMMENT '创建人(审计)',
|
||||
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
|
||||
`update_by` VARCHAR(255) DEFAULT NULL COMMENT '更新人(审计)',
|
||||
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_wph_date` (`date`),
|
||||
KEY `idx_wph_year` (`year`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '法定节假日/调休补班缓存';
|
||||
|
||||
Reference in New Issue
Block a user