diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a46b5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +.idea/ +*.iml +*.log +.DS_Store diff --git a/docker/application-prod.yml b/docker/application-prod.yml new file mode 100644 index 0000000..f0c88b0 --- /dev/null +++ b/docker/application-prod.yml @@ -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 diff --git a/docker/run-app.sh b/docker/run-app.sh new file mode 100644 index 0000000..575b57d --- /dev/null +++ b/docker/run-app.sh @@ -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 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..67293d9 --- /dev/null +++ b/pom.xml @@ -0,0 +1,204 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + work-week-plan + 0.0.1 + WorkWeekPlan + 一周安排(周次工作安排)子应用 + + 1.8 + + + + + + + com.yunhai.platform + yh-platform-dependencies + 1.0.0-SNAPSHOT + pom + import + + + + com.alibaba.cloud + spring-cloud-alibaba-dependencies + 2021.0.5.0 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.security + spring-security-crypto + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.springframework.boot + spring-boot-starter-aop + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + org.springframework.cloud + spring-cloud-starter-bootstrap + 3.1.8 + + + + com.mysql + mysql-connector-j + runtime + + + + org.projectlombok + lombok + true + + + + + com.alibaba + fastjson + 1.2.80 + + + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + + + + + com.yunhai.platform + yh-spring-boot-starter + + + + + org.jasig.cas.client + cas-client-core + 3.5.1 + + + + + com.alibaba + easyexcel + 3.3.2 + + + + + + + + work-week-plan + + + org.apache.maven.plugins + maven-compiler-plugin + + + false + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.workweekplan.WorkWeekPlanApplication + + + + + repackage + + + + + + + + + + + nexus-public + bsch/nexus public + http://nexus.yunhaixingtong.com/repository/maven-public/ + true + true + + + + + + ci + + C:/aca-build/work-week-plan + + + + + diff --git a/src/main/java/com/yunhai/workweekplan/WorkWeekPlanApplication.java b/src/main/java/com/yunhai/workweekplan/WorkWeekPlanApplication.java new file mode 100644 index 0000000..8fa16ff --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/WorkWeekPlanApplication.java @@ -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); + } + +} diff --git a/src/main/java/com/yunhai/workweekplan/aspect/ControllerUserContextAspect.java b/src/main/java/com/yunhai/workweekplan/aspect/ControllerUserContextAspect.java new file mode 100644 index 0000000..3033534 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/aspect/ControllerUserContextAspect.java @@ -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)。 + *

@Order(1) 先于审计日志切面(Order 3)执行,保证日志能取到 UserContext。 + * 取用户优先级:方法参数 BoaUser → BoaUserContext 静态持有者 → request attribute; + * 角色切换信息从 currentrole(Header/Query,URL 编码 JSON)解析。

+ */ +@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 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()); + } +} diff --git a/src/main/java/com/yunhai/workweekplan/aspect/SysOperationLogAspect.java b/src/main/java/com/yunhai/workweekplan/aspect/SysOperationLogAspect.java new file mode 100644 index 0000000..d004d50 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/aspect/SysOperationLogAspect.java @@ -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)。 + *

全量拦截 controller 包所有方法,不依赖注解;排除日志查询控制器自身(防递归); + * 用户信息来自 {@link ControllerUserContextAspect}(Order 1)填充的 UserContext; + * 响应体 GZIP 压缩落库,落库走 @Async 不阻塞请求。

+ */ +@Aspect +@Component +@Order(3) +public class SysOperationLogAspect { + + private static final Logger log = LoggerFactory.getLogger(SysOperationLogAspect.class); + + /** 排除的控制器(日志查询自身,防递归刷屏) */ + private static final List 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 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"; + } +} diff --git a/src/main/java/com/yunhai/workweekplan/common/listener/BaseAuditListener.java b/src/main/java/com/yunhai/workweekplan/common/listener/BaseAuditListener.java new file mode 100644 index 0000000..d5fea76 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/common/listener/BaseAuditListener.java @@ -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(); + } +} diff --git a/src/main/java/com/yunhai/workweekplan/common/validation/ValidationGroups.java b/src/main/java/com/yunhai/workweekplan/common/validation/ValidationGroups.java new file mode 100644 index 0000000..98c4265 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/common/validation/ValidationGroups.java @@ -0,0 +1,10 @@ +package com.yunhai.workweekplan.common.validation; + +/** + * JSR-303 分组校验标记接口(对齐 UserBase 规范)。 + * Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。 + */ +public interface ValidationGroups { + interface Create {} + interface Update {} +} diff --git a/src/main/java/com/yunhai/workweekplan/config/Config.java b/src/main/java/com/yunhai/workweekplan/config/Config.java new file mode 100644 index 0000000..250f5d5 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/config/Config.java @@ -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"; +} diff --git a/src/main/java/com/yunhai/workweekplan/config/JacksonConfig.java b/src/main/java/com/yunhai/workweekplan/config/JacksonConfig.java new file mode 100644 index 0000000..317d3c8 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/config/JacksonConfig.java @@ -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)); + } +} diff --git a/src/main/java/com/yunhai/workweekplan/config/SwaggerConfig.java b/src/main/java/com/yunhai/workweekplan/config/SwaggerConfig.java new file mode 100644 index 0000000..7f2903a --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/config/SwaggerConfig.java @@ -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")); + } +} diff --git a/src/main/java/com/yunhai/workweekplan/controller/api/BaseApiController.java b/src/main/java/com/yunhai/workweekplan/controller/api/BaseApiController.java new file mode 100644 index 0000000..461d810 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/controller/api/BaseApiController.java @@ -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/)。 + *

承载:GetUserInfo / GetUserMod / SetRole / Logout。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/base/") +public class BaseApiController { + + private static final Logger log = LoggerFactory.getLogger(BaseApiController.class); + + @Autowired + private IUserCenterService userCenterService; + + // ============================ 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> 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(); + } +} diff --git a/src/main/java/com/yunhai/workweekplan/controller/api/LogController.java b/src/main/java/com/yunhai/workweekplan/controller/api/LogController.java new file mode 100644 index 0000000..e5ac721 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/controller/api/LogController.java @@ -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/)。 + *

本控制器被 SysOperationLogAspect 排除,避免日志查询自身再产生日志(防递归)。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/log/") +public class LogController { + + @Autowired + private ISysOperationLogService logService; + + /** 分页查询审计日志(responseData 不返回) */ + @GetMapping("Page") + public BasePageResult page(BasePageQuery query, + @RequestParam(value = "operationType", required = false) String operationType, + @RequestParam(value = "statusCode", required = false) Integer statusCode) { + return logService.page(query, operationType, statusCode); + } +} diff --git a/src/main/java/com/yunhai/workweekplan/controller/api/WorkPlanController.java b/src/main/java/com/yunhai/workweekplan/controller/api/WorkPlanController.java new file mode 100644 index 0000000..5e5c6fa --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/controller/api/WorkPlanController.java @@ -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/)。 + *

动作名对齐历史 ASP.NET 接口(GetYear/GetTerm/Weektime/OneWeekPlan/GetOneWeekPlan/ + * AddOneWeekPlan/UpdateWeekRemark/DeleteWeekPlan/GetFirstWeekDay/SetFirstWeekDay/ + * WeekPlanExcelImport/GetWorkPlan),便于前后端对照迁移;新增 Remove(单条删除)。 + * 全部动作自动被 SysOperationLogAspect 记录审计日志。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/workplan/") +public class WorkPlanController { + + @Autowired + private IWorkPlanService workPlanService; + + /** 学年下拉(当前学年在最前) */ + @GetMapping("GetYear") + public List getYear() { + return workPlanService.getYears(); + } + + /** 当前学期(1/2) */ + @GetMapping("GetTerm") + public Integer getTerm() { + return workPlanService.getCurrentTerm(); + } + + /** 周次下拉 */ + @GetMapping("Weektime") + public List 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 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 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); + } +} diff --git a/src/main/java/com/yunhai/workweekplan/controller/oapi/OapiWorkPlanController.java b/src/main/java/com/yunhai/workweekplan/controller/oapi/OapiWorkPlanController.java new file mode 100644 index 0000000..4b60940 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/controller/oapi/OapiWorkPlanController.java @@ -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/**)。 + *

强制验签由 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/密钥)。

+ *

身份由调用方显式传入(userid/schId,不依赖登录态);"本周"(学年/学期/周次) + * 由服务端推算。周安排为全校共享数据,无角色概念,返回内容不因调用方不同 + * (不含站内的 canEditAll/canDelete 权限标记);写操作不开放。

+ */ +@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); + } +} diff --git a/src/main/java/com/yunhai/workweekplan/entity/BaseEntity.java b/src/main/java/com/yunhai/workweekplan/entity/BaseEntity.java new file mode 100644 index 0000000..60cd7f2 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/entity/BaseEntity.java @@ -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; +} diff --git a/src/main/java/com/yunhai/workweekplan/entity/SysOperationLog.java b/src/main/java/com/yunhai/workweekplan/entity/SysOperationLog.java new file mode 100644 index 0000000..9662636 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/entity/SysOperationLog.java @@ -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; +} diff --git a/src/main/java/com/yunhai/workweekplan/entity/WorkPlanFirstDay.java b/src/main/java/com/yunhai/workweekplan/entity/WorkPlanFirstDay.java new file mode 100644 index 0000000..4ab56a4 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/entity/WorkPlanFirstDay.java @@ -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)。 + *

每校每学年每学期一条,作为周次换算锚点;未设置时按默认规则推算。

+ */ +@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; +} diff --git a/src/main/java/com/yunhai/workweekplan/entity/WorkPlanHoliday.java b/src/main/java/com/yunhai/workweekplan/entity/WorkPlanHoliday.java new file mode 100644 index 0000000..7019bb1 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/entity/WorkPlanHoliday.java @@ -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 文件缓存)。 + *

全国法定节假日 + 调休补班日,按公历年一次性同步(数据源:timor.tech / holiday-cn), + * 与学校无关(无 sch_id)。off_day=false 表示调休补班日(周末变工作日)。

+ */ +@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; +} diff --git a/src/main/java/com/yunhai/workweekplan/entity/WorkPlanRecord.java b/src/main/java/com/yunhai/workweekplan/entity/WorkPlanRecord.java new file mode 100644 index 0000000..45257d2 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/entity/WorkPlanRecord.java @@ -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)。 + *

每条为一项具体安排:起止日期(单日安排两者相等)、时间、工作内容、负责人、地点、出席对象。 + * 创建人只存教师/平台用户 id(user_id),不存账号。

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

每周一条,week_date 为该周周一日期。

+ */ +@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; +} diff --git a/src/main/java/com/yunhai/workweekplan/enums/UserType.java b/src/main/java/com/yunhai/workweekplan/enums/UserType.java new file mode 100644 index 0000000..e07521b --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/enums/UserType.java @@ -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; +} diff --git a/src/main/java/com/yunhai/workweekplan/model/BasePageQuery.java b/src/main/java/com/yunhai/workweekplan/model/BasePageQuery.java new file mode 100644 index 0000000..b111519 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/BasePageQuery.java @@ -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; +} diff --git a/src/main/java/com/yunhai/workweekplan/model/BasePageResult.java b/src/main/java/com/yunhai/workweekplan/model/BasePageResult.java new file mode 100644 index 0000000..1bdc94a --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/BasePageResult.java @@ -0,0 +1,33 @@ +package com.yunhai.workweekplan.model; + +import lombok.Data; + +import java.util.List; + +/** + * 统一分页响应外壳。 + */ +@Data +public class BasePageResult { + /** 总记录数 */ + private int total; + /** 当前页数据 */ + private List content; + /** 当前页码(从 1 起) */ + private int pageIndex; + /** 每页条数 */ + private int pageSize; + /** 总页数 */ + private int totalPages; + /** 是否还有下一页 */ + private boolean hasNext; + + public BasePageResult(List content, int total, int pageIndex, int pageSize, int totalPages, boolean hasNext) { + this.content = content; + this.total = total; + this.pageIndex = pageIndex; + this.pageSize = pageSize; + this.totalPages = totalPages; + this.hasNext = hasNext; + } +} diff --git a/src/main/java/com/yunhai/workweekplan/model/UserSysRole.java b/src/main/java/com/yunhai/workweekplan/model/UserSysRole.java new file mode 100644 index 0000000..de2b74a --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/UserSysRole.java @@ -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; +} diff --git a/src/main/java/com/yunhai/workweekplan/model/base/SetRoleRequest.java b/src/main/java/com/yunhai/workweekplan/model/base/SetRoleRequest.java new file mode 100644 index 0000000..a3dee60 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/base/SetRoleRequest.java @@ -0,0 +1,12 @@ +package com.yunhai.workweekplan.model.base; + +import lombok.Data; + +/** + * 角色切换入参(对齐前端 baseApi.setRole)。 + */ +@Data +public class SetRoleRequest { + /** "teacher" 或 "parent" */ + private String as; +} diff --git a/src/main/java/com/yunhai/workweekplan/model/base/SetRoleResult.java b/src/main/java/com/yunhai/workweekplan/model/base/SetRoleResult.java new file mode 100644 index 0000000..6b43ed0 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/base/SetRoleResult.java @@ -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; +} diff --git a/src/main/java/com/yunhai/workweekplan/model/workplan/AllPlanItemVo.java b/src/main/java/com/yunhai/workweekplan/model/workplan/AllPlanItemVo.java new file mode 100644 index 0000000..d1d0cbb --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/workplan/AllPlanItemVo.java @@ -0,0 +1,37 @@ +package com.yunhai.workweekplan.model.workplan; + +import lombok.Data; + +import java.time.LocalDate; + +/** + * 全部安排列表行(对应历史 GetWorkPlan 返回的 Plan_Record 列表)。 + *

year/term/week 为换算结果,供前端"编辑"跳回本周安排页。

+ */ +@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; +} diff --git a/src/main/java/com/yunhai/workweekplan/model/workplan/AllPlanQuery.java b/src/main/java/com/yunhai/workweekplan/model/workplan/AllPlanQuery.java new file mode 100644 index 0000000..7a3c60b --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/workplan/AllPlanQuery.java @@ -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; +} diff --git a/src/main/java/com/yunhai/workweekplan/model/workplan/FirstDayVo.java b/src/main/java/com/yunhai/workweekplan/model/workplan/FirstDayVo.java new file mode 100644 index 0000000..3d551e6 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/workplan/FirstDayVo.java @@ -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; +} diff --git a/src/main/java/com/yunhai/workweekplan/model/workplan/ImportResultVo.java b/src/main/java/com/yunhai/workweekplan/model/workplan/ImportResultVo.java new file mode 100644 index 0000000..82d870e --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/workplan/ImportResultVo.java @@ -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 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; + } + } +} diff --git a/src/main/java/com/yunhai/workweekplan/model/workplan/OapiWeekPlanVo.java b/src/main/java/com/yunhai/workweekplan/model/workplan/OapiWeekPlanVo.java new file mode 100644 index 0000000..e8712fd --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/workplan/OapiWeekPlanVo.java @@ -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)。 + *

全校共享数据,无角色概念:不含站内 OneWeekPlanVo 的 canEditAll/canDelete 权限标记, + * 天结构复用 {@link OneWeekPlanVo.DayVo}。

+ */ +@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 days = new ArrayList<>(); + + /** 周备注 */ + private String remark; + + /** 本周是否已有安排数据 */ + private boolean hasData; +} diff --git a/src/main/java/com/yunhai/workweekplan/model/workplan/OneWeekPlanVo.java b/src/main/java/com/yunhai/workweekplan/model/workplan/OneWeekPlanVo.java new file mode 100644 index 0000000..725c922 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/workplan/OneWeekPlanVo.java @@ -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 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 items = new ArrayList<>(); + /** 节假日类型:0=工作日 1=休息日 2=法定节假日 */ + private Integer dayType; + /** 无安排时占位文案:无 / 休 / 节日名称 */ + private String dayLabel; + } +} diff --git a/src/main/java/com/yunhai/workweekplan/model/workplan/RemoveRequest.java b/src/main/java/com/yunhai/workweekplan/model/workplan/RemoveRequest.java new file mode 100644 index 0000000..dc0dd44 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/workplan/RemoveRequest.java @@ -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; +} diff --git a/src/main/java/com/yunhai/workweekplan/model/workplan/WeekPlanSaveRequest.java b/src/main/java/com/yunhai/workweekplan/model/workplan/WeekPlanSaveRequest.java new file mode 100644 index 0000000..c7174fb --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/workplan/WeekPlanSaveRequest.java @@ -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 items; +} diff --git a/src/main/java/com/yunhai/workweekplan/model/workplan/WeekRemarkRequest.java b/src/main/java/com/yunhai/workweekplan/model/workplan/WeekRemarkRequest.java new file mode 100644 index 0000000..4959670 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/workplan/WeekRemarkRequest.java @@ -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; +} diff --git a/src/main/java/com/yunhai/workweekplan/model/workplan/WeekTimeVo.java b/src/main/java/com/yunhai/workweekplan/model/workplan/WeekTimeVo.java new file mode 100644 index 0000000..bf25f7d --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/workplan/WeekTimeVo.java @@ -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; + } +} diff --git a/src/main/java/com/yunhai/workweekplan/model/workplan/WorkPlanItemVo.java b/src/main/java/com/yunhai/workweekplan/model/workplan/WorkPlanItemVo.java new file mode 100644 index 0000000..226c142 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/model/workplan/WorkPlanItemVo.java @@ -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)。 + *

editable 仅在查询响应中有意义:当前用户是否可编辑该行 + * (管理员/教职工全部可编辑;教师仅本人创建的可编辑)。

+ */ +@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; +} diff --git a/src/main/java/com/yunhai/workweekplan/repository/SysOperationLogRepository.java b/src/main/java/com/yunhai/workweekplan/repository/SysOperationLogRepository.java new file mode 100644 index 0000000..d34eb52 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/repository/SysOperationLogRepository.java @@ -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, JpaSpecificationExecutor { +} diff --git a/src/main/java/com/yunhai/workweekplan/repository/WorkPlanFirstDayRepository.java b/src/main/java/com/yunhai/workweekplan/repository/WorkPlanFirstDayRepository.java new file mode 100644 index 0000000..71b4172 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/repository/WorkPlanFirstDayRepository.java @@ -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 { + + Optional findBySchIdAndYearAndTerm(Integer schId, Integer year, Integer term); +} diff --git a/src/main/java/com/yunhai/workweekplan/repository/WorkPlanHolidayRepository.java b/src/main/java/com/yunhai/workweekplan/repository/WorkPlanHolidayRepository.java new file mode 100644 index 0000000..3e1b19e --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/repository/WorkPlanHolidayRepository.java @@ -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 { + + List findByYear(Integer year); + + boolean existsByYear(Integer year); +} diff --git a/src/main/java/com/yunhai/workweekplan/repository/WorkPlanRecordRepository.java b/src/main/java/com/yunhai/workweekplan/repository/WorkPlanRecordRepository.java new file mode 100644 index 0000000..27b0f5f --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/repository/WorkPlanRecordRepository.java @@ -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, JpaSpecificationExecutor { + + /** + * 查询与 [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 findInWeek(@Param("schId") Integer schId, @Param("start") LocalDate start, @Param("end") LocalDate end); +} diff --git a/src/main/java/com/yunhai/workweekplan/repository/WorkPlanWeekRemarkRepository.java b/src/main/java/com/yunhai/workweekplan/repository/WorkPlanWeekRemarkRepository.java new file mode 100644 index 0000000..28ee43d --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/repository/WorkPlanWeekRemarkRepository.java @@ -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 { + + Optional findBySchIdAndWeekDate(Integer schId, LocalDate weekDate); +} diff --git a/src/main/java/com/yunhai/workweekplan/service/IHolidayService.java b/src/main/java/com/yunhai/workweekplan/service/IHolidayService.java new file mode 100644 index 0000000..1132ef4 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/service/IHolidayService.java @@ -0,0 +1,43 @@ +package com.yunhai.workweekplan.service; + +import java.time.LocalDate; + +/** + * 节假日识别(对应历史 BestSchool.Common.Workday)。 + *

type 语义与历史一致:0=工作日 1=休息日 2=节假日。

+ */ +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; + } + } +} diff --git a/src/main/java/com/yunhai/workweekplan/service/ISysOperationLogService.java b/src/main/java/com/yunhai/workweekplan/service/ISysOperationLogService.java new file mode 100644 index 0000000..58bbec8 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/service/ISysOperationLogService.java @@ -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 page(BasePageQuery query, String operationType, Integer statusCode); +} diff --git a/src/main/java/com/yunhai/workweekplan/service/IUserCenterService.java b/src/main/java/com/yunhai/workweekplan/service/IUserCenterService.java new file mode 100644 index 0000000..35d6e8f --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/service/IUserCenterService.java @@ -0,0 +1,23 @@ +package com.yunhai.workweekplan.service; + +import java.util.List; +import java.util.Map; + +/** + * 用户中心门面服务(对齐 UserBase {@code UserCenterService} 模式)。 + *

封装 {@code YschBaseOpenApi} 静态门面调用,把模块树组装等业务逻辑下沉到 Service 层, + * Controller 只保留"取参 → 调 Service → 返回"的薄壳。

+ */ +public interface IUserCenterService { + + /** + * 获取当前用户可访问的模块树(左侧菜单)。 + *

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

+ * + * @param userId 用户ID + * @param schId 学校ID(可为 null) + * @return 模块树(顶层 List,含 children 嵌套) + */ + List> getUserModuleTree(Integer userId, Integer schId); +} diff --git a/src/main/java/com/yunhai/workweekplan/service/IWorkPlanService.java b/src/main/java/com/yunhai/workweekplan/service/IWorkPlanService.java new file mode 100644 index 0000000..8b4a948 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/service/IWorkPlanService.java @@ -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 getYears(); + + /** 当前学期(1/2) */ + Integer getCurrentTerm(); + + /** 周次下拉 */ + List getWeektime(Integer year, Integer term); + + /** 周视图(7 天安排 + 备注 + 权限标记) */ + OneWeekPlanVo getOneWeekPlan(Integer year, Integer term, Integer week); + + /** + * 开放接口:获取本周安排(当前学年学期当前周,服务端推算周次)。 + *

身份由调用方显式传入(不依赖登录态);周安排为全校共享数据,无角色概念, + * 返回内容不因调用方不同(见 {@link OapiWeekPlanVo},不含权限标记)。

+ */ + OapiWeekPlanVo oapiOneWeekPlan(Integer userId, Integer schId); + + /** 编辑视图(本周安排行列表,含 editable 标记) */ + List 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 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); +} diff --git a/src/main/java/com/yunhai/workweekplan/service/impl/HolidayService.java b/src/main/java/com/yunhai/workweekplan/service/impl/HolidayService.java new file mode 100644 index 0000000..a057c80 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/service/impl/HolidayService.java @@ -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 按年拉取 + 本地缓存 + 异常降级)。 + *

与历史的差异: + * 1) 缓存从本地文件改为 MySQL 表 work_plan_holiday(按公历年一次性同步); + * 2) timor.tech 现有 Cloudflare 防护,请求必须带浏览器 User-Agent,且备用官方数据源 + * holiday-cn(jsdelivr CDN 镜像国务院公告);两源全挂时按周末判断降级,并做 10 分钟 + * 负缓存避免每次请求都外呼; + * 3) 已同步年份不自动刷新(国务院全年公告一经发布不再变化),纠错需手工清表。

+ */ +@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> yearCache = new ConcurrentHashMap<>(); + /** 年份 → 最近一次同步失败时间戳 */ + private final Map 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 loadYear(int year) { + Map 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 rows = holidayRepository.findByYear(year); + if (rows.isEmpty()) { + rows = syncYear(year); + } + Map map = toMap(rows); + yearCache.put(year, map); + return map; + } + } + + /** 远端按年同步;两源全挂返回空表并记负缓存 */ + private List syncYear(int year) { + Long failedAt = failedYears.get(year); + if (failedAt != null && System.currentTimeMillis() - failedAt < FAIL_RETRY_MS) { + return Collections.emptyList(); + } + List 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 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 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 fetchHolidayCn(int year) { + try { + String body = getForString(HOLIDAY_CN_URL + year + ".json"); + JsonNode root = mapper.readTree(body); + List 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 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 toMap(List rows) { + Map map = new HashMap<>(); + for (WorkPlanHoliday r : rows) { + map.put(r.getDate(), r); + } + return map; + } + + private List sort(List 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; + } +} diff --git a/src/main/java/com/yunhai/workweekplan/service/impl/SysOperationLogService.java b/src/main/java/com/yunhai/workweekplan/service/impl/SysOperationLogService.java new file mode 100644 index 0000000..3dd8439 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/service/impl/SysOperationLogService.java @@ -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 page(BasePageQuery query, String operationType, Integer statusCode) { + Specification spec = (root, cq, cb) -> { + List 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 page = repository.findAll(spec, pageable); + page.getContent().forEach(e -> e.setResponseData(null)); + return PageUtils.toResult(page); + } +} diff --git a/src/main/java/com/yunhai/workweekplan/service/impl/UserCenterService.java b/src/main/java/com/yunhai/workweekplan/service/impl/UserCenterService.java new file mode 100644 index 0000000..b3acf36 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/service/impl/UserCenterService.java @@ -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; + +/** + * 用户中心门面服务实现。 + *

跨仓调用统一收敛到 {@link YschBaseOpenApi} 门面,Controller 不再直接静态调用。

+ */ +@Service +public class UserCenterService implements IUserCenterService { + + private static final Logger log = LoggerFactory.getLogger(UserCenterService.class); + + @Override + public List> getUserModuleTree(Integer userId, Integer schId) { + if (userId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空"); + } + List modules = YschBaseOpenApi.getUserModule(userId, schId); + if (modules == null || modules.isEmpty()) { + return Collections.emptyList(); + } + // 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望 + return buildModuleTree(modules); + } + + /** + * 将扁平模块列表组装为树(对应原 NoticeController.buildModuleTree)。 + * 顶层取 parentId=0 或父节点不在列表中的节点,子节点按 orderNumber 排序。 + */ + private List> buildModuleTree(List modules) { + Map moduleMap = modules.stream() + .collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a)); + + // 每个模块节点(含 children 占位) + Map> nodeMap = new LinkedHashMap<>(); + for (BoaModule m : modules) { + Map node = new LinkedHashMap<>(); + node.put("id", m.getId()); + node.put("modId", m.getId()); + node.put("name", m.getName()); + node.put("parentId", m.getParentId() != null ? m.getParentId() : 0); + node.put("page", m.getPath() != null ? m.getPath() : ""); + node.put("level", m.getLevel() != null ? m.getLevel() : 0); + node.put("icon", m.getIcon() != null ? m.getIcon() : ""); + node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0); + node.put("usable", true); + node.put("children", new ArrayList<>()); + nodeMap.put(m.getId(), node); + } + + // 挂父子关系 + List> roots = new ArrayList<>(); + Set moduleIds = new HashSet<>(moduleMap.keySet()); + for (BoaModule m : modules) { + Map 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> children = (List>) nodeMap.get(parentId).get("children"); + children.add(node); + } + } + + // 保持 SDK(用户中心)返回顺序,不再按 orderNumber 重排 + return roots; + } +} diff --git a/src/main/java/com/yunhai/workweekplan/service/impl/WorkPlanImportService.java b/src/main/java/com/yunhai/workweekplan/service/impl/WorkPlanImportService.java new file mode 100644 index 0000000..4417874 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/service/impl/WorkPlanImportService.java @@ -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)。 + *

列契约:本周 | 时间 | 工作内容 | 负责人(部门) | 地点 | 出席对象; + * "本周"列取 周一~周日;末尾可加"备注"行(备注文本写在"时间"列); + * 追加式导入(不清空本周已有数据),同历史行为。

+ */ +@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> rows = readRows(file); + if (rows.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "Excel 内容为空!"); + } + + // 表头校验(同历史:逐列精确匹配) + Map 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 errors = result.getErrors(); + List toSave = new ArrayList<>(); + List remarks = new ArrayList<>(); + + for (int i = 1; i < rows.size(); i++) { + Map 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> readRows(MultipartFile file) { + try { + List> 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; + } + } +} diff --git a/src/main/java/com/yunhai/workweekplan/service/impl/WorkPlanService.java b/src/main/java/com/yunhai/workweekplan/service/impl/WorkPlanService.java new file mode 100644 index 0000000..bf3c7f7 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/service/impl/WorkPlanService.java @@ -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)。 + *

与历史的差异:

+ *
    + *
  • 账号体系改为平台用户:记录只保存教师/平台用户 id(user_id),不存账号;
  • + *
  • 权限映射:历史 role 1=教师/2=校领导/7=管理员 → 现 roleId 2/7 可管理全部,roleId 1 及其余仅本人 + * (roleId 缺失回退账号类型 超管/校管);
  • + *
  • 差集删除同样受权限约束(教师只能删除本人记录),修复了历史实现教师可删他人记录的问题;
  • + *
  • 跨天安排不移植:保存路径恒为单日(startDate=endDate),历史数据亦然;
  • + *
  • 节假日展示移植历史 Workday.GetHoliday:无安排日占位为 节日名/无/休(见 {@link HolidayService})。
  • + *
+ */ +@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 getYears() { + int now = nowSchoolYear(); + List 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 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 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 records = recordRepository.findInWeek(schId, monday, sunday); + Map> 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 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 items = request.getItems() == null ? Collections.emptyList() : request.getItems(); + // 跳过内容全空的行为(前端兜底,双保险) + items = items.stream() + .filter(it -> it != null && StringUtils.isNotBlank(it.getContents())) + .collect(Collectors.toList()); + + List existing = recordRepository.findInWeek(schId, monday, monday.plusDays(6)); + Map existingById = existing.stream() + .collect(Collectors.toMap(WorkPlanRecord::getId, r -> r, (a, b) -> a)); + + Set submittedIds = items.stream() + .map(WorkPlanItemVo::getId) + .filter(id -> id != null && id > 0) + .collect(Collectors.toSet()); + + // 差集删除:库中存在、提交列表没有 → 删除(受权限约束,教师仅删本人) + boolean admin = canEditAll(); + Integer me = UserContext.getUserId(); + List 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 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 pageAllPlan(AllPlanQuery query) { + Integer schId = schId(); + String name = StringUtils.trimToNull(query.getName()); + + Specification spec = (root, cq, cb) -> { + List 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 page = recordRepository.findAll(spec, + PageUtils.toPageable(query, Sort.by(Sort.Order.desc("startDate"), Sort.Order.desc("id")))); + + // 学年学期首周查询缓存(同页多行同学年学期时避免重复查库) + Map firstDayCache = new HashMap<>(); + List 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 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; + } +} diff --git a/src/main/java/com/yunhai/workweekplan/utils/GzipUtil.java b/src/main/java/com/yunhai/workweekplan/utils/GzipUtil.java new file mode 100644 index 0000000..bdfa069 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/utils/GzipUtil.java @@ -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]; + } + } +} diff --git a/src/main/java/com/yunhai/workweekplan/utils/IpUtils.java b/src/main/java/com/yunhai/workweekplan/utils/IpUtils.java new file mode 100644 index 0000000..d3ce1f2 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/utils/IpUtils.java @@ -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; + } +} diff --git a/src/main/java/com/yunhai/workweekplan/utils/PageUtils.java b/src/main/java/com/yunhai/workweekplan/utils/PageUtils.java new file mode 100644 index 0000000..3dab528 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/utils/PageUtils.java @@ -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 BasePageResult toResult(Page page) { + return new BasePageResult( + page.getContent(), + (int) page.getTotalElements(), + page.getNumber() + 1, + page.getSize(), + page.getTotalPages(), + page.hasNext() + ); + } +} diff --git a/src/main/java/com/yunhai/workweekplan/utils/UserContext.java b/src/main/java/com/yunhai/workweekplan/utils/UserContext.java new file mode 100644 index 0000000..1173823 --- /dev/null +++ b/src/main/java/com/yunhai/workweekplan/utils/UserContext.java @@ -0,0 +1,140 @@ +package com.yunhai.workweekplan.utils; + +import lombok.Value; + +/** + * 当前登录用户上下文(ThreadLocal 静态持有器)。 + *

移植自 UserclassService(com.yunhai.bschclassmanage.utils.UserContext), + * 由 {@link com.yunhai.workweekplan.aspect.ControllerUserContextAspect} 在每个请求进入 controller 前填充, + * 请求结束后 clear,防止线程复用串号。

+ */ +public class UserContext { + private static final ThreadLocal USER_ID = new ThreadLocal<>(); + private static final ThreadLocal USER_NAME = new ThreadLocal<>(); + private static final ThreadLocal ROLE_ID = new ThreadLocal<>(); + private static final ThreadLocal SCH_ID = new ThreadLocal<>(); + private static final ThreadLocal IP = new ThreadLocal<>(); + private static final ThreadLocal TYPE = new ThreadLocal<>(); + + /** 当前角色切换上下文(来自 currentrole header),家长场景 childId 即学生ID */ + private static final ThreadLocal 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(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..edc1cf1 --- /dev/null +++ b/src/main/resources/application.yml @@ -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/ diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..ce91dd7 --- /dev/null +++ b/src/main/resources/bootstrap.yml @@ -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 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..20612de --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,35 @@ + + + + + + + + + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET} + + + + + + + /app/logs/app.log + + /app/logs/app.%d{yyyy-MM-dd}.log + 30 + 5GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/sql/sys_operation_log.sql b/src/main/resources/sql/sys_operation_log.sql new file mode 100644 index 0000000..1a16561 --- /dev/null +++ b/src/main/resources/sql/sys_operation_log.sql @@ -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 ='系统审计日志(双轨之审计轨)'; diff --git a/src/main/resources/sql/work_week_plan_schema.sql b/src/main/resources/sql/work_week_plan_schema.sql new file mode 100644 index 0000000..5dadbe4 --- /dev/null +++ b/src/main/resources/sql/work_week_plan_schema.sql @@ -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 = '法定节假日/调休补班缓存'; +