@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)。
|
||||
* <p>@Order(1) 先于审计日志切面(Order 3)执行,保证日志能取到 UserContext。
|
||||
* 取用户优先级:方法参数 BoaUser → BoaUserContext 静态持有者 → request attribute;
|
||||
* 角色切换信息从 currentrole(Header/Query,URL 编码 JSON)解析。</p>
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
@Order(1)
|
||||
public class ControllerUserContextAspect {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ControllerUserContextAspect.class);
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
/** 外部认证框架把解析好的 BoaUser 放进 request attribute 的候选 key */
|
||||
private static final List<String> BOA_USER_ATTR_KEYS = Arrays.asList(
|
||||
"boaUser", "boauser", "currentUser", "user", "authUser", "loginUser"
|
||||
);
|
||||
|
||||
@Around("execution(* com.yunhai.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());
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
* <p>全量拦截 controller 包所有方法,不依赖注解;排除日志查询控制器自身(防递归);
|
||||
* 用户信息来自 {@link ControllerUserContextAspect}(Order 1)填充的 UserContext;
|
||||
* 响应体 GZIP 压缩落库,落库走 @Async 不阻塞请求。</p>
|
||||
*/
|
||||
@Aspect
|
||||
@Component
|
||||
@Order(3)
|
||||
public class SysOperationLogAspect {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SysOperationLogAspect.class);
|
||||
|
||||
/** 排除的控制器(日志查询自身,防递归刷屏) */
|
||||
private static final List<String> EXCLUDE_CONTROLLERS = java.util.Arrays.asList(
|
||||
"com.yunhai.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<Object> filtered = new ArrayList<>();
|
||||
for (Object arg : args) {
|
||||
if (arg == null) {
|
||||
continue;
|
||||
}
|
||||
if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse
|
||||
|| arg instanceof HttpSession || arg instanceof MultipartFile
|
||||
|| arg instanceof MultipartFile[]) {
|
||||
continue;
|
||||
}
|
||||
// BoaUser 等平台对象跳过序列化(无 toString 意义且可能循环引用)
|
||||
if (arg.getClass().getName().startsWith("com.yunhai.platform.")) {
|
||||
continue;
|
||||
}
|
||||
filtered.add(arg);
|
||||
}
|
||||
return filtered.isEmpty() ? "" : JSON.toJSONString(filtered);
|
||||
} catch (Exception ex) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private void fillResponse(SysOperationLog e, Object result, int max) {
|
||||
if (result == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String resp = JSON.toJSONString(result);
|
||||
if (resp.length() > max) {
|
||||
resp = resp.substring(0, max) + "...[截断]";
|
||||
}
|
||||
e.setResponseData(GzipUtil.compress(resp));
|
||||
} catch (Exception ex) {
|
||||
log.warn("审计日志响应序列化失败, uri={}", e.getRequestUri());
|
||||
}
|
||||
}
|
||||
|
||||
/** 成功路径:Controller 返回 ApiResponse/ApiResult 时取其 code,其余视为 SUCCESS(0) */
|
||||
private int resolveSuccessCode(Object result) {
|
||||
if (result instanceof ApiResponse) {
|
||||
return ((ApiResponse<?>) result).getCode();
|
||||
}
|
||||
if (result instanceof ApiResult) {
|
||||
return ((ApiResult<?>) result).getCode();
|
||||
}
|
||||
return ErrorCode.SUCCESS.getCode();
|
||||
}
|
||||
|
||||
/** 异常路径:从业务异常提取错误码,兜底 INTERNAL_SERVER_ERROR */
|
||||
private int resolveErrorCode(Throwable e) {
|
||||
if (e instanceof DException) {
|
||||
IErrorCode ec = ((DException) e).getCode();
|
||||
if (ec != null) {
|
||||
return ec.getCode();
|
||||
}
|
||||
}
|
||||
return ErrorCode.INTERNAL_SERVER_ERROR.getCode();
|
||||
}
|
||||
|
||||
private HttpServletRequest getRequest() {
|
||||
ServletRequestAttributes a = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
return a != null ? a.getRequest() : null;
|
||||
}
|
||||
|
||||
private String buildErrorMsg(Throwable e) {
|
||||
String msg = e.getMessage();
|
||||
String head = (msg == null || msg.trim().isEmpty()) ? e.getClass().getName() : msg;
|
||||
return truncate(head + System.lineSeparator() + ExceptionUtils.getStackTrace(e), MAX_ERROR_MSG);
|
||||
}
|
||||
|
||||
private String truncate(String s, int max) {
|
||||
return s != null && s.length() > max ? s.substring(0, max) : s;
|
||||
}
|
||||
|
||||
/** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */
|
||||
private int resolveLevel(String httpMethod) {
|
||||
if ("DELETE".equalsIgnoreCase(httpMethod)) {
|
||||
return 1;
|
||||
}
|
||||
if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod)) {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
private String resolveOperationType(String httpMethod, String methodName) {
|
||||
String lower = methodName == null ? "" : methodName.toLowerCase();
|
||||
if ("DELETE".equalsIgnoreCase(httpMethod) || lower.startsWith("delete") || lower.startsWith("del")) {
|
||||
return "DELETE";
|
||||
}
|
||||
if (lower.startsWith("create") || lower.startsWith("add")) {
|
||||
return "CREATE";
|
||||
}
|
||||
if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod)
|
||||
|| lower.startsWith("edit") || lower.startsWith("update") || lower.startsWith("set")) {
|
||||
return "UPDATE";
|
||||
}
|
||||
return "QUERY";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.yunhai.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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.yunhai.acasyllabu.common.validation;
|
||||
|
||||
/**
|
||||
* JSR-303 分组校验标记接口(对齐 UserBase 规范)。
|
||||
* Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。
|
||||
*/
|
||||
public interface ValidationGroups {
|
||||
interface Create {}
|
||||
interface Update {}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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/)。
|
||||
* <p>承载:GetUserInfo / GetUserMod / GetSchoolAll / GetClassTree / SetRole / FileUpload / Logout。
|
||||
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/base/")
|
||||
public class BaseApiController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BaseApiController.class);
|
||||
|
||||
/** 家长角色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<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
|
||||
Integer schId = schIdOf(authUserWithDept);
|
||||
return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId);
|
||||
}
|
||||
|
||||
// ============================ GetSchoolAll ============================
|
||||
|
||||
/**
|
||||
* 获取当前用户学校组及教师(树形结构,选人数据源)。
|
||||
*/
|
||||
@GetMapping("GetSchoolAll")
|
||||
public List<Map<String, Object>> getSchoolAll(BoaUser authUserWithDept) {
|
||||
return userCenterService.getSchoolAllTree(schIdOf(authUserWithDept));
|
||||
}
|
||||
|
||||
// ============================ GetClassTree ============================
|
||||
|
||||
/**
|
||||
* 年级→班级树(叶子=班级,附学生数)。数据来自班级服务学生树(5 分钟缓存)。
|
||||
*/
|
||||
@GetMapping("GetClassTree")
|
||||
public List<ClassTreeNodeVo> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<UpFileVo> 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<UpFileVo> list(@RequestParam("topicId") Integer topicId) {
|
||||
return fileService.listByTopic(TOPIC_TYPE, topicId);
|
||||
}
|
||||
|
||||
/** 批量绑定附件到主题 */
|
||||
@PostMapping("Bind")
|
||||
public void bind(@RequestParam("fileIds") List<Integer> fileIds,
|
||||
@RequestParam("topicId") Integer topicId) {
|
||||
fileService.bindToTopic(fileIds, topicId);
|
||||
}
|
||||
}
|
||||
@@ -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/)。
|
||||
* <p>本控制器被 SysOperationLogAspect 排除,避免日志查询自身再产生日志(防递归)。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/log/")
|
||||
public class LogController {
|
||||
|
||||
@Autowired
|
||||
private ISysOperationLogService logService;
|
||||
|
||||
/** 分页查询审计日志(responseData 不返回) */
|
||||
@GetMapping("Page")
|
||||
public BasePageResult<SysOperationLog> page(BasePageQuery query,
|
||||
@RequestParam(value = "operationType", required = false) String operationType,
|
||||
@RequestParam(value = "statusCode", required = false) Integer statusCode) {
|
||||
return logService.page(query, operationType, statusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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/)。
|
||||
* <p>时间设置(时段判定边界 + 节次/课间时长)与节次模板(绑定学期×多年级,行含节次与课间/午休空白行)
|
||||
* 统一在此维护;课表编辑页经 ForGrade 取当前学期该年级生效模板行作为节次骨架。</p>
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -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/)。
|
||||
* <p>电子版:Get / Save;照片版:SavePhoto / RemovePhoto(照片先经 /file/Upload 上传拿 fileId);
|
||||
* 学科下拉数据源:Subjects(本校学科列表);当前学年学期:NowYearTerm(课表按当前学年学期读写)。
|
||||
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
|
||||
*/
|
||||
@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<SubjectVo> subjects() {
|
||||
Integer schId = UserContext.getSchId();
|
||||
return classService.getSubjectList(schId == null ? 0 : schId);
|
||||
}
|
||||
|
||||
/** 当前学年学期(课表按学年学期维护,页面展示用;班级服务不可达时抛业务异常) */
|
||||
@GetMapping("NowYearTerm")
|
||||
public NowYearTermVo nowYearTerm() {
|
||||
return classService.getNowYearTerm();
|
||||
}
|
||||
|
||||
// ============================ 我的课表 ============================
|
||||
|
||||
/**
|
||||
* 「我的课表」可查看班级(管理员/校领导=全校班级全部科目;班主任=所带班级全部科目;
|
||||
* 任教老师=任教班级任教科目)。班级排序按学生树年级→班级顺序。
|
||||
*/
|
||||
@GetMapping("MyClasses")
|
||||
public List<MyClassVo> myClasses() {
|
||||
return timetableService.myClasses();
|
||||
}
|
||||
|
||||
/** 「我的课表」班级课表(先校验访问权限;非班主任的任教老师只返回任教科目格) */
|
||||
@GetMapping("MyGet")
|
||||
public MyTimetableVo myGet(@RequestParam("classId") Integer classId) {
|
||||
return timetableService.myGet(classId);
|
||||
}
|
||||
}
|
||||
@@ -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/**)。
|
||||
* <p>强制验签由 yh-app-auth starter 提供(yml {@code yh.app-auth.server.enforce-patterns: [/oapi/*]}):
|
||||
* 浏览器/匿名直访 401,注册应用带 X-App-Key/X-Timestamp/X-Signature 三件套直调通过
|
||||
* (本地无 Redis 走 yml trusted-apps 白名单,接入新调用方时追加其 appKey/密钥)。</p>
|
||||
* <p>身份由调用方显式传入(userid/roleId/schId),不依赖 CAS 登录态;角色范围:
|
||||
* 管理员(7)/校领导(2)=全部班级全部科目;班主任=所带班级全部科目;
|
||||
* 任教老师=任教班级任教科目;学生(3)=本人所在班级。roleId 缺省按普通教师处理。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/oapi/timetable")
|
||||
public class OapiTimetableController {
|
||||
|
||||
@Autowired
|
||||
private ITimetableService timetableService;
|
||||
|
||||
/**
|
||||
* 按身份查询可见班级课表(当前学年学期)。
|
||||
* 返回数组:每班一项(班级信息 + 科目可见范围 + 该班课表节次;任教老师只含任教科目格)。
|
||||
*/
|
||||
@GetMapping("/Get")
|
||||
public List<OapiTimetableClassVo> get(@RequestParam("userid") Integer userId,
|
||||
@RequestParam(value = "roleId", required = false) Integer roleId,
|
||||
@RequestParam("schId") Integer schId) {
|
||||
return timetableService.oapiTimetable(userId, roleId, schId);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 个年级,如 春×上学期×[高一,高二])。
|
||||
* <p>同一学校同学期、年级有交集的模板同时只允许启用一套(在 Service 内保证);
|
||||
* 课表编辑页按班级年级+当前学期取启用模板的行作为节次与时间骨架。
|
||||
* 启用时段(早自习/上午/中午/下午/晚自习)与节次/课间时长随模板保存(绑定模板ID),
|
||||
* 停用的时段不在模板行列表中维护,课表编辑页也不再显示该时段的节次。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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=空白行如课间/午休)。
|
||||
* <p>空白行可自定义名称与备注、可设起止时间,但不占用节次序号(节次序号只按 period 行顺序数)、
|
||||
* 不能排课程;行顺序即模板行顺序,上下移调整 sort_order。</p>
|
||||
* <p>所属时段 band 直接落在行上(es/am/noon/pm/ev,与模板启用时段位串下标一致):
|
||||
* 节次与空白行都落在时段里,按 band 归组展示与排课,不再按开始时间落入哪一区间反推。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 时段判定时间设置(每校一行)。
|
||||
* <p>课表编辑页/预览按节次「开始时间」落入哪一区间判定时段(早自习/上午/中午/下午/晚自习):
|
||||
* 早自习 = 早于 morningSelfEnd;上午 = [morningSelfEnd, forenoonEnd);
|
||||
* 中午 = [forenoonEnd, afternoonStart)(午休);下午 = [afternoonStart, eveningStart);
|
||||
* 晚自习 = ≥ eveningStart。四个边界须依次递增;enabledBands 控制学校实际启用的时段,
|
||||
* 未启用的时段不参与判定(其时间范围内的节次归入其后的首个启用时段)。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 课程表实体(每班每学年学期一条)。
|
||||
* <p>维护人只存教师/平台用户 id(teacher_id),不存账号;
|
||||
* 班级名称、年级名称冗余存储,避免列表页频繁调班级服务;
|
||||
* 学年学期取自班级服务当前学年学期,历史学年学期数据保留、查询只取当前。</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.yunhai.acasyllabu.model;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 统一分页响应外壳。
|
||||
*/
|
||||
@Data
|
||||
public class BasePageResult<T> {
|
||||
/** 总记录数 */
|
||||
private int total;
|
||||
/** 当前页数据 */
|
||||
private List<T> content;
|
||||
/** 当前页码(从 1 起) */
|
||||
private int pageIndex;
|
||||
/** 每页条数 */
|
||||
private int pageSize;
|
||||
/** 总页数 */
|
||||
private int totalPages;
|
||||
/** 是否还有下一页 */
|
||||
private boolean hasNext;
|
||||
|
||||
public BasePageResult(List<T> content, int total, int pageIndex, int pageSize, int totalPages, boolean hasNext) {
|
||||
this.content = content;
|
||||
this.total = total;
|
||||
this.pageIndex = pageIndex;
|
||||
this.pageSize = pageSize;
|
||||
this.totalPages = totalPages;
|
||||
this.hasNext = hasNext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.yunhai.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<Integer> groupIds;
|
||||
|
||||
/** 教师所属组ID(接口现行字段 GroupID,单个) */
|
||||
@JsonProperty("GroupID")
|
||||
private Integer groupId;
|
||||
|
||||
public Integer getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
public void setGroupId(Integer groupId) {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public String getSchSerID() {
|
||||
return schSerID;
|
||||
}
|
||||
|
||||
public void setSchSerID(String schSerID) {
|
||||
this.schSerID = schSerID;
|
||||
}
|
||||
|
||||
public String getTeaTel() {
|
||||
return teaTel;
|
||||
}
|
||||
|
||||
public void setTeaTel(String teaTel) {
|
||||
this.teaTel = teaTel;
|
||||
}
|
||||
|
||||
public String getTeaName() {
|
||||
return teaName;
|
||||
}
|
||||
|
||||
public void setTeaName(String teaName) {
|
||||
this.teaName = teaName;
|
||||
}
|
||||
|
||||
public Integer getUserTeaSerID() {
|
||||
return userTeaSerID;
|
||||
}
|
||||
|
||||
public void setUserTeaSerID(Integer userTeaSerID) {
|
||||
this.userTeaSerID = userTeaSerID;
|
||||
}
|
||||
|
||||
public String getTeaIDCard() {
|
||||
return teaIDCard;
|
||||
}
|
||||
|
||||
public void setTeaIDCard(String teaIDCard) {
|
||||
this.teaIDCard = teaIDCard;
|
||||
}
|
||||
|
||||
public String getTeaNo() {
|
||||
return teaNo;
|
||||
}
|
||||
|
||||
public void setTeaNo(String teaNo) {
|
||||
this.teaNo = teaNo;
|
||||
}
|
||||
|
||||
public List<Integer> getGroupIds() {
|
||||
return groupIds;
|
||||
}
|
||||
|
||||
public void setGroupIds(List<Integer> groupIds) {
|
||||
this.groupIds = groupIds;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yunhai.acasyllabu.model.base;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 角色切换入参(对齐前端 baseApi.setRole)。
|
||||
*/
|
||||
@Data
|
||||
public class SetRoleRequest {
|
||||
/** "teacher" 或 "parent" */
|
||||
private String as;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ClassTreeNodeVo> children;
|
||||
|
||||
public void setParent(boolean parent) {
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
public boolean isParent() {
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<TemplateRowVo> rows = new ArrayList<>();
|
||||
|
||||
/** 生效模板教学日 7 位 0/1 串(周一~周日);无启用模板时 7 天全开 */
|
||||
private String teachDays = "1111111";
|
||||
|
||||
/** 生效模板名称(如 春),无启用模板时为空 */
|
||||
private String templateName;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yunhai.acasyllabu.model.syllabus;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 「我的课表」可查看班级(按当前用户角色过滤后的班级 + 科目可见范围)。
|
||||
* <p>范围规则:管理员/校领导=全校班级全部科目;班主任=所带班级全部科目;任教老师=任教班级任教科目。</p>
|
||||
*/
|
||||
@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<String> subjects = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yunhai.acasyllabu.model.syllabus;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 「我的课表」视图:班级课表 + 当前用户在该班的科目可见范围。
|
||||
* <p>任教老师(非班主任)只保留任教科目格,其余格清空返回。</p>
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class MyTimetableVo extends TimetableVo {
|
||||
|
||||
/** 是否展示全部科目 */
|
||||
private boolean viewAllSubjects = true;
|
||||
|
||||
/** 本人在该班的任教科目(viewAllSubjects=false 时非空) */
|
||||
private List<String> mySubjects = new ArrayList<>();
|
||||
}
|
||||
@@ -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):可见班级 + 该班课表。
|
||||
* <p>范围规则:管理员/校领导=全部班级全部科目;班主任=所带班级全部科目;
|
||||
* 任教老师=任教班级任教科目(timetable 已剔除非任教科目格);学生=本人所在班级全部科目。</p>
|
||||
*/
|
||||
@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<String> subjects = new ArrayList<>();
|
||||
|
||||
/** 该班当前学年学期课表(电子版节次 + 照片版附件信息;任教老师只含任教科目格) */
|
||||
private TimetableVo timetable;
|
||||
}
|
||||
@@ -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<String> 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<TemplateRowVo> rows = new ArrayList<>();
|
||||
}
|
||||
@@ -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<PeriodTemplateVo> templates = new ArrayList<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String> 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<TemplateRowVo> rows = new ArrayList<>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<TimetableSlotVo> slots = new ArrayList<>();
|
||||
}
|
||||
@@ -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<String> subjects = new ArrayList<>();
|
||||
}
|
||||
@@ -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<TimetableSlotVo> slots = new ArrayList<>();
|
||||
}
|
||||
@@ -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<PeriodTemplate, Integer> {
|
||||
|
||||
List<PeriodTemplate> findBySchIdOrderByIdAsc(Integer schId);
|
||||
|
||||
/** 同学期内名称唯一 */
|
||||
Optional<PeriodTemplate> findBySchIdAndTermAndName(Integer schId, Integer term, String name);
|
||||
|
||||
/** 同学期全部启用模板(年级交集判断在 Service 内存中做) */
|
||||
List<PeriodTemplate> findBySchIdAndTermAndEnabled(Integer schId, Integer term, Boolean enabled);
|
||||
|
||||
/** 全部启用模板(含全年/各学期,学期与年级交集判断在 Service 内存中做) */
|
||||
List<PeriodTemplate> findBySchIdAndEnabledTrueOrderByIdAsc(Integer schId);
|
||||
}
|
||||
@@ -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<SysOperationLog, Long>, JpaSpecificationExecutor<SysOperationLog> {
|
||||
}
|
||||
@@ -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<TemplateRow, Integer> {
|
||||
|
||||
List<TemplateRow> findByTemplateIdOrderBySortOrderAsc(Integer templateId);
|
||||
|
||||
@Modifying
|
||||
@Query("delete from TemplateRow r where r.templateId = :templateId")
|
||||
void deleteByTemplateId(@Param("templateId") Integer templateId);
|
||||
}
|
||||
@@ -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<TimeSetting, Integer> {
|
||||
|
||||
Optional<TimeSetting> findBySchId(Integer schId);
|
||||
}
|
||||
@@ -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<Timetable, Integer> {
|
||||
|
||||
Optional<Timetable> findBySchIdAndClassIdAndYearAndTerm(Integer schId, Integer classId, Integer year, Integer term);
|
||||
|
||||
List<Timetable> findBySchIdOrderByUpdateTimeDesc(Integer schId);
|
||||
}
|
||||
@@ -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<TimetableSlot, Integer> {
|
||||
|
||||
List<TimetableSlot> findByTimetableIdOrderBySortOrderAsc(Integer timetableId);
|
||||
|
||||
void deleteByTimetableId(Integer timetableId);
|
||||
}
|
||||
@@ -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<UpFile, Integer> {
|
||||
|
||||
List<UpFile> findByTopicTypeAndTopicId(String topicType, Integer topicId);
|
||||
|
||||
List<UpFile> findByIdIn(List<Integer> ids);
|
||||
}
|
||||
@@ -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}。
|
||||
* <ul>
|
||||
* <li>教师:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}}</li>
|
||||
* <li>学生树:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?schId={schId}&toUser=true}
|
||||
* (学校→学段→年级→班级→学生)</li>
|
||||
* <li>学科:GET {@code {bschclass}/oapi/sch/getSchSubjectList?schId={schId}}</li>
|
||||
* <li>当前学年学期:GET {@code {bschclass}/oapi/sch/getNowYearTerm}</li>
|
||||
* </ul>
|
||||
*/
|
||||
public interface IClassService {
|
||||
|
||||
/**
|
||||
* 获取当前学年学期。
|
||||
* 返回当前学年 year、当前学期 term(1第一学期 2第二学期)及学年/学期起止日期。
|
||||
*/
|
||||
NowYearTermVo getNowYearTerm();
|
||||
|
||||
/**
|
||||
* 获取指定学校全部教师(含所属组 GroupIds)。
|
||||
*/
|
||||
List<SchTeaList> getSchTeaList(int schId);
|
||||
|
||||
/**
|
||||
* 获取学校学科列表(带 5 分钟内存缓存)。
|
||||
* 外部接口不可达时返回空列表(调用方降级,不阻断课程表编辑)。
|
||||
*/
|
||||
List<SubjectVo> getSubjectList(int schId);
|
||||
|
||||
/**
|
||||
* 获取学校学生树原始节点(带 5 分钟内存缓存)。
|
||||
* 节点字段:id/name/pid/type(1组 2学生)/isParent/children。
|
||||
* 外部接口不可达时返回 null(调用方降级处理,不阻断业务)。
|
||||
*/
|
||||
List<Map<String, Object>> getStuTree(int schId);
|
||||
|
||||
/**
|
||||
* 教师任教列表(bschclass GET /oapi/sch/getTeacherTeachList,带 5 分钟内存缓存)。
|
||||
* 项字段:id/schId/classId(班级组ID)/className/gradeId/gradeName/teaId/teaName/subId/subName。
|
||||
* 外部接口不可达时返回空列表(调用方降级,不阻断业务)。
|
||||
*/
|
||||
List<Map<String, Object>> getTeacherTeachList(int schId, int teaId);
|
||||
|
||||
/**
|
||||
* 学校班级列表(bschclass GET /oapi/sch/getSchClassList,不含毕业班,带 5 分钟内存缓存)。
|
||||
* 项字段:id/classId(班级组ID)/name/gradeId/gradeName/firstTeacherId/firstTeacherName/
|
||||
* secondTeacherId/secondTeacherName/graduate 等。
|
||||
* 外部接口不可达时返回空列表(调用方降级,不阻断业务)。
|
||||
*/
|
||||
List<Map<String, Object>> getSchClassList(int schId);
|
||||
|
||||
/**
|
||||
* 年级→班级树(叶子=班级,附 studentCount),由学生树派生。
|
||||
* 外部接口不可达时返回空列表。
|
||||
*/
|
||||
List<ClassTreeNodeVo> 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<String, Object> resolveStudent(int schId, Integer studentId);
|
||||
|
||||
/**
|
||||
* 班级→学生名单(roster):{studentId, studentName, classId, className} 列表。
|
||||
* classIds 为空集合时返回空列表。
|
||||
*/
|
||||
List<Map<String, Object>> studentsOfClasses(int schId, List<Integer> classIds);
|
||||
}
|
||||
@@ -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<Integer> fileIds, Integer topicId);
|
||||
|
||||
/**
|
||||
* 查询某主题下的附件列表。
|
||||
*
|
||||
* @param appType 应用类型
|
||||
* @param topicId 主题ID
|
||||
* @return 附件列表
|
||||
*/
|
||||
List<UpFileVo> listByTopic(String appType, Integer topicId);
|
||||
|
||||
/**
|
||||
* 下载文件。
|
||||
*
|
||||
* @param fileId 文件记录ID
|
||||
* @param response HTTP 响应
|
||||
*/
|
||||
void download(Integer fileId, HttpServletResponse response);
|
||||
|
||||
/**
|
||||
* 在线预览文件(Content-Disposition: inline,按扩展名输出真实 Content-Type,
|
||||
* 供前端 open-file-viewer 取 Blob / video 播放)。
|
||||
*
|
||||
* @param fileId 文件记录ID
|
||||
* @param response HTTP 响应
|
||||
*/
|
||||
void preview(Integer fileId, HttpServletResponse response);
|
||||
|
||||
/**
|
||||
* 删除文件(同时删除物理文件和数据库记录)。
|
||||
*
|
||||
* @param fileId 文件记录ID
|
||||
*/
|
||||
void delete(Integer fileId);
|
||||
|
||||
/**
|
||||
* 打包下载多个附件为 ZIP。
|
||||
*
|
||||
* @param files 附件记录列表(调用方按 fileIds 查好)
|
||||
* @param zipName 压缩包显示名(自动追加 .zip)
|
||||
* @param response HTTP 响应
|
||||
*/
|
||||
void downloadZip(List<UpFile> files, String zipName, HttpServletResponse response);
|
||||
|
||||
/**
|
||||
* 按主题类型和主题ID打包下载附件为 ZIP(Controller 便捷方法,内部自行查附件列表)。
|
||||
*
|
||||
* @param topicType 主题类型(如 "activity")
|
||||
* @param topicId 主题ID
|
||||
* @param zipName 压缩包显示名
|
||||
* @param response HTTP 响应
|
||||
*/
|
||||
void downloadZipByTopic(String topicType, Integer topicId, String zipName, HttpServletResponse response);
|
||||
|
||||
/**
|
||||
* 批量上传图片(循环上传,返回每个文件的视图对象列表)。
|
||||
*/
|
||||
List<UpFileVo> uploadImages(MultipartFile[] files, String appType);
|
||||
|
||||
/**
|
||||
* 上传单个文件并返回前端兼容的结果 VO(含 fileid/name/fileurl/host/type/size)。
|
||||
*/
|
||||
UpFileResultVo uploadToResult(MultipartFile file, String appType);
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
@@ -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<SysOperationLog> page(BasePageQuery query, String operationType, Integer statusCode);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 课程表业务接口(电子版节次 + 照片版附件)。
|
||||
* <p>租户隔离:所有操作按 {@code UserContext.getSchId()} 过滤;
|
||||
* 维护人只记 {@code UserContext.getUserId()}(教师 id),不存账号。</p>
|
||||
* <p>学年学期:所有读写定位到班级服务当前学年学期(每班每学年学期一条课表),
|
||||
* 历史学年学期数据保留但查询不再返回。</p>
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
* 「我的课表」可查看班级列表(按当前用户角色过滤)。
|
||||
* <ul>
|
||||
* <li>管理员(7)/校领导(2):全校班级,全部科目;</li>
|
||||
* <li>班主任:所带班级(正/副班主任),全部科目;</li>
|
||||
* <li>任教老师:任教班级,仅任教科目;</li>
|
||||
* <li>学生(3):本人所在班级,全部科目。</li>
|
||||
* </ul>
|
||||
* 排序按学生树年级→班级顺序;任教/班级接口不可达时降级返回可得部分。
|
||||
*/
|
||||
List<MyClassVo> myClasses();
|
||||
|
||||
/**
|
||||
* 「我的课表」可查看班级列表(oapi 显式身份版本,身份由调用方传入,不取 UserContext)。
|
||||
* 角色范围同 {@link #myClasses()};roleId 缺省按普通教师处理。
|
||||
*/
|
||||
List<MyClassVo> 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):按传入身份返回其可见班级及各班课表。
|
||||
* <ul>
|
||||
* <li>管理员(7)/校领导(2):全部班级全部科目;</li>
|
||||
* <li>班主任:所带班级全部科目;</li>
|
||||
* <li>任教老师:任教班级任教科目(非任教科目格清空);</li>
|
||||
* <li>学生(3):本人所在班级全部科目。</li>
|
||||
* </ul>
|
||||
*/
|
||||
List<OapiTimetableClassVo> oapiTimetable(Integer userId, Integer roleId, Integer schId);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.yunhai.acasyllabu.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户中心门面服务(对齐 UserBase {@code UserCenterService} 模式)。
|
||||
* <p>封装 {@code YschBaseOpenApi} / {@code OpenApiUserClient} 静态门面调用,
|
||||
* 把原本散落在 Controller 的组树组装、教师挂组、成员解析等业务逻辑下沉到 Service 层,
|
||||
* Controller 只保留"取参 → 调 Service → 返回"的薄壳。</p>
|
||||
*/
|
||||
public interface IUserCenterService {
|
||||
|
||||
/**
|
||||
* 获取当前用户学校组及用户(树形结构)。
|
||||
* <p>组装规则(从原 BaseApiController.getSchoolAll 下沉):</p>
|
||||
* <ol>
|
||||
* <li>userbase 读取当前学校整棵组树(getGroupById(schId, withSub=true)),剔除学生组(type=8)及其子组;</li>
|
||||
* <li>bschclass 获取该校全部教师(/oapi/DbClass/GetSchteaList?Group=schId),按 GroupIds 挂到对应组下,
|
||||
* 无有效组(如所属组全被剔除)的教师兜底挂学校根节点;</li>
|
||||
* <li>扁平节点按 pid 递归组装为树(空组剔除,同 C# FormatUserTree)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param schId 学校ID(用户当前角色所属学校)
|
||||
* @return 用户树形结构(组节点 type=1 / 教师节点 type=2)
|
||||
*/
|
||||
List<Map<String, Object>> getSchoolAllTree(Integer schId);
|
||||
|
||||
/**
|
||||
* 获取指定组内用户(教师 + 管理员)。
|
||||
* <p>对应前端 base/GetGroupUser。原实现为空桩返回空列表,现按 C# 语义
|
||||
* (ITeainfoBLL.GetGroupTeainfo + IAdminfoBLL.GetGroupAdminfo)接入用户中心。
|
||||
* 若用户中心不可用或未配置对应开放接口,则抛出带语义的异常(显式报错而非静默失能)。</p>
|
||||
*
|
||||
* @param gid 组ID
|
||||
* @param role 角色(默认 2=教师;1=管理员等)
|
||||
* @return 组内用户列表
|
||||
*/
|
||||
List<Map<String, Object>> getGroupUser(Integer gid, Integer role);
|
||||
|
||||
/**
|
||||
* 获取当前用户可访问的模块树(左侧菜单)。
|
||||
* <p>从基础平台拉取当前用户模块列表(含学校上下文),组装为前端 layout 期望的树结构
|
||||
* ({@code modId/page/children}),对应前端 GetUserMod 接口。</p>
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param schId 学校ID(可为 null)
|
||||
* @return 模块树(顶层 List,含 children 嵌套)
|
||||
*/
|
||||
List<Map<String, Object>> getUserModuleTree(Integer userId, Integer schId);
|
||||
}
|
||||
@@ -0,0 +1,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<Integer, CacheEntry> stuTreeCache = new ConcurrentHashMap<>();
|
||||
|
||||
/** 学科列表缓存:schId -> {time, subjects} */
|
||||
private final ConcurrentHashMap<Integer, SubjectCacheEntry> subjectCache = new ConcurrentHashMap<>();
|
||||
|
||||
/** 任教列表缓存:schId:teaId -> {time, list} */
|
||||
private final ConcurrentHashMap<String, MapCacheEntry> teachCache = new ConcurrentHashMap<>();
|
||||
|
||||
/** 班级列表缓存:schId -> {time, list} */
|
||||
private final ConcurrentHashMap<Integer, MapCacheEntry> classListCache = new ConcurrentHashMap<>();
|
||||
|
||||
private static class CacheEntry {
|
||||
final long time;
|
||||
final List<Map<String, Object>> tree;
|
||||
|
||||
CacheEntry(long time, List<Map<String, Object>> tree) {
|
||||
this.time = time;
|
||||
this.tree = tree;
|
||||
}
|
||||
}
|
||||
|
||||
private static class SubjectCacheEntry {
|
||||
final long time;
|
||||
final List<SubjectVo> subjects;
|
||||
|
||||
SubjectCacheEntry(long time, List<SubjectVo> subjects) {
|
||||
this.time = time;
|
||||
this.subjects = subjects;
|
||||
}
|
||||
}
|
||||
|
||||
/** 通用 Map 列表缓存条目(任教列表 / 班级列表) */
|
||||
private static class MapCacheEntry {
|
||||
final long time;
|
||||
final List<Map<String, Object>> list;
|
||||
|
||||
MapCacheEntry(long time, List<Map<String, Object>> list) {
|
||||
this.time = time;
|
||||
this.list = list;
|
||||
}
|
||||
}
|
||||
|
||||
/** 学生树一次性索引:年级/班级/学生平铺 */
|
||||
private static class StuIndex {
|
||||
/** classId -> 年级名 */
|
||||
final Map<Integer, String> gradeNames = new LinkedHashMap<>();
|
||||
/** classId -> 年级ID */
|
||||
final Map<Integer, Integer> gradeIds = new LinkedHashMap<>();
|
||||
/** classId -> 班级名 */
|
||||
final Map<Integer, String> classNames = new LinkedHashMap<>();
|
||||
/** classId -> 班级节点(挂学生) */
|
||||
final Map<Integer, Map<String, Object>> classNodes = new LinkedHashMap<>();
|
||||
/** studentId -> {studentId, studentName, classId, className} */
|
||||
final Map<Integer, Map<String, Object>> students = new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
public ClassService() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(5000);
|
||||
factory.setReadTimeout(10000);
|
||||
this.restTemplate = new RestTemplate(factory);
|
||||
}
|
||||
|
||||
// ============================ 教师 ============================
|
||||
|
||||
@Override
|
||||
public List<SchTeaList> getSchTeaList(int schId) {
|
||||
requireHost();
|
||||
String url = bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId;
|
||||
try {
|
||||
ResponseEntity<List<SchTeaList>> resp = restTemplate.exchange(
|
||||
url, HttpMethod.GET, null,
|
||||
new ParameterizedTypeReference<List<SchTeaList>>() {
|
||||
});
|
||||
List<SchTeaList> 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<NowYearTermVo> 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<SubjectVo> 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<SubjectVo> subjects = fetchSubjects(schId);
|
||||
if (subjects == null) {
|
||||
// 拉取失败回退旧缓存(若有)
|
||||
return cached != null ? cached.subjects : Collections.emptyList();
|
||||
}
|
||||
subjectCache.put(schId, new SubjectCacheEntry(now, subjects));
|
||||
return subjects;
|
||||
}
|
||||
}
|
||||
|
||||
private List<SubjectVo> fetchSubjects(int schId) {
|
||||
String url = bschClassHost + "/oapi/sch/getSchSubjectList?schId=" + schId;
|
||||
try {
|
||||
ResponseEntity<List<Map<String, Object>>> resp = restTemplate.exchange(
|
||||
url, HttpMethod.GET, null,
|
||||
new ParameterizedTypeReference<List<Map<String, Object>>>() {
|
||||
});
|
||||
List<Map<String, Object>> body = resp.getBody();
|
||||
if (body == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<SubjectVo> subjects = new ArrayList<>();
|
||||
for (Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<List<Map<String, Object>>> resp = restTemplate.exchange(
|
||||
url, HttpMethod.GET, null,
|
||||
new ParameterizedTypeReference<List<Map<String, Object>>>() {
|
||||
});
|
||||
List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> list = fetchSchClassList(schId);
|
||||
if (list == null) {
|
||||
return cached != null ? cached.list : Collections.emptyList();
|
||||
}
|
||||
classListCache.put(schId, new MapCacheEntry(now, list));
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> 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<List<Map<String, Object>>> resp = restTemplate.exchange(
|
||||
url, HttpMethod.GET, null,
|
||||
new ParameterizedTypeReference<List<Map<String, Object>>>() {
|
||||
});
|
||||
List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<List<Map<String, Object>>> resp = restTemplate.exchange(
|
||||
url, HttpMethod.GET, null,
|
||||
new ParameterizedTypeReference<List<Map<String, Object>>>() {
|
||||
});
|
||||
List<Map<String, Object>> 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<ClassTreeNodeVo> getClassTree(int schId) {
|
||||
List<Map<String, Object>> tree = getStuTree(schId);
|
||||
List<ClassTreeNodeVo> result = new ArrayList<>();
|
||||
if (tree == null) {
|
||||
return result;
|
||||
}
|
||||
StuIndex index = buildIndex(tree);
|
||||
Map<Integer, List<ClassTreeNodeVo>> gradeClasses = new LinkedHashMap<>();
|
||||
Map<Integer, String> gradeNodeNames = new LinkedHashMap<>();
|
||||
for (Map.Entry<Integer, String> 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<Integer, List<ClassTreeNodeVo>> 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<String, Object> 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<String, Object> resolveStudent(int schId, Integer studentId) {
|
||||
StuIndex index = indexOrNull(schId);
|
||||
return index == null || studentId == null ? null : index.students.get(studentId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> studentsOfClasses(int schId, List<Integer> classIds) {
|
||||
StuIndex index = indexOrNull(schId);
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
if (index == null || classIds == null || classIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
for (Integer classId : classIds) {
|
||||
Map<String, Object> node = index.classNodes.get(classId);
|
||||
if (node != null) {
|
||||
collectStudents(node, classId, index.classNames.get(classId), result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================ 内部辅助 ============================
|
||||
|
||||
private StuIndex indexOrNull(int schId) {
|
||||
List<Map<String, Object>> tree = getStuTree(schId);
|
||||
return tree == null ? null : buildIndex(tree);
|
||||
}
|
||||
|
||||
/** 一次遍历学生树,平铺出年级名/班级名/班级节点/学生定位索引 */
|
||||
@SuppressWarnings("unchecked")
|
||||
private StuIndex buildIndex(List<Map<String, Object>> tree) {
|
||||
StuIndex index = new StuIndex();
|
||||
for (Map<String, Object> school : tree) {
|
||||
Object systems = school.get("children");
|
||||
if (!(systems instanceof List)) {
|
||||
continue;
|
||||
}
|
||||
for (Object sysObj : (List<Object>) systems) {
|
||||
Map<String, Object> system = (Map<String, Object>) sysObj;
|
||||
Object grades = system.get("children");
|
||||
if (!(grades instanceof List)) {
|
||||
continue;
|
||||
}
|
||||
for (Object gradeObj : (List<Object>) grades) {
|
||||
Map<String, Object> grade = (Map<String, Object>) gradeObj;
|
||||
String gradeName = asStr(grade.get("name"));
|
||||
Object classes = grade.get("children");
|
||||
if (!(classes instanceof List)) {
|
||||
continue;
|
||||
}
|
||||
for (Object classObj : (List<Object>) classes) {
|
||||
Map<String, Object> clazz = (Map<String, Object>) 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<Object>) getChildren(clazz)) {
|
||||
Map<String, Object> stu = (Map<String, Object>) stuObj;
|
||||
Integer stuId = asInt(stu.get("id"));
|
||||
if (stuId == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> 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<Object> getChildren(Map<String, Object> node) {
|
||||
Object children = node.get("children");
|
||||
return children instanceof List ? (List<Object>) children : Collections.emptyList();
|
||||
}
|
||||
|
||||
private int countStudents(Map<String, Object> classNode) {
|
||||
int count = 0;
|
||||
for (Object stuObj : getChildren(classNode)) {
|
||||
Map<String, Object> stu = (Map<String, Object>) stuObj;
|
||||
if (Integer.valueOf(2).equals(asInt(stu.get("type")))) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private void collectStudents(Map<String, Object> classNode, Integer classId, String className,
|
||||
List<Map<String, Object>> result) {
|
||||
for (Object stuObj : getChildren(classNode)) {
|
||||
Map<String, Object> stu = (Map<String, Object>) stuObj;
|
||||
if (!Integer.valueOf(2).equals(asInt(stu.get("type")))) {
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 文件上传/下载/删除业务实现。
|
||||
* <p>
|
||||
* 存储路径规则:{uploadPath}/{appType}/{yyyyMMdd}/{uuid}.{ext}
|
||||
* 与 C# UpLoadFile.FileSave 逻辑等价,但去除分片上传和 Office 转 PDF(后续按需补充)。
|
||||
*/
|
||||
@Service
|
||||
public class FileService implements IFileService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FileService.class);
|
||||
|
||||
@Autowired
|
||||
private UpFileRepository upFileRepository;
|
||||
|
||||
@Value("${FILE_PATH}")
|
||||
private String uploadPath;
|
||||
|
||||
// ============================ 上传 ============================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public UpFileVo upload(MultipartFile file, String appType, Integer topicId) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不得为空");
|
||||
}
|
||||
String originalName = file.getOriginalFilename();
|
||||
if (!StringUtils.hasText(originalName)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "文件名为空");
|
||||
}
|
||||
// 提取后缀
|
||||
String ext = "";
|
||||
int dotIdx = originalName.lastIndexOf('.');
|
||||
if (dotIdx >= 0) {
|
||||
ext = originalName.substring(dotIdx + 1).toLowerCase();
|
||||
}
|
||||
|
||||
String savedName = UUID.randomUUID().toString().replace("-", "") + (ext.isEmpty() ? "" : "." + ext);
|
||||
|
||||
// 物理写入
|
||||
Path fullPath = Paths.get(uploadPath, savedName);
|
||||
try {
|
||||
Files.createDirectories(fullPath.getParent());
|
||||
file.transferTo(fullPath.toFile());
|
||||
} catch (Exception e) {
|
||||
log.error("[upload] 文件保存失败: {}", fullPath, e);
|
||||
throw new BizException(ErrorCode.ERROR, "文件保存失败", e);
|
||||
}
|
||||
|
||||
// 入库
|
||||
UpFile entity = new UpFile();
|
||||
entity.setTopicType(appType);
|
||||
entity.setTopicId(topicId);
|
||||
entity.setFileTrueName(originalName);
|
||||
entity.setFileName(savedName);
|
||||
entity.setFileSize((int) file.getSize());
|
||||
entity.setFileType(ext);
|
||||
upFileRepository.save(entity);
|
||||
|
||||
return toVo(entity);
|
||||
}
|
||||
|
||||
// ============================ 绑定主题 ============================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void bindToTopic(List<Integer> fileIds, Integer topicId) {
|
||||
if (fileIds == null || fileIds.isEmpty() || topicId == null) {
|
||||
return;
|
||||
}
|
||||
for (Integer fileId : fileIds) {
|
||||
upFileRepository.findById(fileId).ifPresent(f -> {
|
||||
f.setTopicId(topicId);
|
||||
upFileRepository.save(f);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ 查询附件列表 ============================
|
||||
|
||||
@Override
|
||||
public List<UpFileVo> listByTopic(String appType, Integer topicId) {
|
||||
return upFileRepository.findByTopicTypeAndTopicId(appType, topicId).stream()
|
||||
.map(this::toVo)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
// ============================ 下载 ============================
|
||||
|
||||
@Override
|
||||
public void download(Integer fileId, HttpServletResponse response) {
|
||||
writeFile(fileId, response, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void preview(Integer fileId, HttpServletResponse response) {
|
||||
writeFile(fileId, response, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件输出。inline=true 时 Content-Disposition 用 inline 并按扩展名输出真实 Content-Type
|
||||
* (浏览器/前端组件可直接渲染),否则 attachment 强制下载。
|
||||
*/
|
||||
private void writeFile(Integer fileId, HttpServletResponse response, boolean inline) {
|
||||
UpFile file = upFileRepository.findById(fileId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId));
|
||||
Path fullPath = Paths.get(uploadPath, file.getFileName());
|
||||
if (!Files.exists(fullPath)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "物理文件不存在: " + file.getFileName());
|
||||
}
|
||||
try (FileInputStream fis = new FileInputStream(fullPath.toFile());
|
||||
OutputStream os = response.getOutputStream()) {
|
||||
String encodedName = URLEncoder.encode(file.getFileTrueName(), "UTF-8")
|
||||
.replaceAll("\\+", "%20");
|
||||
if (inline) {
|
||||
response.setContentType(resolveContentType(file.getFileName()));
|
||||
response.setHeader("Content-Disposition", "inline; filename=\"" + encodedName + "\"");
|
||||
} else {
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedName + "\"");
|
||||
}
|
||||
response.setContentLengthLong(file.getFileSize());
|
||||
byte[] buffer = new byte[4096];
|
||||
int len;
|
||||
while ((len = fis.read(buffer)) != -1) {
|
||||
os.write(buffer, 0, len);
|
||||
}
|
||||
os.flush();
|
||||
} catch (Exception e) {
|
||||
log.error("[writeFile] 文件输出失败: {}", file.getFileName(), e);
|
||||
throw new BizException(ErrorCode.ERROR, "文件下载失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按扩展名推断 Content-Type(JDK 自带表 + 常见办公/图片格式兜底),未知类型回退流格式。
|
||||
*/
|
||||
private String resolveContentType(String fileName) {
|
||||
String name = String.valueOf(fileName).toLowerCase();
|
||||
Map<String, String> table = new HashMap<>();
|
||||
table.put("pdf", "application/pdf");
|
||||
table.put("png", "image/png");
|
||||
table.put("jpg", "image/jpeg");
|
||||
table.put("jpeg", "image/jpeg");
|
||||
table.put("gif", "image/gif");
|
||||
table.put("bmp", "image/bmp");
|
||||
table.put("webp", "image/webp");
|
||||
table.put("svg", "image/svg+xml");
|
||||
table.put("txt", "text/plain;charset=UTF-8");
|
||||
table.put("mp4", "video/mp4");
|
||||
table.put("webm", "video/webm");
|
||||
table.put("ogg", "video/ogg");
|
||||
table.put("doc", "application/msword");
|
||||
table.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
table.put("xls", "application/vnd.ms-excel");
|
||||
table.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
table.put("ppt", "application/vnd.ms-powerpoint");
|
||||
table.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation");
|
||||
String type = table.get(name.substring(name.lastIndexOf('.') + 1));
|
||||
if (type != null) {
|
||||
return type;
|
||||
}
|
||||
String guessed = URLConnection.guessContentTypeFromName(name);
|
||||
return guessed != null ? guessed : "application/octet-stream";
|
||||
}
|
||||
|
||||
// ============================ 删除 ============================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void delete(Integer fileId) {
|
||||
UpFile file = upFileRepository.findById(fileId)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId));
|
||||
// 删物理文件
|
||||
Path fullPath = Paths.get(uploadPath, file.getFileName());
|
||||
try {
|
||||
Files.deleteIfExists(fullPath);
|
||||
} catch (Exception e) {
|
||||
log.warn("[delete] 物理文件删除失败: {}(数据库记录仍会删除)", fullPath, e);
|
||||
}
|
||||
// 删数据库记录
|
||||
upFileRepository.delete(file);
|
||||
}
|
||||
|
||||
// ============================ 打包下载 ZIP ============================
|
||||
|
||||
@Override
|
||||
public void downloadZip(List<UpFile> files, String zipName, HttpServletResponse response) {
|
||||
if (files == null || files.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载");
|
||||
}
|
||||
try {
|
||||
response.setContentType("application/zip");
|
||||
String name = URLEncoder.encode(zipName == null ? "附件" : zipName, "UTF-8")
|
||||
.replaceAll("\\+", "%20");
|
||||
response.setHeader("Content-Disposition", "attachment; filename=\"" + name + ".zip\"");
|
||||
try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) {
|
||||
Set<String> used = new HashSet<>();
|
||||
for (UpFile f : files) {
|
||||
Path p = Paths.get(uploadPath, f.getFileName());
|
||||
if (!Files.exists(p)) {
|
||||
log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName());
|
||||
continue;
|
||||
}
|
||||
String entryName = sanitizeEntryName(f.getFileTrueName());
|
||||
if (used.contains(entryName)) {
|
||||
entryName = f.getId() + "_" + entryName;
|
||||
}
|
||||
used.add(entryName);
|
||||
zos.putNextEntry(new ZipEntry(entryName));
|
||||
Files.copy(p, zos);
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[downloadZip] 打包下载失败", e);
|
||||
throw new BizException(ErrorCode.ERROR, "打包下载失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 净化 ZIP 条目名:去除路径分隔符等非法字符,防止 Zip Slip。
|
||||
*/
|
||||
private String sanitizeEntryName(String name) {
|
||||
if (name == null || name.trim().isEmpty()) {
|
||||
return "file";
|
||||
}
|
||||
return name.replaceAll("[\\\\/:*?\"<>|]", "_");
|
||||
}
|
||||
|
||||
// ============================ 按主题打包下载 ============================
|
||||
|
||||
@Override
|
||||
public void downloadZipByTopic(String topicType, Integer topicId, String zipName,
|
||||
HttpServletResponse response) {
|
||||
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicId(topicType, topicId);
|
||||
downloadZip(files, zipName, response);
|
||||
}
|
||||
|
||||
// ============================ 批量上传图片 ============================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public List<UpFileVo> uploadImages(MultipartFile[] files, String appType) {
|
||||
List<UpFileVo> results = new ArrayList<>();
|
||||
if (files == null || files.length == 0) {
|
||||
return results;
|
||||
}
|
||||
for (MultipartFile file : files) {
|
||||
results.add(upload(file, appType, null));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ============================ 上传(返回前端兼容 VO) ============================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public UpFileResultVo uploadToResult(MultipartFile file, String appType) {
|
||||
UpFileVo vo = upload(file, appType, null);
|
||||
UpFileResultVo result = new UpFileResultVo();
|
||||
result.setFileid(vo.getId());
|
||||
result.setName(vo.getFileTrueName());
|
||||
result.setFileurl(vo.getFileName());
|
||||
result.setHost("");
|
||||
result.setType(vo.getFileType());
|
||||
result.setSize(vo.getFileSize());
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================ 转换 ============================
|
||||
|
||||
private UpFileVo toVo(UpFile entity) {
|
||||
UpFileVo vo = new UpFileVo();
|
||||
BeanUtils.copyProperties(entity, vo);
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
|
||||
/**
|
||||
* 应用设置业务实现。
|
||||
* <p>时间设置:每校一行(仅时段判定边界,首查自动建默认 07:00/12:30/13:30/18:30),四边界须依次递增;
|
||||
* 节次模板:绑定学期(0=全年/1=上学期/2=下学期,全年与两学期互通视为重叠)与 1~N 个年级
|
||||
* (同学期内名称唯一),行整存整取(period 节次 / break 空白行);
|
||||
* <b>启用时段与节次/课间时长随模板保存(绑定模板ID)</b>,课表编辑页按生效模板的时段与时长渲染;
|
||||
* 所属时段直接落在行上(band=es/am/noon/pm/ev,不再按开始时间判定),节次与空白行都落在时段里,
|
||||
* 每个启用时段至少保留一条节次(停用时段的节次不在模板行列表中维护)。
|
||||
* 学期重叠且年级有交集的同时只启用一套,学期专属模板优先于全年模板生效;
|
||||
* 行时间 HH:mm 且结束晚于开始,节次数上限 30(与课表保存上限一致)。</p>
|
||||
*/
|
||||
@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<TemplateRowVo> 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<String> 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<TemplateRowVo> validateRows(List<TemplateRowVo> 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<TemplateRowVo> 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<String> gradeNames) {
|
||||
if (gradeNames == null || gradeNames.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "请至少选择一个适用年级");
|
||||
}
|
||||
LinkedHashSet<String> 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<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<SysOperationLog> page(BasePageQuery query, String operationType, Integer statusCode) {
|
||||
Specification<SysOperationLog> spec = (root, cq, cb) -> {
|
||||
List<Predicate> ps = new ArrayList<>();
|
||||
if (StringUtils.hasText(operationType)) {
|
||||
ps.add(cb.equal(root.get("operationType"), operationType));
|
||||
}
|
||||
if (statusCode != null) {
|
||||
ps.add(cb.equal(root.get("statusCode"), statusCode));
|
||||
}
|
||||
if (StringUtils.hasText(query.getKeyword())) {
|
||||
String kw = "%" + query.getKeyword().trim() + "%";
|
||||
Predicate uriLike = cb.like(root.get("requestUri"), kw);
|
||||
Predicate nameLike = cb.like(root.get("userName"), kw);
|
||||
Predicate methodLike = cb.like(root.get("classMethod"), kw);
|
||||
ps.add(cb.or(uriLike, nameLike, methodLike));
|
||||
}
|
||||
return cb.and(ps.toArray(new Predicate[0]));
|
||||
};
|
||||
Pageable pageable = PageUtils.toPageable(query, Sort.by(Sort.Direction.DESC, "createTime"));
|
||||
Page<SysOperationLog> page = repository.findAll(spec, pageable);
|
||||
page.getContent().forEach(e -> e.setResponseData(null));
|
||||
return PageUtils.toResult(page);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
|
||||
/**
|
||||
* 课程表业务实现。
|
||||
* <p>课表按学年学期维护(每班每学年学期一条):读写都定位到班级服务当前学年学期,
|
||||
* 历史学年学期数据保留但不再返回;跨学年/学期后首查为空骨架,重新编排即新学期课表。</p>
|
||||
* <p>保存校验:班级属于本校(班级服务);时间为 HH:mm 且结束晚于开始;
|
||||
* 学科名须在本校学科列表内(学科服务不可用时放行并告警)。</p>
|
||||
*/
|
||||
@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<TimetableSlotVo> 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<String> 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<MyClassVo> myClasses() {
|
||||
return myClasses(UserContext.getUserId(), UserContext.getRoleId(), currentSchId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MyClassVo> myClasses(Integer userId, Integer roleId, Integer schId) {
|
||||
List<Map<String, Object>> classList = classService.getSchClassList(schId);
|
||||
|
||||
boolean admin = isAdminRole(roleId);
|
||||
boolean student = !admin && isStudentRole(roleId);
|
||||
// 班主任班级:正/副班主任是本人
|
||||
Set<Integer> headIds = new HashSet<>();
|
||||
// 任教班级:classId -> 任教科目(去重保序)
|
||||
Map<Integer, List<String>> teachSubjects = new LinkedHashMap<>();
|
||||
// 范围:管理员=全部,学生=本人班级,教师=班主任∪任教
|
||||
Set<Integer> scope = new LinkedHashSet<>();
|
||||
if (admin) {
|
||||
for (Map<String, Object> c : classList) {
|
||||
Integer cid = asInt(c.get("classId"));
|
||||
if (cid != null) {
|
||||
scope.add(cid);
|
||||
}
|
||||
}
|
||||
} else if (student) {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String> names = teachSubjects.computeIfAbsent(cid, k -> new ArrayList<>());
|
||||
if (!names.contains(sub.trim())) {
|
||||
names.add(sub.trim());
|
||||
}
|
||||
}
|
||||
scope.addAll(headIds);
|
||||
scope.addAll(teachSubjects.keySet());
|
||||
}
|
||||
|
||||
// 班级信息以班级列表为主(缺项回学生树补)
|
||||
Map<Integer, Map<String, Object>> byId = new LinkedHashMap<>();
|
||||
for (Map<String, Object> c : classList) {
|
||||
Integer cid = asInt(c.get("classId"));
|
||||
if (cid != null) {
|
||||
byId.put(cid, c);
|
||||
}
|
||||
}
|
||||
|
||||
Map<Integer, Integer> order = classOrder(schId);
|
||||
List<MyClassVo> result = new ArrayList<>();
|
||||
for (Integer cid : scope) {
|
||||
Map<String, Object> 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<String> allowSubjects = new LinkedHashSet<>();
|
||||
if (!admin) {
|
||||
if (isStudentRole(roleId)) {
|
||||
// 学生:仅本人所在班级,全部科目
|
||||
Map<String, Object> 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<String> 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<String> 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<OapiTimetableClassVo> 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<MyClassVo> classes = myClasses(userId, roleId, schId);
|
||||
List<OapiTimetableClassVo> 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<String, Object> 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<String> teachSubjectsOf(int schId, Integer userId, Integer classId) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Map<String, Object> 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<Integer, Integer> classOrder(int schId) {
|
||||
Map<Integer, Integer> order = new LinkedHashMap<>();
|
||||
int i = 0;
|
||||
try {
|
||||
for (ClassTreeNodeVo grade : classService.getClassTree(schId)) {
|
||||
for (ClassTreeNodeVo cls : grade.getChildren() == null ? new ArrayList<ClassTreeNodeVo>() : 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<TimetableSlotVo> slots) {
|
||||
Set<String> 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<String> 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<String> validSubjectNames() {
|
||||
Set<String> 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<Timetable> 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<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 用户中心门面服务实现。
|
||||
* <p>原 BaseApiController.getSchoolAll 的组树组装、教师挂组、递归建树、排序逻辑整体下沉至此;
|
||||
* GetGroupUser 空桩改为显式报错(未接入用户中心对应开放接口时提示,而非静默返回空列表)。
|
||||
* 跨仓调用统一收敛到 {@link YschBaseOpenApi} 门面,Controller 不再直接静态调用。</p>
|
||||
*/
|
||||
@Service
|
||||
public class UserCenterService implements IUserCenterService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UserCenterService.class);
|
||||
|
||||
/** 学生组(UserBase GroupTypeEnum.STUDENT_GROUP),组树中剔除 */
|
||||
private static final int GROUP_TYPE_STUDENT = 8;
|
||||
|
||||
@Autowired
|
||||
private IClassService classService;
|
||||
|
||||
// ============================ 学校组织树 ============================
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> getSchoolAllTree(Integer schId) {
|
||||
if (schId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败");
|
||||
}
|
||||
|
||||
// 1. userbase 组树(整棵学校子树),剔除学生组
|
||||
List<Map<String, Object>> flatNodes = new ArrayList<>();
|
||||
BoaGroup root = YschBaseOpenApi.getGroupById(schId, true);
|
||||
if (root == null) {
|
||||
log.warn("[getSchoolAll] 用户中心未找到学校组, schId={}", schId);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
flattenGroup(root, 0, flatNodes);
|
||||
|
||||
// 2. bschclass 全部教师,按 GroupIds 挂到对应组下;无有效组的兜底挂学校根节点
|
||||
Set<Integer> treeGroupIds = new HashSet<>();
|
||||
for (Map<String, Object> node : flatNodes) {
|
||||
treeGroupIds.add((Integer) node.get("id"));
|
||||
}
|
||||
Map<Integer, SchTeaList> teacherMap = new LinkedHashMap<>();
|
||||
Map<Integer, Set<Integer>> teacherPids = new LinkedHashMap<>();
|
||||
List<SchTeaList> teachers = classService.getSchTeaList(schId);
|
||||
for (SchTeaList tea : teachers) {
|
||||
if (tea.getUserTeaSerID() == null) {
|
||||
continue;
|
||||
}
|
||||
// 同一教师多行(多组/接口重复数据)合并,按 (教师, 组) 去重
|
||||
teacherMap.putIfAbsent(tea.getUserTeaSerID(), tea);
|
||||
// 接口现行字段为单个 GroupID,兼容旧字段 GroupIds
|
||||
List<Integer> teaGroupIds = tea.getGroupIds();
|
||||
if ((teaGroupIds == null || teaGroupIds.isEmpty()) && tea.getGroupId() != null) {
|
||||
teaGroupIds = Collections.singletonList(tea.getGroupId());
|
||||
}
|
||||
if (teaGroupIds != null) {
|
||||
for (Integer gid : teaGroupIds) {
|
||||
if (gid != null && treeGroupIds.contains(gid)) {
|
||||
teacherPids.computeIfAbsent(tea.getUserTeaSerID(), k -> new HashSet<>()).add(gid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Map.Entry<Integer, SchTeaList> entry : teacherMap.entrySet()) {
|
||||
int teaId = entry.getKey();
|
||||
SchTeaList tea = entry.getValue();
|
||||
Set<Integer> pids = teacherPids.get(teaId);
|
||||
if (pids == null || pids.isEmpty()) {
|
||||
// 教师所属组均被剔除(如学生组)或无组信息,挂学校根节点
|
||||
pids = new HashSet<>(Collections.singletonList(treeGroupIds.contains(schId) ? schId : 0));
|
||||
}
|
||||
for (Integer pid : pids) {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("id", teaId);
|
||||
node.put("name", tea.getTeaName() != null ? tea.getTeaName() : "");
|
||||
node.put("pid", pid);
|
||||
node.put("no", tea.getTeaNo() != null ? tea.getTeaNo() : 0);
|
||||
node.put("type", 2);
|
||||
node.put("isParent", false);
|
||||
node.put("photo", "");
|
||||
flatNodes.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 扁平节点递归组装为树
|
||||
return formatUserTree(flatNodes, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 BoaGroup 子树扁平化为组节点列表(type=1);学生组(type=8)整棵剔除。
|
||||
* isParent=true 标识组节点,前端 ztree 据此渲染为文件夹。
|
||||
*/
|
||||
private void flattenGroup(BoaGroup group, int pid, List<Map<String, Object>> nodes) {
|
||||
if (group == null || group.getId() == null) {
|
||||
return;
|
||||
}
|
||||
if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("id", group.getId());
|
||||
node.put("name", group.getName() != null ? group.getName() : "");
|
||||
node.put("pid", pid);
|
||||
node.put("no", group.getSort() != null ? group.getSort() : 0);
|
||||
node.put("type", 1);
|
||||
node.put("isParent", true);
|
||||
node.put("photo", "");
|
||||
nodes.add(node);
|
||||
|
||||
if (group.getChildren() != null) {
|
||||
for (BoaGroup child : group.getChildren()) {
|
||||
flattenGroup(child, group.getId(), nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归构建用户树(对应 C# HttpUserApi.FormatUserTree)。
|
||||
* 根层取 pid=0 或父节点不在列表中的节点;type=1 且无子节点的空组剔除。
|
||||
*/
|
||||
private List<Map<String, Object>> formatUserTree(List<Map<String, Object>> treeList, int pid) {
|
||||
List<Map<String, Object>> data;
|
||||
if (pid == 0) {
|
||||
Set<Integer> allIds = new HashSet<>();
|
||||
for (Map<String, Object> n : treeList) {
|
||||
allIds.add((Integer) n.get("id"));
|
||||
}
|
||||
data = new ArrayList<>();
|
||||
for (Map<String, Object> n : treeList) {
|
||||
int p = (Integer) n.get("pid");
|
||||
if (p == 0 || !allIds.contains(p)) {
|
||||
data.add(n);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
data = new ArrayList<>();
|
||||
for (Map<String, Object> n : treeList) {
|
||||
if ((Integer) n.get("pid") == pid) {
|
||||
data.add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map<String, Object> item : data) {
|
||||
int itemId = (Integer) item.get("id");
|
||||
int itemType = (Integer) item.get("type");
|
||||
|
||||
List<Map<String, Object>> children = formatUserTree(treeList, itemId);
|
||||
|
||||
// type=1(组) 且无子节点 → 不加入(C# 逻辑)
|
||||
if (itemType == 1 && children.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Map<String, Object> node = new LinkedHashMap<>(item);
|
||||
// 仅组节点携带 children;叶子不带该字段,避免 ztree 误判 isParent
|
||||
if (itemType == 1) {
|
||||
node.put("children", children);
|
||||
}
|
||||
result.add(node);
|
||||
}
|
||||
|
||||
// 按 no 排序(数字优先,无法解析的排最后)
|
||||
result.sort(Comparator.comparingInt(n -> {
|
||||
Object no = n.get("no");
|
||||
if (no instanceof Integer) {
|
||||
return (Integer) no;
|
||||
}
|
||||
if (no instanceof String) {
|
||||
try {
|
||||
return Integer.parseInt((String) no);
|
||||
} catch (NumberFormatException e) {
|
||||
return 9999;
|
||||
}
|
||||
}
|
||||
return 9999;
|
||||
}));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================ 组内用户 ============================
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> getGroupUser(Integer gid, Integer role) {
|
||||
if (gid == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "组ID不得为空");
|
||||
}
|
||||
log.info("[getGroupUser] gid={}, role={}, 用户中心组内用户开放接口尚未接入", gid, role);
|
||||
// 显式报错而非静默返回空列表:原空桩会让前端误以为组内无用户
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "组内用户查询接口暂未接入用户中心,请联系管理员配置后重试");
|
||||
}
|
||||
|
||||
// ============================ 用户模块树 ============================
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> getUserModuleTree(Integer userId, Integer schId) {
|
||||
if (userId == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空");
|
||||
}
|
||||
List<BoaModule> modules = YschBaseOpenApi.getUserModule(userId, schId);
|
||||
if (modules == null || modules.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
// 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望
|
||||
return buildModuleTree(modules);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将扁平模块列表组装为树(对应原 NoticeController.buildModuleTree)。
|
||||
* 顶层取 parentId=0 或父节点不在列表中的节点,子节点按 orderNumber 排序。
|
||||
*/
|
||||
private List<Map<String, Object>> buildModuleTree(List<BoaModule> modules) {
|
||||
Map<Integer, BoaModule> moduleMap = modules.stream()
|
||||
.collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a));
|
||||
|
||||
// 每个模块节点(含 children 占位)
|
||||
Map<Integer, Map<String, Object>> nodeMap = new LinkedHashMap<>();
|
||||
for (BoaModule m : modules) {
|
||||
Map<String, Object> node = new LinkedHashMap<>();
|
||||
node.put("id", m.getId());
|
||||
node.put("modId", m.getId());
|
||||
node.put("name", m.getName());
|
||||
node.put("parentId", m.getParentId() != null ? m.getParentId() : 0);
|
||||
node.put("page", m.getPath() != null ? m.getPath() : "");
|
||||
node.put("level", m.getLevel() != null ? m.getLevel() : 0);
|
||||
node.put("icon", m.getIcon() != null ? m.getIcon() : "");
|
||||
node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0);
|
||||
node.put("usable", true);
|
||||
node.put("children", new ArrayList<>());
|
||||
nodeMap.put(m.getId(), node);
|
||||
}
|
||||
|
||||
// 挂父子关系
|
||||
List<Map<String, Object>> roots = new ArrayList<>();
|
||||
for (BoaModule m : modules) {
|
||||
Map<String, Object> node = nodeMap.get(m.getId());
|
||||
int parentId = m.getParentId() != null ? m.getParentId() : 0;
|
||||
if (parentId == 0 || !moduleMap.containsKey(parentId)) {
|
||||
roots.add(node);
|
||||
} else {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> children = (List<Map<String, Object>>) nodeMap.get(parentId).get("children");
|
||||
children.add(node);
|
||||
}
|
||||
}
|
||||
|
||||
// 保持 SDK(用户中心)返回顺序,不再按 orderNumber 重排
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <T> BasePageResult<T> toResult(Page<T> page) {
|
||||
return new BasePageResult<T>(
|
||||
page.getContent(),
|
||||
(int) page.getTotalElements(),
|
||||
page.getNumber() + 1,
|
||||
page.getSize(),
|
||||
page.getTotalPages(),
|
||||
page.hasNext()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.yunhai.acasyllabu.utils;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
/**
|
||||
* 当前登录用户上下文(ThreadLocal 静态持有器)。
|
||||
* <p>移植自 UserclassService(com.yunhai.bschclassmanage.utils.UserContext),
|
||||
* 由 {@link com.yunhai.acasyllabu.aspect.ControllerUserContextAspect} 在每个请求进入 controller 前填充,
|
||||
* 请求结束后 clear,防止线程复用串号。</p>
|
||||
*/
|
||||
public class UserContext {
|
||||
private static final ThreadLocal<Integer> USER_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> USER_NAME = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Integer> ROLE_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Integer> SCH_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<String> IP = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Integer> TYPE = new ThreadLocal<>();
|
||||
|
||||
/** 当前角色切换上下文(来自 currentrole header),家长场景 childId 即学生ID */
|
||||
private static final ThreadLocal<Integer> CHILD_ID = new ThreadLocal<>();
|
||||
|
||||
public static void setUserId(Integer userId) {
|
||||
USER_ID.set(userId);
|
||||
}
|
||||
|
||||
public static Integer getUserId() {
|
||||
return USER_ID.get();
|
||||
}
|
||||
|
||||
public static void setUserName(String userName) {
|
||||
USER_NAME.set(userName);
|
||||
}
|
||||
|
||||
public static String getUserName() {
|
||||
return USER_NAME.get();
|
||||
}
|
||||
|
||||
public static void setRoleId(Integer roleId) {
|
||||
ROLE_ID.set(roleId);
|
||||
}
|
||||
|
||||
public static Integer getRoleId() {
|
||||
return ROLE_ID.get();
|
||||
}
|
||||
|
||||
public static void setSchId(Integer schId) {
|
||||
SCH_ID.set(schId);
|
||||
}
|
||||
|
||||
public static Integer getSchId() {
|
||||
return SCH_ID.get();
|
||||
}
|
||||
|
||||
public static void setIp(String ip) {
|
||||
IP.set(ip);
|
||||
}
|
||||
|
||||
public static String getIp() {
|
||||
return IP.get();
|
||||
}
|
||||
|
||||
public static void setType(Integer type) {
|
||||
TYPE.set(type);
|
||||
}
|
||||
|
||||
public static Integer getType() {
|
||||
return TYPE.get();
|
||||
}
|
||||
|
||||
public static void setChildId(Integer childId) {
|
||||
CHILD_ID.set(childId);
|
||||
}
|
||||
|
||||
/** 家长端当前孩子(学生)ID;主账号/教师为 0 或 null */
|
||||
public static Integer getChildId() {
|
||||
return CHILD_ID.get();
|
||||
}
|
||||
|
||||
/** 用户信息快照(用于异步任务传递) */
|
||||
@Value
|
||||
public static class UserInfo {
|
||||
Integer userId;
|
||||
String userName;
|
||||
Integer roleId;
|
||||
Integer schId;
|
||||
String ip;
|
||||
Integer type;
|
||||
Integer childId;
|
||||
}
|
||||
|
||||
public static UserInfo getCurrentUser() {
|
||||
return new UserInfo(
|
||||
USER_ID.get(),
|
||||
USER_NAME.get(),
|
||||
ROLE_ID.get(),
|
||||
SCH_ID.get(),
|
||||
IP.get(),
|
||||
TYPE.get(),
|
||||
CHILD_ID.get()
|
||||
);
|
||||
}
|
||||
|
||||
public static void setUserInfo(UserInfo info) {
|
||||
if (info == null) {
|
||||
return;
|
||||
}
|
||||
USER_ID.set(info.getUserId());
|
||||
USER_NAME.set(info.getUserName());
|
||||
ROLE_ID.set(info.getRoleId());
|
||||
SCH_ID.set(info.getSchId());
|
||||
IP.set(info.getIp());
|
||||
TYPE.set(info.getType());
|
||||
CHILD_ID.set(info.getChildId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 把当前用户快照带入子线程执行异步任务,执行完 clear。
|
||||
*/
|
||||
public static Runnable wrap(Runnable task) {
|
||||
UserInfo info = getCurrentUser();
|
||||
return () -> {
|
||||
try {
|
||||
setUserInfo(info);
|
||||
task.run();
|
||||
} finally {
|
||||
clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
USER_ID.remove();
|
||||
USER_NAME.remove();
|
||||
ROLE_ID.remove();
|
||||
SCH_ID.remove();
|
||||
IP.remove();
|
||||
TYPE.remove();
|
||||
CHILD_ID.remove();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user