@@ -0,0 +1,20 @@
|
||||
package com.yunhai.acaclassassign;
|
||||
|
||||
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.acaclassassign")
|
||||
@EnableTransactionManagement
|
||||
@EnableAsync
|
||||
public class AcaClassAssignApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
// 开启 fastjson 安全模式
|
||||
ParserConfig.getGlobalInstance().setSafeMode(true);
|
||||
SpringApplication.run(AcaClassAssignApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.yunhai.acaclassassign.aspect;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.acaclassassign.enums.UserType;
|
||||
import com.yunhai.acaclassassign.model.UserSysRole;
|
||||
import com.yunhai.acaclassassign.utils.IpUtils;
|
||||
import com.yunhai.acaclassassign.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.acaclassassign.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.acaclassassign.aspect;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.yunhai.acaclassassign.entity.SysOperationLog;
|
||||
import com.yunhai.acaclassassign.service.ISysOperationLogService;
|
||||
import com.yunhai.acaclassassign.utils.GzipUtil;
|
||||
import com.yunhai.acaclassassign.utils.IpUtils;
|
||||
import com.yunhai.acaclassassign.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.acaclassassign.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.acaclassassign.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.acaclassassign.common.listener;
|
||||
|
||||
import com.yunhai.acaclassassign.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.acaclassassign.common.validation;
|
||||
|
||||
/**
|
||||
* JSR-303 分组校验标记接口(对齐 UserBase 规范)。
|
||||
* Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。
|
||||
*/
|
||||
public interface ValidationGroups {
|
||||
interface Create {}
|
||||
interface Update {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yunhai.acaclassassign.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.acaclassassign.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.acaclassassign.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,113 @@
|
||||
package com.yunhai.acaclassassign.controller.api;
|
||||
|
||||
import com.yunhai.acaclassassign.config.Config;
|
||||
import com.yunhai.acaclassassign.model.assign.AssignSubmitCheckVo;
|
||||
import com.yunhai.acaclassassign.model.assign.ClassItemVo;
|
||||
import com.yunhai.acaclassassign.model.assign.ProjectInfoVo;
|
||||
import com.yunhai.acaclassassign.model.assign.PushStatusVo;
|
||||
import com.yunhai.acaclassassign.model.assign.RuleVo;
|
||||
import com.yunhai.acaclassassign.model.assign.SaveClassesRequest;
|
||||
import com.yunhai.acaclassassign.model.assign.SaveRulesRequest;
|
||||
import com.yunhai.acaclassassign.model.bsch.YearTermItemVo;
|
||||
import com.yunhai.acaclassassign.service.IAssignProjectService;
|
||||
import com.yunhai.acaclassassign.service.IPushClassService;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 分班项目接口(/{platform}/assign/project)。
|
||||
* <p>工作台信息、班级设置、规则、提交锁定。返回裸对象由 ApiResponseAdvice 包装。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/assign/project/")
|
||||
public class AssignProjectController {
|
||||
|
||||
@Autowired
|
||||
private IAssignProjectService projectService;
|
||||
|
||||
@Autowired
|
||||
private IPushClassService pushClassService;
|
||||
|
||||
/** 工作台信息(项目不存在自动创建) */
|
||||
@GetMapping("Info")
|
||||
public ProjectInfoVo info(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
BoaUser authUserWithDept) {
|
||||
return projectService.info(stage, year, term == null ? 1 : term);
|
||||
}
|
||||
|
||||
/** 当前学年学期 */
|
||||
@GetMapping("NowYearTerm")
|
||||
public ProjectInfoVo.CurrentYear nowYearTerm() {
|
||||
return projectService.nowYearTerm();
|
||||
}
|
||||
|
||||
/** 学年下拉列表(班级管理学年学期接口,按学年倒序;不可达时为空列表由前端回退) */
|
||||
@GetMapping("YearList")
|
||||
public List<YearTermItemVo> yearList() {
|
||||
return projectService.yearList();
|
||||
}
|
||||
|
||||
/** 保存班级设置 */
|
||||
@PostMapping("SaveClasses")
|
||||
public List<ClassItemVo> saveClasses(@RequestBody SaveClassesRequest request) {
|
||||
return projectService.saveClasses(request);
|
||||
}
|
||||
|
||||
/** 保存分班规则 */
|
||||
@PostMapping("SaveRules")
|
||||
public RuleVo saveRules(@RequestBody SaveRulesRequest request) {
|
||||
return projectService.saveRules(request);
|
||||
}
|
||||
|
||||
/** 提交前检查清单 */
|
||||
@GetMapping("SubmitCheck")
|
||||
public AssignSubmitCheckVo submitCheck(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term) {
|
||||
return projectService.submitCheck(stage, year, term == null ? 1 : term);
|
||||
}
|
||||
|
||||
/** 提交锁定(force=人数差超限时人工确认) */
|
||||
@PostMapping("Submit")
|
||||
public void submit(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
@RequestParam(defaultValue = "false") boolean force) {
|
||||
projectService.submit(stage, year, term == null ? 1 : term, force);
|
||||
}
|
||||
|
||||
/** 重置全部(删除项目全部数据) */
|
||||
@PostMapping("Reset")
|
||||
public void reset(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term) {
|
||||
projectService.reset(stage, year, term == null ? 1 : term);
|
||||
}
|
||||
|
||||
/** 推送分班结果到班级管理(retry=true 只重推失败学生;仅提交后可推,推送中拒绝并发) */
|
||||
@PostMapping("Push")
|
||||
public PushStatusVo push(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
@RequestParam(defaultValue = "false") boolean retry) {
|
||||
return pushClassService.push(projectService.require(stage, year, term == null ? 1 : term), retry);
|
||||
}
|
||||
|
||||
/** 推送状态查询(状态/成功失败计数/失败明细) */
|
||||
@GetMapping("PushStatus")
|
||||
public PushStatusVo pushStatus(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term) {
|
||||
return pushClassService.status(projectService.require(stage, year, term == null ? 1 : term));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.yunhai.acaclassassign.controller.api;
|
||||
|
||||
import com.yunhai.acaclassassign.config.Config;
|
||||
import com.yunhai.acaclassassign.model.assign.AdjustRequest;
|
||||
import com.yunhai.acaclassassign.model.assign.AdjustResultVo;
|
||||
import com.yunhai.acaclassassign.model.assign.BandReportVo;
|
||||
import com.yunhai.acaclassassign.model.assign.ClassReportVo;
|
||||
import com.yunhai.acaclassassign.model.assign.RunResultVo;
|
||||
import com.yunhai.acaclassassign.service.IAssignRunService;
|
||||
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;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* 分班执行与结果接口(/{platform}/assign/run)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/assign/run/")
|
||||
public class AssignRunController {
|
||||
|
||||
@Autowired
|
||||
private IAssignRunService runService;
|
||||
|
||||
/** 执行均衡分班 */
|
||||
@PostMapping("Run")
|
||||
public RunResultVo run(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term) {
|
||||
return runService.run(stage, year, term);
|
||||
}
|
||||
|
||||
/** 结果页数据 */
|
||||
@GetMapping("Result")
|
||||
public RunResultVo result(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term) {
|
||||
return runService.result(stage, year, term);
|
||||
}
|
||||
|
||||
/** 手动调班(约束冲突未强制时返回 conflicts) */
|
||||
@PostMapping("Adjust")
|
||||
public AdjustResultVo adjust(@RequestBody AdjustRequest request) {
|
||||
return runService.adjust(request);
|
||||
}
|
||||
|
||||
/** 随机匹配班主任 */
|
||||
@PostMapping("MatchTeachers")
|
||||
public List<String> matchTeachers(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term) {
|
||||
return runService.matchTeachers(stage, year, term);
|
||||
}
|
||||
|
||||
/** 班级均分报表(高中/初中) */
|
||||
@GetMapping("ClassReport")
|
||||
public List<ClassReportVo> classReport(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term) {
|
||||
return runService.classReport(stage, year, term);
|
||||
}
|
||||
|
||||
/** 分数段报表 */
|
||||
@GetMapping("BandReport")
|
||||
public BandReportVo bandReport(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
@RequestParam(defaultValue = "总分") String subject,
|
||||
@RequestParam(required = false) Double min,
|
||||
@RequestParam(required = false) Double max,
|
||||
@RequestParam(required = false) Double step) {
|
||||
return runService.bandReport(stage, year, term, subject, min, max, step);
|
||||
}
|
||||
|
||||
/** 分班明细 CSV 导出 */
|
||||
@GetMapping("ExportDetail")
|
||||
public void exportDetail(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
HttpServletResponse response) throws Exception {
|
||||
runService.exportDetail(stage, year, term, response);
|
||||
}
|
||||
|
||||
/** 班级统计 CSV 导出 */
|
||||
@GetMapping("ExportClassStats")
|
||||
public void exportClassStats(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
HttpServletResponse response) throws Exception {
|
||||
runService.exportClassStats(stage, year, term, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.yunhai.acaclassassign.controller.api;
|
||||
|
||||
import com.yunhai.acaclassassign.config.Config;
|
||||
import com.yunhai.acaclassassign.model.assign.ImportPasteRequest;
|
||||
import com.yunhai.acaclassassign.model.assign.ImportResultVo;
|
||||
import com.yunhai.acaclassassign.model.assign.PresetBatchRequest;
|
||||
import com.yunhai.acaclassassign.model.assign.PresetBatchResultVo;
|
||||
import com.yunhai.acaclassassign.model.assign.SourceAnalysisVo;
|
||||
import com.yunhai.acaclassassign.model.assign.StudentPageVo;
|
||||
import com.yunhai.acaclassassign.model.assign.StudentStatsVo;
|
||||
import com.yunhai.acaclassassign.model.assign.StudentUpdateRequest;
|
||||
import com.yunhai.acaclassassign.service.IAssignStudentService;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
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.List;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* 分班学生接口(/{platform}/assign/student)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/assign/student/")
|
||||
public class AssignStudentController {
|
||||
|
||||
@Autowired
|
||||
private IAssignStudentService studentService;
|
||||
|
||||
/** 粘贴文本导入 */
|
||||
@PostMapping("ImportPaste")
|
||||
public ImportResultVo importPaste(@RequestBody ImportPasteRequest request) {
|
||||
return studentService.importPaste(request);
|
||||
}
|
||||
|
||||
/** 文件导入(csv/txt/tsv) */
|
||||
@PostMapping("ImportFile")
|
||||
public ImportResultVo importFile(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
@RequestParam(defaultValue = "replace") String mode,
|
||||
@RequestParam(required = false) Boolean hasHeader,
|
||||
@RequestParam("file") MultipartFile file) {
|
||||
return studentService.importFile(stage, year, term, mode, hasHeader, file);
|
||||
}
|
||||
|
||||
/** 示例名单文本(服务端生成,前端填入粘贴框,确认后走 ImportPaste 入库) */
|
||||
@GetMapping("SampleText")
|
||||
public Map<String, String> sampleText(@RequestParam Integer stage) {
|
||||
return studentService.sampleText(stage);
|
||||
}
|
||||
|
||||
/** 名单分页(附识别出的单科/是否/数值列名清单) */
|
||||
@GetMapping("Page")
|
||||
public StudentPageVo page(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
@RequestParam(required = false) Integer classId,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(defaultValue = "1") Integer pageIndex,
|
||||
@RequestParam(defaultValue = "50") Integer pageSize) {
|
||||
return studentService.page(stage, year, term, classId, keyword,
|
||||
pageIndex == null ? 1 : pageIndex, pageSize == null ? 50 : pageSize);
|
||||
}
|
||||
|
||||
/** 学生名单 CSV 下载(与 Page 同筛选口径,导出全部匹配行) */
|
||||
@GetMapping("ExportList")
|
||||
public void exportList(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
@RequestParam(required = false) Integer classId,
|
||||
@RequestParam(required = false) String keyword,
|
||||
HttpServletResponse response) throws Exception {
|
||||
studentService.exportList(stage, year, term, classId, keyword, response);
|
||||
}
|
||||
|
||||
/** 名单简单统计(全量:人数/性别/总分三值/均衡字段/毕业学校所数) */
|
||||
@GetMapping("Stats")
|
||||
public StudentStatsVo stats(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term) {
|
||||
return studentService.stats(stage, year, term);
|
||||
}
|
||||
|
||||
/** 单元格编辑保存 */
|
||||
@PostMapping("Update")
|
||||
public void update(@RequestBody StudentUpdateRequest request) {
|
||||
studentService.update(request);
|
||||
}
|
||||
|
||||
/** 删除学生 */
|
||||
@PostMapping("Delete")
|
||||
public void delete(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
@RequestParam Integer id) {
|
||||
studentService.delete(stage, year, term, id);
|
||||
}
|
||||
|
||||
/** 清空名单 */
|
||||
@PostMapping("Clear")
|
||||
public void clear(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term) {
|
||||
studentService.clear(stage, year, term);
|
||||
}
|
||||
|
||||
/** 按姓名批量预设班级 */
|
||||
@PostMapping("PresetBatch")
|
||||
public PresetBatchResultVo presetBatch(@RequestBody PresetBatchRequest request) {
|
||||
return studentService.presetBatch(request);
|
||||
}
|
||||
|
||||
/** 生源分析(高中/初中) */
|
||||
@GetMapping("SourceAnalysis")
|
||||
public SourceAnalysisVo sourceAnalysis(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
@RequestParam(required = false) List<Double> cuts) {
|
||||
return studentService.sourceAnalysis(stage, year, term, cuts);
|
||||
}
|
||||
|
||||
/** 生源分析交叉表 CSV 下载 */
|
||||
@GetMapping("ExportSource")
|
||||
public void exportSource(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
@RequestParam(required = false) List<Double> cuts,
|
||||
HttpServletResponse response) throws Exception {
|
||||
studentService.exportSource(stage, year, term, cuts, response);
|
||||
}
|
||||
|
||||
/** 导入模板 CSV 下载 */
|
||||
@GetMapping("Template")
|
||||
public void template(@RequestParam Integer stage, HttpServletResponse response) throws Exception {
|
||||
studentService.template(stage, response);
|
||||
}
|
||||
|
||||
/** 班级导入数据 .xls 下载(班级管理「班级导入」模板:年级|班级|班级别名|班主任|副班主任 + 导入规则页) */
|
||||
@GetMapping("ExportClassImport")
|
||||
public void exportClassImport(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
HttpServletResponse response) throws Exception {
|
||||
studentService.exportClassImport(stage, year, term, response);
|
||||
}
|
||||
|
||||
/** 按成绩/序号生成学号(有总分=成绩排名;无总分=序号。scope=class 班内2位 01、02…;scope=grade 学年+4位 如 20250001;覆盖已有学号) */
|
||||
@PostMapping("GenNo")
|
||||
public Map<String, Object> genNo(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
@RequestParam(defaultValue = "class") String scope) {
|
||||
return studentService.genStudentNo(stage, year, term, scope);
|
||||
}
|
||||
|
||||
/** 按成绩/序号生成账号(学校名首字母小写+s+学年+序号段:class=班序2位+班内序2位;grade=年级序4位;覆盖已有账号) */
|
||||
@PostMapping("GenAccount")
|
||||
public Map<String, Object> genAccount(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term,
|
||||
@RequestParam(defaultValue = "class") String scope) {
|
||||
return studentService.genStudentAccount(stage, year, term, scope);
|
||||
}
|
||||
|
||||
/** 生成密码=账号+@(仅已分班且有账号的学生,无账号跳过;覆盖已有密码) */
|
||||
@PostMapping("GenPassword")
|
||||
public Map<String, Object> genPassword(@RequestParam Integer stage,
|
||||
@RequestParam(required = false) String year,
|
||||
@RequestParam(required = false) Integer term) {
|
||||
return studentService.genStudentPassword(stage, year, term);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.yunhai.acaclassassign.controller.api;
|
||||
|
||||
import com.yunhai.acaclassassign.config.Config;
|
||||
import com.yunhai.acaclassassign.model.UserSysRole;
|
||||
import com.yunhai.acaclassassign.model.bsch.ClassTreeNodeVo;
|
||||
import com.yunhai.acaclassassign.model.base.SetRoleRequest;
|
||||
import com.yunhai.acaclassassign.model.base.SetRoleResult;
|
||||
import com.yunhai.acaclassassign.service.IClassService;
|
||||
import com.yunhai.acaclassassign.service.IUserCenterService;
|
||||
import com.yunhai.acaclassassign.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 java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 平台代理域接口(base/)。
|
||||
* <p>承载:GetUserInfo / GetUserMod / GetSchoolAll / GetClassTree / SetRole / Logout。
|
||||
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(value = Config.API_PATH + "/base/")
|
||||
public class BaseApiController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BaseApiController.class);
|
||||
|
||||
/** 家长角色ID(UserRole.PATRIARCH_USER),用于 GetUserInfo 的 role 判定 */
|
||||
private static final Integer ROLE_PARENT = 4;
|
||||
|
||||
@Autowired
|
||||
private IUserCenterService userCenterService;
|
||||
|
||||
@Autowired
|
||||
private IClassService classService;
|
||||
|
||||
// ============================ 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,32 @@
|
||||
package com.yunhai.acaclassassign.controller.api;
|
||||
|
||||
import com.yunhai.acaclassassign.config.Config;
|
||||
import com.yunhai.acaclassassign.entity.SysOperationLog;
|
||||
import com.yunhai.acaclassassign.model.BasePageQuery;
|
||||
import com.yunhai.acaclassassign.model.BasePageResult;
|
||||
import com.yunhai.acaclassassign.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,53 @@
|
||||
package com.yunhai.acaclassassign.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;
|
||||
|
||||
/**
|
||||
* 分班班级设置:项目下的班级清单(序号/班名/班主任/目标人数/A班标记)。
|
||||
* <p>班主任只保存平台教师 id(teacherId),教师候选来自基础平台 GetSchoolAll;
|
||||
* teacherName 为保存时刻的展示冗余,不建任何账号字段。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "aca_assign_class", indexes = {
|
||||
@Index(name = "idx_ac_project", columnList = "project_id"),
|
||||
@Index(name = "idx_ac_teacher", columnList = "teacher_id")})
|
||||
public class AssignClass extends BaseEntity {
|
||||
|
||||
/** 所属分班项目 */
|
||||
@Column(name = "project_id", nullable = false)
|
||||
private Integer projectId;
|
||||
|
||||
/** 班级序号(1 起,决定 A/B 分层时 A 班的先后) */
|
||||
@Column(name = "sort_order", nullable = false)
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 班级名称,默认 {n}班,可自定义 */
|
||||
@Column(name = "name", nullable = false, length = 32)
|
||||
private String name;
|
||||
|
||||
/** 班主任平台教师id(可空=未指定);不存教师账号 */
|
||||
@Column(name = "teacher_id")
|
||||
private Integer teacherId;
|
||||
|
||||
/** 班主任姓名展示冗余(保存时刻快照,来源于平台教师列表) */
|
||||
@Column(name = "teacher_name", length = 64)
|
||||
private String teacherName;
|
||||
|
||||
/** 目标人数(空=自动均分;手动值优先,未填班均分剩余人数) */
|
||||
@Column(name = "target_count")
|
||||
private Integer targetCount;
|
||||
|
||||
/** 是否A班(高中 layer 分层:取列表前 abClassCount 个班) */
|
||||
@Column(name = "ab_flag", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private Boolean abFlag;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.yunhai.acaclassassign.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 分班项目:每校 × 学段 × 学年 × 学期一条,承载提交锁定状态。
|
||||
* <p>提交后该学年学期锁定为只读(不可改数据/规则/重新分班),撤销只能重置全部。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "aca_assign_project",
|
||||
indexes = @Index(name = "idx_ap_sch_stage", columnList = "sch_id,stage"),
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_ap_sch_stage_year_term",
|
||||
columnNames = {"sch_id", "stage", "year", "term"}))
|
||||
public class AssignProject extends BaseEntity {
|
||||
|
||||
/** 学校ID(多校隔离;超管为 0) */
|
||||
@Column(name = "sch_id", nullable = false)
|
||||
private Integer schId;
|
||||
|
||||
/** 学段:2小学 3初中 4高中 */
|
||||
@Column(name = "stage", nullable = false, columnDefinition = "TINYINT")
|
||||
private Integer stage;
|
||||
|
||||
/** 学年起始年,如 2025 表示 2025-2026 学年 */
|
||||
@Column(name = "year", nullable = false, length = 16)
|
||||
private String year;
|
||||
|
||||
/** 学期:1第一学期 2第二学期 */
|
||||
@Column(name = "term", nullable = false, columnDefinition = "TINYINT")
|
||||
private Integer term;
|
||||
|
||||
/** 状态:0草稿 1已提交(提交后锁定) */
|
||||
@Column(name = "status", nullable = false, columnDefinition = "TINYINT DEFAULT 0")
|
||||
private Integer status;
|
||||
|
||||
/** 提交时间 */
|
||||
@Column(name = "submit_time")
|
||||
private LocalDateTime submitTime;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.yunhai.acaclassassign.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 分班结果推送记录:每项目一条(提交分班结果 → 推送班级管理)。
|
||||
* <p>推送与提交解耦:推送异常不回滚提交锁定,只影响本表状态;
|
||||
* 状态机 0未推送 → 4推送中 → 1已推送 / 2部分失败 / 3失败(推送中拒绝重复 Push)。
|
||||
* 重推幂等依赖被推方按 code/account 查重,本表只收敛状态与失败明细。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "aca_assign_push",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_apush_project", columnNames = "project_id"))
|
||||
public class AssignPush extends BaseEntity {
|
||||
|
||||
public static final int STATUS_NONE = 0;
|
||||
|
||||
public static final int STATUS_OK = 1;
|
||||
|
||||
public static final int STATUS_PARTIAL = 2;
|
||||
|
||||
public static final int STATUS_FAIL = 3;
|
||||
|
||||
public static final int STATUS_PUSHING = 4;
|
||||
|
||||
/** 所属分班项目(唯一) */
|
||||
@Column(name = "project_id", nullable = false)
|
||||
private Integer projectId;
|
||||
|
||||
/** 状态:0未推送 1已推送 2部分失败 3失败 4推送中 */
|
||||
@Column(name = "status", nullable = false, columnDefinition = "TINYINT DEFAULT 0")
|
||||
private Integer status;
|
||||
|
||||
/** 最近一次推送完成时间 */
|
||||
@Column(name = "push_time")
|
||||
private LocalDateTime pushTime;
|
||||
|
||||
/** 本地班级id → 平台班级id(班级组id)映射 JSON:{"12":9901,...} */
|
||||
@Column(name = "class_map_json", columnDefinition = "TEXT")
|
||||
private String classMapJson;
|
||||
|
||||
/** 未成功学生明细 JSON:[{"studentId":1,"name":"张三","reason":"..."}](跨多次推送合并收敛) */
|
||||
@Column(name = "fail_json", columnDefinition = "TEXT")
|
||||
private String failJson;
|
||||
|
||||
/** 摘要信息(成功 N 班 M 人 / 失败原因) */
|
||||
@Column(name = "msg", length = 512)
|
||||
private String msg;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.yunhai.acaclassassign.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Table;
|
||||
import javax.persistence.UniqueConstraint;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 分班结果:每项目保留最新一次运行(重新分班覆盖)。
|
||||
* <p>学生落位写回 aca_assign_student.class_id/level;本表存指标、日志与分层元信息。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "aca_assign_result",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_arl_project", columnNames = "project_id"))
|
||||
public class AssignResult extends BaseEntity {
|
||||
|
||||
/** 所属分班项目 */
|
||||
@Column(name = "project_id", nullable = false)
|
||||
private Integer projectId;
|
||||
|
||||
/** 分班时间 */
|
||||
@Column(name = "assign_time", nullable = false)
|
||||
private LocalDateTime assignTime;
|
||||
|
||||
/** 局部搜索迭代次数 */
|
||||
@Column(name = "iterations")
|
||||
private Integer iterations;
|
||||
|
||||
/** 算法耗时 ms */
|
||||
@Column(name = "cost_ms")
|
||||
private Long costMs;
|
||||
|
||||
/** 综合均衡度 0–100 */
|
||||
@Column(name = "score", precision = 5, scale = 2)
|
||||
private BigDecimal score;
|
||||
|
||||
/** 是否含手动调整 */
|
||||
@Column(name = "adjusted", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private Boolean adjusted;
|
||||
|
||||
/** 指标卡 + 均衡明细 JSON(items/score/countSpread/avgSpread/genderDev/abInfo 等) */
|
||||
@Column(name = "metrics_json", columnDefinition = "TEXT")
|
||||
private String metricsJson;
|
||||
|
||||
/** 算法/校验/手动调班/班主任匹配日志文本(每行一条) */
|
||||
@Column(name = "log_text", columnDefinition = "TEXT")
|
||||
private String logText;
|
||||
|
||||
/** 警告 JSON:["...","..."] */
|
||||
@Column(name = "warn_json", columnDefinition = "TEXT")
|
||||
private String warnJson;
|
||||
|
||||
/** 分数段切点 JSON:{"cuts":[...],"labels":[...]} */
|
||||
@Column(name = "band_cuts_json", columnDefinition = "TEXT")
|
||||
private String bandCutsJson;
|
||||
|
||||
/** A/B 分层元信息 JSON:{"enabled":true,"mode":"layer","aClassIds":[...],"groups":{...}} */
|
||||
@Column(name = "groups_json", columnDefinition = "TEXT")
|
||||
private String groupsJson;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.yunhai.acaclassassign.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>均衡维度勾选与权重、约束开关、分数段方案、高中 A/B 分层设置。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "aca_assign_rule",
|
||||
uniqueConstraints = @UniqueConstraint(name = "uk_ar_project", columnNames = "project_id"))
|
||||
public class AssignRule extends BaseEntity {
|
||||
|
||||
/** 所属分班项目 */
|
||||
@Column(name = "project_id", nullable = false)
|
||||
private Integer projectId;
|
||||
|
||||
/**
|
||||
* 均衡维度 JSON:{"total":true,"gender":true,"bands":true,
|
||||
* "subjects":["语文","数学"],"nums":["视力分"],"tags":["特长生"]}
|
||||
* (人数维度恒开,不入库;小学版成绩类恒关)
|
||||
*/
|
||||
@Column(name = "balance_json", nullable = false, columnDefinition = "TEXT")
|
||||
private String balanceJson;
|
||||
|
||||
/**
|
||||
* 约束规则 JSON(4 项硬约束开关,默认全开):
|
||||
* {"sameName":true,"sameSound":true,"diffClass":true,"sameClass":true}
|
||||
*/
|
||||
@Column(name = "constraint_json", nullable = false, columnDefinition = "TEXT")
|
||||
private String constraintJson;
|
||||
|
||||
/** 分数段模式:count 按段数 / step 按分数间隔(小学版空) */
|
||||
@Column(name = "band_mode", length = 8)
|
||||
private String bandMode;
|
||||
|
||||
/** 分数段段数(3/4/5,上限 20) */
|
||||
@Column(name = "band_count")
|
||||
private Integer bandCount;
|
||||
|
||||
/** 分数段间隔(1–200 分/段) */
|
||||
@Column(name = "band_step")
|
||||
private Integer bandStep;
|
||||
|
||||
/** 是否启用 A/B 分层(仅高中) */
|
||||
@Column(name = "ab_enabled", nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0")
|
||||
private Boolean abEnabled;
|
||||
|
||||
/** 分层模式:layer 按总分分层编班 / within 均衡编班+班内分层 */
|
||||
@Column(name = "ab_mode", length = 8)
|
||||
private String abMode;
|
||||
|
||||
/** A 班(实验班)数量:1 ≤ K < 班级数 */
|
||||
@Column(name = "ab_class_count")
|
||||
private Integer abClassCount;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.yunhai.acaclassassign.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Index;
|
||||
import javax.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 分班学生:导入名单的数据行。学号/账号为标识文本列(仅展示/导出,不参与均衡),
|
||||
* 不存联系方式,也不与平台账号做关联。
|
||||
* <p>单科成绩、是否型/数值型自定义字段为动态结构,以 JSON 文本列存储;
|
||||
* 分班结果写回 classId(与 level A/B 层)。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "aca_assign_student", indexes = {
|
||||
@Index(name = "idx_as_project", columnList = "project_id"),
|
||||
@Index(name = "idx_as_project_class", columnList = "project_id,class_id"),
|
||||
@Index(name = "idx_as_name", columnList = "name")})
|
||||
public class AssignStudent extends BaseEntity {
|
||||
|
||||
/** 所属分班项目 */
|
||||
@Column(name = "project_id", nullable = false)
|
||||
private Integer projectId;
|
||||
|
||||
/** 姓名(必填;同名/同音约束、拼音排序依据) */
|
||||
@Column(name = "name", nullable = false, length = 64)
|
||||
private String name;
|
||||
|
||||
/** 性别:1男 2女 0未知 */
|
||||
@Column(name = "gender", nullable = false, columnDefinition = "TINYINT DEFAULT 0")
|
||||
private Integer gender;
|
||||
|
||||
/** 总分(小学版恒空) */
|
||||
@Column(name = "total_score", precision = 6, scale = 2)
|
||||
private BigDecimal totalScore;
|
||||
|
||||
/** 单科成绩 JSON:{"语文":95.5,"数学":88,...} */
|
||||
@Column(name = "scores_json", columnDefinition = "TEXT")
|
||||
private String scoresJson;
|
||||
|
||||
/** 是否型自定义字段 JSON:{"特长生":true,"住宿":false,...} */
|
||||
@Column(name = "tags_json", columnDefinition = "TEXT")
|
||||
private String tagsJson;
|
||||
|
||||
/** 数值型自定义字段 JSON:{"视力分":4.8,...} */
|
||||
@Column(name = "nums_json", columnDefinition = "TEXT")
|
||||
private String numsJson;
|
||||
|
||||
/** 学号(标识文本列,仅展示/导出,不参与均衡) */
|
||||
@Column(name = "student_no", length = 64)
|
||||
private String studentNo;
|
||||
|
||||
/** 账号(标识文本列,仅展示/导出,不参与均衡,不与平台账号关联) */
|
||||
@Column(name = "account", length = 64)
|
||||
private String account;
|
||||
|
||||
/** 密码(标识文本列,生成=账号+@;仅展示/导出/推送初始密码,不参与均衡) */
|
||||
@Column(name = "password", length = 64)
|
||||
private String password;
|
||||
|
||||
/** 毕业学校(仅展示/导出/生源分析,不参与均衡) */
|
||||
@Column(name = "graduate_school", length = 128)
|
||||
private String graduateSchool;
|
||||
|
||||
/** 备注 */
|
||||
@Column(name = "remark", length = 255)
|
||||
private String remark;
|
||||
|
||||
/** 预设班级原文(如 1 / 1班 / 同班:张三 / 不同班:李四) */
|
||||
@Column(name = "preset_raw", length = 255)
|
||||
private String presetRaw;
|
||||
|
||||
/** 预设解析结果 JSON:[{"type":"class|same|diff","value":"1","target":"张三"}] */
|
||||
@Column(name = "preset_json", columnDefinition = "TEXT")
|
||||
private String presetJson;
|
||||
|
||||
/** 分班结果:所在班级id(aca_assign_class.id) */
|
||||
@Column(name = "class_id")
|
||||
private Integer classId;
|
||||
|
||||
/** 分层结果:A/B(高中版用;其他学段空) */
|
||||
@Column(name = "level", length = 4)
|
||||
private String level;
|
||||
|
||||
/** 导入行号(告警定位) */
|
||||
@Column(name = "src_row")
|
||||
private Integer srcRow;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.yunhai.acaclassassign.entity;
|
||||
|
||||
import com.yunhai.acaclassassign.common.listener.BaseAuditListener;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link BaseAuditListener} 自动填充)。
|
||||
*/
|
||||
@MappedSuperclass
|
||||
@Getter
|
||||
@Setter
|
||||
@EntityListeners(BaseAuditListener.class)
|
||||
public abstract class BaseEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Integer id;
|
||||
|
||||
/**
|
||||
* 创建人登录账号
|
||||
*/
|
||||
@Column(name = "create_by", updatable = false)
|
||||
private String createBy;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@Column(name = "create_time", updatable = false)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
/**
|
||||
* 更新人登录账号
|
||||
*/
|
||||
@Column(name = "update_by")
|
||||
private String updateBy;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@Column(name = "update_time")
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.yunhai.acaclassassign.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,20 @@
|
||||
package com.yunhai.acaclassassign.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.acaclassassign.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.acaclassassign.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.acaclassassign.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.acaclassassign.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,24 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 手动调班请求。
|
||||
*/
|
||||
@Data
|
||||
public class AdjustRequest {
|
||||
|
||||
private Integer stage;
|
||||
|
||||
private String year;
|
||||
|
||||
private Integer term;
|
||||
|
||||
private Integer studentId;
|
||||
|
||||
/** 目标班级id */
|
||||
private Integer toClassId;
|
||||
|
||||
/** 约束冲突时是否人工确认强行执行(审计记录) */
|
||||
private Boolean force;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 手动调班结果:冲突未强制时返回 conflicts,applied=true 表示已执行。
|
||||
*/
|
||||
@Data
|
||||
public class AdjustResultVo {
|
||||
|
||||
/** 是否已执行 */
|
||||
private boolean applied;
|
||||
|
||||
/** 约束冲突提示(applied=false 时返回,供前端人工确认) */
|
||||
private List<String> conflicts = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 数据提交页:摘要 + 提交前检查清单。
|
||||
*/
|
||||
@Data
|
||||
public class AssignSubmitCheckVo {
|
||||
|
||||
private Integer status;
|
||||
|
||||
private LocalDateTime submitTime;
|
||||
|
||||
private String year;
|
||||
|
||||
private Integer term;
|
||||
|
||||
private int classCount;
|
||||
|
||||
private long studentCount;
|
||||
|
||||
/** 班主任已指定数 */
|
||||
private int teacherSetCount;
|
||||
|
||||
/** 是否已完成分班 */
|
||||
private boolean assigned;
|
||||
|
||||
/** 人数极差 */
|
||||
private int countSpread;
|
||||
|
||||
/** 校验警告条数 */
|
||||
private int warnCount;
|
||||
|
||||
/** 检查清单:{name, level(ok/warn/bad), msg} */
|
||||
private List<CheckItem> checks = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
public static class CheckItem {
|
||||
|
||||
private String name;
|
||||
|
||||
/** ok / warn / bad */
|
||||
private String level;
|
||||
|
||||
private String msg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 均衡维度勾选(人数维度恒开不入库)。
|
||||
*/
|
||||
@Data
|
||||
public class BalanceVo {
|
||||
|
||||
/** 总分均分(小学版恒 false) */
|
||||
private Boolean total;
|
||||
|
||||
/** 三科平均分(语文+数学+英语;三科齐全时可用) */
|
||||
private Boolean core3;
|
||||
|
||||
/** 男女生比例 */
|
||||
private Boolean gender;
|
||||
|
||||
/** 各分数段人数 */
|
||||
private Boolean bands;
|
||||
|
||||
/** 参与均衡的单科列表 */
|
||||
private List<String> subjects;
|
||||
|
||||
/** 参与均衡的数值型字段列表 */
|
||||
private List<String> nums;
|
||||
|
||||
/** 参与均衡的是否型字段列表 */
|
||||
private List<String> tags;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分数段报表(学科 × 区间人数,全部 + 各班)。
|
||||
*/
|
||||
@Data
|
||||
public class BandReportVo {
|
||||
|
||||
/** 学科名("总分" 或单科名) */
|
||||
private String subject;
|
||||
|
||||
private double minScore;
|
||||
|
||||
private double maxScore;
|
||||
|
||||
private double step;
|
||||
|
||||
/** 区间标签(左闭右开) */
|
||||
private List<String> labels = new ArrayList<>();
|
||||
|
||||
/** 每段一行:全部 + 各班人数 */
|
||||
private List<BandRow> rows = new ArrayList<>();
|
||||
|
||||
private long total;
|
||||
|
||||
private double maxScoreSeen;
|
||||
|
||||
private double minScoreSeen;
|
||||
|
||||
@Data
|
||||
public static class BandRow {
|
||||
|
||||
/** 区间标签 */
|
||||
private String label;
|
||||
|
||||
/** 各班人数,key=班名;含 "全部" */
|
||||
private List<Long> counts = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 结果页图表数据(前端 SVG 渲染)。
|
||||
*/
|
||||
@Data
|
||||
public class ChartsVo {
|
||||
|
||||
/** 各班平均分(无成绩学段为人数) */
|
||||
private List<Bar> avgBars = new ArrayList<>();
|
||||
|
||||
/** 各班性别结构(堆叠条形) */
|
||||
private List<GenderBar> genderBars = new ArrayList<>();
|
||||
|
||||
/** 各班分数段分布 */
|
||||
private List<BandStack> bandStacks = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
public static class Bar {
|
||||
|
||||
private String name;
|
||||
|
||||
private double value;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class GenderBar {
|
||||
|
||||
private String name;
|
||||
|
||||
private int male;
|
||||
|
||||
private int female;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class BandStack {
|
||||
|
||||
private String name;
|
||||
|
||||
/** 与 bandCuts.labels 对齐的各段人数 */
|
||||
private List<Long> counts = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 班级卡片(结果页):统计 + 可展开名单。
|
||||
*/
|
||||
@Data
|
||||
public class ClassCardVo {
|
||||
|
||||
private Integer classId;
|
||||
|
||||
private String name;
|
||||
|
||||
private Integer sortOrder;
|
||||
|
||||
private String teacherName;
|
||||
|
||||
/** A/B(高中 layer:A=实验班;within:无) */
|
||||
private String abGroup;
|
||||
|
||||
private int count;
|
||||
|
||||
private int male;
|
||||
|
||||
private int female;
|
||||
|
||||
/** 总分均分(无成绩学段为空) */
|
||||
private BigDecimal avg;
|
||||
|
||||
private BigDecimal max;
|
||||
|
||||
private BigDecimal min;
|
||||
|
||||
/** 单科均分 {科目:均值} */
|
||||
private Map<String, BigDecimal> subjectAvg = new LinkedHashMap<>();
|
||||
|
||||
/** 数值字段均分 */
|
||||
private Map<String, BigDecimal> numAvg = new LinkedHashMap<>();
|
||||
|
||||
/** 是否型字段人数 {字段:人数} */
|
||||
private Map<String, Long> tagCount = new LinkedHashMap<>();
|
||||
|
||||
/** 分数段人数(与 bandCuts 对齐) */
|
||||
private List<Long> bandCounts = new ArrayList<>();
|
||||
|
||||
/** A/B 层人数 {A:n,B:m}(within 模式) */
|
||||
private Map<String, Integer> levelCounts = new LinkedHashMap<>();
|
||||
|
||||
/** 前若干名(mini 柱状图,按总分降序,最多12) */
|
||||
private List<BigDecimal> topScores = new ArrayList<>();
|
||||
|
||||
/** 全班名单 */
|
||||
private List<StudentVo> students = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 班级设置项 / 保存请求行。
|
||||
*/
|
||||
@Data
|
||||
public class ClassItemVo {
|
||||
|
||||
/** 班级id(保存时已有班级携带;无 id=新增) */
|
||||
private Integer id;
|
||||
|
||||
/** 序号(1 起) */
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 班级名称 */
|
||||
private String name;
|
||||
|
||||
/** 班主任平台教师id(可空;不存账号) */
|
||||
private Integer teacherId;
|
||||
|
||||
/** 班主任姓名(展示冗余,服务端按平台教师列表回填) */
|
||||
private String teacherName;
|
||||
|
||||
/** 目标人数(空=自动均分) */
|
||||
private Integer targetCount;
|
||||
|
||||
/** 是否A班(高中分层) */
|
||||
private Boolean abFlag;
|
||||
|
||||
/** 已预设学生数(只读,服务端计算) */
|
||||
private Integer presetCount;
|
||||
|
||||
/** 剩余 = 目标人数 - 已预设(只读) */
|
||||
private Integer remain;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 班级均分报表行(高中/初中)。
|
||||
*/
|
||||
@Data
|
||||
public class ClassReportVo {
|
||||
|
||||
private Integer classId;
|
||||
|
||||
private String name;
|
||||
|
||||
private String teacherName;
|
||||
|
||||
private int count;
|
||||
|
||||
private int male;
|
||||
|
||||
private int female;
|
||||
|
||||
/** 各科均分 */
|
||||
private Map<String, BigDecimal> subjectAvg = new LinkedHashMap<>();
|
||||
|
||||
/** 三科总分均分(语数英齐才时,参考项) */
|
||||
private BigDecimal core3Avg;
|
||||
|
||||
/** 总分均分 */
|
||||
private BigDecimal totalAvg;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 约束规则开关(硬约束,默认全开;换人优化与手动调班时强制校验)。
|
||||
*/
|
||||
@Data
|
||||
public class ConstraintVo {
|
||||
|
||||
/** 同名学生分到不同班 */
|
||||
private Boolean sameName;
|
||||
|
||||
/** 姓名读音相近分到不同班 */
|
||||
private Boolean sameSound;
|
||||
|
||||
/** 预设「不同班」的学生叉开 */
|
||||
private Boolean diffClass;
|
||||
|
||||
/** 预设「同班」的学生捆绑 */
|
||||
private Boolean sameClass;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 粘贴文本导入请求。
|
||||
*/
|
||||
@Data
|
||||
public class ImportPasteRequest {
|
||||
|
||||
private Integer stage;
|
||||
|
||||
private String year;
|
||||
|
||||
private Integer term;
|
||||
|
||||
/** 粘贴的原始文本(Tab/逗号/空格分隔) */
|
||||
private String text;
|
||||
|
||||
/** 首行是否表头 */
|
||||
private Boolean hasHeader;
|
||||
|
||||
/** replace 替换全部 / append 追加 */
|
||||
private String mode;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 导入结果(统计条 + 告警)。
|
||||
*/
|
||||
@Data
|
||||
public class ImportResultVo {
|
||||
|
||||
/** 本次导入入库人数 */
|
||||
private int inserted;
|
||||
|
||||
/** 项目现有总人数 */
|
||||
private long total;
|
||||
|
||||
/** 告警(列识别、数据异常) */
|
||||
private List<String> warn = new ArrayList<>();
|
||||
|
||||
/** 男生数 */
|
||||
private long male;
|
||||
|
||||
/** 女生数 */
|
||||
private long female;
|
||||
|
||||
/** 识别到的单科列 */
|
||||
private List<String> subjects = new ArrayList<>();
|
||||
|
||||
/** 是否型字段 */
|
||||
private List<String> tags = new ArrayList<>();
|
||||
|
||||
/** 数值型字段 */
|
||||
private List<String> nums = new ArrayList<>();
|
||||
|
||||
/** 毕业学校数 */
|
||||
private int schools;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 均衡明细行:维度 / 最低班 / 最高班 / 极差 / 相对偏差 / 判定 / 权重。
|
||||
*/
|
||||
@Data
|
||||
public class MetricItemVo {
|
||||
|
||||
/** 维度名(人数/总分均分/男女生比例/语文/视力分均值/特长生人数...) */
|
||||
private String dimension;
|
||||
|
||||
/** 类型:count 人数型 / avg 均值型 */
|
||||
private String type;
|
||||
|
||||
/** 权重 */
|
||||
private double weight;
|
||||
|
||||
/** 最低班名 */
|
||||
private String minClass;
|
||||
|
||||
private double minValue;
|
||||
|
||||
/** 最高班名 */
|
||||
private String maxClass;
|
||||
|
||||
private double maxValue;
|
||||
|
||||
/** 极差 */
|
||||
private double spread;
|
||||
|
||||
/** 相对偏差(极差/均值,%) */
|
||||
private double relDev;
|
||||
|
||||
/** 判定:优/良/关注/偏差大/样本过小/信息项 */
|
||||
private String grade;
|
||||
|
||||
/** 各班数值(与 classCards 顺序对齐) */
|
||||
private List<Double> perClass = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 按姓名批量预设班级请求。
|
||||
*/
|
||||
@Data
|
||||
public class PresetBatchRequest {
|
||||
|
||||
private Integer stage;
|
||||
|
||||
private String year;
|
||||
|
||||
private Integer term;
|
||||
|
||||
/** 学生姓名(多个,分隔符已由前端拆分) */
|
||||
private List<String> names;
|
||||
|
||||
/** 目标班级id */
|
||||
private Integer classId;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 批量预设结果:未匹配姓名回填提示。
|
||||
*/
|
||||
@Data
|
||||
public class PresetBatchResultVo {
|
||||
|
||||
private int updated;
|
||||
|
||||
private List<String> unmatched = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 工作台信息汇总(步骤1/2 页面加载入口)。
|
||||
*/
|
||||
@Data
|
||||
public class ProjectInfoVo {
|
||||
|
||||
private Integer projectId;
|
||||
|
||||
private Integer stage;
|
||||
|
||||
private String year;
|
||||
|
||||
private Integer term;
|
||||
|
||||
/** 0草稿 1已提交(锁定) */
|
||||
private Integer status;
|
||||
|
||||
private LocalDateTime submitTime;
|
||||
|
||||
private List<ClassItemVo> classes;
|
||||
|
||||
private RuleVo rule;
|
||||
|
||||
private ProjectStatsVo stats;
|
||||
|
||||
/** 平台当前学年学期(默认选中项) */
|
||||
private String currentYear;
|
||||
|
||||
private Integer currentTerm;
|
||||
|
||||
/** 当前学年学期(NowYearTerm 接口返回) */
|
||||
@Data
|
||||
public static class CurrentYear {
|
||||
|
||||
private String year;
|
||||
|
||||
private Integer term;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 项目统计汇总(名单导入后实时统计)。
|
||||
*/
|
||||
@Data
|
||||
public class ProjectStatsVo {
|
||||
|
||||
private long studentCount;
|
||||
|
||||
private long maleCount;
|
||||
|
||||
private long femaleCount;
|
||||
|
||||
/** 已落位班级的学生数 */
|
||||
private long assignedCount;
|
||||
|
||||
/** 毕业学校数(高中/初中) */
|
||||
private long schoolCount;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import com.yunhai.acaclassassign.entity.AssignPush;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 推送状态(GET /api/assign/project/PushStatus)。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public class PushStatusVo {
|
||||
|
||||
/** 0未推送 1已推送 2部分失败 3失败 4推送中 */
|
||||
private Integer status;
|
||||
|
||||
private String statusText;
|
||||
|
||||
private LocalDateTime pushTime;
|
||||
|
||||
private String msg;
|
||||
|
||||
/** 班级映射数(已建立本地班→平台班映射的班级数) */
|
||||
private Integer classCount;
|
||||
|
||||
/** 已成功学生数(分班结果中已推送成功的人数) */
|
||||
private Integer okCount;
|
||||
|
||||
/** 未成功学生数 */
|
||||
private Integer failCount;
|
||||
|
||||
/** 未成功明细 */
|
||||
private List<FailItem> fails = new ArrayList<>();
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public static class FailItem {
|
||||
|
||||
/** 本地学生id(aca_assign_student.id) */
|
||||
private Integer studentId;
|
||||
|
||||
private String name;
|
||||
|
||||
private String reason;
|
||||
}
|
||||
|
||||
public static String textOf(Integer status) {
|
||||
if (status == null) {
|
||||
return "未推送";
|
||||
}
|
||||
switch (status) {
|
||||
case AssignPush.STATUS_OK:
|
||||
return "已推送";
|
||||
case AssignPush.STATUS_PARTIAL:
|
||||
return "部分失败";
|
||||
case AssignPush.STATUS_FAIL:
|
||||
return "推送失败";
|
||||
case AssignPush.STATUS_PUSHING:
|
||||
return "推送中";
|
||||
default:
|
||||
return "未推送";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 分班规则(读写共用)。
|
||||
*/
|
||||
@Data
|
||||
public class RuleVo {
|
||||
|
||||
private BalanceVo balance;
|
||||
|
||||
private ConstraintVo constraint;
|
||||
|
||||
/** 分数段模式:count 按段数 / step 按分数间隔(小学版空) */
|
||||
private String bandMode;
|
||||
|
||||
/** 段数 3/4/5(上限 20) */
|
||||
private Integer bandCount;
|
||||
|
||||
/** 分数间隔 1-200 */
|
||||
private Integer bandStep;
|
||||
|
||||
/** 启用 A/B 分层(仅高中) */
|
||||
private Boolean abEnabled;
|
||||
|
||||
/** layer 按总分分层编班 / within 均衡编班+班内分层 */
|
||||
private String abMode;
|
||||
|
||||
/** A 班数量:1 ≤ K < 班级数 */
|
||||
private Integer abClassCount;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分班结果页数据(指标卡/图表/均衡明细/日志/班级卡片)。
|
||||
*/
|
||||
@Data
|
||||
public class RunResultVo {
|
||||
|
||||
/** 项目状态:0草稿 1已提交 */
|
||||
private Integer status;
|
||||
|
||||
private LocalDateTime assignTime;
|
||||
|
||||
private Integer iterations;
|
||||
|
||||
private Long costMs;
|
||||
|
||||
/** 综合均衡度 0-100 */
|
||||
private BigDecimal score;
|
||||
|
||||
/** 是否含手动调整 */
|
||||
private Boolean adjusted;
|
||||
|
||||
/** 参与分班人数 */
|
||||
private long total;
|
||||
|
||||
/** 人数极差 */
|
||||
private int countSpread;
|
||||
|
||||
/** 均分极差(小学=0) */
|
||||
private BigDecimal avgSpread;
|
||||
|
||||
/** 男女比例最大偏差%(相对全校平均) */
|
||||
private double genderDev;
|
||||
|
||||
/** A/B 分层信息(高中):aCount/bCount/aAvg/bAvg/layerDiff */
|
||||
private AbInfo abInfo;
|
||||
|
||||
/** 均衡明细 */
|
||||
private List<MetricItemVo> metrics = new ArrayList<>();
|
||||
|
||||
/** 加权相对偏差(%) */
|
||||
private double weightedDev;
|
||||
|
||||
/** 警告 */
|
||||
private List<String> warns = new ArrayList<>();
|
||||
|
||||
/** 日志(算法/校验/手动调班/班主任匹配) */
|
||||
private List<String> logs = new ArrayList<>();
|
||||
|
||||
/** 分数段切点标签 */
|
||||
private List<String> bandLabels = new ArrayList<>();
|
||||
|
||||
private ChartsVo charts = new ChartsVo();
|
||||
|
||||
private List<ClassCardVo> classCards = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
public static class AbInfo {
|
||||
|
||||
private Boolean enabled;
|
||||
|
||||
private String mode;
|
||||
|
||||
/** Jackson 会把 getAClassCount() 推导成 aclassCount,须显式指定与前端一致的键名 */
|
||||
@JsonProperty("aClassCount")
|
||||
private int aClassCount;
|
||||
|
||||
@JsonProperty("aCount")
|
||||
private int aCount;
|
||||
|
||||
@JsonProperty("aAvg")
|
||||
private BigDecimal aAvg;
|
||||
|
||||
@JsonProperty("bCount")
|
||||
private int bCount;
|
||||
|
||||
@JsonProperty("bAvg")
|
||||
private BigDecimal bAvg;
|
||||
|
||||
/** 层间均分差 */
|
||||
private BigDecimal layerDiff;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 保存班级设置请求。
|
||||
*/
|
||||
@Data
|
||||
public class SaveClassesRequest {
|
||||
|
||||
private Integer stage;
|
||||
|
||||
private String year;
|
||||
|
||||
private Integer term;
|
||||
|
||||
/** 全量班级清单(以本列表为准重建:删/改/增) */
|
||||
private List<ClassItemVo> classes;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 保存分班规则请求。
|
||||
*/
|
||||
@Data
|
||||
public class SaveRulesRequest {
|
||||
|
||||
private Integer stage;
|
||||
|
||||
private String year;
|
||||
|
||||
private Integer term;
|
||||
|
||||
private RuleVo rule;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 生源分析:毕业学校 × 分数档交叉表(高中/初中;仅结构分析,不影响分班)。
|
||||
*/
|
||||
@Data
|
||||
public class SourceAnalysisVo {
|
||||
|
||||
/** 分数线(升序) */
|
||||
private List<Double> cuts;
|
||||
|
||||
/** 分数档标签(左闭右开) */
|
||||
private List<String> labels;
|
||||
|
||||
/** 每校一行:counts 与 labels 对齐 */
|
||||
private List<SchoolRow> rows;
|
||||
|
||||
/** 各档合计 */
|
||||
private List<Long> sums;
|
||||
|
||||
/** 录取总人数 */
|
||||
private long total;
|
||||
|
||||
@Data
|
||||
public static class SchoolRow {
|
||||
|
||||
private String school;
|
||||
|
||||
private List<Long> counts;
|
||||
|
||||
private long total;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 学生名单查询(GET 参数绑定)。
|
||||
*/
|
||||
@Data
|
||||
public class StudentPageRequest {
|
||||
|
||||
private Integer stage;
|
||||
|
||||
private String year;
|
||||
|
||||
private Integer term;
|
||||
|
||||
/** 全部=null/0;否则班级id */
|
||||
private Integer classId;
|
||||
|
||||
/** 姓名关键字 */
|
||||
private String keyword;
|
||||
|
||||
private Integer pageIndex = 1;
|
||||
|
||||
private Integer pageSize = 50;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import com.yunhai.acaclassassign.model.BasePageResult;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 学生名单分页响应:在通用分页外壳上补充识别出的列名清单,
|
||||
* 前端据此渲染单科 / 是否型 / 数值型自定义列(刷新后无需重新导入)。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class StudentPageVo extends BasePageResult<StudentVo> {
|
||||
|
||||
/** 单科列名(按导入出现序,如 语文、数学、英语、物理、化学) */
|
||||
private List<String> subjects;
|
||||
|
||||
/** 是否型列名(按导入出现序,如 特长生、住宿) */
|
||||
private List<String> tags;
|
||||
|
||||
/** 数值型列名(按导入出现序,如 视力分) */
|
||||
private List<String> nums;
|
||||
|
||||
/** 年级名称(项目全体学生同一入学年级;班级管理字典名,不可得回退 K12 标准新生年级名) */
|
||||
private String gradeName;
|
||||
|
||||
public StudentPageVo(BasePageResult<StudentVo> page) {
|
||||
super(page.getContent(), page.getTotal(), page.getPageIndex(),
|
||||
page.getPageSize(), page.getTotalPages(), page.isHasNext());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 名单简单统计(全量学生,不分页):人数 / 性别 / 总分三值 / 均衡字段统计 / 毕业学校所数。
|
||||
*/
|
||||
@Data
|
||||
public class StudentStatsVo {
|
||||
|
||||
private long total;
|
||||
|
||||
private long male;
|
||||
|
||||
private long female;
|
||||
|
||||
/** 总分均分(无成绩数据时为 null) */
|
||||
private Double avgScore;
|
||||
|
||||
private Double maxScore;
|
||||
|
||||
private Double minScore;
|
||||
|
||||
/** 均衡字段统计(是否型计人数、数值型计班均值,按首次出现顺序) */
|
||||
private List<FieldStat> fields = new ArrayList<>();
|
||||
|
||||
/** 非空毕业学校去重数 */
|
||||
private int schoolCount;
|
||||
|
||||
@Data
|
||||
public static class FieldStat {
|
||||
|
||||
/** 字段名 */
|
||||
private String name;
|
||||
|
||||
/** tag=是否型(count 有效) / num=数值型(avg 有效) */
|
||||
private String kind;
|
||||
|
||||
/** 是否型:取真人数 */
|
||||
private Long count;
|
||||
|
||||
/** 数值型:全员均值(2 位小数) */
|
||||
private Double avg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 单元格编辑保存请求。
|
||||
*/
|
||||
@Data
|
||||
public class StudentUpdateRequest {
|
||||
|
||||
private Integer stage;
|
||||
|
||||
private String year;
|
||||
|
||||
private Integer term;
|
||||
|
||||
private StudentVo student;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.yunhai.acaclassassign.model.assign;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 学生行(列表/编辑/结果名单共用)。
|
||||
*/
|
||||
@Data
|
||||
public class StudentVo {
|
||||
|
||||
private Integer id;
|
||||
|
||||
private String name;
|
||||
|
||||
/** 1男 2女 0未知 */
|
||||
private Integer gender;
|
||||
|
||||
private BigDecimal totalScore;
|
||||
|
||||
/** 单科成绩 {"语文":95.5} */
|
||||
private Map<String, Object> scores;
|
||||
|
||||
/** 是否型字段 {"特长生":true} */
|
||||
private Map<String, Object> tags;
|
||||
|
||||
/** 数值型字段 {"视力分":4.8} */
|
||||
private Map<String, Object> nums;
|
||||
|
||||
private String graduateSchool;
|
||||
|
||||
/** 学号(标识文本列,仅展示/导出) */
|
||||
private String studentNo;
|
||||
|
||||
/** 账号(标识文本列,仅展示/导出) */
|
||||
private String account;
|
||||
|
||||
/** 密码(标识文本列,生成=账号+@,仅展示/导出/推送初始密码) */
|
||||
private String password;
|
||||
|
||||
private String remark;
|
||||
|
||||
private String presetRaw;
|
||||
|
||||
private Integer classId;
|
||||
|
||||
private String className;
|
||||
|
||||
/** A/B 层(高中) */
|
||||
private String level;
|
||||
|
||||
private Integer srcRow;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yunhai.acaclassassign.model.base;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 角色切换入参(对齐前端 baseApi.setRole)。
|
||||
*/
|
||||
@Data
|
||||
public class SetRoleRequest {
|
||||
/** "teacher" 或 "parent" */
|
||||
private String as;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.yunhai.acaclassassign.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.acaclassassign.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.acaclassassign.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.acaclassassign.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,67 @@
|
||||
package com.yunhai.acaclassassign.model.bsch;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 班级管理 /oapi/class 推送接口出参(反序列化用)。
|
||||
* 与 UserclassService 的 SyncClassResultVO / SyncStudentResultVO 字段对齐。
|
||||
*/
|
||||
public class SyncClassModels {
|
||||
|
||||
/** POST /oapi/class/AddClass 出参:本地班号→平台 classId 映射 */
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class SyncClassResult {
|
||||
|
||||
private Integer gradeId;
|
||||
|
||||
private Integer gradeGroupId;
|
||||
|
||||
private List<ClassItem> classes;
|
||||
}
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class ClassItem {
|
||||
|
||||
/** 班号(=分班侧 sort_order) */
|
||||
private Integer sort;
|
||||
|
||||
/** 平台班级id(班级组id) */
|
||||
private Integer classId;
|
||||
|
||||
private String code;
|
||||
|
||||
private String name;
|
||||
|
||||
/** true=本次新建;false=按 code 查重复用(幂等重推) */
|
||||
private Boolean created;
|
||||
}
|
||||
|
||||
/** POST /oapi/class/AddClassStudent 出参:逐生成败 */
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class SyncStudentResult {
|
||||
|
||||
private List<StudentItem> results;
|
||||
}
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class StudentItem {
|
||||
|
||||
private String name;
|
||||
|
||||
private String account;
|
||||
|
||||
private Boolean ok;
|
||||
|
||||
private Integer stuId;
|
||||
|
||||
/** 失败原因 / 幂等命中说明 */
|
||||
private String reason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yunhai.acaclassassign.model.bsch;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 学年选项(班级服务 GET /oapi/sch/getYearTermList 返回项,按学年倒序)。
|
||||
*/
|
||||
@Data
|
||||
public class YearTermItemVo {
|
||||
|
||||
/** 学年学期记录ID(base_year_term.id) */
|
||||
private Integer id;
|
||||
|
||||
/** 学年(起始年,如 2026) */
|
||||
private Integer year;
|
||||
|
||||
/** 是否当前学年 */
|
||||
private Boolean nowYear;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.yunhai.acaclassassign.repository;
|
||||
|
||||
import com.yunhai.acaclassassign.entity.AssignClass;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AssignClassRepository extends JpaRepository<AssignClass, Integer> {
|
||||
|
||||
|
||||
List<AssignClass> findByProjectIdOrderBySortOrderAsc(Integer projectId);
|
||||
|
||||
long countByProjectId(Integer projectId);
|
||||
|
||||
void deleteByProjectId(Integer projectId);
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.yunhai.acaclassassign.repository;
|
||||
|
||||
import com.yunhai.acaclassassign.entity.AssignProject;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AssignProjectRepository extends JpaRepository<AssignProject, Integer> {
|
||||
|
||||
Optional<AssignProject> findBySchIdAndStageAndYearAndTerm(Integer schId, Integer stage, String year, Integer term);
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.yunhai.acaclassassign.repository;
|
||||
|
||||
import com.yunhai.acaclassassign.entity.AssignPush;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AssignPushRepository extends JpaRepository<AssignPush, Integer> {
|
||||
|
||||
/** 每项目唯一一条推送记录 */
|
||||
Optional<AssignPush> findByProjectId(Integer projectId);
|
||||
|
||||
/** 项目重置时一并清除推送记录(远端数据不随本地重置回收) */
|
||||
void deleteByProjectId(Integer projectId);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.acaclassassign.repository;
|
||||
|
||||
import com.yunhai.acaclassassign.entity.AssignResult;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AssignResultRepository extends JpaRepository<AssignResult, Integer> {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Optional<AssignResult> findByProjectId(Integer projectId);
|
||||
|
||||
void deleteByProjectId(Integer projectId);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.acaclassassign.repository;
|
||||
|
||||
import com.yunhai.acaclassassign.entity.AssignRule;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AssignRuleRepository extends JpaRepository<AssignRule, Integer> {
|
||||
|
||||
|
||||
|
||||
|
||||
Optional<AssignRule> findByProjectId(Integer projectId);
|
||||
|
||||
void deleteByProjectId(Integer projectId);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.yunhai.acaclassassign.repository;
|
||||
|
||||
import com.yunhai.acaclassassign.entity.AssignStudent;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface AssignStudentRepository extends JpaRepository<AssignStudent, Integer> {
|
||||
|
||||
|
||||
|
||||
List<AssignStudent> findByProjectId(Integer projectId);
|
||||
|
||||
long countByProjectId(Integer projectId);
|
||||
|
||||
void deleteByProjectId(Integer projectId);
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yunhai.acaclassassign.repository;
|
||||
|
||||
import com.yunhai.acaclassassign.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,52 @@
|
||||
package com.yunhai.acaclassassign.service;
|
||||
|
||||
import com.yunhai.acaclassassign.entity.AssignProject;
|
||||
import com.yunhai.acaclassassign.model.assign.AssignSubmitCheckVo;
|
||||
import com.yunhai.acaclassassign.model.assign.ClassItemVo;
|
||||
import com.yunhai.acaclassassign.model.assign.ProjectInfoVo;
|
||||
import com.yunhai.acaclassassign.model.assign.RuleVo;
|
||||
import com.yunhai.acaclassassign.model.assign.SaveClassesRequest;
|
||||
import com.yunhai.acaclassassign.model.assign.SaveRulesRequest;
|
||||
import com.yunhai.acaclassassign.model.bsch.YearTermItemVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分班项目:学年学期维度的工作台信息、班级设置、规则、提交锁定。
|
||||
* <p>schId 统一取 {@code UserContext.getSchId()}(多校隔离)。</p>
|
||||
*/
|
||||
public interface IAssignProjectService {
|
||||
|
||||
/** 工作台信息(项目不存在则自动创建草稿) */
|
||||
ProjectInfoVo info(int stage, String year, int term);
|
||||
|
||||
/** 当前学年学期(平台接口优先,失败本地推断) */
|
||||
ProjectInfoVo.CurrentYear nowYearTerm();
|
||||
|
||||
/** 学年下拉列表(班级管理学年学期接口,按学年倒序;不可达返回空列表由前端回退) */
|
||||
List<YearTermItemVo> yearList();
|
||||
|
||||
/** 保存班级设置(全量替换;班主任只存平台 teacherId) */
|
||||
List<ClassItemVo> saveClasses(SaveClassesRequest request);
|
||||
|
||||
/** 保存分班规则 */
|
||||
RuleVo saveRules(SaveRulesRequest request);
|
||||
|
||||
/** 提交前检查清单 */
|
||||
AssignSubmitCheckVo submitCheck(int stage, String year, int term);
|
||||
|
||||
/** 提交锁定(force=人数差>1 时人工确认) */
|
||||
void submit(int stage, String year, int term, boolean force);
|
||||
|
||||
/** 重置全部(删除项目全部数据;已提交也可重置,审计见操作日志) */
|
||||
void reset(int stage, String year, int term);
|
||||
|
||||
/** 取项目(无则建) */
|
||||
AssignProject getOrCreate(int stage, String year, int term);
|
||||
|
||||
/** 取项目(无则抛业务异常) */
|
||||
AssignProject require(int stage, String year, int term);
|
||||
|
||||
/** 已提交锁定校验(已提交抛业务异常) */
|
||||
void checkNotSubmitted(AssignProject project);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.yunhai.acaclassassign.service;
|
||||
|
||||
import com.yunhai.acaclassassign.model.assign.AdjustRequest;
|
||||
import com.yunhai.acaclassassign.model.assign.AdjustResultVo;
|
||||
import com.yunhai.acaclassassign.model.assign.BandReportVo;
|
||||
import com.yunhai.acaclassassign.model.assign.ClassReportVo;
|
||||
import com.yunhai.acaclassassign.model.assign.RunResultVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* 分班执行与结果:算法运行、结果读取、手动调班、随机班主任、统计报表、导出。
|
||||
*/
|
||||
public interface IAssignRunService {
|
||||
|
||||
/** 执行均衡分班(覆盖旧结果) */
|
||||
RunResultVo run(int stage, String year, Integer term);
|
||||
|
||||
/** 结果页数据(含重新统计的班级卡片) */
|
||||
RunResultVo result(int stage, String year, Integer term);
|
||||
|
||||
/** 手动调班(硬约束校验;force 人工确认强行执行并审计) */
|
||||
AdjustResultVo adjust(AdjustRequest request);
|
||||
|
||||
/** 随机匹配班主任(已填教师 id 洗牌重排) */
|
||||
List<String> matchTeachers(int stage, String year, Integer term);
|
||||
|
||||
/** 班级均分报表(高中/初中) */
|
||||
List<ClassReportVo> classReport(int stage, String year, Integer term);
|
||||
|
||||
/** 分数段报表 */
|
||||
BandReportVo bandReport(int stage, String year, Integer term,
|
||||
String subject, Double min, Double max, Double step);
|
||||
|
||||
/** 分班明细 CSV 导出 */
|
||||
void exportDetail(int stage, String year, Integer term, HttpServletResponse response) throws Exception;
|
||||
|
||||
/** 班级统计 CSV 导出 */
|
||||
void exportClassStats(int stage, String year, Integer term, HttpServletResponse response) throws Exception;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.yunhai.acaclassassign.service;
|
||||
|
||||
import com.yunhai.acaclassassign.model.assign.ImportPasteRequest;
|
||||
import com.yunhai.acaclassassign.model.assign.ImportResultVo;
|
||||
import com.yunhai.acaclassassign.model.assign.PresetBatchRequest;
|
||||
import com.yunhai.acaclassassign.model.assign.PresetBatchResultVo;
|
||||
import com.yunhai.acaclassassign.model.assign.SourceAnalysisVo;
|
||||
import com.yunhai.acaclassassign.model.assign.StudentPageVo;
|
||||
import com.yunhai.acaclassassign.model.assign.StudentStatsVo;
|
||||
import com.yunhai.acaclassassign.model.assign.StudentUpdateRequest;
|
||||
import com.yunhai.acaclassassign.model.assign.StudentVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* 分班学生:导入解析、名单维护、批量预设、生源分析、模板下载。
|
||||
*/
|
||||
public interface IAssignStudentService {
|
||||
|
||||
/** 粘贴文本导入 */
|
||||
ImportResultVo importPaste(ImportPasteRequest request);
|
||||
|
||||
/** 文件导入(csv/txt/tsv,UTF-8) */
|
||||
ImportResultVo importFile(int stage, String year, Integer term, String mode, Boolean hasHeader,
|
||||
org.springframework.web.multipart.MultipartFile file);
|
||||
|
||||
/** 示例名单文本(服务端生成,返回 {text},由前端填入粘贴框、确认后走导入) */
|
||||
java.util.Map<String, String> sampleText(int stage);
|
||||
|
||||
/** 名单分页(班级/关键字筛选,班级+姓名拼音序;附全量识别出的列名清单) */
|
||||
StudentPageVo page(int stage, String year, Integer term, Integer classId,
|
||||
String keyword, int pageIndex, int pageSize);
|
||||
|
||||
/** 学生名单 CSV 下载(与 page 同筛选:班级/关键字,导出全部匹配行) */
|
||||
void exportList(int stage, String year, Integer term, Integer classId, String keyword,
|
||||
HttpServletResponse response) throws Exception;
|
||||
|
||||
/** 名单简单统计(全量:人数/性别/总分三值/均衡字段/毕业学校所数) */
|
||||
StudentStatsVo stats(int stage, String year, Integer term);
|
||||
|
||||
/** 单元格编辑保存 */
|
||||
void update(StudentUpdateRequest request);
|
||||
|
||||
/** 删除单个学生 */
|
||||
void delete(int stage, String year, Integer term, Integer id);
|
||||
|
||||
/** 清空名单 */
|
||||
void clear(int stage, String year, Integer term);
|
||||
|
||||
/** 按姓名批量预设班级(同名全部设置),返回未匹配姓名 */
|
||||
PresetBatchResultVo presetBatch(PresetBatchRequest request);
|
||||
|
||||
/** 生源分析:毕业学校 × 分数档交叉表(小学不支持) */
|
||||
SourceAnalysisVo sourceAnalysis(int stage, String year, Integer term, List<Double> cuts);
|
||||
|
||||
/** 生源分析交叉表 CSV 下载(与页面 SourceAnalysis 同口径) */
|
||||
void exportSource(int stage, String year, Integer term, List<Double> cuts,
|
||||
HttpServletResponse response) throws Exception;
|
||||
|
||||
/** 导入模板 CSV 下载 */
|
||||
void template(int stage, HttpServletResponse response) throws Exception;
|
||||
|
||||
/**
|
||||
* 班级导入数据 .xls 下载(班级管理「班级导入」模板格式):数据页 = 标题「班级导入」+ 表头
|
||||
* 年级|班级|班级别名|班主任|副班主任 + 每班一行(班级={序号}班,语义班名进别名,口径同推送);
|
||||
* 附「导入规则」页(年级候选/班级格式/教师名册)。年级列经班级管理接口按 学校+学年+学段
|
||||
* 查学校年级字典真实名称,不可得回退 K12 标准新生年级名(小一/初一/高一)。
|
||||
*/
|
||||
void exportClassImport(int stage, String year, Integer term,
|
||||
HttpServletResponse response) throws Exception;
|
||||
|
||||
/**
|
||||
* 按成绩/序号生成学号(覆盖已有学号;仅已分班学生参与编号,未分班学生学号不动)。
|
||||
* 项目内有总分=成绩模式:scope=class 各班按总分降序=班内名次2位(01、02、03…);
|
||||
* scope=grade 全年级按总分降序=学年+名次4位(如 20250001)。
|
||||
* 无任何总分=序号模式:排序退化为班级序+姓名拼音(class 为班内姓名拼音序)。
|
||||
* 返回 {scope, mode: score|seq, count}。
|
||||
*/
|
||||
java.util.Map<String, Object> genStudentNo(int stage, String year, Integer term, String scope);
|
||||
|
||||
/**
|
||||
* 按成绩/序号生成账号(覆盖已有账号;仅已分班学生参与编号,未分班学生账号不动)。
|
||||
* 格式:学校名称拼音首字母(小写)+s+学年+序号段——scope=class 为 班级序号2位+班内名次/序号2位(如 dngjxxs20260101);
|
||||
* scope=grade 为 年级名次/序号4位(如 dngjxxs20260001)。有总分按成绩排名,无总分按序号(同 genStudentNo 的序号模式)。
|
||||
* 返回 {scope, mode: score|seq, count}。
|
||||
*/
|
||||
java.util.Map<String, Object> genStudentAccount(int stage, String year, Integer term, String scope);
|
||||
|
||||
/**
|
||||
* 生成密码=账号+@(覆盖已有密码)。仅已分班且有账号的学生参与,无账号的跳过并计数;
|
||||
* 全部无账号报错提示先生成账号。返回 {count, skipped}。
|
||||
*/
|
||||
java.util.Map<String, Object> genStudentPassword(int stage, String year, Integer term);
|
||||
|
||||
/** 实体 → VO */
|
||||
StudentVo toVo(com.yunhai.acaclassassign.entity.AssignStudent entity,
|
||||
java.util.Map<Integer, String> classNameById);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.yunhai.acaclassassign.service;
|
||||
|
||||
import com.yunhai.acaclassassign.model.SchTeaList;
|
||||
import com.yunhai.acaclassassign.model.bsch.ClassTreeNodeVo;
|
||||
import com.yunhai.acaclassassign.model.bsch.NowYearTermVo;
|
||||
import com.yunhai.acaclassassign.model.bsch.SubjectVo;
|
||||
import com.yunhai.acaclassassign.model.bsch.YearTermItemVo;
|
||||
|
||||
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>
|
||||
* <li>学年学期列表:GET {@code {bschclass}/oapi/sch/getYearTermList}(按学年倒序,当前学年带 nowYear 标记)</li>
|
||||
* </ul>
|
||||
*/
|
||||
public interface IClassService {
|
||||
|
||||
/**
|
||||
* 获取当前学年学期。
|
||||
* 返回当前学年 year、当前学期 term(1第一学期 2第二学期)及学年/学期起止日期。
|
||||
*/
|
||||
NowYearTermVo getNowYearTerm();
|
||||
|
||||
/**
|
||||
* 获取全部学年列表(带 5 分钟内存缓存,按学年倒序)。
|
||||
* 外部接口不可达时返回空列表(调用方降级,不阻断分班业务)。
|
||||
*/
|
||||
List<YearTermItemVo> getYearTermList();
|
||||
|
||||
/**
|
||||
* 获取指定学校全部教师(含所属组 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);
|
||||
|
||||
/**
|
||||
* 年级名称(bschclass GET /oapi/sch/getGradeName,按学校+入学年份+学段查学校年级字典别称,
|
||||
* 带 5 分钟内存缓存)。stage 为班级管理学段口径(1小学/2初中/3高中)。
|
||||
* 接口不可达 / 学制或年级无匹配时返回 null(调用方降级,不阻断业务)。
|
||||
*/
|
||||
String getGradeName(int schId, int year, int stage);
|
||||
|
||||
/**
|
||||
* 年级→班级树(叶子=班级,附 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,21 @@
|
||||
package com.yunhai.acaclassassign.service;
|
||||
|
||||
import com.yunhai.acaclassassign.entity.AssignProject;
|
||||
import com.yunhai.acaclassassign.model.assign.PushStatusVo;
|
||||
|
||||
/**
|
||||
* 分班结果推送班级管理(userclass /oapi/class)服务。
|
||||
* <p>链路:AddClass(同步组,返回 本地班→平台classId 映射)→ AddClassStudent(用户中心建号+sch_student 落库)。
|
||||
* 推送与提交解耦:推送异常不影响提交锁定状态。</p>
|
||||
*/
|
||||
public interface IPushClassService {
|
||||
|
||||
/**
|
||||
* 推送分班结果(整体:全部班级 + 全部已分班学生)。
|
||||
* retry=true 时只重推 fail_json 中的失败学生(AddClass 幂等仍会整体调用)。
|
||||
*/
|
||||
PushStatusVo push(AssignProject project, boolean retry);
|
||||
|
||||
/** 查询推送状态(无记录返回未推送状态,不抛错) */
|
||||
PushStatusVo status(AssignProject project);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.yunhai.acaclassassign.service;
|
||||
|
||||
import com.yunhai.acaclassassign.entity.SysOperationLog;
|
||||
import com.yunhai.acaclassassign.model.BasePageQuery;
|
||||
import com.yunhai.acaclassassign.model.BasePageResult;
|
||||
|
||||
/**
|
||||
* 系统审计日志业务接口:异步落库 + 分页查询。
|
||||
*/
|
||||
public interface ISysOperationLogService {
|
||||
|
||||
/** 异步保存(失败仅记应用日志,不影响业务请求) */
|
||||
void saveAsync(SysOperationLog entity);
|
||||
|
||||
/** 分页查询(responseData 不返回) */
|
||||
BasePageResult<SysOperationLog> page(BasePageQuery query, String operationType, Integer statusCode);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.yunhai.acaclassassign.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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
package com.yunhai.acaclassassign.service.assign;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 姓名同音判定:内置常用同音字组(姓氏 + 名字常用字)。
|
||||
* <p>同组内任意字视为同音。soundKey 将姓名逐字映射为组代表字/原字后拼接。</p>
|
||||
*/
|
||||
public final class SameSound {
|
||||
|
||||
private SameSound() {
|
||||
}
|
||||
|
||||
/** 同音字组(移植自分班原型 45 组常用同音字表) */
|
||||
private static final String[] GROUPS = {
|
||||
"张章彰樟", "李理莉鲤里", "王旺望", "刘留流榴", "陈晨尘辰橙",
|
||||
"杨扬洋阳羊", "黄煌凰皇", "赵兆照罩", "吴梧吾无芜", "徐绪储",
|
||||
"孙损笋", "马玛码", "朱珠株珠诸", "胡湖糊蝴狐", "郭锅国帼",
|
||||
"何合河荷和", "高膏糕羔", "林淋琳霖麟", "罗锣萝箩络", "郑正证政挣",
|
||||
"梁粱良凉", "谢卸蟹泄", "宋送诵颂", "唐糖塘堂棠", "许序絮绪",
|
||||
"韩含寒涵涵函", "冯逢缝凤", "邓灯登等", "曹槽草漕", "彭澎蓬鹏棚",
|
||||
"曾增憎", "肖削消宵萧潇霄", "田填甜佃", "董懂冬", "袁元园原源圆员",
|
||||
"蔡菜采彩", "蒋桨讲奖", "余鱼渔愉于娱俞豫", "杜肚度渡镀", "叶页夜业",
|
||||
"程成城诚呈橙乘", "苏酥俗", "魏位未卫味畏", "吕铝侣旅履", "丁钉叮盯叮宁"
|
||||
};
|
||||
|
||||
private static final Map<Character, Character> CHAR_TO_REP = new HashMap<>();
|
||||
|
||||
static {
|
||||
for (String group : GROUPS) {
|
||||
final char rep = group.charAt(0);
|
||||
for (char c : group.toCharArray()) {
|
||||
CHAR_TO_REP.putIfAbsent(c, rep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 姓名同音键:逐字映射同音组代表字后拼接。
|
||||
*/
|
||||
public static String soundKey(String name) {
|
||||
if (name == null) {
|
||||
return "";
|
||||
}
|
||||
final String trimmed = name.trim();
|
||||
final StringBuilder sb = new StringBuilder(trimmed.length());
|
||||
for (int i = 0; i < trimmed.length(); i++) {
|
||||
final Character rep = CHAR_TO_REP.get(trimmed.charAt(i));
|
||||
sb.append(rep == null ? trimmed.charAt(i) : rep.charValue());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.yunhai.acaclassassign.service.assign;
|
||||
|
||||
/**
|
||||
* 示例名单生成:服务端生成与导入模板同口径的演示数据文本。
|
||||
*
|
||||
* 2026-10-21 按用户反馈定稿:「载入示例数据」只把文本填进前端粘贴框,由用户确认后
|
||||
* 再走 ImportPaste 通道入库(中途曾改为服务端直接落库,因用户要可预览/可编辑而回退);
|
||||
* 数据内容与历史前端示例保持一致(小学 60 人 / 中学 306 人)。
|
||||
*/
|
||||
public final class SampleDataGenerator {
|
||||
|
||||
private SampleDataGenerator() {
|
||||
}
|
||||
|
||||
/** 按学段生成示例名单文本(首行为表头,Tab 分隔,口径同导入模板) */
|
||||
public static String text(int stage) {
|
||||
final StringBuilder sb = new StringBuilder();
|
||||
if (stage == 2) {
|
||||
sb.append("姓名\t账号\t学号\t性别\t备注\t预设班级\n");
|
||||
for (int i = 1; i <= 60; i++) {
|
||||
sb.append(String.format("学生%03d\tstu%04d\t2026%04d\t%s\t%s\t%s\n",
|
||||
i, i, i,
|
||||
i % 2 == 1 ? "男" : "女",
|
||||
i % 17 == 0 ? "双胞胎" : "",
|
||||
i % 29 == 0 ? "1" : ""));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
sb.append("姓名\t账号\t学号\t性别\t总分\t语文\t数学\t英语\t特长生\t住宿\t毕业学校\t备注\t预设班级\n");
|
||||
for (int i = 1; i <= 300; i++) {
|
||||
final int chinese = 70 + (i % 30);
|
||||
final int math = 65 + (i % 35);
|
||||
final int english = 75 + (i % 25);
|
||||
sb.append(String.format("学生%03d\tstu%04d\t2026%04d\t%s\t%d\t%d\t%d\t%d\t%s\t%s\t%s\t%s\t%s\n",
|
||||
i, i, i,
|
||||
i % 2 == 1 ? "男" : "女",
|
||||
chinese + math + english, chinese, math, english,
|
||||
i % 11 == 0 ? "是" : "否",
|
||||
i % 4 == 0 ? "是" : "否",
|
||||
"毕业学校" + ((i % 6) + 1),
|
||||
i % 23 == 0 ? "同名" : "",
|
||||
i % 37 == 0 ? "1" : ""));
|
||||
}
|
||||
// 约束演示行:同名、双胞胎同班、矛盾学生不同班
|
||||
sb.append("张三\tstu0901\t2026901\t男\t240\t80\t80\t80\t否\t否\t毕业学校1\t\t\n");
|
||||
sb.append("张三\tstu0902\t2026902\t女\t235\t78\t79\t78\t否\t是\t毕业学校2\t\t\n");
|
||||
sb.append("李大\tstu0903\t2026903\t男\t250\t85\t85\t80\t否\t否\t毕业学校1\t双胞胎\t同班:李二\n");
|
||||
sb.append("李二\tstu0904\t2026904\t男\t248\t84\t84\t80\t否\t否\t毕业学校3\t双胞胎\t\n");
|
||||
sb.append("赵六\tstu0905\t2026905\t男\t230\t76\t77\t77\t否\t否\t毕业学校4\t\t不同班:钱七\n");
|
||||
sb.append("钱七\tstu0906\t2026906\t男\t228\t75\t76\t77\t否\t否\t毕业学校5\t\t\n");
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
package com.yunhai.acaclassassign.service.assign;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 学生名单解析器(粘贴文本 / csv·txt·tsv 文件共用)。
|
||||
* <p>移植原型列识别规则:表头关键词识别、性别归一化、是否/数值型字段推断、预设班级解析。</p>
|
||||
*/
|
||||
public final class StudentParser {
|
||||
|
||||
private StudentParser() {
|
||||
}
|
||||
|
||||
/** 单科列名正则 */
|
||||
private static final Pattern SUBJECT = Pattern.compile(
|
||||
".*(?:语文|数学|英语|物理|化学|生物|政治|历史|地理|科学|道法|道德|思想品德|音乐|美术|体育|信息技术|信息|综合).*");
|
||||
/** 毕业学校列名 */
|
||||
private static final Pattern SCHOOL = Pattern.compile(
|
||||
".*(?:毕业学校|毕业小学|毕业院校|原学校|就读学校|来源学校|毕业中学).*");
|
||||
/** 学号列名 */
|
||||
private static final Pattern STU_NO = Pattern.compile(".*(学号|学籍号|考号|准考证).*");
|
||||
/** 账号列名 */
|
||||
private static final Pattern ACCOUNT = Pattern.compile(".*(账号|账户).*");
|
||||
/** 备注列名 */
|
||||
private static final Pattern REMARK = Pattern.compile(".*(备注|说明).*");
|
||||
/** 预设班级列名 */
|
||||
private static final Pattern PRESET = Pattern.compile(".*(?:预设|指定班级|意向|固定班级|要求).*");
|
||||
/** 姓名列名 */
|
||||
private static final Pattern NAME = Pattern.compile(".*(姓名|名字).*");
|
||||
/** 性别列名 */
|
||||
private static final Pattern GENDER = Pattern.compile(".*(性别|sex).*");
|
||||
/** 总分列名 */
|
||||
private static final Pattern TOTAL = Pattern.compile(".*(总分|总成绩|入学总分|合计).*");
|
||||
/** 忽略列名(已有分班结果列等) */
|
||||
private static final Pattern IGNORE = Pattern.compile("^(序号|编号|#|班级|行政班|分班结果)$");
|
||||
|
||||
/** 预设解析结果 */
|
||||
public static class Preset {
|
||||
|
||||
/** class=固定到某班 / same=同班捆绑 / diff=不同班叉开 */
|
||||
public String type;
|
||||
|
||||
/** type=class 时的班级序号(1 起) */
|
||||
public Integer classSort;
|
||||
|
||||
/** type=same/diff 时的对方姓名 */
|
||||
public String target;
|
||||
|
||||
public Preset(String type, Integer classSort, String target) {
|
||||
this.type = type;
|
||||
this.classSort = classSort;
|
||||
this.target = target;
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析后的行 */
|
||||
public static class ParsedRow {
|
||||
|
||||
public String name;
|
||||
|
||||
/** 1男 2女 0未知 */
|
||||
public int gender;
|
||||
|
||||
public Double total;
|
||||
|
||||
public final Map<String, Double> scores = new LinkedHashMap<>();
|
||||
|
||||
public final Map<String, Boolean> tags = new LinkedHashMap<>();
|
||||
|
||||
public final Map<String, Double> nums = new LinkedHashMap<>();
|
||||
|
||||
public String school;
|
||||
|
||||
public String studentNo;
|
||||
|
||||
public String account;
|
||||
|
||||
public String remark;
|
||||
|
||||
public String presetRaw;
|
||||
|
||||
public Preset preset;
|
||||
|
||||
public int srcRow;
|
||||
}
|
||||
|
||||
/** 解析结果 */
|
||||
public static class ParseResult {
|
||||
|
||||
public final List<ParsedRow> rows = new ArrayList<>();
|
||||
|
||||
public final List<String> warn = new ArrayList<>();
|
||||
|
||||
/** 识别到的列(保持顺序) */
|
||||
public final List<String> subjects = new ArrayList<>();
|
||||
|
||||
public final List<String> tags = new ArrayList<>();
|
||||
|
||||
public final List<String> nums = new ArrayList<>();
|
||||
|
||||
public boolean hasScore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析粘贴文本/文件内容。
|
||||
*
|
||||
* @param stage 2小学 3初中 4高中(决定无表头时的默认列序与成绩开关)
|
||||
* @param hasHeader null=自动检测
|
||||
*/
|
||||
public static ParseResult parse(String text, int stage, Boolean hasHeader) {
|
||||
final ParseResult result = new ParseResult();
|
||||
final boolean primary = stage == 2;
|
||||
final List<List<String>> grid = splitGrid(text);
|
||||
if (grid.isEmpty()) {
|
||||
result.warn.add("未解析到有效数据行");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 表头检测:显式指定优先,否则首行出现任一已知关键词即视为表头
|
||||
boolean header = false;
|
||||
List<String> head = null;
|
||||
final List<String> first = grid.get(0);
|
||||
if (hasHeader != null) {
|
||||
header = hasHeader;
|
||||
} else {
|
||||
for (String cell : first) {
|
||||
if (NAME.matcher(cell).matches() || GENDER.matcher(cell).matches()
|
||||
|| TOTAL.matcher(cell).matches() || SUBJECT.matcher(cell).matches()
|
||||
|| SCHOOL.matcher(cell).matches() || REMARK.matcher(cell).matches()
|
||||
|| STU_NO.matcher(cell).matches() || ACCOUNT.matcher(cell).matches()) {
|
||||
header = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
int start = 0;
|
||||
if (header) {
|
||||
head = first;
|
||||
start = 1;
|
||||
}
|
||||
|
||||
// 列分类
|
||||
final int cols = head != null ? head.size() : first.size();
|
||||
final int[] kind = new int[cols]; // 0忽略 1姓名 2性别 3总分 4单科 5学校 6备注 7预设 8自定义 9学号 10账号
|
||||
final String[] customName = new String[cols];
|
||||
Arrays.fill(kind, -1);
|
||||
for (int c = 0; c < cols; c++) {
|
||||
final String h = head != null && c < head.size() ? safe(head.get(c)) : "";
|
||||
kind[c] = classify(h, c);
|
||||
customName[c] = h;
|
||||
}
|
||||
if (head == null) {
|
||||
// 无表头默认列序:成绩版 姓名/性别/总分/语文/数学/英语 + 自定义字段N;小学版 姓名/性别/备注/预设班级
|
||||
if (!primary) {
|
||||
final String[] defaults = {"姓名", "性别", "总分", "语文", "数学", "英语"};
|
||||
for (int c = 0; c < cols; c++) {
|
||||
if (c < defaults.length) {
|
||||
kind[c] = classify(defaults[c], c);
|
||||
customName[c] = defaults[c];
|
||||
} else {
|
||||
kind[c] = 8;
|
||||
customName[c] = "自定义字段" + c;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final String[] defaults = {"姓名", "性别", "备注", "预设班级"};
|
||||
for (int c = 0; c < cols; c++) {
|
||||
if (c < defaults.length) {
|
||||
kind[c] = classify(defaults[c], c);
|
||||
customName[c] = defaults[c];
|
||||
} else {
|
||||
kind[c] = 8;
|
||||
customName[c] = "自定义字段" + c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 数据行
|
||||
for (int r = start; r < grid.size(); r++) {
|
||||
final List<String> cells = grid.get(r);
|
||||
String name = null;
|
||||
for (int c = 0; c < Math.min(cols, cells.size()); c++) {
|
||||
if (kind[c] == 1 && !safe(cells.get(c)).isEmpty()) {
|
||||
name = safe(cells.get(c));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (name == null || name.isEmpty()) {
|
||||
continue; // 空行跳过
|
||||
}
|
||||
final ParsedRow row = new ParsedRow();
|
||||
row.name = name;
|
||||
row.srcRow = r + 1;
|
||||
Double subjectSum = 0.0;
|
||||
boolean hasSubject = false;
|
||||
for (int c = 0; c < Math.min(cols, cells.size()); c++) {
|
||||
final String v = safe(cells.get(c));
|
||||
if (v.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
switch (kind[c]) {
|
||||
case 1:
|
||||
break;
|
||||
case 2:
|
||||
row.gender = normalizeGender(v);
|
||||
break;
|
||||
case 3:
|
||||
row.total = toDouble(v);
|
||||
break;
|
||||
case 4:
|
||||
final Double sv = toDouble(v);
|
||||
final String sub = customName[c];
|
||||
if (sv != null && !sub.isEmpty()) {
|
||||
row.scores.put(sub, sv);
|
||||
if (!result.subjects.contains(sub)) {
|
||||
result.subjects.add(sub);
|
||||
}
|
||||
subjectSum += sv;
|
||||
hasSubject = true;
|
||||
}
|
||||
break;
|
||||
case 5:
|
||||
row.school = v;
|
||||
break;
|
||||
case 9:
|
||||
row.studentNo = v;
|
||||
break;
|
||||
case 10:
|
||||
row.account = v;
|
||||
break;
|
||||
case 6:
|
||||
row.remark = v;
|
||||
break;
|
||||
case 7:
|
||||
row.presetRaw = v;
|
||||
row.preset = parsePreset(v);
|
||||
break;
|
||||
case 8:
|
||||
// 自定义字段类型延后按列数值占比判定
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (row.total == null && hasSubject) {
|
||||
row.total = subjectSum;
|
||||
}
|
||||
result.rows.add(row);
|
||||
}
|
||||
|
||||
// 自定义字段类型判定(某列非空值 ≥80% 为数字 → 数值型,否则是否型)
|
||||
for (int c = 0; c < cols; c++) {
|
||||
if (kind[c] != 8) {
|
||||
continue;
|
||||
}
|
||||
final String colName = customName[c];
|
||||
int nonEmpty = 0;
|
||||
int numeric = 0;
|
||||
for (int r = start; r < grid.size(); r++) {
|
||||
final List<String> cells = grid.get(r);
|
||||
if (c >= cells.size()) {
|
||||
continue;
|
||||
}
|
||||
final String v = safe(cells.get(c));
|
||||
if (v.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
nonEmpty++;
|
||||
if (toDouble(v) != null) {
|
||||
numeric++;
|
||||
}
|
||||
}
|
||||
final boolean asNum = nonEmpty > 0 && numeric * 1.0 / nonEmpty >= 0.8;
|
||||
if (asNum) {
|
||||
result.nums.add(colName);
|
||||
for (int r = start; r < grid.size(); r++) {
|
||||
final List<String> cells = grid.get(r);
|
||||
if (c < cells.size() && !safe(cells.get(c)).isEmpty()) {
|
||||
final ParsedRow row = findRow(result, r + 1);
|
||||
if (row != null) {
|
||||
final Double d = toDouble(safe(cells.get(c)));
|
||||
if (d != null) {
|
||||
row.nums.put(colName, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.tags.add(colName);
|
||||
for (int r = start; r < grid.size(); r++) {
|
||||
final List<String> cells = grid.get(r);
|
||||
if (c < cells.size() && !safe(cells.get(c)).isEmpty()) {
|
||||
final ParsedRow row = findRow(result, r + 1);
|
||||
if (row != null) {
|
||||
row.tags.put(colName, parseBool(safe(cells.get(c))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.hasScore = !primary && (!result.subjects.isEmpty() || anyTotal(result));
|
||||
if (primary && result.hasScore) {
|
||||
result.warn.add("小学版不使用成绩列,已忽略全部成绩数据");
|
||||
}
|
||||
if (result.rows.isEmpty()) {
|
||||
result.warn.add("未解析到姓名非空的数据行");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ParsedRow findRow(ParseResult result, int srcRow) {
|
||||
for (ParsedRow row : result.rows) {
|
||||
if (row.srcRow == srcRow) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean anyTotal(ParseResult result) {
|
||||
for (ParsedRow row : result.rows) {
|
||||
if (row.total != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int classify(String h, int index) {
|
||||
final String head = safe(h);
|
||||
if (head.isEmpty()) {
|
||||
return index == 0 ? 1 : -1; // 无表头首列按姓名
|
||||
}
|
||||
if (IGNORE.matcher(head).matches()) {
|
||||
return -1;
|
||||
}
|
||||
// 标识列优先于其他关键词,避免「学号」等列被当自定义字段/单科
|
||||
if (STU_NO.matcher(head).matches()) {
|
||||
return 9;
|
||||
}
|
||||
if (ACCOUNT.matcher(head).matches()) {
|
||||
return 10;
|
||||
}
|
||||
if (NAME.matcher(head).matches()) {
|
||||
return 1;
|
||||
}
|
||||
if (GENDER.matcher(head).matches()) {
|
||||
return 2;
|
||||
}
|
||||
if (TOTAL.matcher(head).matches()) {
|
||||
return 3;
|
||||
}
|
||||
if (SUBJECT.matcher(head).matches()) {
|
||||
return 4;
|
||||
}
|
||||
if (SCHOOL.matcher(head).matches()) {
|
||||
return 5;
|
||||
}
|
||||
if (REMARK.matcher(head).matches()) {
|
||||
return 6;
|
||||
}
|
||||
if (PRESET.matcher(head).matches()) {
|
||||
return 7;
|
||||
}
|
||||
return 8;
|
||||
}
|
||||
|
||||
/** 拆分为行列网格:优先 Tab(Excel 复制),其次逗号,最后连续空白 */
|
||||
static List<List<String>> splitGrid(String text) {
|
||||
final List<List<String>> grid = new ArrayList<>();
|
||||
if (text == null || text.trim().isEmpty()) {
|
||||
return grid;
|
||||
}
|
||||
final String[] lines = text.replace("\r\n", "\n").replace('\r', '\n').split("\n");
|
||||
for (String line : lines) {
|
||||
if (line.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
final List<String> cells = new ArrayList<>();
|
||||
if (line.contains("\t")) {
|
||||
for (String c : line.split("\t", -1)) {
|
||||
cells.add(clean(c));
|
||||
}
|
||||
} else if (line.contains(",")) {
|
||||
for (String c : splitCsvLine(line)) {
|
||||
cells.add(clean(c));
|
||||
}
|
||||
} else {
|
||||
for (String c : line.trim().split("\\s+")) {
|
||||
cells.add(clean(c));
|
||||
}
|
||||
}
|
||||
grid.add(cells);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
private static List<String> splitCsvLine(String line) {
|
||||
final List<String> out = new ArrayList<>();
|
||||
final StringBuilder cur = new StringBuilder();
|
||||
boolean inQuote = false;
|
||||
for (int i = 0; i < line.length(); i++) {
|
||||
final char ch = line.charAt(i);
|
||||
if (inQuote) {
|
||||
if (ch == '"') {
|
||||
if (i + 1 < line.length() && line.charAt(i + 1) == '"') {
|
||||
cur.append('"');
|
||||
i++;
|
||||
} else {
|
||||
inQuote = false;
|
||||
}
|
||||
} else {
|
||||
cur.append(ch);
|
||||
}
|
||||
} else if (ch == '"') {
|
||||
inQuote = true;
|
||||
} else if (ch == ',') {
|
||||
out.add(cur.toString());
|
||||
cur.setLength(0);
|
||||
} else {
|
||||
cur.append(ch);
|
||||
}
|
||||
}
|
||||
out.add(cur.toString());
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String clean(String cell) {
|
||||
String v = cell == null ? "" : cell.trim();
|
||||
if (v.length() >= 2 && v.startsWith("\"") && v.endsWith("\"")) {
|
||||
v = v.substring(1, v.length() - 1).replace("\"\"", "\"");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private static String safe(String v) {
|
||||
return v == null ? "" : v.trim();
|
||||
}
|
||||
|
||||
/** 性别归一化:男/m/male/1/boy→1;女/f/female/0/girl→2;其余 0 */
|
||||
public static int normalizeGender(String v) {
|
||||
final String s = safe(v).toLowerCase();
|
||||
if (s.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
if (s.equals("男") || s.equals("m") || s.equals("male") || s.equals("1") || s.equals("boy")) {
|
||||
return 1;
|
||||
}
|
||||
if (s.equals("女") || s.equals("f") || s.equals("female") || s.equals("0") || s.equals("girl")) {
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** 是否型取值:是/有/1/true/y/yes/√/✓/对→true;其余 false */
|
||||
public static boolean parseBool(String v) {
|
||||
final String s = safe(v).toLowerCase();
|
||||
return s.equals("是") || s.equals("有") || s.equals("1") || s.equals("true")
|
||||
|| s.equals("y") || s.equals("yes") || s.equals("√") || s.equals("✓") || s.equals("对");
|
||||
}
|
||||
|
||||
public static Double toDouble(String v) {
|
||||
try {
|
||||
return Double.valueOf(safe(v));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设班级解析:1 / 1班 → 固定班级;同班:张三 → 捆绑;不同班:李四 → 叉开。
|
||||
*/
|
||||
public static Preset parsePreset(String raw) {
|
||||
final String s = safe(raw);
|
||||
if (s.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
// 固定班级:数字开头(可带"班"字)
|
||||
final java.util.regex.Matcher m = Pattern.compile("^(\\d+)\\s*班?$").matcher(s);
|
||||
if (m.matches()) {
|
||||
return new Preset("class", Integer.valueOf(m.group(1)), null);
|
||||
}
|
||||
// 同班 / 捆绑 / 绑定 / 同一班 / 双胞胎 : 目标姓名
|
||||
final java.util.regex.Matcher same = Pattern.compile(
|
||||
"^(?:同班|捆绑|绑定|同一班|双胞胎)\\s*[::]\\s*(.+)$").matcher(s);
|
||||
if (same.matches()) {
|
||||
return new Preset("same", null, safe(same.group(1)));
|
||||
}
|
||||
// 不同班 / 叉开 / 分开 / 异班 / 避让 : 对方姓名
|
||||
final java.util.regex.Matcher diff = Pattern.compile(
|
||||
"^(?:不同班|叉开|分开|异班|避让)\\s*[::]\\s*(.+)$").matcher(s);
|
||||
if (diff.matches()) {
|
||||
return new Preset("diff", null, safe(diff.group(1)));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
package com.yunhai.acaclassassign.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.yunhai.acaclassassign.entity.AssignClass;
|
||||
import com.yunhai.acaclassassign.entity.AssignProject;
|
||||
import com.yunhai.acaclassassign.entity.AssignResult;
|
||||
import com.yunhai.acaclassassign.entity.AssignRule;
|
||||
import com.yunhai.acaclassassign.entity.AssignStudent;
|
||||
import com.yunhai.acaclassassign.model.SchTeaList;
|
||||
import com.yunhai.acaclassassign.model.assign.AssignSubmitCheckVo;
|
||||
import com.yunhai.acaclassassign.model.assign.BalanceVo;
|
||||
import com.yunhai.acaclassassign.model.assign.ClassItemVo;
|
||||
import com.yunhai.acaclassassign.model.assign.ConstraintVo;
|
||||
import com.yunhai.acaclassassign.model.assign.ProjectInfoVo;
|
||||
import com.yunhai.acaclassassign.model.assign.ProjectStatsVo;
|
||||
import com.yunhai.acaclassassign.model.assign.RuleVo;
|
||||
import com.yunhai.acaclassassign.model.assign.SaveClassesRequest;
|
||||
import com.yunhai.acaclassassign.model.assign.SaveRulesRequest;
|
||||
import com.yunhai.acaclassassign.model.bsch.NowYearTermVo;
|
||||
import com.yunhai.acaclassassign.model.bsch.YearTermItemVo;
|
||||
import com.yunhai.acaclassassign.repository.AssignClassRepository;
|
||||
import com.yunhai.acaclassassign.repository.AssignProjectRepository;
|
||||
import com.yunhai.acaclassassign.repository.AssignPushRepository;
|
||||
import com.yunhai.acaclassassign.repository.AssignResultRepository;
|
||||
import com.yunhai.acaclassassign.repository.AssignRuleRepository;
|
||||
import com.yunhai.acaclassassign.repository.AssignStudentRepository;
|
||||
import com.yunhai.acaclassassign.service.IAssignProjectService;
|
||||
import com.yunhai.acaclassassign.service.IClassService;
|
||||
import com.yunhai.acaclassassign.utils.UserContext;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Month;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 分班项目服务实现。
|
||||
*/
|
||||
@Service
|
||||
public class AssignProjectService implements IAssignProjectService {
|
||||
|
||||
private static final int STATUS_DRAFT = 0;
|
||||
|
||||
private static final int STATUS_SUBMITTED = 1;
|
||||
|
||||
private static final int MAX_CLASS_COUNT = 40;
|
||||
|
||||
@Autowired
|
||||
private AssignProjectRepository projectRepository;
|
||||
|
||||
@Autowired
|
||||
private AssignClassRepository classRepository;
|
||||
|
||||
@Autowired
|
||||
private AssignStudentRepository studentRepository;
|
||||
|
||||
@Autowired
|
||||
private AssignRuleRepository ruleRepository;
|
||||
|
||||
@Autowired
|
||||
private AssignResultRepository resultRepository;
|
||||
|
||||
@Autowired
|
||||
private AssignPushRepository pushRepository;
|
||||
|
||||
@Autowired
|
||||
private IClassService classService;
|
||||
|
||||
// ==================== 查询 ====================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public ProjectInfoVo info(int stage, String year, int term) {
|
||||
final AssignProject project = getOrCreate(stage, year, term);
|
||||
final ProjectInfoVo vo = new ProjectInfoVo();
|
||||
vo.setProjectId(project.getId());
|
||||
vo.setStage(project.getStage());
|
||||
vo.setYear(project.getYear());
|
||||
vo.setTerm(project.getTerm());
|
||||
vo.setStatus(project.getStatus());
|
||||
vo.setSubmitTime(project.getSubmitTime());
|
||||
vo.setClasses(classVos(project.getId()));
|
||||
vo.setRule(ruleVo(project.getId()));
|
||||
vo.setStats(stats(project.getId()));
|
||||
final ProjectInfoVo.CurrentYear cur = nowYearTerm();
|
||||
vo.setCurrentYear(cur.getYear());
|
||||
vo.setCurrentTerm(cur.getTerm());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProjectInfoVo.CurrentYear nowYearTerm() {
|
||||
final ProjectInfoVo.CurrentYear vo = new ProjectInfoVo.CurrentYear();
|
||||
try {
|
||||
final NowYearTermVo remote = classService.getNowYearTerm();
|
||||
if (remote != null && remote.getYear() != null) {
|
||||
vo.setYear(String.valueOf(remote.getYear()));
|
||||
vo.setTerm(remote.getTerm() == null ? 1 : remote.getTerm());
|
||||
return vo;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 平台不可达时本地推断,不阻断工作台
|
||||
}
|
||||
final LocalDateTime now = LocalDateTime.now();
|
||||
final int startYear = now.getMonth() == Month.JANUARY ? now.getYear() - 1 : now.getYear();
|
||||
vo.setYear(String.valueOf(startYear));
|
||||
vo.setTerm(now.getMonth() == Month.JANUARY || now.getMonth().getValue() >= 9 ? 1 : 2);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<YearTermItemVo> yearList() {
|
||||
try {
|
||||
final List<YearTermItemVo> list = classService.getYearTermList();
|
||||
return list == null ? Collections.emptyList() : list;
|
||||
} catch (Exception e) {
|
||||
// 平台不可达时返回空列表,前端回退本地推断的学年范围
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 班级设置 ====================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public List<ClassItemVo> saveClasses(SaveClassesRequest request) {
|
||||
if (request == null || request.getStage() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "参数缺失");
|
||||
}
|
||||
final AssignProject project = getOrCreate(request.getStage(), request.getYear(), request.getTerm());
|
||||
checkNotSubmitted(project);
|
||||
final List<ClassItemVo> items = request.getClasses() == null ? new ArrayList<>() : request.getClasses();
|
||||
if (items.isEmpty() || items.size() > MAX_CLASS_COUNT) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "班级数量须在 1-" + MAX_CLASS_COUNT + " 之间");
|
||||
}
|
||||
// 校验:名称非空、无重名
|
||||
final Set<String> names = new HashSet<>();
|
||||
for (ClassItemVo item : items) {
|
||||
if (item.getName() == null || item.getName().trim().isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "班级名称不能为空");
|
||||
}
|
||||
if (!names.add(item.getName().trim())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "班级名称重复:" + item.getName());
|
||||
}
|
||||
if (item.getTargetCount() != null && item.getTargetCount() <= 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "目标人数须为正整数");
|
||||
}
|
||||
}
|
||||
// 教师名册(teacherId → 姓名校验与冗余)
|
||||
final Map<Integer, String> teacherNames = teacherNameMap();
|
||||
// 全量替换:先删后插(分班结果一并清除,因班级清单变化使结果失效)
|
||||
classRepository.deleteByProjectId(project.getId());
|
||||
resultRepository.deleteByProjectId(project.getId());
|
||||
final List<AssignClass> entities = new ArrayList<>();
|
||||
int sort = 1;
|
||||
for (ClassItemVo item : items) {
|
||||
final AssignClass c = new AssignClass();
|
||||
c.setProjectId(project.getId());
|
||||
c.setSortOrder(sort++);
|
||||
c.setName(item.getName().trim());
|
||||
if (item.getTeacherId() != null) {
|
||||
final String tname = teacherNames.get(item.getTeacherId());
|
||||
if (tname == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"班主任教师id不存在(平台教师列表中未找到:" + item.getTeacherId() + ")");
|
||||
}
|
||||
c.setTeacherId(item.getTeacherId());
|
||||
c.setTeacherName(tname);
|
||||
}
|
||||
c.setTargetCount(item.getTargetCount());
|
||||
c.setAbFlag(Boolean.TRUE.equals(item.getAbFlag()));
|
||||
entities.add(c);
|
||||
}
|
||||
classRepository.saveAll(entities);
|
||||
return classVos(project.getId());
|
||||
}
|
||||
|
||||
// ==================== 规则 ====================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public RuleVo saveRules(SaveRulesRequest request) {
|
||||
if (request == null || request.getStage() == null || request.getRule() == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "参数缺失");
|
||||
}
|
||||
final AssignProject project = getOrCreate(request.getStage(), request.getYear(), request.getTerm());
|
||||
checkNotSubmitted(project);
|
||||
final RuleVo rule = request.getRule();
|
||||
validateRule(project, rule);
|
||||
// A 班标记:layer 模式取前 K 个班
|
||||
if (rule.getAbEnabled() && "layer".equals(rule.getAbMode())) {
|
||||
final List<AssignClass> classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId());
|
||||
int i = 1;
|
||||
for (AssignClass c : classes) {
|
||||
c.setAbFlag(i++ <= safeInt(rule.getAbClassCount(), 1));
|
||||
}
|
||||
classRepository.saveAll(classes);
|
||||
}
|
||||
AssignRule entity = ruleRepository.findByProjectId(project.getId()).orElse(null);
|
||||
if (entity == null) {
|
||||
entity = new AssignRule();
|
||||
entity.setProjectId(project.getId());
|
||||
}
|
||||
entity.setBalanceJson(JSON.toJSONString(nvlBalance(rule.getBalance())));
|
||||
entity.setConstraintJson(JSON.toJSONString(nvlConstraint(rule.getConstraint())));
|
||||
entity.setBandMode(rule.getBandMode());
|
||||
entity.setBandCount(rule.getBandCount());
|
||||
entity.setBandStep(rule.getBandStep());
|
||||
entity.setAbEnabled(Boolean.TRUE.equals(rule.getAbEnabled()));
|
||||
entity.setAbMode(rule.getAbMode());
|
||||
entity.setAbClassCount(rule.getAbClassCount());
|
||||
ruleRepository.save(entity);
|
||||
return ruleVo(project.getId());
|
||||
}
|
||||
|
||||
private void validateRule(AssignProject project, RuleVo rule) {
|
||||
final String mode = rule.getBandMode();
|
||||
if (mode != null && !mode.isEmpty()) {
|
||||
if (!"count".equals(mode) && !"step".equals(mode)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "分数段模式非法");
|
||||
}
|
||||
if ("count".equals(mode)) {
|
||||
final int n = safeInt(rule.getBandCount(), 4);
|
||||
if (n < 2 || n > 20) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "分数段段数须在 2-20 之间");
|
||||
}
|
||||
}
|
||||
if ("step".equals(mode)) {
|
||||
final int step = safeInt(rule.getBandStep(), 10);
|
||||
if (step < 1 || step > 200) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "分数间隔须在 1-200 之间");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rule.getAbEnabled()) {
|
||||
if (project.getStage() != 4) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "仅高中版支持 A/B 分层");
|
||||
}
|
||||
final long classCount = classRepository.countByProjectId(project.getId());
|
||||
final int k = safeInt(rule.getAbClassCount(), 1);
|
||||
if (classCount == 0) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "请先完成班级设置");
|
||||
}
|
||||
if (k < 1 || k >= classCount) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "A班数量须满足 1 ≤ K < 班级数");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 提交 ====================
|
||||
|
||||
@Override
|
||||
public AssignSubmitCheckVo submitCheck(int stage, String year, int term) {
|
||||
final AssignProject project = require(stage, year, term);
|
||||
final AssignSubmitCheckVo vo = new AssignSubmitCheckVo();
|
||||
vo.setStatus(project.getStatus());
|
||||
vo.setSubmitTime(project.getSubmitTime());
|
||||
vo.setYear(project.getYear());
|
||||
vo.setTerm(project.getTerm());
|
||||
final List<AssignClass> classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId());
|
||||
final long studentCount = studentRepository.countByProjectId(project.getId());
|
||||
vo.setClassCount(classes.size());
|
||||
vo.setStudentCount(studentCount);
|
||||
int teacherSet = 0;
|
||||
for (AssignClass c : classes) {
|
||||
if (c.getTeacherId() != null) {
|
||||
teacherSet++;
|
||||
}
|
||||
}
|
||||
vo.setTeacherSetCount(teacherSet);
|
||||
final boolean assigned = resultRepository.findByProjectId(project.getId()).isPresent();
|
||||
vo.setAssigned(assigned);
|
||||
vo.setWarnCount(0);
|
||||
final AssignResult result = resultRepository.findByProjectId(project.getId()).orElse(null);
|
||||
if (result != null && result.getWarnJson() != null) {
|
||||
try {
|
||||
vo.setWarnCount(JSON.parseArray(result.getWarnJson(), String.class).size());
|
||||
} catch (Exception ignored) {
|
||||
// 警告解析失败不影响检查
|
||||
}
|
||||
}
|
||||
int minSize = Integer.MAX_VALUE;
|
||||
int maxSize = Integer.MIN_VALUE;
|
||||
if (assigned) {
|
||||
final Map<Integer, Long> byClass = new HashMap<>();
|
||||
for (AssignStudent s : studentRepository.findByProjectId(project.getId())) {
|
||||
if (s.getClassId() != null) {
|
||||
byClass.merge(s.getClassId(), 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
for (AssignClass c : classes) {
|
||||
final long n = byClass.getOrDefault(c.getId(), 0L);
|
||||
minSize = Math.min(minSize, (int) n);
|
||||
maxSize = Math.max(maxSize, (int) n);
|
||||
}
|
||||
vo.setCountSpread(maxSize - Math.max(minSize, 0));
|
||||
}
|
||||
|
||||
final List<AssignSubmitCheckVo.CheckItem> checks = new ArrayList<>();
|
||||
checks.add(check(studentCount > 0, "学生数据已导入",
|
||||
studentCount > 0 ? studentCount + " 人" : "尚未导入学生"));
|
||||
checks.add(check(!classes.isEmpty(), "班级设置就绪",
|
||||
classes.isEmpty() ? "未设置班级" : classes.size() + " 个班,班主任已指定 " + teacherSet + "/" + classes.size()));
|
||||
checks.add(check(assigned, "分班已完成", assigned ? "均衡分班已执行" : "尚未执行分班"));
|
||||
checks.add(check(!assigned || vo.getCountSpread() <= 1, "班间人数差 ≤ 1 人",
|
||||
assigned ? "当前人数差 " + vo.getCountSpread() + " 人" : "未分班"));
|
||||
checks.add(check(vo.getWarnCount() == 0, "分班校验无异常",
|
||||
vo.getWarnCount() == 0 ? "校验通过" : "存在 " + vo.getWarnCount() + " 条校验提示"));
|
||||
checks.add(check(project.getStatus() != STATUS_SUBMITTED, "提交状态",
|
||||
project.getStatus() == STATUS_SUBMITTED ? "已提交(锁定)" : "待提交"));
|
||||
vo.setChecks(checks);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private AssignSubmitCheckVo.CheckItem check(boolean ok, String name, String msg) {
|
||||
final AssignSubmitCheckVo.CheckItem item = new AssignSubmitCheckVo.CheckItem();
|
||||
item.setName(name);
|
||||
item.setLevel(ok ? "ok" : "warn");
|
||||
item.setMsg(msg);
|
||||
return item;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void submit(int stage, String year, int term, boolean force) {
|
||||
final AssignProject project = require(stage, year, term);
|
||||
checkNotSubmitted(project);
|
||||
final AssignSubmitCheckVo checks = submitCheck(stage, year, term);
|
||||
if (checks.getStudentCount() <= 0 || !checks.isAssigned()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "尚未完成分班,无法提交");
|
||||
}
|
||||
if (checks.getCountSpread() > 1 && !force) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"班间人数差为 " + checks.getCountSpread() + " 人(>1),请确认后强制提交");
|
||||
}
|
||||
project.setStatus(STATUS_SUBMITTED);
|
||||
project.setSubmitTime(LocalDateTime.now());
|
||||
projectRepository.save(project);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void reset(int stage, String year, int term) {
|
||||
final AssignProject project = require(stage, year, term);
|
||||
resultRepository.deleteByProjectId(project.getId());
|
||||
ruleRepository.deleteByProjectId(project.getId());
|
||||
studentRepository.deleteByProjectId(project.getId());
|
||||
classRepository.deleteByProjectId(project.getId());
|
||||
// 推送记录一并清除(远端班级/账号不随本地重置回收,见推送边界声明)
|
||||
pushRepository.deleteByProjectId(project.getId());
|
||||
projectRepository.delete(project);
|
||||
}
|
||||
|
||||
// ==================== 项目存取 ====================
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public AssignProject getOrCreate(int stage, String year, int term) {
|
||||
final int schId = schId();
|
||||
final String y = year == null || year.isEmpty()
|
||||
? nowYearTerm().getYear() : year;
|
||||
final int t = term;
|
||||
return projectRepository.findBySchIdAndStageAndYearAndTerm(schId, stage, y, t)
|
||||
.orElseGet(() -> {
|
||||
final AssignProject p = new AssignProject();
|
||||
p.setSchId(schId);
|
||||
p.setStage(stage);
|
||||
p.setYear(y);
|
||||
p.setTerm(t);
|
||||
p.setStatus(STATUS_DRAFT);
|
||||
return projectRepository.save(p);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public AssignProject require(int stage, String year, int term) {
|
||||
final int schId = schId();
|
||||
final String y = year == null || year.isEmpty()
|
||||
? nowYearTerm().getYear() : year;
|
||||
final int t = term;
|
||||
return projectRepository.findBySchIdAndStageAndYearAndTerm(schId, stage, y, t)
|
||||
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "分班项目不存在,请先保存设置"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkNotSubmitted(AssignProject project) {
|
||||
if (project.getStatus() != null && project.getStatus() == STATUS_SUBMITTED) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"本学年学期分班结果已提交锁定为只读,不可修改(撤销只能重置全部)");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 内部 ====================
|
||||
|
||||
private int schId() {
|
||||
final Integer schId = UserContext.getSchId();
|
||||
return schId == null ? 0 : schId;
|
||||
}
|
||||
|
||||
private List<ClassItemVo> classVos(Integer projectId) {
|
||||
final Map<Integer, Long> presetCounts = presetCounts(projectId);
|
||||
final List<ClassItemVo> out = new ArrayList<>();
|
||||
for (AssignClass c : classRepository.findByProjectIdOrderBySortOrderAsc(projectId)) {
|
||||
final ClassItemVo vo = new ClassItemVo();
|
||||
vo.setId(c.getId());
|
||||
vo.setSortOrder(c.getSortOrder());
|
||||
vo.setName(c.getName());
|
||||
vo.setTeacherId(c.getTeacherId());
|
||||
vo.setTeacherName(c.getTeacherName());
|
||||
vo.setTargetCount(c.getTargetCount());
|
||||
vo.setAbFlag(Boolean.TRUE.equals(c.getAbFlag()));
|
||||
final long preset = presetCounts.getOrDefault(c.getSortOrder(), 0L);
|
||||
vo.setPresetCount((int) preset);
|
||||
if (vo.getTargetCount() != null) {
|
||||
vo.setRemain(vo.getTargetCount() - (int) preset);
|
||||
}
|
||||
out.add(vo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 各班(按序号)预设固定学生数 */
|
||||
private Map<Integer, Long> presetCounts(Integer projectId) {
|
||||
final Map<Integer, Long> out = new HashMap<>();
|
||||
for (AssignStudent s : studentRepository.findByProjectId(projectId)) {
|
||||
if (s.getPresetJson() == null || s.getPresetJson().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
for (PresetVo p : JSON.parseArray(s.getPresetJson(), PresetVo.class)) {
|
||||
if ("class".equals(p.type) && p.value != null) {
|
||||
out.merge(p.value, 1L, Long::sum);
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 预设解析失败不计入
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 预设解析结果(与 StudentParser.Preset 对应) */
|
||||
public static class PresetVo {
|
||||
|
||||
public String type;
|
||||
|
||||
public Integer value;
|
||||
|
||||
public String target;
|
||||
}
|
||||
|
||||
public RuleVo ruleVo(Integer projectId) {
|
||||
final AssignRule entity = ruleRepository.findByProjectId(projectId).orElse(null);
|
||||
final RuleVo vo = new RuleVo();
|
||||
if (entity == null) {
|
||||
// subjects/nums/tags 不设值(null):前端按「未配置=全部识别列默认参与」渲染
|
||||
final BalanceVo b = new BalanceVo();
|
||||
b.setTotal(true);
|
||||
b.setGender(true);
|
||||
b.setBands(true);
|
||||
vo.setBalance(b);
|
||||
final ConstraintVo c = new ConstraintVo();
|
||||
c.setSameName(true);
|
||||
c.setSameSound(true);
|
||||
c.setDiffClass(true);
|
||||
c.setSameClass(true);
|
||||
vo.setConstraint(c);
|
||||
vo.setBandMode("count");
|
||||
vo.setBandCount(4);
|
||||
vo.setAbEnabled(false);
|
||||
vo.setAbMode("layer");
|
||||
vo.setAbClassCount(2);
|
||||
return vo;
|
||||
}
|
||||
try {
|
||||
vo.setBalance(JSON.parseObject(entity.getBalanceJson(), BalanceVo.class));
|
||||
} catch (Exception e) {
|
||||
vo.setBalance(new BalanceVo());
|
||||
}
|
||||
try {
|
||||
vo.setConstraint(JSON.parseObject(entity.getConstraintJson(), ConstraintVo.class));
|
||||
} catch (Exception e) {
|
||||
vo.setConstraint(new ConstraintVo());
|
||||
}
|
||||
vo.setBandMode(entity.getBandMode());
|
||||
vo.setBandCount(entity.getBandCount());
|
||||
vo.setBandStep(entity.getBandStep());
|
||||
vo.setAbEnabled(Boolean.TRUE.equals(entity.getAbEnabled()));
|
||||
vo.setAbMode(entity.getAbMode());
|
||||
vo.setAbClassCount(entity.getAbClassCount());
|
||||
return vo;
|
||||
}
|
||||
|
||||
public ProjectStatsVo stats(Integer projectId) {
|
||||
final ProjectStatsVo st = new ProjectStatsVo();
|
||||
long male = 0;
|
||||
long female = 0;
|
||||
long assigned = 0;
|
||||
final Set<String> schools = new HashSet<>();
|
||||
final List<AssignStudent> students = studentRepository.findByProjectId(projectId);
|
||||
for (AssignStudent s : students) {
|
||||
if (s.getGender() != null && s.getGender() == 1) {
|
||||
male++;
|
||||
} else if (s.getGender() != null && s.getGender() == 2) {
|
||||
female++;
|
||||
}
|
||||
if (s.getClassId() != null) {
|
||||
assigned++;
|
||||
}
|
||||
if (s.getGraduateSchool() != null && !s.getGraduateSchool().isEmpty()) {
|
||||
schools.add(s.getGraduateSchool());
|
||||
}
|
||||
}
|
||||
st.setStudentCount(students.size());
|
||||
st.setMaleCount(male);
|
||||
st.setFemaleCount(female);
|
||||
st.setAssignedCount(assigned);
|
||||
st.setSchoolCount(schools.size());
|
||||
return st;
|
||||
}
|
||||
|
||||
/** 平台教师名册(teacherId=userTeaSerID → 姓名) */
|
||||
private Map<Integer, String> teacherNameMap() {
|
||||
final Map<Integer, String> out = new HashMap<>();
|
||||
try {
|
||||
for (SchTeaList t : classService.getSchTeaList(schId())) {
|
||||
if (t.getUserTeaSerID() != null) {
|
||||
out.put(t.getUserTeaSerID(), t.getTeaName());
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 平台不可达时名单为空:已填教师 id 校验将失败提示
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int safeInt(Integer v, int dft) {
|
||||
return v == null ? dft : v;
|
||||
}
|
||||
|
||||
/** 均衡配置:subjects/nums/tags 保留 null(= 默认全部识别列参与),不做空列表规范化 */
|
||||
private static BalanceVo nvlBalance(BalanceVo b) {
|
||||
if (b != null) {
|
||||
return b;
|
||||
}
|
||||
final BalanceVo nb = new BalanceVo();
|
||||
nb.setTotal(true);
|
||||
nb.setGender(true);
|
||||
nb.setBands(true);
|
||||
return nb;
|
||||
}
|
||||
|
||||
private static ConstraintVo nvlConstraint(ConstraintVo c) {
|
||||
final ConstraintVo n = c != null ? c : new ConstraintVo();
|
||||
if (n.getSameName() == null) {
|
||||
n.setSameName(true);
|
||||
}
|
||||
if (n.getSameSound() == null) {
|
||||
n.setSameSound(true);
|
||||
}
|
||||
if (n.getDiffClass() == null) {
|
||||
n.setDiffClass(true);
|
||||
}
|
||||
if (n.getSameClass() == null) {
|
||||
n.setSameClass(true);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,723 @@
|
||||
package com.yunhai.acaclassassign.service.impl;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.acaclassassign.model.SchTeaList;
|
||||
import com.yunhai.acaclassassign.model.bsch.ClassTreeNodeVo;
|
||||
import com.yunhai.acaclassassign.model.bsch.NowYearTermVo;
|
||||
import com.yunhai.acaclassassign.model.bsch.SubjectVo;
|
||||
import com.yunhai.acaclassassign.model.bsch.YearTermItemVo;
|
||||
import com.yunhai.acaclassassign.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 分钟):学年以年计变化,短缓存省去每次进工作台的远程调用 */
|
||||
private volatile YearListCacheEntry yearListCache;
|
||||
|
||||
private static class YearListCacheEntry {
|
||||
final long time;
|
||||
final List<YearTermItemVo> list;
|
||||
|
||||
YearListCacheEntry(long time, List<YearTermItemVo> list) {
|
||||
this.time = time;
|
||||
this.list = list;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部学年列表(班级服务 GET /oapi/sch/getYearTermList,按学年倒序,5 分钟缓存)。
|
||||
* 不可达时返回空列表降级(前端回退本地推断),不阻断分班业务。
|
||||
*/
|
||||
@Override
|
||||
public List<YearTermItemVo> getYearTermList() {
|
||||
long now = System.currentTimeMillis();
|
||||
YearListCacheEntry cached = yearListCache;
|
||||
if (cached != null && now - cached.time < CACHE_TTL_MS) {
|
||||
return cached.list;
|
||||
}
|
||||
synchronized (this) {
|
||||
cached = yearListCache;
|
||||
if (cached != null && now - cached.time < CACHE_TTL_MS) {
|
||||
return cached.list;
|
||||
}
|
||||
List<YearTermItemVo> list = fetchYearTermList();
|
||||
if (list == null) {
|
||||
// 拉取失败回退旧缓存(若有),否则空列表由调用方降级
|
||||
return cached != null ? cached.list : Collections.emptyList();
|
||||
}
|
||||
yearListCache = new YearListCacheEntry(now, list);
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
private List<YearTermItemVo> fetchYearTermList() {
|
||||
if (!StringUtils.hasText(bschClassHost)) {
|
||||
log.warn("[fetchYearTermList] 班级管理接口未配置(yh.open.bschclass)");
|
||||
return Collections.emptyList();
|
||||
}
|
||||
String url = bschClassHost + "/oapi/sch/getYearTermList";
|
||||
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();
|
||||
List<YearTermItemVo> list = new ArrayList<>();
|
||||
if (body != null) {
|
||||
for (Map<String, Object> row : body) {
|
||||
Integer year = asInt(row.get("year"));
|
||||
if (year == null) {
|
||||
continue;
|
||||
}
|
||||
YearTermItemVo vo = new YearTermItemVo();
|
||||
vo.setId(asInt(row.get("id")));
|
||||
vo.setYear(year);
|
||||
vo.setNowYear(Boolean.TRUE.equals(row.get("nowYear")));
|
||||
list.add(vo);
|
||||
}
|
||||
}
|
||||
log.info("[fetchYearTermList] 学年 {} 项", list.size());
|
||||
return list;
|
||||
} catch (RestClientException e) {
|
||||
log.error("[fetchYearTermList] 请求学年列表失败, 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;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ 年级名称 ============================
|
||||
|
||||
/** 年级名称缓存:schId:year:stage -> {time, name}(null 结果同样缓存,字典缺失学校不反复打远程) */
|
||||
private final ConcurrentHashMap<String, ValueCacheEntry> gradeNameCache = new ConcurrentHashMap<>();
|
||||
|
||||
private static class ValueCacheEntry {
|
||||
final long time;
|
||||
final String name;
|
||||
|
||||
ValueCacheEntry(long time, String name) {
|
||||
this.time = time;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 年级名称(班级服务 GET /oapi/sch/getGradeName,按学校+入学年份+学段查学校年级字典,5 分钟缓存)。
|
||||
* 不可达 / 无匹配返回 null,由调用方降级(班级导入数据下载回退 K12 标准新生年级名)。
|
||||
*/
|
||||
@Override
|
||||
public String getGradeName(int schId, int year, int stage) {
|
||||
final String key = schId + ":" + year + ":" + stage;
|
||||
final long now = System.currentTimeMillis();
|
||||
ValueCacheEntry cached = gradeNameCache.get(key);
|
||||
if (cached != null && now - cached.time < CACHE_TTL_MS) {
|
||||
return cached.name;
|
||||
}
|
||||
synchronized (this) {
|
||||
cached = gradeNameCache.get(key);
|
||||
if (cached != null && now - cached.time < CACHE_TTL_MS) {
|
||||
return cached.name;
|
||||
}
|
||||
String name = fetchGradeName(schId, year, stage);
|
||||
gradeNameCache.put(key, new ValueCacheEntry(now, name));
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
private String fetchGradeName(int schId, int year, int stage) {
|
||||
if (!StringUtils.hasText(bschClassHost)) {
|
||||
log.warn("[fetchGradeName] 班级管理接口未配置(yh.open.bschclass)");
|
||||
return null;
|
||||
}
|
||||
String url = bschClassHost + "/oapi/sch/getGradeName?schId=" + schId + "&year=" + year + "&stage=" + stage;
|
||||
try {
|
||||
ResponseEntity<Map<String, Object>> resp = restTemplate.exchange(
|
||||
url, HttpMethod.GET, null,
|
||||
new ParameterizedTypeReference<Map<String, Object>>() {
|
||||
});
|
||||
Map<String, Object> body = resp.getBody();
|
||||
String name = body == null ? null : asStr(body.get("gradeName"));
|
||||
log.info("[fetchGradeName] schId={}, year={}, stage={} -> {}", schId, year, stage, name);
|
||||
return StringUtils.hasText(name) ? name : null;
|
||||
} catch (RestClientException e) {
|
||||
log.error("[fetchGradeName] 请求年级名称接口失败, url={}", 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,468 @@
|
||||
package com.yunhai.acaclassassign.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.yunhai.acaclassassign.entity.AssignClass;
|
||||
import com.yunhai.acaclassassign.entity.AssignProject;
|
||||
import com.yunhai.acaclassassign.entity.AssignPush;
|
||||
import com.yunhai.acaclassassign.entity.AssignStudent;
|
||||
import com.yunhai.acaclassassign.model.assign.PushStatusVo;
|
||||
import com.yunhai.acaclassassign.model.bsch.SyncClassModels;
|
||||
import com.yunhai.acaclassassign.repository.AssignClassRepository;
|
||||
import com.yunhai.acaclassassign.repository.AssignPushRepository;
|
||||
import com.yunhai.acaclassassign.repository.AssignStudentRepository;
|
||||
import com.yunhai.acaclassassign.service.IPushClassService;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.crypto.SignUtil;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.web.client.RestClientException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 分班结果推送班级管理实现。
|
||||
* <p>推送客户端仿 {@link ClassService}(RestTemplate 直连 userclass oapi),差异:写操作、
|
||||
* 读超时放宽到 60s(学生批量一次提交)、带 X-App-Key/X-Timestamp/X-Signature 三件套头
|
||||
* (服务端未强制验签时带头无副作用,先带头后启用)。
|
||||
* 签名口径:待签串=方法+"\n"+target+"\n"+ts,target 仅路径+字典序 query,body 不参与签名。</p>
|
||||
* <p>刻意不加 @Transactional:推送含远程调用(最长 60s+),各写点(置推送中/落结果)独立提交,
|
||||
* 崩溃后凭 status=4 的 updateTime 陈旧检测解锁重推。</p>
|
||||
*/
|
||||
@Service
|
||||
public class PushClassService implements IPushClassService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PushClassService.class);
|
||||
|
||||
/** status=4 视为陈旧(进程崩溃残留)的阈值:超时即允许重新推送 */
|
||||
private static final int STALE_PUSHING_MINUTES = 15;
|
||||
|
||||
@Value("${yh.open.bschclass:}")
|
||||
private String bschClassHost;
|
||||
|
||||
@Value("${yh.app-auth.app-key:}")
|
||||
private String appKey;
|
||||
|
||||
@Value("${yh.app-auth.app-secret:}")
|
||||
private String appSecret;
|
||||
|
||||
/**
|
||||
* 学生建号初始密码(用户中心强制校验:8-32 位、含符号、无连续自然数序列,账号/密码均必填——
|
||||
* 本地联调实测,计划 §九.3/4 验证项结论:空 account/pwd 不允许,须推送侧兜底)。
|
||||
*/
|
||||
@Value("${yh.push.default-student-pwd:Yun@Hai7x}")
|
||||
private String defaultStudentPwd;
|
||||
|
||||
/**
|
||||
* 是否带应用间签名三件套头。⚠️ 实测(2026-10-21):被推方 app-auth 见 X-App-Key 即校验,
|
||||
* appKey 未登记其 trusted-apps 时直接 401(比"未强制验签则无副作用"的计划假设更严)。
|
||||
* 故默认 false;被推方登记 100026 并启用 enforce 后置 true。
|
||||
*/
|
||||
@Value("${yh.push.sign-enabled:false}")
|
||||
private boolean signEnabled;
|
||||
|
||||
@Autowired
|
||||
private AssignClassRepository classRepository;
|
||||
|
||||
@Autowired
|
||||
private AssignStudentRepository studentRepository;
|
||||
|
||||
@Autowired
|
||||
private AssignPushRepository pushRepository;
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
public PushClassService() {
|
||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
||||
factory.setConnectTimeout(5000);
|
||||
// 学生批量单次提交(单校数千人),读超时放宽
|
||||
factory.setReadTimeout(60000);
|
||||
this.restTemplate = new RestTemplate(factory);
|
||||
}
|
||||
|
||||
// ==================== 推送 ====================
|
||||
|
||||
@Override
|
||||
public PushStatusVo push(AssignProject project, boolean retry) {
|
||||
if (project.getStatus() == null || project.getStatus() != 1) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "请先提交分班结果,再推送到班级管理");
|
||||
}
|
||||
AssignPush record = lockForPush(project.getId(), retry);
|
||||
boolean onlyFailed = retry;
|
||||
Map<Integer, AssignStudent> failedStudents = null;
|
||||
if (retry) {
|
||||
List<PushStatusVo.FailItem> fails = parseFails(record.getFailJson());
|
||||
if (!fails.isEmpty()) {
|
||||
failedStudents = new LinkedHashMap<>();
|
||||
for (AssignStudent s : studentRepository.findByProjectId(project.getId())) {
|
||||
for (PushStatusVo.FailItem f : fails) {
|
||||
if (s.getId().equals(f.getStudentId())) {
|
||||
failedStudents.put(s.getId(), s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 失败明细为空(如 AddClass 阶段整体失败)→ 退化为整体重推
|
||||
onlyFailed = failedStudents != null && !failedStudents.isEmpty();
|
||||
}
|
||||
try {
|
||||
doPush(project, record, onlyFailed ? failedStudents : null);
|
||||
} catch (Exception e) {
|
||||
String msg = e instanceof BizException ? e.getMessage() : "推送异常:" + e.getMessage();
|
||||
log.error("[push] projectId={} 推送失败", project.getId(), e);
|
||||
record.setStatus(AssignPush.STATUS_FAIL);
|
||||
record.setMsg(msg);
|
||||
record.setPushTime(LocalDateTime.now());
|
||||
}
|
||||
pushRepository.save(record);
|
||||
return status(project);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行推送:AddClass(幂等)→ AddClassStudent(聚合模式)→ 汇总写 aca_assign_push。
|
||||
*
|
||||
* @param failedOnly 非空=只推这些学生(重推失败项);null=全量
|
||||
*/
|
||||
private void doPush(AssignProject project, AssignPush record, Map<Integer, AssignStudent> failedOnly) {
|
||||
Integer schId = project.getSchId();
|
||||
Integer enrollYear = parseYear(project.getYear());
|
||||
int stage = toOapiStage(project.getStage());
|
||||
|
||||
List<AssignClass> classes = classRepository.findByProjectIdOrderBySortOrderAsc(project.getId());
|
||||
if (classes.isEmpty()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "无班级设置,无法推送");
|
||||
}
|
||||
|
||||
// Ⅰ AddClass:同步组(学生组→[学段组]→年级组→班级组+sch_class),返回本地班号→平台 classId
|
||||
List<Map<String, Object>> classItems = new ArrayList<>();
|
||||
for (AssignClass c : classes) {
|
||||
classItems.add(buildClassItem(c));
|
||||
}
|
||||
Map<String, Object> addClassBody = new HashMap<>();
|
||||
addClassBody.put("schId", schId);
|
||||
addClassBody.put("stage", stage);
|
||||
addClassBody.put("enrollYear", enrollYear);
|
||||
addClassBody.put("classes", classItems);
|
||||
SyncClassModels.SyncClassResult classResult =
|
||||
postJson("/oapi/class/AddClass", addClassBody, SyncClassModels.SyncClassResult.class);
|
||||
|
||||
Map<Integer, Integer> sortToPlatform = new HashMap<>();
|
||||
for (SyncClassModels.ClassItem ci : classResult.getClasses()) {
|
||||
if (ci.getSort() != null && ci.getClassId() != null) {
|
||||
sortToPlatform.put(ci.getSort(), ci.getClassId());
|
||||
}
|
||||
}
|
||||
Map<Integer, Integer> localToPlatform = new LinkedHashMap<>();
|
||||
for (AssignClass c : classes) {
|
||||
Integer platformId = sortToPlatform.get(c.getSortOrder());
|
||||
if (platformId != null) {
|
||||
localToPlatform.put(c.getId(), platformId);
|
||||
}
|
||||
}
|
||||
if (localToPlatform.size() != classes.size()) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
"推送班级返回映射不完整(" + localToPlatform.size() + "/" + classes.size() + ")");
|
||||
}
|
||||
record.setClassMapJson(JSON.toJSONString(localToPlatform));
|
||||
|
||||
// Ⅱ AddClassStudent:按平台 classId 分组逐生推送(聚合模式,个别失败不中断)
|
||||
List<AssignStudent> students = failedOnly != null
|
||||
? new ArrayList<>(failedOnly.values())
|
||||
: studentRepository.findByProjectId(project.getId());
|
||||
List<Map<String, Object>> stuItems = new ArrayList<>();
|
||||
// 推送名单中无法定位平台班级/未分班的学生,直接记失败(不发起建号)
|
||||
Map<Integer, PushStatusVo.FailItem> newFails = new LinkedHashMap<>();
|
||||
List<AssignStudent> pushable = new ArrayList<>();
|
||||
// 推送序号:同班内未提供账号的学生按此生成 p{year}{班号}{序号}(确定性,重推同号)
|
||||
Map<Integer, Integer> classSeq = new HashMap<>();
|
||||
for (AssignStudent s : students) {
|
||||
Integer platformClassId = s.getClassId() == null ? null : localToPlatform.get(s.getClassId());
|
||||
if (platformClassId == null) {
|
||||
PushStatusVo.FailItem f = new PushStatusVo.FailItem();
|
||||
f.setStudentId(s.getId());
|
||||
f.setName(s.getName());
|
||||
f.setReason(s.getClassId() == null ? "未分班" : "班级映射缺失");
|
||||
newFails.put(s.getId(), f);
|
||||
continue;
|
||||
}
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("classId", platformClassId);
|
||||
item.put("name", s.getName());
|
||||
item.put("gender", toOapiGender(s.getGender()));
|
||||
item.put("account", accountOf(s, classes, project.getYear(), classSeq));
|
||||
item.put("stuClassNo", s.getStudentNo());
|
||||
// 用户中心建号 pwd 必填(空则报"密码不能为空");优先学生自带密码(生成密码=账号+@),无则统一初始密码
|
||||
item.put("pwd", s.getPassword() == null || s.getPassword().trim().isEmpty()
|
||||
? defaultStudentPwd : s.getPassword().trim());
|
||||
stuItems.add(item);
|
||||
pushable.add(s);
|
||||
}
|
||||
|
||||
if (!stuItems.isEmpty()) {
|
||||
Map<String, Object> addStuBody = new HashMap<>();
|
||||
addStuBody.put("schId", schId);
|
||||
addStuBody.put("students", stuItems);
|
||||
SyncClassModels.SyncStudentResult stuResult =
|
||||
postJson("/oapi/class/AddClassStudent", addStuBody, SyncClassModels.SyncStudentResult.class);
|
||||
List<SyncClassModels.StudentItem> results = stuResult.getResults() == null
|
||||
? new ArrayList<>() : stuResult.getResults();
|
||||
for (int i = 0; i < pushable.size() && i < results.size(); i++) {
|
||||
SyncClassModels.StudentItem r = results.get(i);
|
||||
if (Boolean.TRUE.equals(r.getOk())) {
|
||||
continue;
|
||||
}
|
||||
PushStatusVo.FailItem f = new PushStatusVo.FailItem();
|
||||
f.setStudentId(pushable.get(i).getId());
|
||||
f.setName(pushable.get(i).getName());
|
||||
f.setReason(r.getReason() == null ? "推送失败" : r.getReason());
|
||||
newFails.put(f.getStudentId(), f);
|
||||
}
|
||||
if (results.size() != pushable.size()) {
|
||||
log.error("[push] AddClassStudent 返回结果数 {} 与推送数 {} 不一致", results.size(), pushable.size());
|
||||
}
|
||||
}
|
||||
|
||||
// 汇总:重推场景与本批失败合并(newFails 已含本批全部失败;重推成功项自然消失)
|
||||
record.setFailJson(newFails.isEmpty() ? null : JSON.toJSONString(new ArrayList<>(newFails.values())));
|
||||
int okCount = countAssigned(project.getId()) - newFails.size();
|
||||
int classCount = localToPlatform.size();
|
||||
if (newFails.isEmpty()) {
|
||||
record.setStatus(AssignPush.STATUS_OK);
|
||||
record.setMsg("推送成功:" + classCount + " 班 " + okCount + " 人");
|
||||
} else if (okCount > 0) {
|
||||
record.setStatus(AssignPush.STATUS_PARTIAL);
|
||||
record.setMsg("部分失败:成功 " + okCount + " 人,失败 " + newFails.size() + " 人");
|
||||
} else {
|
||||
record.setStatus(AssignPush.STATUS_FAIL);
|
||||
record.setMsg("推送失败:失败 " + newFails.size() + " 人");
|
||||
}
|
||||
record.setPushTime(LocalDateTime.now());
|
||||
log.info("[push] projectId={} 完成:status={}, msg={}, classMap={} 条, fails={} 条",
|
||||
project.getId(), record.getStatus(), record.getMsg(), classCount, newFails.size());
|
||||
}
|
||||
|
||||
// ==================== 状态查询 ====================
|
||||
|
||||
@Override
|
||||
public PushStatusVo status(AssignProject project) {
|
||||
AssignPush record = pushRepository.findByProjectId(project.getId()).orElse(null);
|
||||
PushStatusVo vo = new PushStatusVo();
|
||||
List<PushStatusVo.FailItem> fails;
|
||||
if (record == null) {
|
||||
vo.setStatus(AssignPush.STATUS_NONE);
|
||||
fails = new ArrayList<>();
|
||||
} else {
|
||||
vo.setStatus(record.getStatus());
|
||||
vo.setPushTime(record.getPushTime());
|
||||
vo.setMsg(record.getMsg());
|
||||
fails = parseFails(record.getFailJson());
|
||||
}
|
||||
vo.setStatusText(PushStatusVo.textOf(vo.getStatus()));
|
||||
vo.setFails(fails);
|
||||
vo.setFailCount(fails.size());
|
||||
vo.setOkCount(Math.max(countAssigned(project.getId()) - fails.size(), 0));
|
||||
JSONObject map = parseClassMap(record == null ? null : record.getClassMapJson());
|
||||
vo.setClassCount(map.size());
|
||||
return vo;
|
||||
}
|
||||
|
||||
// ==================== 内部 ====================
|
||||
|
||||
/**
|
||||
* 组装 AddClass 单班条目。班级别名口径:仅语义班名(如"阳光班/苹果班")才带 name
|
||||
* (被推方非空才写 cname);按序号生成的班名("1班/2班"这种)不带,平台侧别名留空。
|
||||
*/
|
||||
static Map<String, Object> buildClassItem(AssignClass c) {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("sort", c.getSortOrder());
|
||||
if (!isSeqClassName(c.getName())) {
|
||||
item.put("name", c.getName());
|
||||
}
|
||||
item.put("teacherId", c.getTeacherId());
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 序号风格班名(纯数字+"班",如 1班/2班/01班)或空名:默认生成的序号名,无语义,不作别名推送 */
|
||||
static boolean isSeqClassName(String name) {
|
||||
String n = name == null ? "" : name.trim();
|
||||
return n.isEmpty() || n.matches("\\d{1,4}班");
|
||||
}
|
||||
|
||||
/** 置「推送中」并防并发:status=4 且非陈旧(updateTime 15 分钟内)时拒绝 */
|
||||
private AssignPush lockForPush(Integer projectId, boolean retry) {
|
||||
AssignPush record = pushRepository.findByProjectId(projectId).orElseGet(() -> {
|
||||
AssignPush r = new AssignPush();
|
||||
r.setProjectId(projectId);
|
||||
r.setStatus(AssignPush.STATUS_NONE);
|
||||
return r;
|
||||
});
|
||||
if (record.getStatus() != null && record.getStatus() == AssignPush.STATUS_PUSHING && !isStale(record)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "推送进行中,请勿重复操作");
|
||||
}
|
||||
record.setStatus(AssignPush.STATUS_PUSHING);
|
||||
record.setMsg(retry ? "重推失败项中…" : "推送中…");
|
||||
// failJson 不在此清空:推送中途崩溃时保留旧失败明细,完成时由 doPush 整体覆盖
|
||||
return pushRepository.save(record);
|
||||
}
|
||||
|
||||
private boolean isStale(AssignPush record) {
|
||||
return record.getUpdateTime() == null
|
||||
|| record.getUpdateTime().isBefore(LocalDateTime.now().minusMinutes(STALE_PUSHING_MINUTES));
|
||||
}
|
||||
|
||||
/** 已分班(有 classId)学生数:推送成功口径的分母 */
|
||||
private int countAssigned(Integer projectId) {
|
||||
int assigned = 0;
|
||||
for (AssignStudent s : studentRepository.findByProjectId(projectId)) {
|
||||
if (s.getClassId() != null) {
|
||||
assigned++;
|
||||
}
|
||||
}
|
||||
return assigned;
|
||||
}
|
||||
|
||||
private List<PushStatusVo.FailItem> parseFails(String failJson) {
|
||||
List<PushStatusVo.FailItem> out = new ArrayList<>();
|
||||
if (StringUtils.isBlank(failJson)) {
|
||||
return out;
|
||||
}
|
||||
try {
|
||||
JSONArray arr = JSON.parseArray(failJson);
|
||||
for (int i = 0; i < arr.size(); i++) {
|
||||
JSONObject o = arr.getJSONObject(i);
|
||||
PushStatusVo.FailItem f = new PushStatusVo.FailItem();
|
||||
f.setStudentId(o.getInteger("studentId"));
|
||||
f.setName(o.getString("name"));
|
||||
f.setReason(o.getString("reason"));
|
||||
out.add(f);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[parseFails] 失败明细解析失败:{}", failJson, e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private JSONObject parseClassMap(String classMapJson) {
|
||||
if (StringUtils.isNotBlank(classMapJson)) {
|
||||
try {
|
||||
return JSON.parseObject(classMapJson);
|
||||
} catch (Exception e) {
|
||||
log.warn("[parseClassMap] 班级映射解析失败", e);
|
||||
}
|
||||
}
|
||||
return new JSONObject();
|
||||
}
|
||||
|
||||
/** 分班侧 gender(1男/2女/0未知)→ 班级管理口径(1男/0女/null未知) */
|
||||
private Integer toOapiGender(Integer gender) {
|
||||
if (gender == null) {
|
||||
return null;
|
||||
}
|
||||
if (gender == 1) {
|
||||
return 1;
|
||||
}
|
||||
if (gender == 2) {
|
||||
return 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推送账号:导入 account 优先,空则学号,仍空则生成 p{year}{班号两位}{本班序号两位}
|
||||
* (用户中心 account 必填;生成号确定可重推,studentId 保证全局唯一)。
|
||||
*/
|
||||
private String accountOf(AssignStudent s, List<AssignClass> classes, String year, Map<Integer, Integer> classSeq) {
|
||||
if (StringUtils.isNotBlank(s.getAccount())) {
|
||||
return s.getAccount().trim();
|
||||
}
|
||||
if (StringUtils.isNotBlank(s.getStudentNo())) {
|
||||
return s.getStudentNo().trim();
|
||||
}
|
||||
int sort = 1;
|
||||
for (AssignClass c : classes) {
|
||||
if (c.getId().equals(s.getClassId())) {
|
||||
sort = c.getSortOrder();
|
||||
break;
|
||||
}
|
||||
}
|
||||
int seq = classSeq.merge(s.getClassId(), 1, Integer::sum);
|
||||
return "p" + year + String.format("%02d%02d", sort, seq) + s.getId();
|
||||
}
|
||||
|
||||
/** 分班侧 stage(2小学/3初中/4高中)→ AcademicStage 口径(1小学/2初中/3高中) */
|
||||
private int toOapiStage(Integer stage) {
|
||||
int s = stage == null ? 0 : stage - 1;
|
||||
if (s < 1 || s > 3) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "学段参数非法:" + stage);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
private Integer parseYear(String year) {
|
||||
try {
|
||||
return Integer.parseInt(year);
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "学年参数非法(须为纯数字年份):" + year);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST JSON 并反序列化;带应用间签名三件套头。
|
||||
* 被推方业务异常以 HTTP 200 + {code,msg,data} 返回(ValidationExceptionHandler),code!=0 视为失败。
|
||||
*/
|
||||
private <T> T postJson(String path, Object body, Class<T> type) {
|
||||
if (StringUtils.isBlank(bschClassHost)) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)");
|
||||
}
|
||||
String url = bschClassHost + path;
|
||||
String json = JSON.toJSONString(body);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
// 签名:target 仅路径(无 query),body 不参与待签串;仅在被推方登记本应用 appKey 后启用
|
||||
if (signEnabled && StringUtils.isNotBlank(appKey) && StringUtils.isNotBlank(appSecret)) {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
headers.set("X-App-Key", appKey);
|
||||
headers.set("X-Timestamp", ts);
|
||||
headers.set("X-Signature", SignUtil.signRequest("POST", path, ts, appSecret));
|
||||
}
|
||||
try {
|
||||
org.springframework.http.ResponseEntity<String> resp =
|
||||
restTemplate.postForEntity(url, new HttpEntity<>(json, headers), String.class);
|
||||
String respBody = resp.getBody();
|
||||
if (respBody == null || respBody.isEmpty()) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口空响应:" + path);
|
||||
}
|
||||
JSONObject obj = JSON.parseObject(respBody);
|
||||
Integer code = obj.getInteger("code");
|
||||
if (code != null && code != 0) {
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
"班级管理接口返回失败(" + path + "):" + obj.getString("msg"));
|
||||
}
|
||||
// 成功响应为裸 VO(ResponseEntity 绕过包装);若被 {code,msg,data} 包装则从 data 取
|
||||
String payload = obj.containsKey("data") && obj.getJSONObject("data") != null
|
||||
? obj.getJSONObject("data").toJSONString() : respBody;
|
||||
return JSON.parseObject(payload, type);
|
||||
} catch (RestClientException e) {
|
||||
log.error("[postJson] 请求班级管理接口失败 url={}", url, e);
|
||||
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口不可达:" + path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.yunhai.acaclassassign.service.impl;
|
||||
|
||||
import com.yunhai.acaclassassign.aspect.SysOperationLogAspect;
|
||||
import com.yunhai.acaclassassign.entity.SysOperationLog;
|
||||
import com.yunhai.acaclassassign.model.BasePageQuery;
|
||||
import com.yunhai.acaclassassign.model.BasePageResult;
|
||||
import com.yunhai.acaclassassign.repository.SysOperationLogRepository;
|
||||
import com.yunhai.acaclassassign.service.ISysOperationLogService;
|
||||
import com.yunhai.acaclassassign.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,268 @@
|
||||
package com.yunhai.acaclassassign.service.impl;
|
||||
|
||||
import com.yunhai.acaclassassign.model.SchTeaList;
|
||||
import com.yunhai.acaclassassign.service.IClassService;
|
||||
import com.yunhai.acaclassassign.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,65 @@
|
||||
package com.yunhai.acaclassassign.utils;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
/**
|
||||
* CSV 导出辅助:UTF-8 BOM(Excel 直接打开不乱码)+ 下载响应头。
|
||||
*/
|
||||
public final class CsvUtil {
|
||||
|
||||
private CsvUtil() {
|
||||
}
|
||||
|
||||
/** 拼接一行(自动转义逗号/引号/换行) */
|
||||
public static String line(List<?> cells) {
|
||||
final StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < cells.size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append(',');
|
||||
}
|
||||
sb.append(escape(cells.get(i) == null ? "" : String.valueOf(cells.get(i))));
|
||||
}
|
||||
sb.append("\r\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String escape(String v) {
|
||||
if (v.contains(",") || v.contains("\"") || v.contains("\n") || v.contains("\r")) {
|
||||
return '"' + v.replace("\"", "\"\"") + '"';
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 设置 CSV 下载响应头并写入 BOM */
|
||||
public static void prepare(HttpServletResponse response, String fileName) throws Exception {
|
||||
final String encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20");
|
||||
response.setContentType("text/csv;charset=UTF-8");
|
||||
response.setHeader("Content-Disposition",
|
||||
"attachment;filename*=UTF-8''" + encoded);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
response.getOutputStream().write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF});
|
||||
}
|
||||
|
||||
public static String nvl(Object v) {
|
||||
return v == null ? "" : String.valueOf(v);
|
||||
}
|
||||
|
||||
/** 数字去掉多余的 .0 */
|
||||
public static String num(Object v) {
|
||||
if (v == null) {
|
||||
return "";
|
||||
}
|
||||
if (v instanceof Double || v instanceof Float) {
|
||||
final double d = ((Number) v).doubleValue();
|
||||
if (d == Math.rint(d) && !Double.isInfinite(d)) {
|
||||
return String.valueOf((long) d);
|
||||
}
|
||||
return String.valueOf(Math.round(d * 100.0) / 100.0);
|
||||
}
|
||||
return String.valueOf(v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.yunhai.acaclassassign.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.acaclassassign.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.acaclassassign.utils;
|
||||
|
||||
import com.yunhai.acaclassassign.model.BasePageQuery;
|
||||
import com.yunhai.acaclassassign.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,74 @@
|
||||
package com.yunhai.acaclassassign.utils;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
|
||||
/**
|
||||
* GB2312 一级汉字 → 拼音首字母(利用一级汉字按拼音排序的区位特性,无需第三方拼音库)。
|
||||
* <p>局限:仅覆盖 GB2312 一级常用字;多音字按区位序取字母(如「重」归 z)。
|
||||
* 非汉字字符:字母/数字原样保留(转小写),其他字符与无法编码的汉字丢弃。</p>
|
||||
*/
|
||||
public final class PinyinInitials {
|
||||
|
||||
/** GB2312 一级汉字 23 个拼音分区(a~z,无 i/u/v)的首字,启动时换算成编码边界值 */
|
||||
private static final String BOUNDARY_CHARS = "啊芭擦搭蛾发噶哈击喀垃妈拿哦啪期然撒塌挖昔压匝";
|
||||
private static final char[] LETTERS = "ABCDEFGHJKLMNOPQRSTWXYZ".toCharArray();
|
||||
private static final int[] BOUNDARIES = buildBoundaries();
|
||||
|
||||
private PinyinInitials() {
|
||||
}
|
||||
|
||||
/** 学校名等字符串 → 小写拼音首字母串,如 大宁国际小学 → dngjxx */
|
||||
public static String initials(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
final StringBuilder sb = new StringBuilder(text.length());
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
append(sb, text.charAt(i));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static void append(StringBuilder sb, char c) {
|
||||
if (c < 128) {
|
||||
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
|
||||
sb.append(c);
|
||||
} else if (c >= 'A' && c <= 'Z') {
|
||||
sb.append((char) (c + 32));
|
||||
}
|
||||
return;
|
||||
}
|
||||
final int code;
|
||||
try {
|
||||
final byte[] bytes = String.valueOf(c).getBytes("GB2312");
|
||||
if (bytes.length != 2) {
|
||||
return;
|
||||
}
|
||||
code = ((bytes[0] & 0xFF) << 8) | (bytes[1] & 0xFF);
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new IllegalStateException("JDK 缺少 GB2312 字符集", e);
|
||||
}
|
||||
if (code < BOUNDARIES[0]) {
|
||||
return; // 二级汉字/生僻字/全角符号等,丢弃
|
||||
}
|
||||
for (int i = BOUNDARIES.length - 1; i >= 0; i--) {
|
||||
if (code >= BOUNDARIES[i]) {
|
||||
sb.append(Character.toLowerCase(LETTERS[i]));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int[] buildBoundaries() {
|
||||
try {
|
||||
final byte[] all = BOUNDARY_CHARS.getBytes("GB2312");
|
||||
final int[] out = new int[BOUNDARY_CHARS.length()];
|
||||
for (int i = 0; i < out.length; i++) {
|
||||
out[i] = ((all[2 * i] & 0xFF) << 8) | (all[2 * i + 1] & 0xFF);
|
||||
}
|
||||
return out;
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new IllegalStateException("JDK 缺少 GB2312 字符集", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.yunhai.acaclassassign.utils;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
/**
|
||||
* 当前登录用户上下文(ThreadLocal 静态持有器)。
|
||||
* <p>移植自 UserclassService(com.yunhai.bschclassmanage.utils.UserContext),
|
||||
* 由 {@link com.yunhai.acaclassassign.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