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..88ed2b6 --- /dev/null +++ b/docker/application-prod.yml @@ -0,0 +1,52 @@ +# AcaSyllabu 生产配置(线上以 nacos 的 acasyllabu.yaml 为准,本文件是 nacos 不可达时的兜底) +# 真正上线请在 Nacos 新建 dataId=acasyllabu.yaml、group=OA_APP 的配置覆盖以下项 +# (尤其 datasource 的库名/账号、yh.sso.client-host-url)。 +server: + port: 8070 + servlet: + # 对外经 caddy 暴露为 http://base.yunhaixingtong.com/acasyllabu + context-path: /acasyllabu + +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_acasyllabu(需先在 mysql8 上建库) + url: jdbc:mysql://mysql8:3306/bsch_acasyllabu?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/acasyllabu + 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..4e543f2 --- /dev/null +++ b/docker/run-app.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# AcaSyllabu 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml) +# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到 +# 镜像内烤好的 application-prod.yml 兜底默认。 +set -e + +echo "[run-app] starting AcaSyllabu (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..9075f2a --- /dev/null +++ b/pom.xml @@ -0,0 +1,204 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + acasyllabu + 0.0.1 + AcaSyllabu + 课程表子应用 + + 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 + + + + + + + + acasyllabu + + + org.apache.maven.plugins + maven-compiler-plugin + + + false + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.acasyllabu.AcaSyllabuApplication + + + + + repackage + + + + + + + + + + + nexus-public + bsch/nexus public + http://nexus.yunhaixingtong.com/repository/maven-public/ + true + true + + + + + + ci + + C:/aca-build/acasyllabu + + + + + diff --git a/src/main/java/com/yunhai/acasyllabu/AcaSyllabuApplication.java b/src/main/java/com/yunhai/acasyllabu/AcaSyllabuApplication.java new file mode 100644 index 0000000..4ed5e53 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/AcaSyllabuApplication.java @@ -0,0 +1,20 @@ +package com.yunhai.acasyllabu; + +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.acasyllabu") +@EnableTransactionManagement +@EnableAsync +public class AcaSyllabuApplication { + + public static void main(String[] args) { + // 开启 fastjson 安全模式 + ParserConfig.getGlobalInstance().setSafeMode(true); + SpringApplication.run(AcaSyllabuApplication.class, args); + } + +} diff --git a/src/main/java/com/yunhai/acasyllabu/aspect/ControllerUserContextAspect.java b/src/main/java/com/yunhai/acasyllabu/aspect/ControllerUserContextAspect.java new file mode 100644 index 0000000..6bbaf32 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/aspect/ControllerUserContextAspect.java @@ -0,0 +1,154 @@ +package com.yunhai.acasyllabu.aspect; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.acasyllabu.enums.UserType; +import com.yunhai.acasyllabu.model.UserSysRole; +import com.yunhai.acasyllabu.utils.IpUtils; +import com.yunhai.acasyllabu.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.acasyllabu.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()); + } + + 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/acasyllabu/aspect/SysOperationLogAspect.java b/src/main/java/com/yunhai/acasyllabu/aspect/SysOperationLogAspect.java new file mode 100644 index 0000000..4db7ea6 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/aspect/SysOperationLogAspect.java @@ -0,0 +1,223 @@ +package com.yunhai.acasyllabu.aspect; + +import com.alibaba.fastjson.JSON; +import com.yunhai.acasyllabu.entity.SysOperationLog; +import com.yunhai.acasyllabu.service.ISysOperationLogService; +import com.yunhai.acasyllabu.utils.GzipUtil; +import com.yunhai.acasyllabu.utils.IpUtils; +import com.yunhai.acasyllabu.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.acasyllabu.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.acasyllabu.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/acasyllabu/common/listener/BaseAuditListener.java b/src/main/java/com/yunhai/acasyllabu/common/listener/BaseAuditListener.java new file mode 100644 index 0000000..3f41547 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/common/listener/BaseAuditListener.java @@ -0,0 +1,47 @@ +package com.yunhai.acasyllabu.common.listener; + +import com.yunhai.acasyllabu.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/acasyllabu/common/validation/ValidationGroups.java b/src/main/java/com/yunhai/acasyllabu/common/validation/ValidationGroups.java new file mode 100644 index 0000000..a1630ac --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/common/validation/ValidationGroups.java @@ -0,0 +1,10 @@ +package com.yunhai.acasyllabu.common.validation; + +/** + * JSR-303 分组校验标记接口(对齐 UserBase 规范)。 + * Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。 + */ +public interface ValidationGroups { + interface Create {} + interface Update {} +} diff --git a/src/main/java/com/yunhai/acasyllabu/config/Config.java b/src/main/java/com/yunhai/acasyllabu/config/Config.java new file mode 100644 index 0000000..6750189 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/config/Config.java @@ -0,0 +1,11 @@ +package com.yunhai.acasyllabu.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/acasyllabu/config/JacksonConfig.java b/src/main/java/com/yunhai/acasyllabu/config/JacksonConfig.java new file mode 100644 index 0000000..6023d7b --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/config/JacksonConfig.java @@ -0,0 +1,35 @@ +package com.yunhai.acasyllabu.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/acasyllabu/config/SwaggerConfig.java b/src/main/java/com/yunhai/acasyllabu/config/SwaggerConfig.java new file mode 100644 index 0000000..2c6ff3e --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/config/SwaggerConfig.java @@ -0,0 +1,22 @@ +package com.yunhai.acasyllabu.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/acasyllabu/controller/api/BaseApiController.java b/src/main/java/com/yunhai/acasyllabu/controller/api/BaseApiController.java new file mode 100644 index 0000000..eb20fe6 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/controller/api/BaseApiController.java @@ -0,0 +1,164 @@ +package com.yunhai.acasyllabu.controller.api; + +import com.yunhai.acasyllabu.config.Config; +import com.yunhai.acasyllabu.model.UserSysRole; +import com.yunhai.acasyllabu.model.bsch.ClassTreeNodeVo; +import com.yunhai.acasyllabu.model.base.SetRoleRequest; +import com.yunhai.acasyllabu.model.base.SetRoleResult; +import com.yunhai.acasyllabu.model.file.UpFileResultVo; +import com.yunhai.acasyllabu.service.IClassService; +import com.yunhai.acasyllabu.service.IFileService; +import com.yunhai.acasyllabu.service.IUserCenterService; +import com.yunhai.acasyllabu.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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 平台代理域接口(base/)。 + *

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

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/base/") +public class BaseApiController { + + private static final Logger log = LoggerFactory.getLogger(BaseApiController.class); + + /** 家长角色ID(UserRole.PATRIARCH_USER),用于 GetUserInfo 的 role 判定 */ + private static final Integer ROLE_PARENT = 4; + + @Autowired + private IUserCenterService userCenterService; + + @Autowired + private IFileService fileService; + + @Autowired + private IClassService classService; + + // ============================ FileUpload ============================ + + /** + * 附件上传(单文件)。 + * 返回: { fileid, host, fileurl, name, type, size } + */ + @PostMapping("FileUpload") + public UpFileResultVo fileUpload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, "timetable"); + } + + // ============================ 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); + } + + // ============================ GetSchoolAll ============================ + + /** + * 获取当前用户学校组及教师(树形结构,选人数据源)。 + */ + @GetMapping("GetSchoolAll") + public List> getSchoolAll(BoaUser authUserWithDept) { + return userCenterService.getSchoolAllTree(schIdOf(authUserWithDept)); + } + + // ============================ GetClassTree ============================ + + /** + * 年级→班级树(叶子=班级,附学生数)。数据来自班级服务学生树(5 分钟缓存)。 + */ + @GetMapping("GetClassTree") + public List getClassTree(BoaUser authUserWithDept) { + return classService.getClassTree(schIdOf(authUserWithDept)); + } + + // ============================ 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(); + } + + private UserSysRole currentRole(BoaUser user) { + UserSysRole role = new UserSysRole(); + if (user.getSchool() != null) { + role.setSchId(user.getSchool().getId()); + } + role.setUserId(user.getId()); + if (user.getRoleIds() != null && !user.getRoleIds().isEmpty()) { + role.setRoleId(user.getRoleIds().get(0)); + } + // childId 由 ControllerUserContextAspect 填充进 UserContext + Integer childId = UserContext.getChildId(); + role.setChildId(childId == null ? 0 : childId); + return role; + } + + /** 家长判定:角色=家长(4) 或带有效 childId */ + private boolean isParent(UserSysRole role) { + if (role == null) { + return false; + } + if (ROLE_PARENT.equals(role.getRoleId())) { + return true; + } + return role.getChildId() != null && role.getChildId() > 0; + } +} diff --git a/src/main/java/com/yunhai/acasyllabu/controller/api/FileController.java b/src/main/java/com/yunhai/acasyllabu/controller/api/FileController.java new file mode 100644 index 0000000..5a1c553 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/controller/api/FileController.java @@ -0,0 +1,74 @@ +package com.yunhai.acasyllabu.controller.api; + +import com.yunhai.acasyllabu.config.Config; +import com.yunhai.acasyllabu.model.file.UpFileResultVo; +import com.yunhai.acasyllabu.model.file.UpFileVo; +import com.yunhai.acasyllabu.service.IFileService; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 附件域接口(file/)。课程表照片统一挂 topicType=timetable。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/file/") +public class FileController { + + private static final String TOPIC_TYPE = "timetable"; + + @Autowired + private IFileService fileService; + + /** 附件上传(单文件),返回 { fileid, host, fileurl, name, type, size } */ + @PostMapping("Upload") + public UpFileResultVo upload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, TOPIC_TYPE); + } + + /** 图片上传(多文件) */ + @PostMapping("UploadImage") + public List uploadImage(@RequestParam("file") MultipartFile[] files) { + return fileService.uploadImages(files, TOPIC_TYPE); + } + + /** 文件下载(浏览器直接 location.href 跳转) */ + @GetMapping("Down") + public void down(@RequestParam("id") Integer id, HttpServletResponse response) { + fileService.download(id, response); + } + + /** 文件在线预览(inline 输出) */ + @GetMapping("Preview") + public void preview(@RequestParam("id") Integer id, + @RequestParam(value = "type", required = false) Integer type, + HttpServletResponse response) { + fileService.preview(id, response); + } + + /** 删除文件 */ + @PostMapping("Delete") + public void delete(@RequestParam("fileId") Integer fileId) { + fileService.delete(fileId); + } + + /** 查询某主题下的附件列表 */ + @GetMapping("List") + public List list(@RequestParam("topicId") Integer topicId) { + return fileService.listByTopic(TOPIC_TYPE, topicId); + } + + /** 批量绑定附件到主题 */ + @PostMapping("Bind") + public void bind(@RequestParam("fileIds") List fileIds, + @RequestParam("topicId") Integer topicId) { + fileService.bindToTopic(fileIds, topicId); + } +} diff --git a/src/main/java/com/yunhai/acasyllabu/controller/api/LogController.java b/src/main/java/com/yunhai/acasyllabu/controller/api/LogController.java new file mode 100644 index 0000000..7fbad5c --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/controller/api/LogController.java @@ -0,0 +1,32 @@ +package com.yunhai.acasyllabu.controller.api; + +import com.yunhai.acasyllabu.config.Config; +import com.yunhai.acasyllabu.entity.SysOperationLog; +import com.yunhai.acasyllabu.model.BasePageQuery; +import com.yunhai.acasyllabu.model.BasePageResult; +import com.yunhai.acasyllabu.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/acasyllabu/controller/api/SettingController.java b/src/main/java/com/yunhai/acasyllabu/controller/api/SettingController.java new file mode 100644 index 0000000..504e088 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/controller/api/SettingController.java @@ -0,0 +1,59 @@ +package com.yunhai.acasyllabu.controller.api; + +import com.yunhai.acasyllabu.config.Config; +import com.yunhai.acasyllabu.model.syllabus.EffectiveSettingVo; +import com.yunhai.acasyllabu.model.syllabus.SettingGetVo; +import com.yunhai.acasyllabu.model.syllabus.TemplateSaveRequest; +import com.yunhai.acasyllabu.model.syllabus.TimeSettingVo; +import com.yunhai.acasyllabu.service.ISettingService; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 应用设置接口(setting/)。 + *

时间设置(时段判定边界 + 节次/课间时长)与节次模板(绑定学期×多年级,行含节次与课间/午休空白行) + * 统一在此维护;课表编辑页经 ForGrade 取当前学期该年级生效模板行作为节次骨架。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/setting/") +public class SettingController { + + @Autowired + private ISettingService settingService; + + /** 查询本校应用设置(时间设置 + 全部节次模板含行) */ + @GetMapping("Get") + public SettingGetVo get() { + return settingService.get(); + } + + /** 保存时段判定时间设置 */ + @PostMapping("SaveTime") + public void saveTime(@RequestBody TimeSettingVo time) { + settingService.saveTime(time); + } + + /** 新建/更新节次模板(全量替换行),返回模板ID */ + @PostMapping("SaveTemplate") + public Integer saveTemplate(@RequestBody TemplateSaveRequest request) { + return settingService.saveTemplate(request); + } + + /** 删除节次模板(连带行) */ + @PostMapping("DeleteTemplate") + public void deleteTemplate(@RequestParam("id") Integer id) { + settingService.deleteTemplate(id); + } + + /** 课表编辑页生效设置:时间边界 + 当前学期下指定年级启用模板的行(gradeName 如 高一,term 1/2) */ + @GetMapping("ForGrade") + public EffectiveSettingVo forGrade(@RequestParam(value = "gradeName", required = false) String gradeName, + @RequestParam(value = "term", required = false) Integer term) { + return settingService.effective(gradeName, term); + } +} diff --git a/src/main/java/com/yunhai/acasyllabu/controller/api/TimetableController.java b/src/main/java/com/yunhai/acasyllabu/controller/api/TimetableController.java new file mode 100644 index 0000000..7138492 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/controller/api/TimetableController.java @@ -0,0 +1,93 @@ +package com.yunhai.acasyllabu.controller.api; + +import com.yunhai.acasyllabu.config.Config; +import com.yunhai.acasyllabu.model.bsch.NowYearTermVo; +import com.yunhai.acasyllabu.model.bsch.SubjectVo; +import com.yunhai.acasyllabu.model.syllabus.MyClassVo; +import com.yunhai.acasyllabu.model.syllabus.MyTimetableVo; +import com.yunhai.acasyllabu.model.syllabus.TimetableSaveRequest; +import com.yunhai.acasyllabu.model.syllabus.TimetableVo; +import com.yunhai.acasyllabu.service.IClassService; +import com.yunhai.acasyllabu.service.ITimetableService; +import com.yunhai.acasyllabu.utils.UserContext; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 课程表域接口(timetable/)。 + *

电子版:Get / Save;照片版:SavePhoto / RemovePhoto(照片先经 /file/Upload 上传拿 fileId); + * 学科下拉数据源:Subjects(本校学科列表);当前学年学期:NowYearTerm(课表按当前学年学期读写)。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/timetable/") +public class TimetableController { + + @Autowired + private ITimetableService timetableService; + + @Autowired + private IClassService classService; + + /** 查询班级课程表(电子版节次 + 照片版附件信息) */ + @GetMapping("Get") + public TimetableVo get(@RequestParam("classId") Integer classId) { + return timetableService.get(classId); + } + + /** 整表保存电子课程表(全量替换节次),返回课表ID(参数校验在 Service 内以 BizException 返回) */ + @PostMapping("Save") + public Integer save(@RequestBody TimetableSaveRequest request) { + return timetableService.save(request); + } + + /** 绑定/替换照片课表(fileId 为已上传附件ID) */ + @PostMapping("SavePhoto") + public void savePhoto(@RequestParam("classId") Integer classId, + @RequestParam("fileId") Integer fileId) { + timetableService.savePhoto(classId, fileId); + } + + /** 移除照片课表 */ + @PostMapping("RemovePhoto") + public void removePhoto(@RequestParam("classId") Integer classId) { + timetableService.removePhoto(classId); + } + + /** 本校学科列表(学科下拉数据源;不可达时返回空列表) */ + @GetMapping("Subjects") + public List subjects() { + Integer schId = UserContext.getSchId(); + return classService.getSubjectList(schId == null ? 0 : schId); + } + + /** 当前学年学期(课表按学年学期维护,页面展示用;班级服务不可达时抛业务异常) */ + @GetMapping("NowYearTerm") + public NowYearTermVo nowYearTerm() { + return classService.getNowYearTerm(); + } + + // ============================ 我的课表 ============================ + + /** + * 「我的课表」可查看班级(管理员/校领导=全校班级全部科目;班主任=所带班级全部科目; + * 任教老师=任教班级任教科目)。班级排序按学生树年级→班级顺序。 + */ + @GetMapping("MyClasses") + public List myClasses() { + return timetableService.myClasses(); + } + + /** 「我的课表」班级课表(先校验访问权限;非班主任的任教老师只返回任教科目格) */ + @GetMapping("MyGet") + public MyTimetableVo myGet(@RequestParam("classId") Integer classId) { + return timetableService.myGet(classId); + } +} diff --git a/src/main/java/com/yunhai/acasyllabu/controller/oapi/OapiTimetableController.java b/src/main/java/com/yunhai/acasyllabu/controller/oapi/OapiTimetableController.java new file mode 100644 index 0000000..ef76211 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/controller/oapi/OapiTimetableController.java @@ -0,0 +1,39 @@ +package com.yunhai.acasyllabu.controller.oapi; + +import com.yunhai.acasyllabu.model.syllabus.OapiTimetableClassVo; +import com.yunhai.acasyllabu.service.ITimetableService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 课程表开放接口(参考 noticeservice /oapi 试点,AcaSyllabu 作为提供方开放 /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/roleId/schId),不依赖 CAS 登录态;角色范围: + * 管理员(7)/校领导(2)=全部班级全部科目;班主任=所带班级全部科目; + * 任教老师=任教班级任教科目;学生(3)=本人所在班级。roleId 缺省按普通教师处理。

+ */ +@RestController +@RequestMapping("/oapi/timetable") +public class OapiTimetableController { + + @Autowired + private ITimetableService timetableService; + + /** + * 按身份查询可见班级课表(当前学年学期)。 + * 返回数组:每班一项(班级信息 + 科目可见范围 + 该班课表节次;任教老师只含任教科目格)。 + */ + @GetMapping("/Get") + public List get(@RequestParam("userid") Integer userId, + @RequestParam(value = "roleId", required = false) Integer roleId, + @RequestParam("schId") Integer schId) { + return timetableService.oapiTimetable(userId, roleId, schId); + } +} diff --git a/src/main/java/com/yunhai/acasyllabu/entity/BaseEntity.java b/src/main/java/com/yunhai/acasyllabu/entity/BaseEntity.java new file mode 100644 index 0000000..beb92ab --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/entity/BaseEntity.java @@ -0,0 +1,49 @@ +package com.yunhai.acasyllabu.entity; + +import com.yunhai.acasyllabu.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/acasyllabu/entity/PeriodTemplate.java b/src/main/java/com/yunhai/acasyllabu/entity/PeriodTemplate.java new file mode 100644 index 0000000..1aa97a9 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/entity/PeriodTemplate.java @@ -0,0 +1,70 @@ +package com.yunhai.acasyllabu.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; + +/** + * 节次模板(每校多套,按学期+名称区分,绑定 1~N 个年级,如 春×上学期×[高一,高二])。 + *

同一学校同学期、年级有交集的模板同时只允许启用一套(在 Service 内保证); + * 课表编辑页按班级年级+当前学期取启用模板的行作为节次与时间骨架。 + * 启用时段(早自习/上午/中午/下午/晚自习)与节次/课间时长随模板保存(绑定模板ID), + * 停用的时段不在模板行列表中维护,课表编辑页也不再显示该时段的节次。

+ */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "timetable_period_template", + indexes = { + @Index(name = "idx_tpl_sch", columnList = "sch_id"), + @Index(name = "uk_tpl_sch_term_name", columnList = "sch_id, term, name", unique = true) + }) +public class PeriodTemplate extends BaseEntity { + + /** 学校ID */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 模板名称(春/秋 等),同学期内唯一 */ + @Column(name = "name", length = 16, nullable = false) + private String name; + + /** 绑定学期:0=全年 1=上学期 2=下学期 */ + @Column(name = "term", nullable = false) + private Integer term = 1; + + /** 适用年级(逗号分隔,如 高一,高二,高三) */ + @Column(name = "grade_name", length = 64, nullable = false) + private String gradeName; + + /** 是否启用(同校同年级唯一启用) */ + @Column(name = "enabled", nullable = false) + private Boolean enabled = Boolean.FALSE; + + /** + * 教学日开关(7 位 0/1 串,下标对应周一~周日,1=显示该列可排课),默认 7 天全开。 + */ + @Column(name = "teach_days", length = 7, nullable = false) + private String teachDays = "1111111"; + + /** + * 启用时段(5 位 0/1 串,顺序固定:早自习 上午 中午 下午 晚自习),默认全部启用。 + * 停用的时段不参与课表时段判定与展示,其时间范围内的节次也不在模板行列表中维护。 + */ + @Column(name = "enabled_bands", length = 16, nullable = false) + private String enabledBands = "11111"; + + /** 节次时长(分钟):模板编辑器「+节次」自动推算结束时间的默认时长,默认 45 */ + @Column(name = "period_minutes", nullable = false) + private Integer periodMinutes = 45; + + /** 课间时长(分钟):模板编辑器「+空白行」自动推算结束时间的默认时长,默认 10 */ + @Column(name = "break_minutes", nullable = false) + private Integer breakMinutes = 10; +} diff --git a/src/main/java/com/yunhai/acasyllabu/entity/SysOperationLog.java b/src/main/java/com/yunhai/acasyllabu/entity/SysOperationLog.java new file mode 100644 index 0000000..8a1d496 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/entity/SysOperationLog.java @@ -0,0 +1,84 @@ +package com.yunhai.acasyllabu.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/acasyllabu/entity/TemplateRow.java b/src/main/java/com/yunhai/acasyllabu/entity/TemplateRow.java new file mode 100644 index 0000000..e102ed1 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/entity/TemplateRow.java @@ -0,0 +1,57 @@ +package com.yunhai.acasyllabu.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; + +/** + * 节次模板行(period=教学节次,break=空白行如课间/午休)。 + *

空白行可自定义名称与备注、可设起止时间,但不占用节次序号(节次序号只按 period 行顺序数)、 + * 不能排课程;行顺序即模板行顺序,上下移调整 sort_order。

+ *

所属时段 band 直接落在行上(es/am/noon/pm/ev,与模板启用时段位串下标一致): + * 节次与空白行都落在时段里,按 band 归组展示与排课,不再按开始时间落入哪一区间反推。

+ */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "timetable_template_row", indexes = @Index(name = "idx_tpl_row_tpl", columnList = "template_id")) +public class TemplateRow extends BaseEntity { + + /** 行类型:period=教学节次 / break=空白行(课间、午休等) */ + @Column(name = "row_type", length = 8, nullable = false) + private String rowType; + + /** 所属模板ID */ + @Column(name = "template_id", nullable = false) + private Integer templateId; + + /** 行顺序(从 1 起) */ + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + /** 所属时段(es/am/noon/pm/ev;节次与空白行都落在时段里) */ + @Column(name = "band", length = 8) + private String band; + + /** 行名称(period 行可自定义,留空由前端显示“第X节”;break 行可自定义,如 课间/午休) */ + @Column(name = "name", length = 32) + private String name; + + /** 备注(仅空白行可填写) */ + @Column(name = "remark", length = 200) + private String remark; + + /** 开始时间 HH:mm */ + @Column(name = "start_time", length = 5) + private String startTime; + + /** 结束时间 HH:mm */ + @Column(name = "end_time", length = 5) + private String endTime; +} diff --git a/src/main/java/com/yunhai/acasyllabu/entity/TimeSetting.java b/src/main/java/com/yunhai/acasyllabu/entity/TimeSetting.java new file mode 100644 index 0000000..3746ef2 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/entity/TimeSetting.java @@ -0,0 +1,61 @@ +package com.yunhai.acasyllabu.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; + +/** + * 时段判定时间设置(每校一行)。 + *

课表编辑页/预览按节次「开始时间」落入哪一区间判定时段(早自习/上午/中午/下午/晚自习): + * 早自习 = 早于 morningSelfEnd;上午 = [morningSelfEnd, forenoonEnd); + * 中午 = [forenoonEnd, afternoonStart)(午休);下午 = [afternoonStart, eveningStart); + * 晚自习 = ≥ eveningStart。四个边界须依次递增;enabledBands 控制学校实际启用的时段, + * 未启用的时段不参与判定(其时间范围内的节次归入其后的首个启用时段)。

+ */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "timetable_time_setting", indexes = @Index(name = "uk_time_setting_sch", columnList = "sch_id", unique = true)) +public class TimeSetting extends BaseEntity { + + /** 学校ID */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 早自习截止 HH:mm(开始时间早于此记为早自习),默认 07:00 */ + @Column(name = "morning_self_end", length = 5, nullable = false) + private String morningSelfEnd = "07:00"; + + /** 上午截止 HH:mm,默认 12:30 */ + @Column(name = "forenoon_end", length = 5, nullable = false) + private String forenoonEnd = "12:30"; + + /** 下午开始 HH:mm,默认 13:30 */ + @Column(name = "afternoon_start", length = 5, nullable = false) + private String afternoonStart = "13:30"; + + /** 晚自习开始 HH:mm(开始时间晚于此记为晚自习),默认 18:30 */ + @Column(name = "evening_start", length = 5, nullable = false) + private String eveningStart = "18:30"; + + /** + * 启用时段(5 位 0/1 串,顺序固定:早自习 上午 中午 下午 晚自习),默认全部启用。 + * 未启用的时段不参与课表时段判定与展示。 + */ + @Column(name = "enabled_bands", length = 16, nullable = false) + private String enabledBands = "11111"; + + /** 节次时长(分钟):模板编辑器「+节次」自动推算结束时间的默认时长,默认 45 */ + @Column(name = "period_minutes", nullable = false) + private Integer periodMinutes = 45; + + /** 课间时长(分钟):模板编辑器「+空白行」自动推算结束时间的默认时长,默认 10 */ + @Column(name = "break_minutes", nullable = false) + private Integer breakMinutes = 10; +} diff --git a/src/main/java/com/yunhai/acasyllabu/entity/Timetable.java b/src/main/java/com/yunhai/acasyllabu/entity/Timetable.java new file mode 100644 index 0000000..9d5081d --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/entity/Timetable.java @@ -0,0 +1,56 @@ +package com.yunhai.acasyllabu.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; + +/** + * 课程表实体(每班每学年学期一条)。 + *

维护人只存教师/平台用户 id(teacher_id),不存账号; + * 班级名称、年级名称冗余存储,避免列表页频繁调班级服务; + * 学年学期取自班级服务当前学年学期,历史学年学期数据保留、查询只取当前。

+ */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "timetable", uniqueConstraints = @UniqueConstraint(name = "uk_timetable_sch_class_year_term", columnNames = {"sch_id", "class_id", "year", "term"})) +public class Timetable extends BaseEntity { + + /** 学校ID(多租户隔离) */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 学年(如 2026,取班级服务当前学年) */ + @Column(name = "year", nullable = false) + private Integer year; + + /** 学期(1第一学期 2第二学期,取班级服务当前学期) */ + @Column(name = "term", nullable = false) + private Integer term; + + /** 班级ID(班级服务学生树节点 id) */ + @Column(name = "class_id", nullable = false) + private Integer classId; + + /** 班级名称(冗余) */ + @Column(name = "class_name", length = 100) + private String className; + + /** 年级名称(冗余) */ + @Column(name = "grade_name", length = 100) + private String gradeName; + + /** 维护教师/平台用户 id(不存账号) */ + @Column(name = "teacher_id") + private Integer teacherId; + + /** 照片课表附件ID(up_file.id,可空) */ + @Column(name = "photo_file_id") + private Integer photoFileId; +} diff --git a/src/main/java/com/yunhai/acasyllabu/entity/TimetableSlot.java b/src/main/java/com/yunhai/acasyllabu/entity/TimetableSlot.java new file mode 100644 index 0000000..309db79 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/entity/TimetableSlot.java @@ -0,0 +1,66 @@ +package com.yunhai.acasyllabu.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.Index; + +/** + * 课程表节次实体(每节一行,列存周一~周日学科名,空串=未排)。 + * 时段(上午/下午/晚自习)不落库,由前端按开始时间实时判定。 + */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "timetable_slot", indexes = @Index(name = "idx_slot_timetable", columnList = "timetable_id")) +public class TimetableSlot extends BaseEntity { + + /** 所属课程表ID */ + @Column(name = "timetable_id", nullable = false) + private Integer timetableId; + + /** 节次顺序(从 1 起) */ + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + /** 开始时间 HH:mm */ + @Column(name = "start_time", length = 5) + private String startTime; + + /** 结束时间 HH:mm */ + @Column(name = "end_time", length = 5) + private String endTime; + + /** 星期一学科名(空=未排) */ + @Column(name = "day_1", length = 64) + private String day1; + + /** 星期二学科名 */ + @Column(name = "day_2", length = 64) + private String day2; + + /** 星期三学科名 */ + @Column(name = "day_3", length = 64) + private String day3; + + /** 星期四学科名 */ + @Column(name = "day_4", length = 64) + private String day4; + + /** 星期五学科名 */ + @Column(name = "day_5", length = 64) + private String day5; + + /** 星期六学科名 */ + @Column(name = "day_6", length = 64) + private String day6; + + /** 星期日学科名 */ + @Column(name = "day_7", length = 64) + private String day7; +} diff --git a/src/main/java/com/yunhai/acasyllabu/entity/UpFile.java b/src/main/java/com/yunhai/acasyllabu/entity/UpFile.java new file mode 100644 index 0000000..0dfa887 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/entity/UpFile.java @@ -0,0 +1,44 @@ +package com.yunhai.acasyllabu.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 附件元信息实体(按前端 UpFileVo 契约重建)。 + */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "up_file") +public class UpFile extends BaseEntity { + + /** 主题类型(如 "activity") */ + @Column(name = "topic_type", length = 50) + private String topicType; + + /** 主题ID(活动ID;未绑定时为空) */ + @Column(name = "topic_id") + private Integer topicId; + + /** 原始文件名 */ + @Column(name = "file_true_name", length = 255) + private String fileTrueName; + + /** 存储相对路径 */ + @Column(name = "file_name", length = 255) + private String fileName; + + /** 文件大小(字节) */ + @Column(name = "file_size") + private Integer fileSize; + + /** 扩展名(小写) */ + @Column(name = "file_type", length = 50) + private String fileType; +} diff --git a/src/main/java/com/yunhai/acasyllabu/enums/UserType.java b/src/main/java/com/yunhai/acasyllabu/enums/UserType.java new file mode 100644 index 0000000..cf42336 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/enums/UserType.java @@ -0,0 +1,20 @@ +package com.yunhai.acasyllabu.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/acasyllabu/model/BasePageQuery.java b/src/main/java/com/yunhai/acasyllabu/model/BasePageQuery.java new file mode 100644 index 0000000..59c081b --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/BasePageQuery.java @@ -0,0 +1,14 @@ +package com.yunhai.acasyllabu.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/acasyllabu/model/BasePageResult.java b/src/main/java/com/yunhai/acasyllabu/model/BasePageResult.java new file mode 100644 index 0000000..9050236 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/BasePageResult.java @@ -0,0 +1,33 @@ +package com.yunhai.acasyllabu.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/acasyllabu/model/SchTeaList.java b/src/main/java/com/yunhai/acasyllabu/model/SchTeaList.java new file mode 100644 index 0000000..226dcc5 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/SchTeaList.java @@ -0,0 +1,106 @@ +package com.yunhai.acasyllabu.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * 班级管理接口-教师(bschclass {@code /oapi/DbClass/GetSchteaList} 响应项)。 + * 对应 C# BestSchool.Entity.Model.SchTeaList。 + */ +public class SchTeaList { + + /** 学校 GUID(SchSerID) */ + @JsonProperty("SchSerID") + private String schSerID; + + @JsonProperty("TeaTel") + private String teaTel; + + /** 教师姓名 */ + @JsonProperty("TeaName") + private String teaName; + + /** 教师用户ID */ + @JsonProperty("UserTeaSerID") + private Integer userTeaSerID; + + @JsonProperty("TeaIDCard") + private String teaIDCard; + + /** 教师工号 */ + @JsonProperty("TeaNo") + private String teaNo; + + /** 教师所属组ID列表(兼容旧字段 GroupIds) */ + @JsonProperty("GroupIds") + private List groupIds; + + /** 教师所属组ID(接口现行字段 GroupID,单个) */ + @JsonProperty("GroupID") + private Integer groupId; + + public Integer getGroupId() { + return groupId; + } + + public void setGroupId(Integer groupId) { + this.groupId = groupId; + } + + public String getSchSerID() { + return schSerID; + } + + public void setSchSerID(String schSerID) { + this.schSerID = schSerID; + } + + public String getTeaTel() { + return teaTel; + } + + public void setTeaTel(String teaTel) { + this.teaTel = teaTel; + } + + public String getTeaName() { + return teaName; + } + + public void setTeaName(String teaName) { + this.teaName = teaName; + } + + public Integer getUserTeaSerID() { + return userTeaSerID; + } + + public void setUserTeaSerID(Integer userTeaSerID) { + this.userTeaSerID = userTeaSerID; + } + + public String getTeaIDCard() { + return teaIDCard; + } + + public void setTeaIDCard(String teaIDCard) { + this.teaIDCard = teaIDCard; + } + + public String getTeaNo() { + return teaNo; + } + + public void setTeaNo(String teaNo) { + this.teaNo = teaNo; + } + + public List getGroupIds() { + return groupIds; + } + + public void setGroupIds(List groupIds) { + this.groupIds = groupIds; + } +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/UserSysRole.java b/src/main/java/com/yunhai/acasyllabu/model/UserSysRole.java new file mode 100644 index 0000000..b518edc --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/UserSysRole.java @@ -0,0 +1,29 @@ +package com.yunhai.acasyllabu.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/acasyllabu/model/base/SetRoleRequest.java b/src/main/java/com/yunhai/acasyllabu/model/base/SetRoleRequest.java new file mode 100644 index 0000000..65c7027 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/base/SetRoleRequest.java @@ -0,0 +1,12 @@ +package com.yunhai.acasyllabu.model.base; + +import lombok.Data; + +/** + * 角色切换入参(对齐前端 baseApi.setRole)。 + */ +@Data +public class SetRoleRequest { + /** "teacher" 或 "parent" */ + private String as; +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/base/SetRoleResult.java b/src/main/java/com/yunhai/acasyllabu/model/base/SetRoleResult.java new file mode 100644 index 0000000..28510ae --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/base/SetRoleResult.java @@ -0,0 +1,15 @@ +package com.yunhai.acasyllabu.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/acasyllabu/model/bsch/ClassTreeNodeVo.java b/src/main/java/com/yunhai/acasyllabu/model/bsch/ClassTreeNodeVo.java new file mode 100644 index 0000000..1ed93b7 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/bsch/ClassTreeNodeVo.java @@ -0,0 +1,29 @@ +package com.yunhai.acasyllabu.model.bsch; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * 年级→班级树节点(对齐前端 ClassTreeNode;叶子=班级,附学生数)。 + */ +@Data +public class ClassTreeNodeVo { + private Integer id; + private String name; + /** 前端固定字段名 isParent */ + @JsonProperty("isParent") + private boolean parent; + /** 叶子(班级)附学生数 */ + private Integer studentCount; + private List children; + + public void setParent(boolean parent) { + this.parent = parent; + } + + public boolean isParent() { + return parent; + } +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/bsch/NowYearTermVo.java b/src/main/java/com/yunhai/acasyllabu/model/bsch/NowYearTermVo.java new file mode 100644 index 0000000..ab73c44 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/bsch/NowYearTermVo.java @@ -0,0 +1,50 @@ +package com.yunhai.acasyllabu.model.bsch; + +import lombok.Data; + +/** + * 当前学年学期(班级服务 GET /oapi/sch/getNowYearTerm 返回)。 + * 日期字段为源服务序列化的 yyyy-MM-dd 字符串。 + */ +@Data +public class NowYearTermVo { + + /** 学年学期记录ID(base_year_term.id) */ + private Integer id; + + /** 学年(如 2026) */ + private Integer year; + + /** 当前学期(1第一学期 2第二学期),按当前日期落在学期区间判定 */ + private Integer term; + + /** 学年起始日期 */ + private String yearStartDate; + + /** 学年结束日期 */ + private String yearEndDate; + + /** 第一学期开始日期 */ + private String firstTermStartDate; + + /** 第一学期结束日期 */ + private String firstTermEndDate; + + /** 第二学期开始日期 */ + private String secondTermStartDate; + + /** 第二学期结束日期 */ + private String secondTermEndDate; + + /** 升级日期(上学年→本学年的切换日) */ + private String upgradeDate; + + /** 当前学期开始日期(按 term 取一/二学期开始日期) */ + private String termStartDate; + + /** 当前学期结束日期(按 term 取一/二学期结束日期) */ + private String termEndDate; + + /** 是否当前学年学期,恒为 true */ + private Boolean nowYear; +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/bsch/SubjectVo.java b/src/main/java/com/yunhai/acasyllabu/model/bsch/SubjectVo.java new file mode 100644 index 0000000..f03df94 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/bsch/SubjectVo.java @@ -0,0 +1,19 @@ +package com.yunhai.acasyllabu.model.bsch; + +import lombok.Data; + +/** + * 学校学科(班级服务 sch_dic_subject 派生)。 + */ +@Data +public class SubjectVo { + + /** 学科ID(sch_dic_subject.id) */ + private Integer id; + + /** 学科编码 */ + private String code; + + /** 学科名称 */ + private String name; +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/file/UpFileResultVo.java b/src/main/java/com/yunhai/acasyllabu/model/file/UpFileResultVo.java new file mode 100644 index 0000000..d48f99c --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/file/UpFileResultVo.java @@ -0,0 +1,16 @@ +package com.yunhai.acasyllabu.model.file; + +import lombok.Data; + +/** + * 文件上传结果(对齐前端 UpFileResultVo,含旧字段 fileid/host/fileurl)。 + */ +@Data +public class UpFileResultVo { + private Integer fileid; + private String name; + private String fileurl; + private String host; + private String type; + private Integer size; +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/file/UpFileVo.java b/src/main/java/com/yunhai/acasyllabu/model/file/UpFileVo.java new file mode 100644 index 0000000..c24922f --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/file/UpFileVo.java @@ -0,0 +1,25 @@ +package com.yunhai.acasyllabu.model.file; + +import com.yunhai.acasyllabu.entity.UpFile; +import lombok.Data; +import org.springframework.beans.BeanUtils; + +/** + * 附件视图对象(对齐前端 UpFileVo)。 + */ +@Data +public class UpFileVo { + private Integer id; + private String fileTrueName; + private String fileName; + private Integer fileSize; + private String fileType; + private Integer topicId; + private String topicType; + + public static UpFileVo from(UpFile entity) { + UpFileVo vo = new UpFileVo(); + BeanUtils.copyProperties(entity, vo); + return vo; + } +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/EffectiveSettingVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/EffectiveSettingVo.java new file mode 100644 index 0000000..1b876bb --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/EffectiveSettingVo.java @@ -0,0 +1,27 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +/** + * 课表编辑页生效设置:时段判定边界 + 指定年级启用模板的行。 + * 该年级无启用模板时 rows 为空,前端回退内置默认 10 节。 + */ +@Getter +@Setter +public class EffectiveSettingVo { + + private TimeSettingVo time = new TimeSettingVo(); + + /** 生效模板行(节次 + 空白行,按顺序);无启用模板时为空 */ + private List rows = new ArrayList<>(); + + /** 生效模板教学日 7 位 0/1 串(周一~周日);无启用模板时 7 天全开 */ + private String teachDays = "1111111"; + + /** 生效模板名称(如 春),无启用模板时为空 */ + private String templateName; +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/MyClassVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/MyClassVo.java new file mode 100644 index 0000000..ef25c15 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/MyClassVo.java @@ -0,0 +1,32 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 「我的课表」可查看班级(按当前用户角色过滤后的班级 + 科目可见范围)。 + *

范围规则:管理员/校领导=全校班级全部科目;班主任=所带班级全部科目;任教老师=任教班级任教科目。

+ */ +@Data +public class MyClassVo { + + /** 班级ID(班级组ID,与课表 classId 同一体系) */ + private Integer classId; + + /** 班级名称 */ + private String className; + + /** 年级名称 */ + private String gradeName; + + /** 是否班主任(正/副班主任) */ + private boolean headTeacher; + + /** 是否可查看全部科目(管理员/校领导/班主任 true;仅任教 false) */ + private boolean viewAllSubjects; + + /** 本人在该班任教科目(viewAllSubjects=false 时非空;去重保序) */ + private List subjects = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/MyTimetableVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/MyTimetableVo.java new file mode 100644 index 0000000..eb989f2 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/MyTimetableVo.java @@ -0,0 +1,22 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.ArrayList; +import java.util.List; + +/** + * 「我的课表」视图:班级课表 + 当前用户在该班的科目可见范围。 + *

任教老师(非班主任)只保留任教科目格,其余格清空返回。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +public class MyTimetableVo extends TimetableVo { + + /** 是否展示全部科目 */ + private boolean viewAllSubjects = true; + + /** 本人在该班的任教科目(viewAllSubjects=false 时非空) */ + private List mySubjects = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/OapiTimetableClassVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/OapiTimetableClassVo.java new file mode 100644 index 0000000..aa25ac7 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/OapiTimetableClassVo.java @@ -0,0 +1,36 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * oapi 课表查询返回项(/oapi/timetable/Get):可见班级 + 该班课表。 + *

范围规则:管理员/校领导=全部班级全部科目;班主任=所带班级全部科目; + * 任教老师=任教班级任教科目(timetable 已剔除非任教科目格);学生=本人所在班级全部科目。

+ */ +@Data +public class OapiTimetableClassVo { + + /** 班级ID(班级组ID,与课表 classId 同一体系) */ + private Integer classId; + + /** 班级名称 */ + private String className; + + /** 年级名称 */ + private String gradeName; + + /** 是否班主任(正/副班主任) */ + private boolean headTeacher; + + /** 是否全部科目(管理员/校领导/班主任/学生 true;仅任教 false) */ + private boolean viewAllSubjects; + + /** 可见科目(仅任教老师非空,为任教科目;去重保序) */ + private List subjects = new ArrayList<>(); + + /** 该班当前学年学期课表(电子版节次 + 照片版附件信息;任教老师只含任教科目格) */ + private TimetableVo timetable; +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/PeriodTemplateVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/PeriodTemplateVo.java new file mode 100644 index 0000000..1ac89aa --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/PeriodTemplateVo.java @@ -0,0 +1,44 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +/** + * 节次模板 VO(含全部行;行数少,列表接口直接带出,省一次详情请求)。 + */ +@Getter +@Setter +public class PeriodTemplateVo { + + private Integer id; + + /** 模板名称(春/秋 等) */ + private String name; + + /** 绑定学期:1=上学期 2=下学期 */ + private Integer term = 1; + + /** 适用年级(可多选) */ + private List gradeNames = new ArrayList<>(); + + /** 是否启用(同学期年级有交集的模板唯一) */ + private Boolean enabled; + + /** 教学日 7 位 0/1 串(周一~周日) */ + private String teachDays = "1111111"; + + /** 启用时段 5 位 0/1 串(顺序固定:早自习 上午 中午 下午 晚自习) */ + private String enabledBands = "11111"; + + /** 节次时长(分钟),模板加节次的默认用时 */ + private Integer periodMinutes = 45; + + /** 课间时长(分钟),模板加空白行的默认用时 */ + private Integer breakMinutes = 10; + + /** 模板行(节次 + 空白行,按顺序) */ + private List rows = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/SettingGetVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/SettingGetVo.java new file mode 100644 index 0000000..dae3076 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/SettingGetVo.java @@ -0,0 +1,19 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +/** + * 应用设置聚合 VO:时段判定时间设置 + 全部节次模板(含行)。 + */ +@Getter +@Setter +public class SettingGetVo { + + private TimeSettingVo time = new TimeSettingVo(); + + private List templates = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/TemplateRowVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TemplateRowVo.java new file mode 100644 index 0000000..058bb50 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TemplateRowVo.java @@ -0,0 +1,32 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Getter; +import lombok.Setter; + +/** + * 节次模板行 VO。rowType:period=教学节次(name 可自定义,留空时前端按顺序显示“第X节”); + * break=空白行(课间/午休等,name 自定义,可设时间、备注,不占节次序号、不排课程)。 + * band 为所属时段(es/am/noon/pm/ev),随行保存;节次与空白行都落在时段里。 + */ +@Getter +@Setter +public class TemplateRowVo { + + /** period / break */ + private String rowType; + + /** 所属时段(es/am/noon/pm/ev;节次与空白行都落在时段里,与模板启用时段位串下标一致) */ + private String band; + + /** 行名称:period 行可自定义(空按第X节显示);break 行自定义(如 课间/午休) */ + private String name; + + /** 备注(仅空白行可填写) */ + private String remark; + + /** 开始时间 HH:mm */ + private String start; + + /** 结束时间 HH:mm */ + private String end; +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/TemplateSaveRequest.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TemplateSaveRequest.java new file mode 100644 index 0000000..9b5a32f --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TemplateSaveRequest.java @@ -0,0 +1,42 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +/** + * 节次模板整存请求(全量替换行)。id 为空=新建;enabled=true 时服务端自动停用 + * 同校同学期、年级有交集的其它启用模板。 + */ +@Getter +@Setter +public class TemplateSaveRequest { + + private Integer id; + + private String name; + + /** 绑定学期:1=上学期 2=下学期(空/非法按 1 处理) */ + private Integer term; + + /** 适用年级(可多选) */ + private List gradeNames = new ArrayList<>(); + + private Boolean enabled; + + /** 教学日 7 位 0/1 串(周一~周日),空=全开 */ + private String teachDays; + + /** 启用时段 5 位 0/1 串(早自习 上午 中午 下午 晚自习),空=沿用现值 */ + private String enabledBands; + + /** 节次时长(分钟),空=沿用现值 */ + private Integer periodMinutes; + + /** 课间时长(分钟),空=沿用现值 */ + private Integer breakMinutes; + + private List rows = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimeSettingVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimeSettingVo.java new file mode 100644 index 0000000..5424738 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimeSettingVo.java @@ -0,0 +1,33 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Getter; +import lombok.Setter; + +/** + * 时段判定时间设置 VO(边界均为 HH:mm,依次递增;enabledBands 为启用时段位串)。 + */ +@Getter +@Setter +public class TimeSettingVo { + + /** 早自习截止(开始时间早于此 → 早自习) */ + private String morningSelfEnd = "07:00"; + + /** 上午截止 */ + private String forenoonEnd = "12:30"; + + /** 下午开始 */ + private String afternoonStart = "13:30"; + + /** 晚自习开始(开始时间晚于此 → 晚自习) */ + private String eveningStart = "18:30"; + + /** 启用时段 5 位 0/1 串,顺序固定:早自习 上午 中午 下午 晚自习(如 11111) */ + private String enabledBands = "11111"; + + /** 节次时长(分钟),模板加节次的默认时长 */ + private Integer periodMinutes = 45; + + /** 课间时长(分钟),模板加空白行的默认时长 */ + private Integer breakMinutes = 10; +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimetableSaveRequest.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimetableSaveRequest.java new file mode 100644 index 0000000..fe15476 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimetableSaveRequest.java @@ -0,0 +1,22 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +import javax.validation.constraints.NotNull; + +/** + * 电子课程表整表保存请求(全量替换节次)。 + */ +@Data +public class TimetableSaveRequest { + + /** 班级ID */ + @NotNull(message = "班级不能为空") + private Integer classId; + + /** 节次列表(1~30 节) */ + private List slots = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimetableSlotVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimetableSlotVo.java new file mode 100644 index 0000000..347f710 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimetableSlotVo.java @@ -0,0 +1,25 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 课程表节次视图(subjects 固定 7 位,下标 0~6 对应周一~周日,空串=未排)。 + */ +@Data +public class TimetableSlotVo { + + /** 节次序号(从 1 起) */ + private Integer slotNo; + + /** 开始时间 HH:mm */ + private String start; + + /** 结束时间 HH:mm */ + private String end; + + /** 周一~周日学科名(7 位,空=未排) */ + private List subjects = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimetableVo.java b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimetableVo.java new file mode 100644 index 0000000..1fc74c3 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/model/syllabus/TimetableVo.java @@ -0,0 +1,43 @@ +package com.yunhai.acasyllabu.model.syllabus; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 课程表视图(电子版节次 + 照片版附件信息)。 + */ +@Data +public class TimetableVo { + + /** 班级ID */ + private Integer classId; + + /** 学年(如 2026;课表按当前学年学期读写) */ + private Integer year; + + /** 学期(1第一学期 2第二学期) */ + private Integer term; + + /** 班级名称 */ + private String className; + + /** 年级名称 */ + private String gradeName; + + /** 维护教师/平台用户 id */ + private Integer teacherId; + + /** 照片课表附件ID(未上传为 null) */ + private Integer photoFileId; + + /** 照片原始文件名 */ + private String photoName; + + /** 照片大小(字节) */ + private Integer photoSize; + + /** 节次列表(无数据时前端补默认 10 节) */ + private List slots = new ArrayList<>(); +} diff --git a/src/main/java/com/yunhai/acasyllabu/repository/PeriodTemplateRepository.java b/src/main/java/com/yunhai/acasyllabu/repository/PeriodTemplateRepository.java new file mode 100644 index 0000000..6948911 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/repository/PeriodTemplateRepository.java @@ -0,0 +1,21 @@ +package com.yunhai.acasyllabu.repository; + +import com.yunhai.acasyllabu.entity.PeriodTemplate; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PeriodTemplateRepository extends JpaRepository { + + List findBySchIdOrderByIdAsc(Integer schId); + + /** 同学期内名称唯一 */ + Optional findBySchIdAndTermAndName(Integer schId, Integer term, String name); + + /** 同学期全部启用模板(年级交集判断在 Service 内存中做) */ + List findBySchIdAndTermAndEnabled(Integer schId, Integer term, Boolean enabled); + + /** 全部启用模板(含全年/各学期,学期与年级交集判断在 Service 内存中做) */ + List findBySchIdAndEnabledTrueOrderByIdAsc(Integer schId); +} diff --git a/src/main/java/com/yunhai/acasyllabu/repository/SysOperationLogRepository.java b/src/main/java/com/yunhai/acasyllabu/repository/SysOperationLogRepository.java new file mode 100644 index 0000000..da1ee09 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/repository/SysOperationLogRepository.java @@ -0,0 +1,12 @@ +package com.yunhai.acasyllabu.repository; + +import com.yunhai.acasyllabu.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/acasyllabu/repository/TemplateRowRepository.java b/src/main/java/com/yunhai/acasyllabu/repository/TemplateRowRepository.java new file mode 100644 index 0000000..461fe18 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/repository/TemplateRowRepository.java @@ -0,0 +1,18 @@ +package com.yunhai.acasyllabu.repository; + +import com.yunhai.acasyllabu.entity.TemplateRow; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface TemplateRowRepository extends JpaRepository { + + List findByTemplateIdOrderBySortOrderAsc(Integer templateId); + + @Modifying + @Query("delete from TemplateRow r where r.templateId = :templateId") + void deleteByTemplateId(@Param("templateId") Integer templateId); +} diff --git a/src/main/java/com/yunhai/acasyllabu/repository/TimeSettingRepository.java b/src/main/java/com/yunhai/acasyllabu/repository/TimeSettingRepository.java new file mode 100644 index 0000000..378d0bc --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/repository/TimeSettingRepository.java @@ -0,0 +1,11 @@ +package com.yunhai.acasyllabu.repository; + +import com.yunhai.acasyllabu.entity.TimeSetting; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TimeSettingRepository extends JpaRepository { + + Optional findBySchId(Integer schId); +} diff --git a/src/main/java/com/yunhai/acasyllabu/repository/TimetableRepository.java b/src/main/java/com/yunhai/acasyllabu/repository/TimetableRepository.java new file mode 100644 index 0000000..eeac7b9 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/repository/TimetableRepository.java @@ -0,0 +1,14 @@ +package com.yunhai.acasyllabu.repository; + +import com.yunhai.acasyllabu.entity.Timetable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface TimetableRepository extends JpaRepository { + + Optional findBySchIdAndClassIdAndYearAndTerm(Integer schId, Integer classId, Integer year, Integer term); + + List findBySchIdOrderByUpdateTimeDesc(Integer schId); +} diff --git a/src/main/java/com/yunhai/acasyllabu/repository/TimetableSlotRepository.java b/src/main/java/com/yunhai/acasyllabu/repository/TimetableSlotRepository.java new file mode 100644 index 0000000..040cbda --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/repository/TimetableSlotRepository.java @@ -0,0 +1,13 @@ +package com.yunhai.acasyllabu.repository; + +import com.yunhai.acasyllabu.entity.TimetableSlot; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface TimetableSlotRepository extends JpaRepository { + + List findByTimetableIdOrderBySortOrderAsc(Integer timetableId); + + void deleteByTimetableId(Integer timetableId); +} diff --git a/src/main/java/com/yunhai/acasyllabu/repository/UpFileRepository.java b/src/main/java/com/yunhai/acasyllabu/repository/UpFileRepository.java new file mode 100644 index 0000000..ee08a04 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/repository/UpFileRepository.java @@ -0,0 +1,16 @@ +package com.yunhai.acasyllabu.repository; + +import com.yunhai.acasyllabu.entity.UpFile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +/** + * 附件元信息仓储。 + */ +public interface UpFileRepository extends JpaRepository { + + List findByTopicTypeAndTopicId(String topicType, Integer topicId); + + List findByIdIn(List ids); +} diff --git a/src/main/java/com/yunhai/acasyllabu/service/IClassService.java b/src/main/java/com/yunhai/acasyllabu/service/IClassService.java new file mode 100644 index 0000000..62794eb --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/service/IClassService.java @@ -0,0 +1,89 @@ +package com.yunhai.acasyllabu.service; + +import com.yunhai.acasyllabu.model.SchTeaList; +import com.yunhai.acasyllabu.model.bsch.ClassTreeNodeVo; +import com.yunhai.acasyllabu.model.bsch.NowYearTermVo; +import com.yunhai.acasyllabu.model.bsch.SubjectVo; + +import java.util.List; +import java.util.Map; + +/** + * 班级管理(bschclass)开放接口客户端。 + * 基址取配置 {@code yh.open.bschclass}。 + *
    + *
  • 教师:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}}
  • + *
  • 学生树:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?schId={schId}&toUser=true} + * (学校→学段→年级→班级→学生)
  • + *
  • 学科:GET {@code {bschclass}/oapi/sch/getSchSubjectList?schId={schId}}
  • + *
  • 当前学年学期:GET {@code {bschclass}/oapi/sch/getNowYearTerm}
  • + *
+ */ +public interface IClassService { + + /** + * 获取当前学年学期。 + * 返回当前学年 year、当前学期 term(1第一学期 2第二学期)及学年/学期起止日期。 + */ + NowYearTermVo getNowYearTerm(); + + /** + * 获取指定学校全部教师(含所属组 GroupIds)。 + */ + List getSchTeaList(int schId); + + /** + * 获取学校学科列表(带 5 分钟内存缓存)。 + * 外部接口不可达时返回空列表(调用方降级,不阻断课程表编辑)。 + */ + List getSubjectList(int schId); + + /** + * 获取学校学生树原始节点(带 5 分钟内存缓存)。 + * 节点字段:id/name/pid/type(1组 2学生)/isParent/children。 + * 外部接口不可达时返回 null(调用方降级处理,不阻断业务)。 + */ + List> getStuTree(int schId); + + /** + * 教师任教列表(bschclass GET /oapi/sch/getTeacherTeachList,带 5 分钟内存缓存)。 + * 项字段:id/schId/classId(班级组ID)/className/gradeId/gradeName/teaId/teaName/subId/subName。 + * 外部接口不可达时返回空列表(调用方降级,不阻断业务)。 + */ + List> getTeacherTeachList(int schId, int teaId); + + /** + * 学校班级列表(bschclass GET /oapi/sch/getSchClassList,不含毕业班,带 5 分钟内存缓存)。 + * 项字段:id/classId(班级组ID)/name/gradeId/gradeName/firstTeacherId/firstTeacherName/ + * secondTeacherId/secondTeacherName/graduate 等。 + * 外部接口不可达时返回空列表(调用方降级,不阻断业务)。 + */ + List> getSchClassList(int schId); + + /** + * 年级→班级树(叶子=班级,附 studentCount),由学生树派生。 + * 外部接口不可达时返回空列表。 + */ + List getClassTree(int schId); + + /** 班级学生数;班级不存在或数据不可得返回 0 */ + int studentCountOf(int schId, Integer classId); + + /** 班级名称;未知返回 null */ + String classNameOf(int schId, Integer classId); + + /** 年级名(按班级反查其父年级节点名);未知返回 null */ + String gradeNameOf(int schId, Integer classId); + + /** + * 学生定位:按学生ID在学生树中找到所在班级。 + * 返回 {studentId, studentName, classId, className};找不到或不可得返回 null。 + */ + Map resolveStudent(int schId, Integer studentId); + + /** + * 班级→学生名单(roster):{studentId, studentName, classId, className} 列表。 + * classIds 为空集合时返回空列表。 + */ + List> studentsOfClasses(int schId, List classIds); +} diff --git a/src/main/java/com/yunhai/acasyllabu/service/IFileService.java b/src/main/java/com/yunhai/acasyllabu/service/IFileService.java new file mode 100644 index 0000000..373b57a --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/service/IFileService.java @@ -0,0 +1,94 @@ +package com.yunhai.acasyllabu.service; + +import com.yunhai.acasyllabu.entity.UpFile; +import com.yunhai.acasyllabu.model.file.UpFileVo; +import com.yunhai.acasyllabu.model.file.UpFileResultVo; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 文件上传/下载/删除业务接口。 + */ +public interface IFileService { + /** + * 上传单个文件。 + * + * @param file 上传的文件 + * @param appType 应用类型(如 "activity") + * @param topicId 关联主题ID(可为空,通知保存后再绑定) + * @return 文件视图对象 + */ + UpFileVo upload(MultipartFile file, String appType, Integer topicId); + + /** + * 将已上传但未绑定主题的附件绑定到指定通知。 + * + * @param fileIds 文件记录ID列表 + * @param topicId 通知ID + */ + void bindToTopic(List fileIds, Integer topicId); + + /** + * 查询某主题下的附件列表。 + * + * @param appType 应用类型 + * @param topicId 主题ID + * @return 附件列表 + */ + List listByTopic(String appType, Integer topicId); + + /** + * 下载文件。 + * + * @param fileId 文件记录ID + * @param response HTTP 响应 + */ + void download(Integer fileId, HttpServletResponse response); + + /** + * 在线预览文件(Content-Disposition: inline,按扩展名输出真实 Content-Type, + * 供前端 open-file-viewer 取 Blob / video 播放)。 + * + * @param fileId 文件记录ID + * @param response HTTP 响应 + */ + void preview(Integer fileId, HttpServletResponse response); + + /** + * 删除文件(同时删除物理文件和数据库记录)。 + * + * @param fileId 文件记录ID + */ + void delete(Integer fileId); + + /** + * 打包下载多个附件为 ZIP。 + * + * @param files 附件记录列表(调用方按 fileIds 查好) + * @param zipName 压缩包显示名(自动追加 .zip) + * @param response HTTP 响应 + */ + void downloadZip(List files, String zipName, HttpServletResponse response); + + /** + * 按主题类型和主题ID打包下载附件为 ZIP(Controller 便捷方法,内部自行查附件列表)。 + * + * @param topicType 主题类型(如 "activity") + * @param topicId 主题ID + * @param zipName 压缩包显示名 + * @param response HTTP 响应 + */ + void downloadZipByTopic(String topicType, Integer topicId, String zipName, HttpServletResponse response); + + /** + * 批量上传图片(循环上传,返回每个文件的视图对象列表)。 + */ + List uploadImages(MultipartFile[] files, String appType); + + /** + * 上传单个文件并返回前端兼容的结果 VO(含 fileid/name/fileurl/host/type/size)。 + */ + UpFileResultVo uploadToResult(MultipartFile file, String appType); +} diff --git a/src/main/java/com/yunhai/acasyllabu/service/ISettingService.java b/src/main/java/com/yunhai/acasyllabu/service/ISettingService.java new file mode 100644 index 0000000..48e6255 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/service/ISettingService.java @@ -0,0 +1,27 @@ +package com.yunhai.acasyllabu.service; + +import com.yunhai.acasyllabu.model.syllabus.EffectiveSettingVo; +import com.yunhai.acasyllabu.model.syllabus.SettingGetVo; +import com.yunhai.acasyllabu.model.syllabus.TemplateSaveRequest; +import com.yunhai.acasyllabu.model.syllabus.TimeSettingVo; + +/** + * 应用设置业务:时段判定时间设置 + 节次模板(含行)维护。 + */ +public interface ISettingService { + + /** 查询本校设置(时间设置首查自动建默认行;模板含全部行) */ + SettingGetVo get(); + + /** 保存时段判定时间设置(四个边界 HH:mm 且依次递增) */ + void saveTime(TimeSettingVo time); + + /** 新建/更新节次模板(全量替换行;启用时自动停用同学期年级有交集的其它模板),返回模板ID */ + Integer saveTemplate(TemplateSaveRequest request); + + /** 删除本校模板(连带行) */ + void deleteTemplate(Integer templateId); + + /** 课表编辑页生效设置:时间边界 + 当前学期下指定年级启用模板的行(无启用模板则 rows 为空) */ + EffectiveSettingVo effective(String gradeName, Integer term); +} diff --git a/src/main/java/com/yunhai/acasyllabu/service/ISysOperationLogService.java b/src/main/java/com/yunhai/acasyllabu/service/ISysOperationLogService.java new file mode 100644 index 0000000..fc713b2 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/service/ISysOperationLogService.java @@ -0,0 +1,17 @@ +package com.yunhai.acasyllabu.service; + +import com.yunhai.acasyllabu.entity.SysOperationLog; +import com.yunhai.acasyllabu.model.BasePageQuery; +import com.yunhai.acasyllabu.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/acasyllabu/service/ITimetableService.java b/src/main/java/com/yunhai/acasyllabu/service/ITimetableService.java new file mode 100644 index 0000000..4273605 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/service/ITimetableService.java @@ -0,0 +1,87 @@ +package com.yunhai.acasyllabu.service; + +import com.yunhai.acasyllabu.model.syllabus.MyClassVo; +import com.yunhai.acasyllabu.model.syllabus.MyTimetableVo; +import com.yunhai.acasyllabu.model.syllabus.OapiTimetableClassVo; +import com.yunhai.acasyllabu.model.syllabus.TimetableSaveRequest; +import com.yunhai.acasyllabu.model.syllabus.TimetableVo; + +import java.util.List; + +/** + * 课程表业务接口(电子版节次 + 照片版附件)。 + *

租户隔离:所有操作按 {@code UserContext.getSchId()} 过滤; + * 维护人只记 {@code UserContext.getUserId()}(教师 id),不存账号。

+ *

学年学期:所有读写定位到班级服务当前学年学期(每班每学年学期一条课表), + * 历史学年学期数据保留但查询不再返回。

+ */ +public interface ITimetableService { + + /** + * 查询班级当前学年学期课程表(含节次与照片信息;未建课时返回班级骨架 + 空节次,带当前学年学期)。 + */ + TimetableVo get(Integer classId); + + /** + * 查询指定学校班级当前学年学期课程表(oapi 显式身份版本,学校由参数指定,不取 UserContext)。 + */ + TimetableVo get(Integer classId, Integer schId); + + /** + * 整表保存电子课程表(全量替换节次,自动建课表记录)。 + * + * @return 课表ID + */ + Integer save(TimetableSaveRequest request); + + /** + * 绑定/替换照片课表(fileId 为已上传附件ID)。 + */ + void savePhoto(Integer classId, Integer fileId); + + /** + * 移除照片课表(解绑并删除附件)。 + */ + void removePhoto(Integer classId); + + /** + * 「我的课表」可查看班级列表(按当前用户角色过滤)。 + *
    + *
  • 管理员(7)/校领导(2):全校班级,全部科目;
  • + *
  • 班主任:所带班级(正/副班主任),全部科目;
  • + *
  • 任教老师:任教班级,仅任教科目;
  • + *
  • 学生(3):本人所在班级,全部科目。
  • + *
+ * 排序按学生树年级→班级顺序;任教/班级接口不可达时降级返回可得部分。 + */ + List myClasses(); + + /** + * 「我的课表」可查看班级列表(oapi 显式身份版本,身份由调用方传入,不取 UserContext)。 + * 角色范围同 {@link #myClasses()};roleId 缺省按普通教师处理。 + */ + List myClasses(Integer userId, Integer roleId, Integer schId); + + /** + * 「我的课表」查询当前用户在指定班级的课表(先做访问权限校验)。 + * 非班主任的任教老师只保留任教科目格,其余格清空;无任何关系时拒绝访问。 + */ + MyTimetableVo myGet(Integer classId); + + /** + * 「我的课表」查询指定身份在指定班级的课表(oapi 显式身份版本)。 + * 权限与科目过滤同 {@link #myGet(Integer)};学生仅可查本人所在班级。 + */ + MyTimetableVo myGet(Integer classId, Integer userId, Integer roleId, Integer schId); + + /** + * oapi 课表查询(/oapi/timetable/Get):按传入身份返回其可见班级及各班课表。 + *
    + *
  • 管理员(7)/校领导(2):全部班级全部科目;
  • + *
  • 班主任:所带班级全部科目;
  • + *
  • 任教老师:任教班级任教科目(非任教科目格清空);
  • + *
  • 学生(3):本人所在班级全部科目。
  • + *
+ */ + List oapiTimetable(Integer userId, Integer roleId, Integer schId); +} diff --git a/src/main/java/com/yunhai/acasyllabu/service/IUserCenterService.java b/src/main/java/com/yunhai/acasyllabu/service/IUserCenterService.java new file mode 100644 index 0000000..bb0523c --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/service/IUserCenterService.java @@ -0,0 +1,51 @@ +package com.yunhai.acasyllabu.service; + +import java.util.List; +import java.util.Map; + +/** + * 用户中心门面服务(对齐 UserBase {@code UserCenterService} 模式)。 + *

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

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

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

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

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

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

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

+ * + * @param userId 用户ID + * @param schId 学校ID(可为 null) + * @return 模块树(顶层 List,含 children 嵌套) + */ + List> getUserModuleTree(Integer userId, Integer schId); +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/acasyllabu/service/impl/ClassService.java b/src/main/java/com/yunhai/acasyllabu/service/impl/ClassService.java new file mode 100644 index 0000000..565faf9 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/service/impl/ClassService.java @@ -0,0 +1,590 @@ +package com.yunhai.acasyllabu.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.acasyllabu.model.SchTeaList; +import com.yunhai.acasyllabu.model.bsch.ClassTreeNodeVo; +import com.yunhai.acasyllabu.model.bsch.NowYearTermVo; +import com.yunhai.acasyllabu.model.bsch.SubjectVo; +import com.yunhai.acasyllabu.service.IClassService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 班级管理(bschclass)开放接口实现。 + * 独立 RestTemplate 直连;学生树做 5 分钟内存缓存(TTL)。 + * 学生树层级:学校(type=1)→学段(1)→年级(1)→班级(1)→学生(2)。 + * 派生查询(班级树/学生数/学生定位/名单)基于缓存树一次遍历建索引后计算。 + */ +@Service +public class ClassService implements IClassService { + + private static final Logger log = LoggerFactory.getLogger(ClassService.class); + + /** 缓存有效期 ms */ + private static final long CACHE_TTL_MS = 5 * 60 * 1000L; + + /** 班级管理服务基址 */ + @Value("${yh.open.bschclass:}") + private String bschClassHost; + + private final RestTemplate restTemplate; + + /** 学生树缓存:schId -> {time, tree} */ + private final ConcurrentHashMap stuTreeCache = new ConcurrentHashMap<>(); + + /** 学科列表缓存:schId -> {time, subjects} */ + private final ConcurrentHashMap subjectCache = new ConcurrentHashMap<>(); + + /** 任教列表缓存:schId:teaId -> {time, list} */ + private final ConcurrentHashMap teachCache = new ConcurrentHashMap<>(); + + /** 班级列表缓存:schId -> {time, list} */ + private final ConcurrentHashMap classListCache = new ConcurrentHashMap<>(); + + private static class CacheEntry { + final long time; + final List> tree; + + CacheEntry(long time, List> tree) { + this.time = time; + this.tree = tree; + } + } + + private static class SubjectCacheEntry { + final long time; + final List subjects; + + SubjectCacheEntry(long time, List subjects) { + this.time = time; + this.subjects = subjects; + } + } + + /** 通用 Map 列表缓存条目(任教列表 / 班级列表) */ + private static class MapCacheEntry { + final long time; + final List> list; + + MapCacheEntry(long time, List> list) { + this.time = time; + this.list = list; + } + } + + /** 学生树一次性索引:年级/班级/学生平铺 */ + private static class StuIndex { + /** classId -> 年级名 */ + final Map gradeNames = new LinkedHashMap<>(); + /** classId -> 年级ID */ + final Map gradeIds = new LinkedHashMap<>(); + /** classId -> 班级名 */ + final Map classNames = new LinkedHashMap<>(); + /** classId -> 班级节点(挂学生) */ + final Map> classNodes = new LinkedHashMap<>(); + /** studentId -> {studentId, studentName, classId, className} */ + final Map> students = new LinkedHashMap<>(); + } + + public ClassService() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(10000); + this.restTemplate = new RestTemplate(factory); + } + + // ============================ 教师 ============================ + + @Override + public List getSchTeaList(int schId) { + requireHost(); + String url = bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId; + try { + ResponseEntity> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>() { + }); + List list = resp.getBody(); + log.info("[getSchTeaList] schId={}, 获取教师 {} 人", schId, list == null ? 0 : list.size()); + return list != null ? list : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[getSchTeaList] 请求班级管理接口失败, schId={}, url={}", schId, url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取教师数据失败"); + } + } + + // ============================ 学年学期 ============================ + + /** 学年学期缓存(全局单条,5 分钟):学年学期切换以天计,短缓存省去每次课表操作的远程调用 */ + private volatile YearTermCacheEntry yearTermCache; + + private static class YearTermCacheEntry { + final long time; + final NowYearTermVo vo; + + YearTermCacheEntry(long time, NowYearTermVo vo) { + this.time = time; + this.vo = vo; + } + } + + /** + * 当前学年学期(班级服务 GET /oapi/sch/getNowYearTerm,5 分钟缓存)。 + * 缓存缺失且接口不可达时抛业务异常(学年学期是课表读写的定位维度,不做静默降级); + * 接口失败但已有缓存时回退旧值。 + */ + @Override + public NowYearTermVo getNowYearTerm() { + YearTermCacheEntry cached = yearTermCache; + if (cached != null && System.currentTimeMillis() - cached.time < CACHE_TTL_MS) { + return cached.vo; + } + synchronized (this) { + cached = yearTermCache; + if (cached != null && System.currentTimeMillis() - cached.time < CACHE_TTL_MS) { + return cached.vo; + } + NowYearTermVo vo = fetchNowYearTerm(); + if (vo == null) { + if (cached != null) { + return cached.vo; + } + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取学年学期失败"); + } + yearTermCache = new YearTermCacheEntry(System.currentTimeMillis(), vo); + return vo; + } + } + + private NowYearTermVo fetchNowYearTerm() { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[fetchNowYearTerm] 班级管理接口未配置(yh.open.bschclass)"); + return null; + } + String url = bschClassHost + "/oapi/sch/getNowYearTerm"; + try { + ResponseEntity resp = restTemplate.getForEntity(url, NowYearTermVo.class); + NowYearTermVo vo = resp.getBody(); + if (vo == null || vo.getYear() == null || vo.getTerm() == null) { + log.error("[fetchNowYearTerm] 学年学期接口返回不完整, url={}", url); + return null; + } + log.info("[fetchNowYearTerm] year={}, term={}", vo.getYear(), vo.getTerm()); + return vo; + } catch (RestClientException e) { + log.error("[fetchNowYearTerm] 请求学年学期接口失败, url={}", url, e); + return null; + } + } + + // ============================ 学科 ============================ + + /** + * 学校学科列表(5 分钟缓存;不可达时返回空列表降级,不阻断课程表编辑)。 + */ + @Override + public List getSubjectList(int schId) { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[getSubjectList] 班级管理接口未配置(yh.open.bschclass)"); + return Collections.emptyList(); + } + long now = System.currentTimeMillis(); + SubjectCacheEntry cached = subjectCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.subjects; + } + synchronized (this) { + cached = subjectCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.subjects; + } + List subjects = fetchSubjects(schId); + if (subjects == null) { + // 拉取失败回退旧缓存(若有) + return cached != null ? cached.subjects : Collections.emptyList(); + } + subjectCache.put(schId, new SubjectCacheEntry(now, subjects)); + return subjects; + } + } + + private List fetchSubjects(int schId) { + String url = bschClassHost + "/oapi/sch/getSchSubjectList?schId=" + schId; + try { + ResponseEntity>> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>>() { + }); + List> body = resp.getBody(); + if (body == null) { + return Collections.emptyList(); + } + List subjects = new ArrayList<>(); + for (Map row : body) { + SubjectVo vo = new SubjectVo(); + vo.setId(asInt(row.get("id"))); + vo.setCode(asStr(row.get("code"))); + String name = asStr(row.get("name")); + if (!StringUtils.hasText(name)) { + continue; + } + vo.setName(name); + subjects.add(vo); + } + log.info("[fetchSubjects] schId={}, 获取学科 {} 项", schId, subjects.size()); + return subjects; + } catch (RestClientException e) { + log.error("[fetchSubjects] 请求学科列表失败, schId={}, url={}", schId, url, e); + return null; + } + } + + // ============================ 任教 / 班级列表 ============================ + + /** + * 教师任教列表(GET /oapi/sch/getTeacherTeachList,5 分钟缓存;不可达返回空列表降级)。 + */ + @Override + public List> getTeacherTeachList(int schId, int teaId) { + String key = schId + ":" + teaId; + long now = System.currentTimeMillis(); + MapCacheEntry cached = teachCache.get(key); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.list; + } + synchronized (this) { + cached = teachCache.get(key); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.list; + } + List> list = fetchTeachList(schId, teaId); + if (list == null) { + return cached != null ? cached.list : Collections.emptyList(); + } + teachCache.put(key, new MapCacheEntry(now, list)); + return list; + } + } + + private List> fetchTeachList(int schId, int teaId) { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[fetchTeachList] 班级管理接口未配置(yh.open.bschclass)"); + return Collections.emptyList(); + } + String url = bschClassHost + "/oapi/sch/getTeacherTeachList?schId=" + schId + "&teaId=" + teaId; + try { + ResponseEntity>> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>>() { + }); + List> body = resp.getBody(); + log.info("[fetchTeachList] schId={}, teaId={}, 任教记录 {} 条", schId, teaId, body == null ? 0 : body.size()); + return body != null ? body : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[fetchTeachList] 请求任教列表失败, schId={}, teaId={}, url={}", schId, teaId, url, e); + return null; + } + } + + /** + * 学校班级列表(GET /oapi/sch/getSchClassList,不含毕业班,5 分钟缓存;不可达返回空列表降级)。 + */ + @Override + public List> getSchClassList(int schId) { + long now = System.currentTimeMillis(); + MapCacheEntry cached = classListCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.list; + } + synchronized (this) { + cached = classListCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.list; + } + List> list = fetchSchClassList(schId); + if (list == null) { + return cached != null ? cached.list : Collections.emptyList(); + } + classListCache.put(schId, new MapCacheEntry(now, list)); + return list; + } + } + + private List> fetchSchClassList(int schId) { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[fetchSchClassList] 班级管理接口未配置(yh.open.bschclass)"); + return Collections.emptyList(); + } + String url = bschClassHost + "/oapi/sch/getSchClassList?schId=" + schId + "&includeGraduate=false"; + try { + ResponseEntity>> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>>() { + }); + List> body = resp.getBody(); + log.info("[fetchSchClassList] schId={}, 班级 {} 个", schId, body == null ? 0 : body.size()); + return body != null ? body : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[fetchSchClassList] 请求班级列表失败, schId={}, url={}", schId, url, e); + return null; + } + } + + // ============================ 学生树 ============================ + @Override + public List> getStuTree(int schId) { + long now = System.currentTimeMillis(); + CacheEntry cached = stuTreeCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.tree; + } + synchronized (this) { + cached = stuTreeCache.get(schId); + if (cached != null && now - cached.time < CACHE_TTL_MS) { + return cached.tree; + } + List> tree = fetchStuTree(schId); + if (tree == null) { + // 拉取失败时回退旧值(若有),避免外部抖动击穿业务 + return cached != null ? cached.tree : null; + } + stuTreeCache.put(schId, new CacheEntry(now, tree)); + return tree; + } + } + + @SuppressWarnings("unchecked") + private List> fetchStuTree(int schId) { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[fetchStuTree] 班级管理接口未配置(yh.open.bschclass)"); + return null; + } + String url = bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId + "&toUser=true"; + try { + ResponseEntity>> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>>() { + }); + List> body = resp.getBody(); + log.info("[fetchStuTree] schId={}, 根节点 {} 个", schId, body == null ? 0 : body.size()); + return body; + } catch (RestClientException e) { + log.error("[fetchStuTree] 请求学生树失败, schId={}, url={}", schId, url, e); + return null; + } + } + + // ============================ 派生查询 ============================ + + @Override + public List getClassTree(int schId) { + List> tree = getStuTree(schId); + List result = new ArrayList<>(); + if (tree == null) { + return result; + } + StuIndex index = buildIndex(tree); + Map> gradeClasses = new LinkedHashMap<>(); + Map gradeNodeNames = new LinkedHashMap<>(); + for (Map.Entry e : index.classNames.entrySet()) { + Integer classId = e.getKey(); + String gradeName = index.gradeNames.getOrDefault(classId, ""); + Integer gradeId = index.gradeIds.getOrDefault(classId, 0); + gradeClasses.computeIfAbsent(gradeId, k -> new ArrayList<>()) + .add(toClassNode(classId, index)); + gradeNodeNames.putIfAbsent(gradeId, gradeName); + } + for (Map.Entry> e : gradeClasses.entrySet()) { + ClassTreeNodeVo gradeNode = new ClassTreeNodeVo(); + gradeNode.setId(e.getKey()); + gradeNode.setName(gradeNodeNames.get(e.getKey())); + gradeNode.setParent(true); + gradeNode.setChildren(e.getValue()); + result.add(gradeNode); + } + return result; + } + + @Override + public int studentCountOf(int schId, Integer classId) { + StuIndex index = indexOrNull(schId); + if (index == null || classId == null) { + return 0; + } + Map node = index.classNodes.get(classId); + return node == null ? 0 : countStudents(node); + } + + @Override + public String classNameOf(int schId, Integer classId) { + StuIndex index = indexOrNull(schId); + return index == null || classId == null ? null : index.classNames.get(classId); + } + + @Override + public String gradeNameOf(int schId, Integer classId) { + StuIndex index = indexOrNull(schId); + return index == null || classId == null ? null : index.gradeNames.get(classId); + } + + @Override + public Map resolveStudent(int schId, Integer studentId) { + StuIndex index = indexOrNull(schId); + return index == null || studentId == null ? null : index.students.get(studentId); + } + + @Override + public List> studentsOfClasses(int schId, List classIds) { + StuIndex index = indexOrNull(schId); + List> result = new ArrayList<>(); + if (index == null || classIds == null || classIds.isEmpty()) { + return result; + } + for (Integer classId : classIds) { + Map node = index.classNodes.get(classId); + if (node != null) { + collectStudents(node, classId, index.classNames.get(classId), result); + } + } + return result; + } + + // ============================ 内部辅助 ============================ + + private StuIndex indexOrNull(int schId) { + List> tree = getStuTree(schId); + return tree == null ? null : buildIndex(tree); + } + + /** 一次遍历学生树,平铺出年级名/班级名/班级节点/学生定位索引 */ + @SuppressWarnings("unchecked") + private StuIndex buildIndex(List> tree) { + StuIndex index = new StuIndex(); + for (Map school : tree) { + Object systems = school.get("children"); + if (!(systems instanceof List)) { + continue; + } + for (Object sysObj : (List) systems) { + Map system = (Map) sysObj; + Object grades = system.get("children"); + if (!(grades instanceof List)) { + continue; + } + for (Object gradeObj : (List) grades) { + Map grade = (Map) gradeObj; + String gradeName = asStr(grade.get("name")); + Object classes = grade.get("children"); + if (!(classes instanceof List)) { + continue; + } + for (Object classObj : (List) classes) { + Map clazz = (Map) classObj; + Integer classId = asInt(clazz.get("id")); + if (classId == null) { + continue; + } + index.gradeNames.put(classId, gradeName); + index.gradeIds.put(classId, asInt(grade.get("id"))); + index.classNames.put(classId, asStr(clazz.get("name"))); + index.classNodes.put(classId, clazz); + for (Object stuObj : (List) getChildren(clazz)) { + Map stu = (Map) stuObj; + Integer stuId = asInt(stu.get("id")); + if (stuId == null) { + continue; + } + Map info = new LinkedHashMap<>(); + info.put("studentId", stuId); + info.put("studentName", asStr(stu.get("name"))); + info.put("classId", classId); + info.put("className", asStr(clazz.get("name"))); + index.students.put(stuId, info); + } + } + } + } + } + return index; + } + + @SuppressWarnings("unchecked") + private List getChildren(Map node) { + Object children = node.get("children"); + return children instanceof List ? (List) children : Collections.emptyList(); + } + + private int countStudents(Map classNode) { + int count = 0; + for (Object stuObj : getChildren(classNode)) { + Map stu = (Map) stuObj; + if (Integer.valueOf(2).equals(asInt(stu.get("type")))) { + count++; + } + } + return count; + } + + private void collectStudents(Map classNode, Integer classId, String className, + List> result) { + for (Object stuObj : getChildren(classNode)) { + Map stu = (Map) stuObj; + if (!Integer.valueOf(2).equals(asInt(stu.get("type")))) { + continue; + } + Map info = new LinkedHashMap<>(); + info.put("studentId", asInt(stu.get("id"))); + info.put("studentName", asStr(stu.get("name"))); + info.put("classId", classId); + info.put("className", className); + result.add(info); + } + } + + private ClassTreeNodeVo toClassNode(Integer classId, StuIndex index) { + ClassTreeNodeVo node = new ClassTreeNodeVo(); + node.setId(classId); + node.setName(index.classNames.get(classId)); + node.setParent(false); + Map classNode = index.classNodes.get(classId); + node.setStudentCount(classNode == null ? 0 : countStudents(classNode)); + return node; + } + + private void requireHost() { + if (!StringUtils.hasText(bschClassHost)) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)"); + } + } + + private Integer asInt(Object v) { + if (v == null) { + return null; + } + if (v instanceof Number) { + return ((Number) v).intValue(); + } + try { + return Integer.valueOf(String.valueOf(v)); + } catch (NumberFormatException e) { + return null; + } + } + + private String asStr(Object v) { + return v == null ? null : String.valueOf(v); + } +} diff --git a/src/main/java/com/yunhai/acasyllabu/service/impl/FileService.java b/src/main/java/com/yunhai/acasyllabu/service/impl/FileService.java new file mode 100644 index 0000000..a0187dc --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/service/impl/FileService.java @@ -0,0 +1,309 @@ +package com.yunhai.acasyllabu.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.acasyllabu.entity.UpFile; +import com.yunhai.acasyllabu.model.file.UpFileResultVo; +import com.yunhai.acasyllabu.model.file.UpFileVo; +import com.yunhai.acasyllabu.repository.UpFileRepository; +import com.yunhai.acasyllabu.service.IFileService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.io.FileInputStream; +import java.io.OutputStream; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 文件上传/下载/删除业务实现。 + *

+ * 存储路径规则:{uploadPath}/{appType}/{yyyyMMdd}/{uuid}.{ext} + * 与 C# UpLoadFile.FileSave 逻辑等价,但去除分片上传和 Office 转 PDF(后续按需补充)。 + */ +@Service +public class FileService implements IFileService { + + private static final Logger log = LoggerFactory.getLogger(FileService.class); + + @Autowired + private UpFileRepository upFileRepository; + + @Value("${FILE_PATH}") + private String uploadPath; + + // ============================ 上传 ============================ + + @Override + @Transactional + public UpFileVo upload(MultipartFile file, String appType, Integer topicId) { + if (file == null || file.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不得为空"); + } + String originalName = file.getOriginalFilename(); + if (!StringUtils.hasText(originalName)) { + throw new BizException(ErrorCode.PARAM_ERROR, "文件名为空"); + } + // 提取后缀 + String ext = ""; + int dotIdx = originalName.lastIndexOf('.'); + if (dotIdx >= 0) { + ext = originalName.substring(dotIdx + 1).toLowerCase(); + } + + String savedName = UUID.randomUUID().toString().replace("-", "") + (ext.isEmpty() ? "" : "." + ext); + + // 物理写入 + Path fullPath = Paths.get(uploadPath, savedName); + try { + Files.createDirectories(fullPath.getParent()); + file.transferTo(fullPath.toFile()); + } catch (Exception e) { + log.error("[upload] 文件保存失败: {}", fullPath, e); + throw new BizException(ErrorCode.ERROR, "文件保存失败", e); + } + + // 入库 + UpFile entity = new UpFile(); + entity.setTopicType(appType); + entity.setTopicId(topicId); + entity.setFileTrueName(originalName); + entity.setFileName(savedName); + entity.setFileSize((int) file.getSize()); + entity.setFileType(ext); + upFileRepository.save(entity); + + return toVo(entity); + } + + // ============================ 绑定主题 ============================ + + @Override + @Transactional + public void bindToTopic(List fileIds, Integer topicId) { + if (fileIds == null || fileIds.isEmpty() || topicId == null) { + return; + } + for (Integer fileId : fileIds) { + upFileRepository.findById(fileId).ifPresent(f -> { + f.setTopicId(topicId); + upFileRepository.save(f); + }); + } + } + + // ============================ 查询附件列表 ============================ + + @Override + public List listByTopic(String appType, Integer topicId) { + return upFileRepository.findByTopicTypeAndTopicId(appType, topicId).stream() + .map(this::toVo) + .collect(Collectors.toList()); + } + + // ============================ 下载 ============================ + + @Override + public void download(Integer fileId, HttpServletResponse response) { + writeFile(fileId, response, false); + } + + @Override + public void preview(Integer fileId, HttpServletResponse response) { + writeFile(fileId, response, true); + } + + /** + * 文件输出。inline=true 时 Content-Disposition 用 inline 并按扩展名输出真实 Content-Type + * (浏览器/前端组件可直接渲染),否则 attachment 强制下载。 + */ + private void writeFile(Integer fileId, HttpServletResponse response, boolean inline) { + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId)); + Path fullPath = Paths.get(uploadPath, file.getFileName()); + if (!Files.exists(fullPath)) { + throw new BizException(ErrorCode.FORBIDDEN, "物理文件不存在: " + file.getFileName()); + } + try (FileInputStream fis = new FileInputStream(fullPath.toFile()); + OutputStream os = response.getOutputStream()) { + String encodedName = URLEncoder.encode(file.getFileTrueName(), "UTF-8") + .replaceAll("\\+", "%20"); + if (inline) { + response.setContentType(resolveContentType(file.getFileName())); + response.setHeader("Content-Disposition", "inline; filename=\"" + encodedName + "\""); + } else { + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedName + "\""); + } + response.setContentLengthLong(file.getFileSize()); + byte[] buffer = new byte[4096]; + int len; + while ((len = fis.read(buffer)) != -1) { + os.write(buffer, 0, len); + } + os.flush(); + } catch (Exception e) { + log.error("[writeFile] 文件输出失败: {}", file.getFileName(), e); + throw new BizException(ErrorCode.ERROR, "文件下载失败", e); + } + } + + /** + * 按扩展名推断 Content-Type(JDK 自带表 + 常见办公/图片格式兜底),未知类型回退流格式。 + */ + private String resolveContentType(String fileName) { + String name = String.valueOf(fileName).toLowerCase(); + Map table = new HashMap<>(); + table.put("pdf", "application/pdf"); + table.put("png", "image/png"); + table.put("jpg", "image/jpeg"); + table.put("jpeg", "image/jpeg"); + table.put("gif", "image/gif"); + table.put("bmp", "image/bmp"); + table.put("webp", "image/webp"); + table.put("svg", "image/svg+xml"); + table.put("txt", "text/plain;charset=UTF-8"); + table.put("mp4", "video/mp4"); + table.put("webm", "video/webm"); + table.put("ogg", "video/ogg"); + table.put("doc", "application/msword"); + table.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + table.put("xls", "application/vnd.ms-excel"); + table.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + table.put("ppt", "application/vnd.ms-powerpoint"); + table.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"); + String type = table.get(name.substring(name.lastIndexOf('.') + 1)); + if (type != null) { + return type; + } + String guessed = URLConnection.guessContentTypeFromName(name); + return guessed != null ? guessed : "application/octet-stream"; + } + + // ============================ 删除 ============================ + + @Override + @Transactional + public void delete(Integer fileId) { + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId)); + // 删物理文件 + Path fullPath = Paths.get(uploadPath, file.getFileName()); + try { + Files.deleteIfExists(fullPath); + } catch (Exception e) { + log.warn("[delete] 物理文件删除失败: {}(数据库记录仍会删除)", fullPath, e); + } + // 删数据库记录 + upFileRepository.delete(file); + } + + // ============================ 打包下载 ZIP ============================ + + @Override + public void downloadZip(List files, String zipName, HttpServletResponse response) { + if (files == null || files.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载"); + } + try { + response.setContentType("application/zip"); + String name = URLEncoder.encode(zipName == null ? "附件" : zipName, "UTF-8") + .replaceAll("\\+", "%20"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + name + ".zip\""); + try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) { + Set used = new HashSet<>(); + for (UpFile f : files) { + Path p = Paths.get(uploadPath, f.getFileName()); + if (!Files.exists(p)) { + log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName()); + continue; + } + String entryName = sanitizeEntryName(f.getFileTrueName()); + if (used.contains(entryName)) { + entryName = f.getId() + "_" + entryName; + } + used.add(entryName); + zos.putNextEntry(new ZipEntry(entryName)); + Files.copy(p, zos); + zos.closeEntry(); + } + } + } catch (Exception e) { + log.error("[downloadZip] 打包下载失败", e); + throw new BizException(ErrorCode.ERROR, "打包下载失败", e); + } + } + + /** + * 净化 ZIP 条目名:去除路径分隔符等非法字符,防止 Zip Slip。 + */ + private String sanitizeEntryName(String name) { + if (name == null || name.trim().isEmpty()) { + return "file"; + } + return name.replaceAll("[\\\\/:*?\"<>|]", "_"); + } + + // ============================ 按主题打包下载 ============================ + + @Override + public void downloadZipByTopic(String topicType, Integer topicId, String zipName, + HttpServletResponse response) { + List files = upFileRepository.findByTopicTypeAndTopicId(topicType, topicId); + downloadZip(files, zipName, response); + } + + // ============================ 批量上传图片 ============================ + + @Override + @Transactional + public List uploadImages(MultipartFile[] files, String appType) { + List results = new ArrayList<>(); + if (files == null || files.length == 0) { + return results; + } + for (MultipartFile file : files) { + results.add(upload(file, appType, null)); + } + return results; + } + + // ============================ 上传(返回前端兼容 VO) ============================ + + @Override + @Transactional + public UpFileResultVo uploadToResult(MultipartFile file, String appType) { + UpFileVo vo = upload(file, appType, null); + UpFileResultVo result = new UpFileResultVo(); + result.setFileid(vo.getId()); + result.setName(vo.getFileTrueName()); + result.setFileurl(vo.getFileName()); + result.setHost(""); + result.setType(vo.getFileType()); + result.setSize(vo.getFileSize()); + return result; + } + + // ============================ 转换 ============================ + + private UpFileVo toVo(UpFile entity) { + UpFileVo vo = new UpFileVo(); + BeanUtils.copyProperties(entity, vo); + return vo; + } +} diff --git a/src/main/java/com/yunhai/acasyllabu/service/impl/SettingService.java b/src/main/java/com/yunhai/acasyllabu/service/impl/SettingService.java new file mode 100644 index 0000000..0ea6bc0 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/service/impl/SettingService.java @@ -0,0 +1,597 @@ +package com.yunhai.acasyllabu.service.impl; + +import com.yunhai.acasyllabu.entity.PeriodTemplate; +import com.yunhai.acasyllabu.entity.TemplateRow; +import com.yunhai.acasyllabu.entity.TimeSetting; +import com.yunhai.acasyllabu.model.syllabus.EffectiveSettingVo; +import com.yunhai.acasyllabu.model.syllabus.PeriodTemplateVo; +import com.yunhai.acasyllabu.model.syllabus.SettingGetVo; +import com.yunhai.acasyllabu.model.syllabus.TemplateRowVo; +import com.yunhai.acasyllabu.model.syllabus.TemplateSaveRequest; +import com.yunhai.acasyllabu.model.syllabus.TimeSettingVo; +import com.yunhai.acasyllabu.repository.PeriodTemplateRepository; +import com.yunhai.acasyllabu.repository.TemplateRowRepository; +import com.yunhai.acasyllabu.repository.TimeSettingRepository; +import com.yunhai.acasyllabu.service.ISettingService; +import com.yunhai.acasyllabu.utils.UserContext; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * 应用设置业务实现。 + *

时间设置:每校一行(仅时段判定边界,首查自动建默认 07:00/12:30/13:30/18:30),四边界须依次递增; + * 节次模板:绑定学期(0=全年/1=上学期/2=下学期,全年与两学期互通视为重叠)与 1~N 个年级 + * (同学期内名称唯一),行整存整取(period 节次 / break 空白行); + * 启用时段与节次/课间时长随模板保存(绑定模板ID),课表编辑页按生效模板的时段与时长渲染; + * 所属时段直接落在行上(band=es/am/noon/pm/ev,不再按开始时间判定),节次与空白行都落在时段里, + * 每个启用时段至少保留一条节次(停用时段的节次不在模板行列表中维护)。 + * 学期重叠且年级有交集的同时只启用一套,学期专属模板优先于全年模板生效; + * 行时间 HH:mm 且结束晚于开始,节次数上限 30(与课表保存上限一致)。

+ */ +@Service +public class SettingService implements ISettingService { + + private static final Logger log = LoggerFactory.getLogger(SettingService.class); + + private static final Pattern TIME_PATTERN = Pattern.compile("^([01]\\d|2[0-3]):[0-5]\\d$"); + + private static final String TYPE_PERIOD = "period"; + private static final String TYPE_BREAK = "break"; + + /** 教学节次数上限(与课表保存 MAX_SLOTS 对齐) */ + private static final int MAX_PERIODS = 30; + /** 模板总行数上限(节次 + 空白行) */ + private static final int MAX_ROWS = 45; + + /** 时段启用位串长度(顺序固定:早自习 上午 中午 下午 晚自习) */ + private static final int BAND_COUNT = 5; + private static final String ALL_BANDS = "11111"; + /** 时段名(下标与位串一致,报错提示用) */ + private static final String[] BAND_LABELS = {"早自习", "上午", "中午", "下午", "晚自习"}; + /** 时段键(下标与位串一致,前后端共用;落库到模板行的 band 列) */ + private static final String[] BAND_KEYS = {"es", "am", "noon", "pm", "ev"}; + + /** 节次时长允许范围(分钟) */ + private static final int MIN_PERIOD_MINUTES = 15; + private static final int MAX_PERIOD_MINUTES = 120; + private static final int DEFAULT_PERIOD_MINUTES = 45; + /** 课间时长允许范围(分钟) */ + private static final int MIN_BREAK_MINUTES = 5; + private static final int MAX_BREAK_MINUTES = 60; + private static final int DEFAULT_BREAK_MINUTES = 10; + /** 年级串(逗号拼接)总长上限,与实体列长一致 */ + private static final int MAX_GRADES_LENGTH = 64; + + @Autowired + private TimeSettingRepository timeSettingRepository; + + @Autowired + private PeriodTemplateRepository templateRepository; + + @Autowired + private TemplateRowRepository rowRepository; + + // ============================ 查询 ============================ + + @Override + public SettingGetVo get() { + Integer schId = currentSchId(); + SettingGetVo vo = new SettingGetVo(); + vo.setTime(toTimeVo(ensureTimeSetting(schId))); + for (PeriodTemplate tpl : templateRepository.findBySchIdOrderByIdAsc(schId)) { + vo.getTemplates().add(toTemplateVo(tpl)); + } + return vo; + } + + @Override + public EffectiveSettingVo effective(String gradeName, Integer term) { + Integer schId = currentSchId(); + EffectiveSettingVo vo = new EffectiveSettingVo(); + vo.setTime(toTimeVo(ensureTimeSetting(schId))); + if (!StringUtils.hasText(gradeName)) { + return vo; + } + String grade = gradeName.trim(); + // 请求学期只可能是 1/2(课表页按当前学年学期查询);学期专属模板优先于全年模板 + Integer reqTerm = Integer.valueOf(2).equals(term) ? 2 : 1; + templateRepository.findBySchIdAndEnabledTrueOrderByIdAsc(schId).stream() + .filter(tpl -> gradeNamesOf(tpl).contains(grade)) + .filter(tpl -> termOverlaps(reqTerm, tpl.getTerm())) + .sorted(Comparator.comparingInt(tpl -> Objects.equals(tpl.getTerm(), reqTerm) ? 0 : 1)) + .findFirst() + .ifPresent(tpl -> { + vo.setTeachDays(tpl.getTeachDays()); + vo.setTemplateName(tpl.getName()); + // 启用时段与节次/课间时长绑定模板:课表编辑页按生效模板的时段判定与展示 + vo.getTime().setEnabledBands(sanitizeBands(tpl.getEnabledBands())); + if (tpl.getPeriodMinutes() != null) { + vo.getTime().setPeriodMinutes(tpl.getPeriodMinutes()); + } + if (tpl.getBreakMinutes() != null) { + vo.getTime().setBreakMinutes(tpl.getBreakMinutes()); + } + for (TemplateRow row : rowRepository.findByTemplateIdOrderBySortOrderAsc(tpl.getId())) { + vo.getRows().add(toRowVo(row)); + } + }); + return vo; + } + + // ============================ 时间设置 ============================ + + @Override + @Transactional + public void saveTime(TimeSettingVo time) { + if (time == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "时间设置不能为空"); + } + String es = orNull(time.getMorningSelfEnd()); + String am = orNull(time.getForenoonEnd()); + String pm = orNull(time.getAfternoonStart()); + String ev = orNull(time.getEveningStart()); + if (!isTime(es) || !isTime(am) || !isTime(pm) || !isTime(ev)) { + throw new BizException(ErrorCode.PARAM_ERROR, "时间格式应为 HH:mm"); + } + if (!(toMinutes(es) < toMinutes(am) && toMinutes(am) <= toMinutes(pm) && toMinutes(pm) < toMinutes(ev))) { + throw new BizException(ErrorCode.PARAM_ERROR, "时间边界须依次递增:早自习 < 上午 ≤ 下午开始 < 晚自习"); + } + TimeSetting setting = ensureTimeSetting(currentSchId()); + setting.setMorningSelfEnd(es); + setting.setForenoonEnd(am); + setting.setAfternoonStart(pm); + setting.setEveningStart(ev); + setting.setEnabledBands(normalizeBands(time.getEnabledBands(), ALL_BANDS)); + setting.setPeriodMinutes(normalizeMinutes(time.getPeriodMinutes(), + MIN_PERIOD_MINUTES, MAX_PERIOD_MINUTES, setting.getPeriodMinutes(), "节次时间")); + setting.setBreakMinutes(normalizeMinutes(time.getBreakMinutes(), + MIN_BREAK_MINUTES, MAX_BREAK_MINUTES, setting.getBreakMinutes(), "课间时间")); + timeSettingRepository.save(setting); + log.info("[saveTime] schId={}, 早自习<{}, 上午<{}, 下午>={}, 晚自习>={}, 时段[{}], 节次{}分钟/课间{}分钟", + setting.getSchId(), es, am, pm, ev, setting.getEnabledBands(), + setting.getPeriodMinutes(), setting.getBreakMinutes()); + } + + // ============================ 节次模板 ============================ + + @Override + @Transactional + public Integer saveTemplate(TemplateSaveRequest request) { + Integer schId = currentSchId(); + if (request == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "模板不能为空"); + } + String name = trimmed(request.getName(), "模板名称"); + if (name.length() > 16) { + throw new BizException(ErrorCode.PARAM_ERROR, "模板名称不能超过 16 字"); + } + Integer term = normalizeTerm(request.getTerm()); + String gradeName = normalizeGrades(request.getGradeNames()); + boolean enabled = Boolean.TRUE.equals(request.getEnabled()); + String teachDays = normalizeTeachDays(request.getTeachDays()); + + PeriodTemplate tpl; + if (request.getId() == null) { + if (templateRepository.findBySchIdAndTermAndName(schId, term, name).isPresent()) { + throw new BizException(ErrorCode.PARAM_ERROR, termName(term) + "已有同名模板「" + name + "」"); + } + tpl = new PeriodTemplate(); + tpl.setSchId(schId); + } else { + tpl = templateRepository.findById(request.getId()) + .filter(t -> Objects.equals(t.getSchId(), schId)) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "模板不存在: " + request.getId())); + final PeriodTemplate existing = tpl; + boolean duplicated = templateRepository + .findBySchIdAndTermAndName(schId, term, name) + .filter(t -> !Objects.equals(t.getId(), existing.getId())) + .isPresent(); + if (duplicated) { + throw new BizException(ErrorCode.PARAM_ERROR, termName(term) + "已有同名模板「" + name + "」"); + } + } + // 启用时段 / 节次与课间时长:随模板保存(空则沿用现值,新建回退全启用与默认时长) + String bands = normalizeBands(request.getEnabledBands(), tpl.getEnabledBands()); + int periodMinutes = normalizeMinutes(request.getPeriodMinutes(), + MIN_PERIOD_MINUTES, MAX_PERIOD_MINUTES, orDefault(tpl.getPeriodMinutes(), DEFAULT_PERIOD_MINUTES), "节次时间"); + int breakMinutes = normalizeMinutes(request.getBreakMinutes(), + MIN_BREAK_MINUTES, MAX_BREAK_MINUTES, orDefault(tpl.getBreakMinutes(), DEFAULT_BREAK_MINUTES), "课间时间"); + List rows = validateRows(request.getRows(), bands); + + tpl.setName(name); + tpl.setTerm(term); + tpl.setGradeName(gradeName); + tpl.setEnabled(enabled); + tpl.setTeachDays(teachDays); + tpl.setEnabledBands(bands); + tpl.setPeriodMinutes(periodMinutes); + tpl.setBreakMinutes(breakMinutes); + tpl = templateRepository.save(tpl); + + if (enabled) { + // 学期重叠(全年与上/下学期互通)且年级有交集的其它启用模板自动停用 + List grades = gradeNamesOf(tpl); + for (PeriodTemplate other : templateRepository.findBySchIdAndEnabledTrueOrderByIdAsc(schId)) { + if (!Objects.equals(other.getId(), tpl.getId()) + && termOverlaps(tpl.getTerm(), other.getTerm()) + && !Collections.disjoint(gradeNamesOf(other), grades)) { + other.setEnabled(Boolean.FALSE); + templateRepository.save(other); + } + } + } + + rowRepository.deleteByTemplateId(tpl.getId()); + rowRepository.flush(); + int order = 1; + for (TemplateRowVo rowVo : rows) { + TemplateRow row = new TemplateRow(); + row.setTemplateId(tpl.getId()); + row.setSortOrder(order++); + row.setRowType(rowVo.getRowType()); + row.setName(rowVo.getName()); + row.setRemark(rowVo.getRemark()); + row.setBand(rowVo.getBand()); + row.setStartTime(rowVo.getStart()); + row.setEndTime(rowVo.getEnd()); + rowRepository.save(row); + } + log.info("[saveTemplate] schId={}, id={}, {}·{}·年级[{}], enabled={}, 行 {} 条", + schId, tpl.getId(), termName(term), name, gradeName, enabled, rows.size()); + return tpl.getId(); + } + + @Override + @Transactional + public void deleteTemplate(Integer templateId) { + Integer schId = currentSchId(); + PeriodTemplate tpl = templateRepository.findById(templateId) + .filter(t -> Objects.equals(t.getSchId(), schId)) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "模板不存在: " + templateId)); + rowRepository.deleteByTemplateId(tpl.getId()); + templateRepository.delete(tpl); + log.info("[deleteTemplate] schId={}, id={}, {}·{}", schId, tpl.getId(), tpl.getGradeName(), tpl.getName()); + } + + // ============================ 校验与辅助 ============================ + + /** + * 行校验:类型合法、时间 HH:mm 且结束晚于开始、节次数/总行数受限, + * 且节次行的所属时段(band)须为有效时段且处于启用状态——时段不再按开始时间反推, + * 每个启用时段至少保留一条节次;空白行同样落在时段里(band 缺省时按相邻行补齐); + * 返回规整后的行。 + */ + private List validateRows(List rows, String enabledBands) { + if (rows == null) { + rows = new ArrayList<>(); + } + if (rows.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "模板至少需要一行节次"); + } + if (rows.size() > MAX_ROWS) { + throw new BizException(ErrorCode.PARAM_ERROR, "模板行数不得超过 " + MAX_ROWS); + } + int periods = 0; + // 每个时段承载的行数(节次与空白行都算):时段可以只剩一条空白行,但不能一行都没有 + int[] bandRows = new int[BAND_COUNT]; + String lastBand = null; + for (int i = 0; i < rows.size(); i++) { + TemplateRowVo row = rows.get(i); + String type = TYPE_PERIOD.equals(row.getRowType()) || TYPE_BREAK.equals(row.getRowType()) + ? row.getRowType() : null; + if (type == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "第 " + (i + 1) + " 行类型不合法"); + } + int band = bandIndexOfKey(row.getBand()); + if (band >= 0 && enabledBands.charAt(band) == '0') { + // 落在停用时段的行:节次报错,空白行按相邻行补齐 + if (TYPE_PERIOD.equals(type)) { + throw new BizException(ErrorCode.PARAM_ERROR, + "第 " + (i + 1) + " 行节次属于已停用的「" + BAND_LABELS[band] + "」时段,请调整时段或先启用该时段"); + } + band = -1; + } + if (band < 0) { + // 缺时段/停在停用时段:沿用相邻行所属的启用时段,兜底首个启用时段 + band = firstEnabledBand(enabledBands, lastBand, rows, i); + } + row.setBand(BAND_KEYS[band]); + lastBand = BAND_KEYS[band]; + bandRows[band]++; + + if (TYPE_PERIOD.equals(type)) { + periods++; + // 节次名称可自定义,留空由前端显示“第X节”;备注仅空白行可填 + row.setName(trimName(row.getName(), i)); + row.setRemark(null); + } else { + // 空白行名称可自定义,缺省“课间”;备注仅空白行可填 + String name = trimName(row.getName(), i); + row.setName(name != null ? name : "课间"); + row.setRemark(trimRemark(row.getRemark(), i)); + } + if (!isTime(row.getStart()) || !isTime(row.getEnd())) { + throw new BizException(ErrorCode.PARAM_ERROR, "第 " + (i + 1) + " 行时间格式应为 HH:mm"); + } + if (toMinutes(row.getEnd()) <= toMinutes(row.getStart())) { + throw new BizException(ErrorCode.PARAM_ERROR, "第 " + (i + 1) + " 行结束时间须晚于开始时间"); + } + } + if (periods > MAX_PERIODS) { + throw new BizException(ErrorCode.PARAM_ERROR, "节次数不得超过 " + MAX_PERIODS); + } + if (periods == 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "模板至少需要一节教学节次"); + } + // 启用哪些时段就维护哪些时段的行:每个启用时段至少一行(节次或空白行) + for (int i = 0; i < BAND_COUNT; i++) { + if (enabledBands.charAt(i) == '1' && bandRows[i] == 0) { + throw new BizException(ErrorCode.PARAM_ERROR, + "「" + BAND_LABELS[i] + "」时段至少需要一行(节次或空白行),不需要该时段请先停用"); + } + } + return rows; + } + + /** + * 未指定(或已停用)时段时的兜底归属:优先上一行所属时段,其次下一行所属时段, + * 都没有则取首个启用时段。 + */ + private int firstEnabledBand(String enabledBands, String lastBand, List rows, int at) { + int last = bandIndexOfKey(lastBand); + if (last >= 0 && enabledBands.charAt(last) == '1') { + return last; + } + for (int i = at + 1; i < rows.size(); i++) { + int next = bandIndexOfKey(rows.get(i).getBand()); + if (next >= 0 && enabledBands.charAt(next) == '1') { + return next; + } + } + for (int i = 0; i < BAND_COUNT; i++) { + if (enabledBands.charAt(i) == '1') { + return i; + } + } + throw new BizException(ErrorCode.PARAM_ERROR, "请至少启用一个时段"); + } + + /** 时段键(es/am/noon/pm/ev)转下标;非有效键返回 -1 */ + private int bandIndexOfKey(String band) { + if (!StringUtils.hasText(band)) { + return -1; + } + String v = band.trim(); + for (int i = 0; i < BAND_KEYS.length; i++) { + if (BAND_KEYS[i].equalsIgnoreCase(v)) { + return i; + } + } + return -1; + } + + /** 教学日串规整:空=7 天全开;须为 7 位 0/1 */ + private String normalizeTeachDays(String teachDays) { + if (!StringUtils.hasText(teachDays)) { + return "1111111"; + } + if (!teachDays.matches("^[01]{7}$")) { + throw new BizException(ErrorCode.PARAM_ERROR, "教学日格式不合法"); + } + return teachDays; + } + + /** 行名称规整:去空白,超长报错,空返回 null */ + private String trimName(String name, int rowNo) { + if (!StringUtils.hasText(name)) { + return null; + } + String v = name.trim(); + if (v.length() > 32) { + throw new BizException(ErrorCode.PARAM_ERROR, "第 " + (rowNo + 1) + " 行名称过长:最多 32 字"); + } + return v; + } + + /** 行备注规整:去空白,超长报错,空返回 null */ + private String trimRemark(String remark, int rowNo) { + if (!StringUtils.hasText(remark)) { + return null; + } + String v = remark.trim(); + if (v.length() > 200) { + throw new BizException(ErrorCode.PARAM_ERROR, "第 " + (rowNo + 1) + " 行备注过长:最多 200 字"); + } + return v; + } + + /** 学期规整:0=全年 1=上学期 2=下学期,其余(含空)按上学期处理 */ + private Integer normalizeTerm(Integer term) { + if (Integer.valueOf(0).equals(term)) { + return 0; + } + return Integer.valueOf(2).equals(term) ? 2 : 1; + } + + /** 学期显示名(日志/报错用) */ + private String termName(Integer term) { + Integer t = normalizeTerm(term); + if (t == 0) { + return "全年"; + } + return t == 2 ? "下学期" : "上学期"; + } + + /** 学期是否重叠:全年(0)与任意学期重叠 */ + private boolean termOverlaps(Integer a, Integer b) { + return a == null || b == null || a == 0 || b == 0 || a.equals(b); + } + + /** 年级多选规整:逐个去空白、去重、丢弃空项,逗号拼接;至少一个且总长受限 */ + private String normalizeGrades(List gradeNames) { + if (gradeNames == null || gradeNames.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请至少选择一个适用年级"); + } + LinkedHashSet grades = new LinkedHashSet<>(); + for (String raw : gradeNames) { + if (!StringUtils.hasText(raw)) { + continue; + } + String g = raw.trim(); + if (g.length() > 16) { + throw new BizException(ErrorCode.PARAM_ERROR, "年级名称不能超过 16 字: " + g); + } + grades.add(g); + } + if (grades.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请至少选择一个适用年级"); + } + String joined = String.join(",", grades); + if (joined.length() > MAX_GRADES_LENGTH) { + throw new BizException(ErrorCode.PARAM_ERROR, "适用年级过多"); + } + return joined; + } + + /** 实体 grade_name 逗号串拆为列表 */ + private List gradeNamesOf(PeriodTemplate tpl) { + if (!StringUtils.hasText(tpl.getGradeName())) { + return Collections.emptyList(); + } + return Arrays.stream(tpl.getGradeName().split(",")) + .map(String::trim) + .filter(StringUtils::hasText) + .collect(Collectors.toList()); + } + + /** 时长规整:空沿用现值,超范围报错 */ + private int normalizeMinutes(Integer value, int min, int max, int fallback, String field) { + if (value == null) { + return fallback; + } + if (value < min || value > max) { + throw new BizException(ErrorCode.PARAM_ERROR, field + "须在 " + min + " ~ " + max + " 分钟之间"); + } + return value; + } + + /** 启用时段位串规整:空=沿用 fallback(再空则全启用);须为 5 位 0/1 且至少启用一个时段 */ + private String normalizeBands(String bands, String fallback) { + if (!StringUtils.hasText(bands)) { + return sanitizeBands(fallback); + } + String v = bands.trim(); + if (!v.matches("^[01]{" + BAND_COUNT + "}$")) { + throw new BizException(ErrorCode.PARAM_ERROR, "启用时段格式不合法"); + } + if (v.indexOf('1') < 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "请至少启用一个时段"); + } + return v; + } + + /** 位串兜底(读库/回退用):非 5 位 0/1 或缺省时按全部启用 */ + private String sanitizeBands(String bands) { + return bands != null && bands.matches("^[01]{" + BAND_COUNT + "}$") ? bands : ALL_BANDS; + } + + /** 整数兜底:空取 fallback */ + private int orDefault(Integer value, int fallback) { + return value != null ? value : fallback; + } + + /** 本校时间设置行,无则建默认值 */ + private TimeSetting ensureTimeSetting(Integer schId) { + return timeSettingRepository.findBySchId(schId).orElseGet(() -> { + TimeSetting created = new TimeSetting(); + created.setSchId(schId); + return timeSettingRepository.save(created); + }); + } + + private TimeSettingVo toTimeVo(TimeSetting setting) { + TimeSettingVo vo = new TimeSettingVo(); + vo.setMorningSelfEnd(setting.getMorningSelfEnd()); + vo.setForenoonEnd(setting.getForenoonEnd()); + vo.setAfternoonStart(setting.getAfternoonStart()); + vo.setEveningStart(setting.getEveningStart()); + // 旧数据/空值兜底为全部启用 + String bands = setting.getEnabledBands(); + vo.setEnabledBands(bands != null && bands.matches("^[01]{" + BAND_COUNT + "}$") ? bands : ALL_BANDS); + vo.setPeriodMinutes(setting.getPeriodMinutes()); + vo.setBreakMinutes(setting.getBreakMinutes()); + return vo; + } + + private PeriodTemplateVo toTemplateVo(PeriodTemplate tpl) { + PeriodTemplateVo vo = new PeriodTemplateVo(); + vo.setId(tpl.getId()); + vo.setName(tpl.getName()); + vo.setTerm(tpl.getTerm()); + vo.setGradeNames(gradeNamesOf(tpl)); + vo.setEnabled(tpl.getEnabled()); + vo.setTeachDays(tpl.getTeachDays()); + vo.setEnabledBands(sanitizeBands(tpl.getEnabledBands())); + vo.setPeriodMinutes(orDefault(tpl.getPeriodMinutes(), DEFAULT_PERIOD_MINUTES)); + vo.setBreakMinutes(orDefault(tpl.getBreakMinutes(), DEFAULT_BREAK_MINUTES)); + for (TemplateRow row : rowRepository.findByTemplateIdOrderBySortOrderAsc(tpl.getId())) { + vo.getRows().add(toRowVo(row)); + } + return vo; + } + + private TemplateRowVo toRowVo(TemplateRow row) { + TemplateRowVo vo = new TemplateRowVo(); + vo.setRowType(row.getRowType()); + vo.setBand(row.getBand()); + vo.setName(row.getName()); + vo.setRemark(row.getRemark()); + vo.setStart(row.getStartTime()); + vo.setEnd(row.getEndTime()); + return vo; + } + + private Integer currentSchId() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + throw new BizException(ErrorCode.FORBIDDEN, "未获取到当前用户学校信息"); + } + return schId; + } + + private boolean isTime(String time) { + return time != null && TIME_PATTERN.matcher(time).matches(); + } + + private static int toMinutes(String time) { + String[] parts = time.split(":"); + return Integer.parseInt(parts[0]) * 60 + Integer.parseInt(parts[1]); + } + + private String trimmed(String value, String field) { + if (!StringUtils.hasText(value)) { + throw new BizException(ErrorCode.PARAM_ERROR, field + "不能为空"); + } + return value.trim(); + } + + private String orNull(String v) { + return v == null ? null : v.trim(); + } +} diff --git a/src/main/java/com/yunhai/acasyllabu/service/impl/SysOperationLogService.java b/src/main/java/com/yunhai/acasyllabu/service/impl/SysOperationLogService.java new file mode 100644 index 0000000..fd6074e --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/service/impl/SysOperationLogService.java @@ -0,0 +1,73 @@ +package com.yunhai.acasyllabu.service.impl; + +import com.yunhai.acasyllabu.aspect.SysOperationLogAspect; +import com.yunhai.acasyllabu.entity.SysOperationLog; +import com.yunhai.acasyllabu.model.BasePageQuery; +import com.yunhai.acasyllabu.model.BasePageResult; +import com.yunhai.acasyllabu.repository.SysOperationLogRepository; +import com.yunhai.acasyllabu.service.ISysOperationLogService; +import com.yunhai.acasyllabu.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/acasyllabu/service/impl/TimetableService.java b/src/main/java/com/yunhai/acasyllabu/service/impl/TimetableService.java new file mode 100644 index 0000000..590ea95 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/service/impl/TimetableService.java @@ -0,0 +1,615 @@ +package com.yunhai.acasyllabu.service.impl; + +import com.yunhai.acasyllabu.entity.Timetable; +import com.yunhai.acasyllabu.entity.TimetableSlot; +import com.yunhai.acasyllabu.entity.UpFile; +import com.yunhai.acasyllabu.model.bsch.ClassTreeNodeVo; +import com.yunhai.acasyllabu.model.bsch.NowYearTermVo; +import com.yunhai.acasyllabu.model.bsch.SubjectVo; +import com.yunhai.acasyllabu.model.syllabus.MyClassVo; +import com.yunhai.acasyllabu.model.syllabus.MyTimetableVo; +import com.yunhai.acasyllabu.model.syllabus.OapiTimetableClassVo; +import com.yunhai.acasyllabu.model.syllabus.TimetableSaveRequest; +import com.yunhai.acasyllabu.model.syllabus.TimetableSlotVo; +import com.yunhai.acasyllabu.model.syllabus.TimetableVo; +import com.yunhai.acasyllabu.repository.TimetableRepository; +import com.yunhai.acasyllabu.repository.TimetableSlotRepository; +import com.yunhai.acasyllabu.repository.UpFileRepository; +import com.yunhai.acasyllabu.service.IClassService; +import com.yunhai.acasyllabu.service.IFileService; +import com.yunhai.acasyllabu.service.ITimetableService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.acasyllabu.utils.UserContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * 课程表业务实现。 + *

课表按学年学期维护(每班每学年学期一条):读写都定位到班级服务当前学年学期, + * 历史学年学期数据保留但不再返回;跨学年/学期后首查为空骨架,重新编排即新学期课表。

+ *

保存校验:班级属于本校(班级服务);时间为 HH:mm 且结束晚于开始; + * 学科名须在本校学科列表内(学科服务不可用时放行并告警)。

+ */ +@Service +public class TimetableService implements ITimetableService { + + private static final Logger log = LoggerFactory.getLogger(TimetableService.class); + + private static final Pattern TIME_PATTERN = Pattern.compile("^([01]\\d|2[0-3]):[0-5]\\d$"); + + /** 节次上限(前端默认 10,允许增删) */ + private static final int MAX_SLOTS = 30; + + /** 校领导角色(UserRole.SCH_LEADER_USER) */ + private static final int ROLE_SCH_LEADER = 2; + + /** 管理员角色(UserRole.AREAADMIN) */ + private static final int ROLE_ADMIN = 7; + + /** 学生角色(UserRole.STU_USER) */ + private static final int ROLE_STUDENT = 3; + + @Autowired + private TimetableRepository timetableRepository; + + @Autowired + private TimetableSlotRepository slotRepository; + + @Autowired + private UpFileRepository upFileRepository; + + @Autowired + private IClassService classService; + + @Autowired + private IFileService fileService; + + // ============================ 查询 ============================ + + @Override + public TimetableVo get(Integer classId) { + return get(classId, currentSchId()); + } + + @Override + public TimetableVo get(Integer classId, Integer schId) { + requireClassId(classId); + NowYearTermVo yearTerm = currentYearTerm(); + Timetable timetable = timetableRepository + .findBySchIdAndClassIdAndYearAndTerm(schId, classId, yearTerm.getYear(), yearTerm.getTerm()) + .orElse(null); + TimetableVo vo = new TimetableVo(); + vo.setClassId(classId); + vo.setYear(yearTerm.getYear()); + vo.setTerm(yearTerm.getTerm()); + if (timetable == null) { + // 本学年学期未建过课表:返回班级骨架,前端补默认节次 + fillClassInfo(vo, classId, schId); + return vo; + } + vo.setClassName(timetable.getClassName()); + vo.setGradeName(timetable.getGradeName()); + vo.setTeacherId(timetable.getTeacherId()); + vo.setPhotoFileId(timetable.getPhotoFileId()); + if (timetable.getPhotoFileId() != null) { + upFileRepository.findById(timetable.getPhotoFileId()) + .ifPresent(f -> { + vo.setPhotoName(f.getFileTrueName()); + vo.setPhotoSize(f.getFileSize()); + }); + } + for (TimetableSlot slot : slotRepository.findByTimetableIdOrderBySortOrderAsc(timetable.getId())) { + TimetableSlotVo slotVo = new TimetableSlotVo(); + slotVo.setSlotNo(slot.getSortOrder()); + slotVo.setStart(slot.getStartTime()); + slotVo.setEnd(slot.getEndTime()); + slotVo.setSubjects(Arrays.asList( + orEmpty(slot.getDay1()), orEmpty(slot.getDay2()), orEmpty(slot.getDay3()), + orEmpty(slot.getDay4()), orEmpty(slot.getDay5()), orEmpty(slot.getDay6()), + orEmpty(slot.getDay7()))); + vo.getSlots().add(slotVo); + } + return vo; + } + + // ============================ 保存(电子版) ============================ + + @Override + @Transactional + public Integer save(TimetableSaveRequest request) { + if (request == null || request.getClassId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "班级不能为空"); + } + Integer classId = request.getClassId(); + List slots = request.getSlots() == null ? new ArrayList<>() : request.getSlots(); + if (slots.size() > MAX_SLOTS) { + throw new BizException(ErrorCode.PARAM_ERROR, "节次数不得超过 " + MAX_SLOTS); + } + validateSlots(slots); + Integer schId = currentSchId(); + String className = classService.classNameOf(schId, classId); + if (!StringUtils.hasText(className)) { + throw new BizException(ErrorCode.PARAM_ERROR, "班级不存在或不属于当前学校"); + } + + NowYearTermVo yearTerm = currentYearTerm(); + Timetable timetable = timetableRepository + .findBySchIdAndClassIdAndYearAndTerm(schId, classId, yearTerm.getYear(), yearTerm.getTerm()) + .orElseGet(Timetable::new); + timetable.setSchId(schId); + timetable.setYear(yearTerm.getYear()); + timetable.setTerm(yearTerm.getTerm()); + timetable.setClassId(classId); + timetable.setClassName(className); + timetable.setGradeName(classService.gradeNameOf(schId, classId)); + timetable.setTeacherId(UserContext.getUserId()); + timetable = timetableRepository.save(timetable); + + slotRepository.deleteByTimetableId(timetable.getId()); + slotRepository.flush(); + int order = 1; + for (TimetableSlotVo slotVo : slots) { + TimetableSlot slot = new TimetableSlot(); + slot.setTimetableId(timetable.getId()); + slot.setSortOrder(order++); + slot.setStartTime(slotVo.getStart()); + slot.setEndTime(slotVo.getEnd()); + List subjects = slotVo.getSubjects(); + slot.setDay1(at(subjects, 0)); + slot.setDay2(at(subjects, 1)); + slot.setDay3(at(subjects, 2)); + slot.setDay4(at(subjects, 3)); + slot.setDay5(at(subjects, 4)); + slot.setDay6(at(subjects, 5)); + slot.setDay7(at(subjects, 6)); + slotRepository.save(slot); + } + log.info("[save] schId={}, classId={}, teacherId={}, 节次 {} 个", + currentSchId(), classId, UserContext.getUserId(), slots.size()); + return timetable.getId(); + } + + // ============================ 照片版 ============================ + + @Override + @Transactional + public void savePhoto(Integer classId, Integer fileId) { + requireClassId(classId); + if (fileId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "附件不能为空"); + } + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "附件不存在: " + fileId)); + Timetable timetable = requireTimetable(classId); + Integer oldFileId = timetable.getPhotoFileId(); + timetable.setPhotoFileId(fileId); + timetable.setTeacherId(UserContext.getUserId()); + timetableRepository.save(timetable); + file.setTopicId(timetable.getId()); + upFileRepository.save(file); + if (oldFileId != null && !oldFileId.equals(fileId)) { + deleteFileQuietly(oldFileId); + } + log.info("[savePhoto] schId={}, classId={}, fileId={}(替换旧附件 {})", + currentSchId(), classId, fileId, oldFileId); + } + + @Override + @Transactional + public void removePhoto(Integer classId) { + requireClassId(classId); + Timetable timetable = currentTermTimetable(classId).orElse(null); + if (timetable == null || timetable.getPhotoFileId() == null) { + return; + } + Integer oldFileId = timetable.getPhotoFileId(); + timetable.setPhotoFileId(null); + timetableRepository.save(timetable); + deleteFileQuietly(oldFileId); + log.info("[removePhoto] schId={}, classId={}, fileId={}", currentSchId(), classId, oldFileId); + } + + // ============================ 我的课表 ============================ + + @Override + public List myClasses() { + return myClasses(UserContext.getUserId(), UserContext.getRoleId(), currentSchId()); + } + + @Override + public List myClasses(Integer userId, Integer roleId, Integer schId) { + List> classList = classService.getSchClassList(schId); + + boolean admin = isAdminRole(roleId); + boolean student = !admin && isStudentRole(roleId); + // 班主任班级:正/副班主任是本人 + Set headIds = new HashSet<>(); + // 任教班级:classId -> 任教科目(去重保序) + Map> teachSubjects = new LinkedHashMap<>(); + // 范围:管理员=全部,学生=本人班级,教师=班主任∪任教 + Set scope = new LinkedHashSet<>(); + if (admin) { + for (Map c : classList) { + Integer cid = asInt(c.get("classId")); + if (cid != null) { + scope.add(cid); + } + } + } else if (student) { + Map stu = userId == null ? null : classService.resolveStudent(schId, userId); + Integer cid = stu == null ? null : asInt(stu.get("classId")); + if (cid != null) { + scope.add(cid); + } + } else if (userId != null) { + for (Map c : classList) { + Integer cid = asInt(c.get("classId")); + if (cid != null && (idEquals(c.get("firstTeacherId"), userId) || idEquals(c.get("secondTeacherId"), userId))) { + headIds.add(cid); + } + } + for (Map t : classService.getTeacherTeachList(schId, userId)) { + Integer cid = asInt(t.get("classId")); + String sub = asStr(t.get("subName")); + if (cid == null || !StringUtils.hasText(sub)) { + continue; + } + List names = teachSubjects.computeIfAbsent(cid, k -> new ArrayList<>()); + if (!names.contains(sub.trim())) { + names.add(sub.trim()); + } + } + scope.addAll(headIds); + scope.addAll(teachSubjects.keySet()); + } + + // 班级信息以班级列表为主(缺项回学生树补) + Map> byId = new LinkedHashMap<>(); + for (Map c : classList) { + Integer cid = asInt(c.get("classId")); + if (cid != null) { + byId.put(cid, c); + } + } + + Map order = classOrder(schId); + List result = new ArrayList<>(); + for (Integer cid : scope) { + Map c = byId.get(cid); + MyClassVo vo = new MyClassVo(); + vo.setClassId(cid); + vo.setClassName(c != null ? asStr(c.get("name")) : classService.classNameOf(schId, cid)); + vo.setGradeName(c != null ? asStr(c.get("gradeName")) : classService.gradeNameOf(schId, cid)); + vo.setHeadTeacher(headIds.contains(cid)); + vo.setViewAllSubjects(admin || student || headIds.contains(cid)); + vo.setSubjects(teachSubjects.getOrDefault(cid, new ArrayList<>())); + result.add(vo); + } + result.sort(Comparator.comparing(v -> order.getOrDefault(v.getClassId(), Integer.MAX_VALUE))); + log.info("[myClasses] schId={}, userId={}, roleId={}, 范围={}, 班级 {} 个", + schId, userId, roleId, admin ? "全部" : student ? "本人班级" : "所带/任教", result.size()); + return result; + } + + @Override + public MyTimetableVo myGet(Integer classId) { + return myGet(classId, UserContext.getUserId(), UserContext.getRoleId(), currentSchId()); + } + + @Override + public MyTimetableVo myGet(Integer classId, Integer userId, Integer roleId, Integer schId) { + requireClassId(classId); + boolean admin = isAdminRole(roleId); + + boolean viewAll = admin; + Set allowSubjects = new LinkedHashSet<>(); + if (!admin) { + if (isStudentRole(roleId)) { + // 学生:仅本人所在班级,全部科目 + Map stu = userId == null ? null : classService.resolveStudent(schId, userId); + Integer ownClassId = stu == null ? null : asInt(stu.get("classId")); + if (ownClassId == null || !ownClassId.equals(classId)) { + throw new BizException(ErrorCode.FORBIDDEN, "无权查看该班级课表"); + } + viewAll = true; + } else { + if (userId == null) { + throw new BizException(ErrorCode.FORBIDDEN, "未获取到当前用户身份信息"); + } + if (isHeadTeacher(schId, userId, classId)) { + viewAll = true; + } else { + List subjects = teachSubjectsOf(schId, userId, classId); + if (subjects.isEmpty()) { + // 既非班主任也无任教记录(含任教接口不可达降级为空):拒绝,防越权 + throw new BizException(ErrorCode.FORBIDDEN, "无权查看该班级课表"); + } + allowSubjects.addAll(subjects); + } + } + } + + TimetableVo base = get(classId, schId); + MyTimetableVo vo = new MyTimetableVo(); + BeanUtils.copyProperties(base, vo); + vo.setViewAllSubjects(viewAll); + vo.setMySubjects(new ArrayList<>(allowSubjects)); + if (!viewAll) { + for (TimetableSlotVo slot : vo.getSlots()) { + List subjects = slot.getSubjects(); + for (int d = 0; d < subjects.size(); d++) { + String name = subjects.get(d); + if (StringUtils.hasText(name) && !allowSubjects.contains(name.trim())) { + subjects.set(d, ""); + } + } + } + } + log.info("[myGet] schId={}, userId={}, classId={}, viewAll={}, 科目={}", + schId, userId, classId, viewAll, allowSubjects); + return vo; + } + + @Override + public List oapiTimetable(Integer userId, Integer roleId, Integer schId) { + if (userId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "userid 不能为空"); + } + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "schId 不能为空"); + } + List classes = myClasses(userId, roleId, schId); + List result = new ArrayList<>(classes.size()); + for (MyClassVo c : classes) { + OapiTimetableClassVo item = new OapiTimetableClassVo(); + item.setClassId(c.getClassId()); + item.setClassName(c.getClassName()); + item.setGradeName(c.getGradeName()); + item.setHeadTeacher(c.isHeadTeacher()); + item.setViewAllSubjects(c.isViewAllSubjects()); + item.setSubjects(c.getSubjects()); + item.setTimetable(myGet(c.getClassId(), userId, roleId, schId)); + result.add(item); + } + log.info("[oapiTimetable] schId={}, userId={}, roleId={}, 返回 {} 个班级课表", + schId, userId, roleId, result.size()); + return result; + } + + /** 管理员/校领导角色判定(roleId 缺失按普通教师处理) */ + private boolean isAdminRole(Integer roleId) { + return roleId != null && (roleId == ROLE_SCH_LEADER || roleId == ROLE_ADMIN); + } + + /** 学生角色判定 */ + private boolean isStudentRole(Integer roleId) { + return roleId != null && roleId == ROLE_STUDENT; + } + + /** 班级列表里判断该班正/副班主任是否本人 */ + private boolean isHeadTeacher(int schId, Integer userId, Integer classId) { + for (Map c : classService.getSchClassList(schId)) { + if (!classId.equals(asInt(c.get("classId")))) { + continue; + } + return idEquals(c.get("firstTeacherId"), userId) || idEquals(c.get("secondTeacherId"), userId); + } + return false; + } + + /** 本人在该班的任教科目(去重保序);无任教记录或接口不可达返回空列表 */ + private List teachSubjectsOf(int schId, Integer userId, Integer classId) { + List names = new ArrayList<>(); + for (Map t : classService.getTeacherTeachList(schId, userId)) { + if (!classId.equals(asInt(t.get("classId")))) { + continue; + } + String sub = asStr(t.get("subName")); + if (StringUtils.hasText(sub) && !names.contains(sub.trim())) { + names.add(sub.trim()); + } + } + return names; + } + + /** 学生树年级→班级顺序(classId -> 序号),用于「我的课表」班级排序;树不可得时返回空表(按原顺序排) */ + private Map classOrder(int schId) { + Map order = new LinkedHashMap<>(); + int i = 0; + try { + for (ClassTreeNodeVo grade : classService.getClassTree(schId)) { + for (ClassTreeNodeVo cls : grade.getChildren() == null ? new ArrayList() : grade.getChildren()) { + if (cls.getId() != null) { + order.put(cls.getId(), i++); + } + } + } + } catch (Exception e) { + log.warn("[classOrder] 班级树获取失败,我的课表按接口原序展示: {}", e.getMessage()); + } + return order; + } + + private static boolean idEquals(Object left, Integer right) { + return left != null && right != null && String.valueOf(left).equals(String.valueOf(right)); + } + + private static Integer asInt(Object v) { + if (v == null) { + return null; + } + if (v instanceof Number) { + return ((Number) v).intValue(); + } + try { + return Integer.valueOf(String.valueOf(v)); + } catch (NumberFormatException e) { + return null; + } + } + + private static String asStr(Object v) { + return v == null ? null : String.valueOf(v); + } + + // ============================ 校验 ============================ + + private void validateSlots(List slots) { + Set subjectNames = validSubjectNames(); + for (int i = 0; i < slots.size(); i++) { + TimetableSlotVo slot = slots.get(i); + String start = slot.getStart(); + String end = slot.getEnd(); + if (!TIME_PATTERN.matcher(orEmpty(start)).matches() || !TIME_PATTERN.matcher(orEmpty(end)).matches()) { + throw new BizException(ErrorCode.PARAM_ERROR, "第 " + (i + 1) + " 节时间格式应为 HH:mm"); + } + if (toMinutes(end) <= toMinutes(start)) { + throw new BizException(ErrorCode.PARAM_ERROR, "第 " + (i + 1) + " 节结束时间须晚于开始时间"); + } + List subjects = slot.getSubjects(); + if (subjects != null && subjects.size() > 7) { + throw new BizException(ErrorCode.PARAM_ERROR, "第 " + (i + 1) + " 节学科数不得超过 7(周一~周日)"); + } + if (subjects == null) { + continue; + } + for (int d = 0; d < subjects.size(); d++) { + String name = subjects.get(d); + if (!StringUtils.hasText(name)) { + continue; + } + if (name.length() > 64) { + throw new BizException(ErrorCode.PARAM_ERROR, "学科名过长: " + name); + } + if (!subjectNames.isEmpty() && !subjectNames.contains(name)) { + throw new BizException(ErrorCode.PARAM_ERROR, + "第 " + (i + 1) + " 节「" + weekName(d) + "」学科不在本校学科列表中: " + name); + } + } + } + } + + /** 本校学科名集合;学科服务不可用时返回空集(放行校验,由前端下拉约束) */ + private Set validSubjectNames() { + Set names = new HashSet<>(); + try { + for (SubjectVo subject : classService.getSubjectList(currentSchId())) { + if (StringUtils.hasText(subject.getName())) { + names.add(subject.getName().trim()); + } + } + } catch (Exception e) { + log.warn("[validSubjectNames] 获取学科列表失败,本次保存跳过学科校验: {}", e.getMessage()); + } + return names; + } + + // ============================ 内部辅助 ============================ + + private Integer currentSchId() { + Integer schId = UserContext.getSchId(); + if (schId == null) { + throw new BizException(ErrorCode.FORBIDDEN, "未获取到当前用户学校信息"); + } + return schId; + } + + /** 当前学年学期(班级服务),学年学期是课表读写的定位维度,不可得时直接失败 */ + private NowYearTermVo currentYearTerm() { + return classService.getNowYearTerm(); + } + + /** 当前学年学期范围内本校某班的课表记录 */ + private Optional currentTermTimetable(Integer classId) { + NowYearTermVo yearTerm = currentYearTerm(); + return timetableRepository.findBySchIdAndClassIdAndYearAndTerm( + currentSchId(), classId, yearTerm.getYear(), yearTerm.getTerm()); + } + + private void requireClassId(Integer classId) { + if (classId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "班级不能为空"); + } + } + + /** 取当前学年学期本校课表记录;无则按班级骨架新建(照片可先于电子版保存) */ + private Timetable requireTimetable(Integer classId) { + return currentTermTimetable(classId).orElseGet(() -> { + NowYearTermVo yearTerm = currentYearTerm(); + String className = classService.classNameOf(currentSchId(), classId); + if (!StringUtils.hasText(className)) { + throw new BizException(ErrorCode.PARAM_ERROR, "班级不存在或不属于当前学校"); + } + Timetable created = new Timetable(); + created.setSchId(currentSchId()); + created.setYear(yearTerm.getYear()); + created.setTerm(yearTerm.getTerm()); + created.setClassId(classId); + created.setClassName(className); + created.setGradeName(classService.gradeNameOf(currentSchId(), classId)); + created.setTeacherId(UserContext.getUserId()); + return timetableRepository.save(created); + }); + } + + private void fillClassInfo(TimetableVo vo, Integer classId, Integer schId) { + try { + vo.setClassName(classService.classNameOf(schId, classId)); + vo.setGradeName(classService.gradeNameOf(schId, classId)); + } catch (Exception e) { + log.warn("[fillClassInfo] 班级信息获取失败 classId={}: {}", classId, e.getMessage()); + } + } + + private void deleteFileQuietly(Integer fileId) { + try { + fileService.delete(fileId); + } catch (Exception e) { + log.warn("[deleteFileQuietly] 旧照片删除失败 fileId={}: {}", fileId, e.getMessage()); + } + } + + private static int toMinutes(String time) { + String[] parts = time.split(":"); + return Integer.parseInt(parts[0]) * 60 + Integer.parseInt(parts[1]); + } + + private static String weekName(int index) { + final String[] weeks = {"周一", "周二", "周三", "周四", "周五", "周六", "周日"}; + return weeks[index]; + } + + private static String orEmpty(String v) { + return v == null ? "" : v; + } + + private static String at(List list, int index) { + if (list == null || index >= list.size()) { + return ""; + } + String v = list.get(index); + if (!StringUtils.hasText(v) || Objects.equals("null", v)) { + return ""; + } + return v.trim(); + } +} diff --git a/src/main/java/com/yunhai/acasyllabu/service/impl/UserCenterService.java b/src/main/java/com/yunhai/acasyllabu/service/impl/UserCenterService.java new file mode 100644 index 0000000..5f2ca77 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/service/impl/UserCenterService.java @@ -0,0 +1,268 @@ +package com.yunhai.acasyllabu.service.impl; + +import com.yunhai.acasyllabu.model.SchTeaList; +import com.yunhai.acasyllabu.service.IClassService; +import com.yunhai.acasyllabu.service.IUserCenterService; +import com.yunhai.platform.openapi.base.YschBaseOpenApi; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 用户中心门面服务实现。 + *

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

+ */ +@Service +public class UserCenterService implements IUserCenterService { + + private static final Logger log = LoggerFactory.getLogger(UserCenterService.class); + + /** 学生组(UserBase GroupTypeEnum.STUDENT_GROUP),组树中剔除 */ + private static final int GROUP_TYPE_STUDENT = 8; + + @Autowired + private IClassService classService; + + // ============================ 学校组织树 ============================ + + @Override + public List> getSchoolAllTree(Integer schId) { + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + + // 1. userbase 组树(整棵学校子树),剔除学生组 + List> flatNodes = new ArrayList<>(); + BoaGroup root = YschBaseOpenApi.getGroupById(schId, true); + if (root == null) { + log.warn("[getSchoolAll] 用户中心未找到学校组, schId={}", schId); + return Collections.emptyList(); + } + flattenGroup(root, 0, flatNodes); + + // 2. bschclass 全部教师,按 GroupIds 挂到对应组下;无有效组的兜底挂学校根节点 + Set treeGroupIds = new HashSet<>(); + for (Map node : flatNodes) { + treeGroupIds.add((Integer) node.get("id")); + } + Map teacherMap = new LinkedHashMap<>(); + Map> teacherPids = new LinkedHashMap<>(); + List teachers = classService.getSchTeaList(schId); + for (SchTeaList tea : teachers) { + if (tea.getUserTeaSerID() == null) { + continue; + } + // 同一教师多行(多组/接口重复数据)合并,按 (教师, 组) 去重 + teacherMap.putIfAbsent(tea.getUserTeaSerID(), tea); + // 接口现行字段为单个 GroupID,兼容旧字段 GroupIds + List teaGroupIds = tea.getGroupIds(); + if ((teaGroupIds == null || teaGroupIds.isEmpty()) && tea.getGroupId() != null) { + teaGroupIds = Collections.singletonList(tea.getGroupId()); + } + if (teaGroupIds != null) { + for (Integer gid : teaGroupIds) { + if (gid != null && treeGroupIds.contains(gid)) { + teacherPids.computeIfAbsent(tea.getUserTeaSerID(), k -> new HashSet<>()).add(gid); + } + } + } + } + for (Map.Entry entry : teacherMap.entrySet()) { + int teaId = entry.getKey(); + SchTeaList tea = entry.getValue(); + Set pids = teacherPids.get(teaId); + if (pids == null || pids.isEmpty()) { + // 教师所属组均被剔除(如学生组)或无组信息,挂学校根节点 + pids = new HashSet<>(Collections.singletonList(treeGroupIds.contains(schId) ? schId : 0)); + } + for (Integer pid : pids) { + Map node = new LinkedHashMap<>(); + node.put("id", teaId); + node.put("name", tea.getTeaName() != null ? tea.getTeaName() : ""); + node.put("pid", pid); + node.put("no", tea.getTeaNo() != null ? tea.getTeaNo() : 0); + node.put("type", 2); + node.put("isParent", false); + node.put("photo", ""); + flatNodes.add(node); + } + } + + // 3. 扁平节点递归组装为树 + return formatUserTree(flatNodes, 0); + } + + /** + * 将 BoaGroup 子树扁平化为组节点列表(type=1);学生组(type=8)整棵剔除。 + * isParent=true 标识组节点,前端 ztree 据此渲染为文件夹。 + */ + private void flattenGroup(BoaGroup group, int pid, List> nodes) { + if (group == null || group.getId() == null) { + return; + } + if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) { + return; + } + Map node = new LinkedHashMap<>(); + node.put("id", group.getId()); + node.put("name", group.getName() != null ? group.getName() : ""); + node.put("pid", pid); + node.put("no", group.getSort() != null ? group.getSort() : 0); + node.put("type", 1); + node.put("isParent", true); + node.put("photo", ""); + nodes.add(node); + + if (group.getChildren() != null) { + for (BoaGroup child : group.getChildren()) { + flattenGroup(child, group.getId(), nodes); + } + } + } + + /** + * 递归构建用户树(对应 C# HttpUserApi.FormatUserTree)。 + * 根层取 pid=0 或父节点不在列表中的节点;type=1 且无子节点的空组剔除。 + */ + private List> formatUserTree(List> treeList, int pid) { + List> data; + if (pid == 0) { + Set allIds = new HashSet<>(); + for (Map n : treeList) { + allIds.add((Integer) n.get("id")); + } + data = new ArrayList<>(); + for (Map n : treeList) { + int p = (Integer) n.get("pid"); + if (p == 0 || !allIds.contains(p)) { + data.add(n); + } + } + } else { + data = new ArrayList<>(); + for (Map n : treeList) { + if ((Integer) n.get("pid") == pid) { + data.add(n); + } + } + } + + List> result = new ArrayList<>(); + for (Map item : data) { + int itemId = (Integer) item.get("id"); + int itemType = (Integer) item.get("type"); + + List> children = formatUserTree(treeList, itemId); + + // type=1(组) 且无子节点 → 不加入(C# 逻辑) + if (itemType == 1 && children.isEmpty()) { + continue; + } + + Map node = new LinkedHashMap<>(item); + // 仅组节点携带 children;叶子不带该字段,避免 ztree 误判 isParent + if (itemType == 1) { + node.put("children", children); + } + result.add(node); + } + + // 按 no 排序(数字优先,无法解析的排最后) + result.sort(Comparator.comparingInt(n -> { + Object no = n.get("no"); + if (no instanceof Integer) { + return (Integer) no; + } + if (no instanceof String) { + try { + return Integer.parseInt((String) no); + } catch (NumberFormatException e) { + return 9999; + } + } + return 9999; + })); + + return result; + } + + // ============================ 组内用户 ============================ + + @Override + public List> getGroupUser(Integer gid, Integer role) { + if (gid == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "组ID不得为空"); + } + log.info("[getGroupUser] gid={}, role={}, 用户中心组内用户开放接口尚未接入", gid, role); + // 显式报错而非静默返回空列表:原空桩会让前端误以为组内无用户 + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "组内用户查询接口暂未接入用户中心,请联系管理员配置后重试"); + } + + // ============================ 用户模块树 ============================ + + @Override + public List> getUserModuleTree(Integer userId, Integer schId) { + if (userId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空"); + } + List modules = YschBaseOpenApi.getUserModule(userId, schId); + if (modules == null || modules.isEmpty()) { + 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<>(); + for (BoaModule m : modules) { + Map node = nodeMap.get(m.getId()); + int parentId = m.getParentId() != null ? m.getParentId() : 0; + if (parentId == 0 || !moduleMap.containsKey(parentId)) { + roots.add(node); + } else { + @SuppressWarnings("unchecked") + List> children = (List>) nodeMap.get(parentId).get("children"); + children.add(node); + } + } + + // 保持 SDK(用户中心)返回顺序,不再按 orderNumber 重排 + return roots; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/acasyllabu/utils/GzipUtil.java b/src/main/java/com/yunhai/acasyllabu/utils/GzipUtil.java new file mode 100644 index 0000000..1b60ff1 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/utils/GzipUtil.java @@ -0,0 +1,27 @@ +package com.yunhai.acasyllabu.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/acasyllabu/utils/IpUtils.java b/src/main/java/com/yunhai/acasyllabu/utils/IpUtils.java new file mode 100644 index 0000000..db82425 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/utils/IpUtils.java @@ -0,0 +1,44 @@ +package com.yunhai.acasyllabu.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/acasyllabu/utils/PageUtils.java b/src/main/java/com/yunhai/acasyllabu/utils/PageUtils.java new file mode 100644 index 0000000..dc55c23 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/utils/PageUtils.java @@ -0,0 +1,37 @@ +package com.yunhai.acasyllabu.utils; + +import com.yunhai.acasyllabu.model.BasePageQuery; +import com.yunhai.acasyllabu.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/acasyllabu/utils/UserContext.java b/src/main/java/com/yunhai/acasyllabu/utils/UserContext.java new file mode 100644 index 0000000..e62ec22 --- /dev/null +++ b/src/main/java/com/yunhai/acasyllabu/utils/UserContext.java @@ -0,0 +1,140 @@ +package com.yunhai.acasyllabu.utils; + +import lombok.Value; + +/** + * 当前登录用户上下文(ThreadLocal 静态持有器)。 + *

移植自 UserclassService(com.yunhai.bschclassmanage.utils.UserContext), + * 由 {@link com.yunhai.acasyllabu.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..bdcf0a3 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,79 @@ +server: + port: 8109 + +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/aca_syllabu?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: acasyllabu + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + sso: + server-url-prefix: http://sso.yunhaixingtong.com + client-host-url: http://localhost:8109 + allow-headers: all + allow-origins: all + url-pattern: /api/* + mobi-api-pattern: /mobiapi/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用) + app-key: 100021 + app-secret: d3ee380e38c549d1978e89f364cc1164 + # 提供方强制验签(参考 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..9f5ebeb --- /dev/null +++ b/src/main/resources/bootstrap.yml @@ -0,0 +1,33 @@ +spring: + application: + name: acasyllabu + 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},即 acasyllabu.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/acasyllabu_schema.sql b/src/main/resources/sql/acasyllabu_schema.sql new file mode 100644 index 0000000..0bb928e --- /dev/null +++ b/src/main/resources/sql/acasyllabu_schema.sql @@ -0,0 +1,157 @@ +-- ============================================================ +-- AcaSyllabu(课程表)业务表结构 +-- 本地/生产均由 JPA ddl-auto=update 自动建表,本脚本作为 +-- 结构说明与手工初始化兜底。旧 dorm/activity 系列表保留不动。 +-- ============================================================ + +-- 课程表主表(每班一条) +CREATE TABLE IF NOT EXISTS `timetable` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` INT NOT NULL COMMENT '学校ID(多租户)', + `class_id` INT NOT NULL COMMENT '班级ID(班级服务学生树节点id)', + `class_name` VARCHAR(100) NULL COMMENT '班级名称(冗余)', + `grade_name` VARCHAR(100) NULL COMMENT '年级名称(冗余)', + `teacher_id` INT NULL COMMENT '维护教师/平台用户id(不存账号)', + `photo_file_id` INT NULL COMMENT '照片课表附件ID(up_file.id)', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_timetable_sch_class` (`sch_id`, `class_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='课程表'; + +-- 课程表节次表(每节一行;时段由前端按开始时间判定,不落库) +CREATE TABLE IF NOT EXISTS `timetable_slot` ( + `id` INT NOT NULL AUTO_INCREMENT, + `timetable_id` INT NOT NULL COMMENT '所属课程表ID', + `sort_order` INT NOT NULL COMMENT '节次顺序(从1起)', + `start_time` VARCHAR(5) NULL COMMENT '开始时间 HH:mm', + `end_time` VARCHAR(5) NULL COMMENT '结束时间 HH:mm', + `day_1` VARCHAR(64) NULL COMMENT '星期一学科名(空=未排)', + `day_2` VARCHAR(64) NULL COMMENT '星期二学科名', + `day_3` VARCHAR(64) NULL COMMENT '星期三学科名', + `day_4` VARCHAR(64) NULL COMMENT '星期四学科名', + `day_5` VARCHAR(64) NULL COMMENT '星期五学科名', + `day_6` VARCHAR(64) NULL COMMENT '星期六学科名', + `day_7` VARCHAR(64) NULL COMMENT '星期日学科名', + `create_by` VARCHAR(64) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(64) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_slot_timetable` (`timetable_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='课程表节次'; + +-- ============================================================ +-- 应用设置(2026-10-12) +-- ============================================================ + +-- 时段判定时间设置(每校一行,只存四个判定边界; +-- 启用时段与节次/课间时长已改为随节次模板保存,这里保留列仅作历史/回退默认) +CREATE TABLE IF NOT EXISTS `timetable_time_setting` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` INT NOT NULL COMMENT '学校ID(多租户)', + `morning_self_end` VARCHAR(5) NOT NULL COMMENT '早自习截止 HH:mm,默认 07:00', + `forenoon_end` VARCHAR(5) NOT NULL COMMENT '上午截止 HH:mm,默认 12:30', + `afternoon_start` VARCHAR(5) NOT NULL COMMENT '下午开始 HH:mm,默认 13:30', + `evening_start` VARCHAR(5) NOT NULL COMMENT '晚自习开始 HH:mm,默认 18:30', + `enabled_bands` VARCHAR(16) NOT NULL DEFAULT '11111' COMMENT '(历史)启用时段 5 位 0/1 串,现以模板为准', + `period_minutes` INT NOT NULL DEFAULT 45 COMMENT '(历史)节次时长(分钟),现以模板为准', + `break_minutes` INT NOT NULL DEFAULT 10 COMMENT '(历史)课间时长(分钟),现以模板为准', + `create_by` VARCHAR(255) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(255) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_time_setting_sch` (`sch_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='时段判定时间设置'; + +-- 节次模板(绑定学期×多年级;同学期内名称唯一,同学期年级有交集的同时只启用一套; +-- 启用时段与节次/课间时长绑定模板ID:启用哪些时段,模板行列表就维护哪些时段的节次) +CREATE TABLE IF NOT EXISTS `timetable_period_template` ( + `id` INT NOT NULL AUTO_INCREMENT, + `sch_id` INT NOT NULL COMMENT '学校ID(多租户)', + `name` VARCHAR(16) NOT NULL COMMENT '模板名称(春/秋 等)', + `term` INT NOT NULL DEFAULT 1 COMMENT '绑定学期 0=全年 1=上学期 2=下学期', + `grade_name` VARCHAR(64) NOT NULL COMMENT '适用年级(逗号分隔多个,如 高一,高二)', + `enabled` BIT NOT NULL COMMENT '是否启用', + `teach_days` VARCHAR(7) NOT NULL COMMENT '教学日 7 位 0/1 串(周一~周日)', + `enabled_bands` VARCHAR(16) NOT NULL DEFAULT '11111' COMMENT '启用时段 5 位 0/1 串(早自习 上午 中午 下午 晚自习)', + `period_minutes` INT NOT NULL DEFAULT 45 COMMENT '节次时长(分钟),模板加节次的默认用时', + `break_minutes` INT NOT NULL DEFAULT 10 COMMENT '课间时长(分钟),模板加空白行的默认用时', + `create_by` VARCHAR(255) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(255) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tpl_sch_term_name` (`sch_id`, `term`, `name`), + KEY `idx_tpl_sch` (`sch_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='节次模板'; + +-- 节次模板行(period=教学节次 / break=空白行,按 sort_order 整存整取) +CREATE TABLE IF NOT EXISTS `timetable_template_row` ( + `id` INT NOT NULL AUTO_INCREMENT, + `template_id` INT NOT NULL COMMENT '所属模板ID', + `sort_order` INT NOT NULL COMMENT '行顺序(从1起)', + `row_type` VARCHAR(16) NOT NULL COMMENT 'period / break', + `band` VARCHAR(8) NULL COMMENT '所属时段 es/am/noon/pm/ev(节次与空白行都落在时段里)', + `name` VARCHAR(32) NULL COMMENT '行名称(节次行可自定义,空由前端显示第X节;空白行如 课间/午休)', + `remark` VARCHAR(200) NULL COMMENT '备注(仅空白行可填写)', + `start_time` VARCHAR(5) NOT NULL COMMENT '开始时间 HH:mm', + `end_time` VARCHAR(5) NOT NULL COMMENT '结束时间 HH:mm', + `create_by` VARCHAR(255) NULL, + `create_time` DATETIME NULL, + `update_by` VARCHAR(255) NULL, + `update_time` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_row_template` (`template_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='节次模板行'; + +-- 旧库升级(2026-10-12:模板绑定学期 + 年级多选;本地/生产均已执行或由 ddl-auto 加列后手动收尾): +-- ALTER TABLE `timetable_period_template` +-- ADD COLUMN `term` INT NOT NULL DEFAULT 1 COMMENT '绑定学期 1=上学期 2=下学期', +-- MODIFY COLUMN `grade_name` VARCHAR(64) NOT NULL COMMENT '适用年级(逗号分隔多个)', +-- DROP INDEX `uk_tpl_sch_name_grade`, +-- ADD UNIQUE KEY `uk_tpl_sch_term_name` (`sch_id`, `term`, `name`); +-- UPDATE `timetable_period_template` SET `term` = 1 WHERE `term` NOT IN (1, 2); +-- ALTER TABLE `timetable_time_setting` +-- ADD COLUMN `period_minutes` INT NOT NULL DEFAULT 45 COMMENT '节次时长(分钟)', +-- ADD COLUMN `break_minutes` INT NOT NULL DEFAULT 10 COMMENT '课间时长(分钟)'; +-- UPDATE `timetable_time_setting` SET `period_minutes` = 45, `break_minutes` = 10 WHERE `period_minutes` = 0; +-- 旧库升级(2026-10-12:模板行节次名称可自定义 + 空白行备注;由 ddl-auto 加列,或手动执行): +-- ALTER TABLE `timetable_template_row` +-- ADD COLUMN `remark` VARCHAR(64) NULL COMMENT '备注(仅空白行可填写)' AFTER `name`, +-- MODIFY COLUMN `name` VARCHAR(32) NULL COMMENT '行名称(节次行可自定义,空由前端显示第X节;空白行如 课间/午休)'; +-- 旧库升级(2026-10-12:空白行备注加长 64 → 200,本地已执行): +-- ALTER TABLE `timetable_template_row` +-- MODIFY COLUMN `remark` VARCHAR(200) NULL COMMENT '备注(仅空白行可填写)'; +-- 旧库升级(2026-10-12:时段启用开关;由 ddl-auto 加列,或手动执行): +-- ALTER TABLE `timetable_time_setting` +-- ADD COLUMN `enabled_bands` VARCHAR(16) NOT NULL DEFAULT '11111' COMMENT '启用时段 5 位 0/1 串(早自习 上午 中午 下午 晚自习)' AFTER `evening_start`; +-- 旧库升级(2026-10-12:时段启用 + 节次/课间时长改为绑定节次模板;由 ddl-auto 加列后手动回填)。 +-- 说明:模板上的三列是新增的权威值,回填自本校原有时间设置以保持当前行为不变; +-- timetable_time_setting 的三列保留不再维护(仅作历史/回退默认)。本地 sch=3 已执行。 +-- ALTER TABLE `timetable_period_template` +-- ADD COLUMN `enabled_bands` VARCHAR(16) NOT NULL DEFAULT '11111' COMMENT '启用时段 5 位 0/1 串(早自习 上午 中午 下午 晚自习)' AFTER `teach_days`, +-- ADD COLUMN `period_minutes` INT NOT NULL DEFAULT 45 COMMENT '节次时长(分钟)' AFTER `enabled_bands`, +-- ADD COLUMN `break_minutes` INT NOT NULL DEFAULT 10 COMMENT '课间时长(分钟)' AFTER `period_minutes`; +-- UPDATE `timetable_period_template` t JOIN `timetable_time_setting` s ON s.sch_id = t.sch_id +-- SET t.enabled_bands = s.enabled_bands, t.period_minutes = s.period_minutes, t.break_minutes = s.break_minutes; +-- 旧库升级(2026-10-12:所属时段落到行上,移除按开始时间的时段判定;由 ddl-auto 加列后手动回填)。 +-- 说明:回填按本校时间设置的四条边界推当时口径的所属时段,保证升级后展示与原先一致; +-- 之后行的时段以 band 列为准(不再随开始时间变化),每个启用时段须至少保留一条节次。 +-- 空白行同样落在时段里:下述 UPDATE 对空白行一并回填(按开始时间推),本地已执行。 +-- ALTER TABLE `timetable_template_row` +-- ADD COLUMN `band` VARCHAR(8) NULL COMMENT '所属时段 es/am/noon/pm/ev(节次与空白行都落在时段里)' AFTER `row_type`; +-- UPDATE `timetable_template_row` r +-- JOIN `timetable_period_template` t ON t.id = r.template_id +-- JOIN `timetable_time_setting` s ON s.sch_id = t.sch_id +-- SET r.band = CASE +-- WHEN r.start_time < s.morning_self_end THEN 'es' +-- WHEN r.start_time < s.forenoon_end THEN 'am' +-- WHEN r.start_time < s.afternoon_start THEN 'noon' +-- WHEN r.start_time < s.evening_start THEN 'pm' +-- ELSE 'ev' END +-- WHERE r.band IS NULL OR r.band = ''; +-- 若空白行归到“看似不合理”的段(如课间跨在边界上),在应用设置编辑器里用 ↑ ↓ 调整即可,保存后即以行为准。 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/test/java/com/yunhai/acasyllabu/service/impl/TimetableServiceMyTest.java b/src/test/java/com/yunhai/acasyllabu/service/impl/TimetableServiceMyTest.java new file mode 100644 index 0000000..ed80843 --- /dev/null +++ b/src/test/java/com/yunhai/acasyllabu/service/impl/TimetableServiceMyTest.java @@ -0,0 +1,306 @@ +package com.yunhai.acasyllabu.service.impl; + +import com.yunhai.acasyllabu.entity.Timetable; +import com.yunhai.acasyllabu.entity.TimetableSlot; +import com.yunhai.acasyllabu.model.bsch.ClassTreeNodeVo; +import com.yunhai.acasyllabu.model.bsch.NowYearTermVo; +import com.yunhai.acasyllabu.model.syllabus.MyClassVo; +import com.yunhai.acasyllabu.model.syllabus.MyTimetableVo; +import com.yunhai.acasyllabu.repository.TimetableRepository; +import com.yunhai.acasyllabu.repository.TimetableSlotRepository; +import com.yunhai.acasyllabu.repository.UpFileRepository; +import com.yunhai.acasyllabu.service.IClassService; +import com.yunhai.acasyllabu.service.IFileService; +import com.yunhai.acasyllabu.utils.UserContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 「我的课表」角色过滤单元测试(mock 班级服务/仓储,不起 Spring 容器)。 + * 覆盖:管理员/校领导=全部班级;班主任=所带班级全部科目;任教老师=任教班级任教科目;越权拒绝。 + */ +public class TimetableServiceMyTest { + + private static final int SCH_ID = 3; + private static final int TEA_ID = 10008; + + private TimetableService service; + private IClassService classService; + private TimetableRepository timetableRepository; + private TimetableSlotRepository slotRepository; + + @BeforeEach + public void setUp() { + service = new TimetableService(); + classService = mock(IClassService.class); + timetableRepository = mock(TimetableRepository.class); + slotRepository = mock(TimetableSlotRepository.class); + ReflectionTestUtils.setField(service, "classService", classService); + ReflectionTestUtils.setField(service, "timetableRepository", timetableRepository); + ReflectionTestUtils.setField(service, "slotRepository", slotRepository); + ReflectionTestUtils.setField(service, "upFileRepository", mock(UpFileRepository.class)); + ReflectionTestUtils.setField(service, "fileService", mock(IFileService.class)); + + NowYearTermVo yearTerm = new NowYearTermVo(); + yearTerm.setYear(2026); + yearTerm.setTerm(1); + when(classService.getNowYearTerm()).thenReturn(yearTerm); + } + + @AfterEach + public void tearDown() { + UserContext.clear(); + } + + // ============================ 数据造数 ============================ + + /** 班级列表项(对齐 getSchClassList oapi 字段) */ + private Map classItem(int classId, String name, String gradeName, Integer firstTeacherId) { + Map c = new HashMap<>(); + c.put("classId", classId); + c.put("name", name); + c.put("gradeName", gradeName); + if (firstTeacherId != null) { + c.put("firstTeacherId", firstTeacherId); + } + return c; + } + + /** 任教记录项(对齐 getTeacherTeachList oapi 字段) */ + private Map teachItem(int classId, String subName) { + Map t = new HashMap<>(); + t.put("classId", classId); + t.put("subName", subName); + return t; + } + + /** 学生树班级树(用于排序) */ + private List classTree(Object... classIds) { + ClassTreeNodeVo grade = new ClassTreeNodeVo(); + grade.setId(10); + grade.setName("高一"); + List children = new ArrayList<>(); + for (Object id : classIds) { + ClassTreeNodeVo cls = new ClassTreeNodeVo(); + cls.setId((Integer) id); + cls.setName(id + "班"); + children.add(cls); + } + grade.setChildren(children); + return Collections.singletonList(grade); + } + + /** 已保存课表:2 节次,每节 7 天,学科由 subjects 指定(7 位) */ + private void mockSavedTimetable(int classId, String[][] slots) { + Timetable entity = new Timetable(); + entity.setId(9); + entity.setSchId(SCH_ID); + entity.setClassId(classId); + entity.setClassName(classId + "班"); + entity.setGradeName("高一"); + when(timetableRepository.findBySchIdAndClassIdAndYearAndTerm(eq(SCH_ID), eq(classId), eq(2026), eq(1))) + .thenReturn(Optional.of(entity)); + + List rows = new ArrayList<>(); + int order = 1; + for (String[] s : slots) { + TimetableSlot row = new TimetableSlot(); + row.setTimetableId(9); + row.setSortOrder(order++); + row.setStartTime("08:00"); + row.setEndTime("08:45"); + row.setDay1(s[0]); + row.setDay2(s[1]); + row.setDay3(s[2]); + row.setDay4(s[3]); + row.setDay5(s[4]); + row.setDay6(s[5]); + row.setDay7(s[6]); + rows.add(row); + } + when(slotRepository.findByTimetableIdOrderBySortOrderAsc(9)).thenReturn(rows); + } + + private void loginAs(int roleId) { + UserContext.setSchId(SCH_ID); + UserContext.setUserId(TEA_ID); + UserContext.setRoleId(roleId); + } + + // ============================ myClasses ============================ + + @Test + public void myClasses_admin_seesAllClasses() { + loginAs(7); + when(classService.getSchClassList(SCH_ID)).thenReturn(Arrays.asList( + classItem(371, "2班", "高一", null), + classItem(12, "1班", "高一", TEA_ID))); + when(classService.getClassTree(SCH_ID)).thenReturn(classTree(12, 371)); + + List result = service.myClasses(); + + assertEquals(2, result.size()); + // 按学生树顺序排序:1班(12) 在前 + assertEquals(Integer.valueOf(12), result.get(0).getClassId()); + assertEquals(Integer.valueOf(371), result.get(1).getClassId()); + assertTrue(result.get(0).isViewAllSubjects()); + assertFalse(result.get(0).isHeadTeacher()); + assertTrue(result.get(1).isViewAllSubjects()); + } + + @Test + public void myClasses_schLeader_seesAllClasses() { + loginAs(2); + when(classService.getSchClassList(SCH_ID)).thenReturn(Collections.singletonList(classItem(12, "1班", "高一", null))); + when(classService.getClassTree(SCH_ID)).thenReturn(classTree(12)); + + List result = service.myClasses(); + + assertEquals(1, result.size()); + assertTrue(result.get(0).isViewAllSubjects()); + } + + @Test + public void myClasses_headTeacherAndTeach_union() { + loginAs(1); + // 班主任(12) + 任教 12/371 语文;373 无关系 + when(classService.getSchClassList(SCH_ID)).thenReturn(Arrays.asList( + classItem(12, "1班", "高一", TEA_ID), + classItem(371, "2班", "高一", 20001), + classItem(373, "3班", "高一", 20002))); + when(classService.getTeacherTeachList(SCH_ID, TEA_ID)).thenReturn(Arrays.asList( + teachItem(12, "语文"), teachItem(371, "语文"))); + when(classService.getClassTree(SCH_ID)).thenReturn(classTree(12, 371, 373)); + + List result = service.myClasses(); + + assertEquals(2, result.size()); + MyClassVo first = result.get(0); + assertEquals(Integer.valueOf(12), first.getClassId()); + assertTrue(first.isHeadTeacher()); + assertTrue(first.isViewAllSubjects()); + // 班主任身份下 subjects 可为任教科目,但不影响 viewAll + MyClassVo second = result.get(1); + assertEquals(Integer.valueOf(371), second.getClassId()); + assertFalse(second.isHeadTeacher()); + assertFalse(second.isViewAllSubjects()); + assertEquals(Collections.singletonList("语文"), second.getSubjects()); + } + + @Test + public void myClasses_teacherWithoutRelation_empty() { + loginAs(1); + when(classService.getSchClassList(SCH_ID)).thenReturn(Collections.singletonList(classItem(12, "1班", "高一", 20001))); + when(classService.getTeacherTeachList(SCH_ID, TEA_ID)).thenReturn(Collections.emptyList()); + + assertTrue(service.myClasses().isEmpty()); + } + + // ============================ myGet ============================ + + @Test + public void myGet_admin_fullView() { + loginAs(7); + mockSavedTimetable(12, new String[][]{{"语文", "数学", "", "英语", "", "", ""}}); + + MyTimetableVo vo = service.myGet(12); + + assertTrue(vo.isViewAllSubjects()); + assertTrue(vo.getMySubjects().isEmpty()); + assertEquals("语文", vo.getSlots().get(0).getSubjects().get(0)); + assertEquals("数学", vo.getSlots().get(0).getSubjects().get(1)); + } + + @Test + public void myGet_headTeacher_fullView() { + loginAs(1); + when(classService.getSchClassList(SCH_ID)).thenReturn(Collections.singletonList(classItem(12, "1班", "高一", TEA_ID))); + when(classService.getTeacherTeachList(SCH_ID, TEA_ID)).thenReturn(Collections.emptyList()); + mockSavedTimetable(12, new String[][]{{"语文", "数学", "", "英语", "", "", ""}}); + + MyTimetableVo vo = service.myGet(12); + + assertTrue(vo.isViewAllSubjects()); + assertEquals("数学", vo.getSlots().get(0).getSubjects().get(1)); + } + + @Test + public void myGet_teachOnly_onlyTeachSubjectsRemain() { + loginAs(1); + when(classService.getSchClassList(SCH_ID)).thenReturn(Collections.singletonList(classItem(371, "2班", "高一", 20001))); + when(classService.getTeacherTeachList(SCH_ID, TEA_ID)).thenReturn(Collections.singletonList(teachItem(371, "语文"))); + mockSavedTimetable(371, new String[][]{ + {"语文", "数学", "英语", "体育", "语文", "", ""}, + {"数学", "语文", "", "", "", "音乐", ""}}); + + MyTimetableVo vo = service.myGet(371); + + assertFalse(vo.isViewAllSubjects()); + assertEquals(Collections.singletonList("语文"), vo.getMySubjects()); + List row1 = vo.getSlots().get(0).getSubjects(); + assertEquals(Arrays.asList("语文", "", "", "", "语文", "", ""), row1); + List row2 = vo.getSlots().get(1).getSubjects(); + assertEquals(Arrays.asList("", "语文", "", "", "", "", ""), row2); + } + + @Test + public void myGet_unrelatedTeacher_forbidden() { + loginAs(1); + when(classService.getSchClassList(SCH_ID)).thenReturn(Collections.singletonList(classItem(373, "3班", "高一", 20002))); + when(classService.getTeacherTeachList(SCH_ID, TEA_ID)).thenReturn(Collections.emptyList()); + + try { + service.myGet(373); + fail("越权访问未拦截"); + } catch (Exception e) { + assertTrue(e.getClass().getSimpleName().contains("BizException"), + "应抛业务异常,实际: " + e.getClass().getName()); + } + } + + @Test + public void myGet_teacherWithoutUserId_forbidden() { + UserContext.setSchId(SCH_ID); + UserContext.setRoleId(1); + // 不设置 userId(模拟身份缺失) + + try { + service.myGet(12); + fail("缺 userId 未拦截"); + } catch (Exception e) { + assertTrue(e.getClass().getSimpleName().contains("BizException"), "实际: " + e.getClass().getName()); + } + } + + @Test + public void myClasses_roleMissing_treatedAsTeacher() { + UserContext.setSchId(SCH_ID); + UserContext.setUserId(TEA_ID); + // roleId 缺失:按普通教师处理 + when(classService.getSchClassList(SCH_ID)).thenReturn(Collections.singletonList(classItem(12, "1班", "高一", null))); + when(classService.getTeacherTeachList(SCH_ID, TEA_ID)).thenReturn(Collections.emptyList()); + + assertTrue(service.myClasses().isEmpty()); + } +} diff --git a/src/test/java/com/yunhai/acasyllabu/service/impl/TimetableServiceOapiTest.java b/src/test/java/com/yunhai/acasyllabu/service/impl/TimetableServiceOapiTest.java new file mode 100644 index 0000000..118a844 --- /dev/null +++ b/src/test/java/com/yunhai/acasyllabu/service/impl/TimetableServiceOapiTest.java @@ -0,0 +1,256 @@ +package com.yunhai.acasyllabu.service.impl; + +import com.yunhai.acasyllabu.entity.Timetable; +import com.yunhai.acasyllabu.entity.TimetableSlot; +import com.yunhai.acasyllabu.model.bsch.NowYearTermVo; +import com.yunhai.acasyllabu.model.syllabus.OapiTimetableClassVo; +import com.yunhai.acasyllabu.repository.TimetableRepository; +import com.yunhai.acasyllabu.repository.TimetableSlotRepository; +import com.yunhai.acasyllabu.repository.UpFileRepository; +import com.yunhai.acasyllabu.service.IClassService; +import com.yunhai.acasyllabu.service.IFileService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * oapi 课表查询(/oapi/timetable/Get)角色范围单元测试(mock 班级服务/仓储,不起 Spring 容器)。 + * 覆盖:管理员=全部班级全部科目;班主任=所带班级全部科目;任教老师=任教班级任教科目; + * 学生=本人所在班级;学生越权/参数缺失拒绝。 + */ +public class TimetableServiceOapiTest { + + private static final int SCH_ID = 3; + private static final int TEA_ID = 10008; + private static final int STU_ID = 30001; + private static final int STU_CLASS_ID = 371; + + private TimetableService service; + private IClassService classService; + private TimetableRepository timetableRepository; + private TimetableSlotRepository slotRepository; + + @BeforeEach + public void setUp() { + service = new TimetableService(); + classService = mock(IClassService.class); + timetableRepository = mock(TimetableRepository.class); + slotRepository = mock(TimetableSlotRepository.class); + ReflectionTestUtils.setField(service, "classService", classService); + ReflectionTestUtils.setField(service, "timetableRepository", timetableRepository); + ReflectionTestUtils.setField(service, "slotRepository", slotRepository); + ReflectionTestUtils.setField(service, "upFileRepository", mock(UpFileRepository.class)); + ReflectionTestUtils.setField(service, "fileService", mock(IFileService.class)); + + NowYearTermVo yearTerm = new NowYearTermVo(); + yearTerm.setYear(2026); + yearTerm.setTerm(1); + when(classService.getNowYearTerm()).thenReturn(yearTerm); + } + + // ============================ 数据造数 ============================ + + private Map classItem(int classId, String name, String gradeName, Integer firstTeacherId) { + Map c = new HashMap<>(); + c.put("classId", classId); + c.put("name", name); + c.put("gradeName", gradeName); + if (firstTeacherId != null) { + c.put("firstTeacherId", firstTeacherId); + } + return c; + } + + private Map teachItem(int classId, String subName) { + Map t = new HashMap<>(); + t.put("classId", classId); + t.put("subName", subName); + return t; + } + + private Map studentInfo(int studentId, int classId, String className) { + Map stu = new LinkedHashMap<>(); + stu.put("studentId", studentId); + stu.put("studentName", "学生" + studentId); + stu.put("classId", classId); + stu.put("className", className); + return stu; + } + + /** 已保存课表:1 节次 7 天,学科由 subjects 指定(每班独立课表ID,避免 stub 相互覆盖) */ + private void mockSavedTimetable(int classId, String[] subjects) { + int timetableId = 900 + classId; + Timetable entity = new Timetable(); + entity.setId(timetableId); + entity.setSchId(SCH_ID); + entity.setClassId(classId); + entity.setClassName(classId + "班"); + entity.setGradeName("高一"); + when(timetableRepository.findBySchIdAndClassIdAndYearAndTerm(SCH_ID, classId, 2026, 1)) + .thenReturn(Optional.of(entity)); + + TimetableSlot row = new TimetableSlot(); + row.setTimetableId(timetableId); + row.setSortOrder(1); + row.setStartTime("08:00"); + row.setEndTime("08:45"); + row.setDay1(subjects[0]); + row.setDay2(subjects[1]); + row.setDay3(subjects[2]); + row.setDay4(subjects[3]); + row.setDay5(subjects[4]); + row.setDay6(subjects[5]); + row.setDay7(subjects[6]); + when(slotRepository.findByTimetableIdOrderBySortOrderAsc(timetableId)) + .thenReturn(new ArrayList<>(Collections.singletonList(row))); + } + + // ============================ 管理员/校领导 ============================ + + @Test + public void oapiTimetable_admin_allClassesAllSubjects() { + when(classService.getSchClassList(SCH_ID)).thenReturn(Arrays.asList( + classItem(12, "1班", "高一", TEA_ID), + classItem(371, "2班", "高一", 20001))); + mockSavedTimetable(12, new String[]{"语文", "数学", "", "英语", "", "", ""}); + mockSavedTimetable(371, new String[]{"体育", "音乐", "", "", "", "", ""}); + + List result = service.oapiTimetable(TEA_ID, 7, SCH_ID); + + assertEquals(2, result.size()); + for (OapiTimetableClassVo item : result) { + assertTrue(item.isViewAllSubjects()); + assertFalse(item.isHeadTeacher()); + assertEquals(1, item.getTimetable().getSlots().size()); + } + assertEquals("数学", result.get(0).getTimetable().getSlots().get(0).getSubjects().get(1)); + assertEquals("音乐", result.get(1).getTimetable().getSlots().get(0).getSubjects().get(1)); + } + + @Test + public void oapiTimetable_schLeader_sameAsAdmin() { + when(classService.getSchClassList(SCH_ID)).thenReturn(Collections.singletonList(classItem(12, "1班", "高一", null))); + mockSavedTimetable(12, new String[]{"语文", "", "", "", "", "", ""}); + + List result = service.oapiTimetable(TEA_ID, 2, SCH_ID); + + assertEquals(1, result.size()); + assertTrue(result.get(0).isViewAllSubjects()); + } + + // ============================ 班主任 / 任教老师 ============================ + + @Test + public void oapiTimetable_teacher_headFullTeachFiltered() { + when(classService.getSchClassList(SCH_ID)).thenReturn(Arrays.asList( + classItem(12, "1班", "高一", TEA_ID), + classItem(371, "2班", "高一", 20001))); + when(classService.getTeacherTeachList(SCH_ID, TEA_ID)).thenReturn(Collections.singletonList(teachItem(371, "语文"))); + mockSavedTimetable(12, new String[]{"语文", "数学", "", "英语", "", "", ""}); + mockSavedTimetable(371, new String[]{"语文", "数学", "", "", "", "", ""}); + + List result = service.oapiTimetable(TEA_ID, 1, SCH_ID); + + assertEquals(2, result.size()); + // 班主任班级:全部科目 + OapiTimetableClassVo head = result.get(0); + assertEquals(Integer.valueOf(12), head.getClassId()); + assertTrue(head.isHeadTeacher()); + assertTrue(head.isViewAllSubjects()); + assertEquals("数学", head.getTimetable().getSlots().get(0).getSubjects().get(1)); + // 任教班级:仅任教科目格 + OapiTimetableClassVo teach = result.get(1); + assertEquals(Integer.valueOf(371), teach.getClassId()); + assertFalse(teach.isHeadTeacher()); + assertFalse(teach.isViewAllSubjects()); + assertEquals(Collections.singletonList("语文"), teach.getSubjects()); + assertEquals(Arrays.asList("语文", "", "", "", "", "", ""), + teach.getTimetable().getSlots().get(0).getSubjects()); + } + + @Test + public void oapiTimetable_teacherWithoutRelation_empty() { + when(classService.getSchClassList(SCH_ID)).thenReturn(Collections.singletonList(classItem(12, "1班", "高一", 20001))); + when(classService.getTeacherTeachList(SCH_ID, TEA_ID)).thenReturn(Collections.emptyList()); + + assertTrue(service.oapiTimetable(TEA_ID, 1, SCH_ID).isEmpty()); + } + + // ============================ 学生 ============================ + + @Test + public void oapiTimetable_student_ownClassAllSubjects() { + when(classService.getSchClassList(SCH_ID)).thenReturn(Arrays.asList( + classItem(12, "1班", "高一", TEA_ID), + classItem(STU_CLASS_ID, "2班", "高一", 20001))); + when(classService.resolveStudent(SCH_ID, STU_ID)) + .thenReturn(studentInfo(STU_ID, STU_CLASS_ID, "2班")); + mockSavedTimetable(STU_CLASS_ID, new String[]{"语文", "数学", "", "英语", "", "", ""}); + + List result = service.oapiTimetable(STU_ID, 3, SCH_ID); + + assertEquals(1, result.size()); + OapiTimetableClassVo item = result.get(0); + assertEquals(Integer.valueOf(STU_CLASS_ID), item.getClassId()); + assertTrue(item.isViewAllSubjects()); + assertFalse(item.isHeadTeacher()); + assertEquals("数学", item.getTimetable().getSlots().get(0).getSubjects().get(1)); + } + + @Test + public void oapiTimetable_studentNotInSchool_empty() { + when(classService.getSchClassList(SCH_ID)).thenReturn(Collections.emptyList()); + when(classService.resolveStudent(SCH_ID, STU_ID)).thenReturn(null); + + assertTrue(service.oapiTimetable(STU_ID, 3, SCH_ID).isEmpty()); + } + + @Test + public void myGet_studentOtherClass_forbidden() { + when(classService.getSchClassList(SCH_ID)).thenReturn(Collections.singletonList(classItem(12, "1班", "高一", 20001))); + when(classService.resolveStudent(SCH_ID, STU_ID)) + .thenReturn(studentInfo(STU_ID, STU_CLASS_ID, "2班")); + + try { + service.myGet(12, STU_ID, 3, SCH_ID); + fail("学生越权访问未拦截"); + } catch (Exception e) { + assertTrue(e.getClass().getSimpleName().contains("BizException"), + "应抛业务异常,实际: " + e.getClass().getName()); + } + } + + // ============================ 参数校验 ============================ + + @Test + public void oapiTimetable_missingParams_rejected() { + try { + service.oapiTimetable(null, 7, SCH_ID); + fail("缺 userid 未拦截"); + } catch (Exception e) { + assertTrue(e.getClass().getSimpleName().contains("BizException"), "实际: " + e.getClass().getName()); + } + try { + service.oapiTimetable(TEA_ID, 7, null); + fail("缺 schId 未拦截"); + } catch (Exception e) { + assertTrue(e.getClass().getSimpleName().contains("BizException"), "实际: " + e.getClass().getName()); + } + } +}