diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a46b5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +target/ +.idea/ +*.iml +*.log +.DS_Store diff --git a/docker/application-prod.yml b/docker/application-prod.yml new file mode 100644 index 0000000..5e13ef7 --- /dev/null +++ b/docker/application-prod.yml @@ -0,0 +1,55 @@ +# Profession 生产配置(线上以 nacos 的 profession.yaml 为准,本文件是 nacos 不可达时的兜底) +# 真正上线请在 Nacos 新建 dataId=profession.yaml、group=OA_APP 的配置覆盖以下项 +# (尤其 datasource 的库名/账号、yh.sso.client-host-url)。 +server: + port: 8071 + servlet: + # 对外经 caddy 暴露为 http://base.yunhaixingtong.com/profession + context-path: /profession + +spring: + mvc: + date-format: yyyy-MM-dd HH:mm:ss + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + datasource: + # 与 UserBase 共用同一台 mysql8;库名为 bsch_profession(需先在 mysql8 上建库) + # ⚠️ 建库必须显式指定字符集,否则表继承 MySQL 默认值,中文写入报 SQL 1366: + # CREATE DATABASE bsch_profession DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; + url: jdbc:mysql://mysql8:3306/bsch_profession?useUnicode=true&characterEncoding=utf-8&connectionCollation=utf8mb4_general_ci&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: 'CVf^x$sW*b6CGHxh' + driver-class-name: com.mysql.cj.jdbc.Driver + min-idle: 10 + max-active: 30 + jpa: + database: MySQL + show-sql: false + hibernate: + ddl-auto: update + # MySQL8Dialect 建表会带 DEFAULT CHARSET=utf8mb4(MySQL5InnoDBDialect 不会) + database-platform: org.hibernate.dialect.MySQL8Dialect + properties: + hibernate.order_by.default_null_ordering: last + open-in-view: true + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + sso: + server-url-prefix: http://sso.yunhaixingtong.com + client-host-url: http://base.yunhaixingtong.com/profession + allow-headers: all + allow-origins: all + url-pattern: /api/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用) + app-key: 100617 + app-secret: 2a3b4c5d6e7f8091a2b3c4d5e6f70819 + # 受信应用(服务端验签用):appKey -> appSecret + trusted-apps: + 100617: 2a3b4c5d6e7f8091a2b3c4d5e6f70819 + +# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data +FILE_PATH: /data/upload diff --git a/docker/run-app.sh b/docker/run-app.sh new file mode 100644 index 0000000..61dd254 --- /dev/null +++ b/docker/run-app.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Profession 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml) +# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到 +# 镜像内烤好的 application-prod.yml 兜底默认。 +set -e + +echo "[run-app] starting Profession (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..." +exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/app.jar \ + --spring.profiles.active=prod diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..afb7643 --- /dev/null +++ b/pom.xml @@ -0,0 +1,204 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.yunhai + profession + 0.0.1 + Profession + 教师专业发展子应用 + + 1.8 + + + + + + + com.yunhai.platform + yh-platform-dependencies + 1.0.0-SNAPSHOT + pom + import + + + + com.alibaba.cloud + spring-cloud-alibaba-dependencies + 2021.0.5.0 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.security + spring-security-crypto + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.springframework.boot + spring-boot-starter-aop + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + org.springframework.cloud + spring-cloud-starter-bootstrap + 3.1.8 + + + + com.mysql + mysql-connector-j + runtime + + + + org.projectlombok + lombok + true + + + + + com.alibaba + fastjson + 1.2.80 + + + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + + + + + com.yunhai.platform + yh-spring-boot-starter + + + + + org.jasig.cas.client + cas-client-core + 3.5.1 + + + + + com.alibaba + easyexcel + 3.3.2 + + + + + + + + profession + + + org.apache.maven.plugins + maven-compiler-plugin + + + false + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.yunhai.profession.ProfessionApplication + + + + + repackage + + + + + + + + + + + nexus-public + bsch/nexus public + http://nexus.yunhaixingtong.com/repository/maven-public/ + true + true + + + + + + ci + + C:/notice-build/profession + + + + + diff --git a/src/main/java/com/yunhai/profession/ProfessionApplication.java b/src/main/java/com/yunhai/profession/ProfessionApplication.java new file mode 100644 index 0000000..c107539 --- /dev/null +++ b/src/main/java/com/yunhai/profession/ProfessionApplication.java @@ -0,0 +1,20 @@ +package com.yunhai.profession; + +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.profession") +@EnableTransactionManagement +@EnableAsync +public class ProfessionApplication { + + public static void main(String[] args) { + // 开启 fastjson 安全模式 + ParserConfig.getGlobalInstance().setSafeMode(true); + SpringApplication.run(ProfessionApplication.class, args); + } + +} diff --git a/src/main/java/com/yunhai/profession/aspect/ControllerUserContextAspect.java b/src/main/java/com/yunhai/profession/aspect/ControllerUserContextAspect.java new file mode 100644 index 0000000..7be9d20 --- /dev/null +++ b/src/main/java/com/yunhai/profession/aspect/ControllerUserContextAspect.java @@ -0,0 +1,161 @@ +package com.yunhai.profession.aspect; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.profession.enums.UserType; +import com.yunhai.profession.model.UserSysRole; +import com.yunhai.profession.utils.IpUtils; +import com.yunhai.profession.utils.UserContext; +import com.yunhai.platform.auth.BoaUserContext; +import com.yunhai.platform.utils.model.BoaUser; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +/** + * 当前用户上下文填充切面(移植自 UserclassService ControllerUserContextAspect)。 + *

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

+ */ +@Aspect +@Component +@Order(1) +public class ControllerUserContextAspect { + + private static final Logger log = LoggerFactory.getLogger(ControllerUserContextAspect.class); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 外部认证框架把解析好的 BoaUser 放进 request attribute 的候选 key */ + private static final List BOA_USER_ATTR_KEYS = Arrays.asList( + "boaUser", "boauser", "currentUser", "user", "authUser", "loginUser" + ); + + @Around("execution(* com.yunhai.profession.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) { + // 开发联调兜底:无 CAS 会话时允许 currentrole 携带 userId/schId(生产 CAS 下不会走到这里) + if (userSysRole.getUserId() != null) { + UserContext.setUserId(userSysRole.getUserId()); + UserContext.setUserName("teacher-" + userSysRole.getUserId()); + } + if (userSysRole.getSchId() != null) { + UserContext.setSchId(userSysRole.getSchId()); + } + } + log.warn("No BoaUser found, using anonymous"); + } + + if (userSysRole != null) { + UserContext.setRoleId(userSysRole.getRoleId()); + UserContext.setChildId(userSysRole.getChildId()); + } + + UserContext.setType(boaUser != null ? boaUser.getType() : null); + + // 超级管理员(TYPE=1)不绑定具体学校,schId 统一置 0 + if (UserType.SUPER_ADMINISTRATOR.getId().equals(UserContext.getType())) { + UserContext.setSchId(0); + } + + UserContext.setIp(IpUtils.getClientIp()); + } +} diff --git a/src/main/java/com/yunhai/profession/aspect/SysOperationLogAspect.java b/src/main/java/com/yunhai/profession/aspect/SysOperationLogAspect.java new file mode 100644 index 0000000..b10c1ff --- /dev/null +++ b/src/main/java/com/yunhai/profession/aspect/SysOperationLogAspect.java @@ -0,0 +1,223 @@ +package com.yunhai.profession.aspect; + +import com.alibaba.fastjson.JSON; +import com.yunhai.profession.entity.SysOperationLog; +import com.yunhai.profession.service.ISysOperationLogService; +import com.yunhai.profession.utils.GzipUtil; +import com.yunhai.profession.utils.IpUtils; +import com.yunhai.profession.utils.UserContext; +import com.yunhai.platform.apistd.ApiResponse; +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.exception.IErrorCode; +import com.yunhai.platform.utils.http.ApiResult; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 系统审计日志切面(移植自 UserclassService SysOperationLogAspect)。 + *

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

+ */ +@Aspect +@Component +@Order(3) +public class SysOperationLogAspect { + + private static final Logger log = LoggerFactory.getLogger(SysOperationLogAspect.class); + + /** 排除的控制器(日志查询自身,防递归刷屏) */ + private static final List EXCLUDE_CONTROLLERS = java.util.Arrays.asList( + "com.yunhai.profession.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.profession.controller..*.*(..))") + public Object around(ProceedingJoinPoint point) throws Throwable { + String declType = point.getSignature().getDeclaringTypeName(); + if (EXCLUDE_CONTROLLERS.contains(declType)) { + return point.proceed(); + } + + HttpServletRequest request = getRequest(); + String httpMethod = request != null ? request.getMethod() : ""; + + long start = System.currentTimeMillis(); + SysOperationLog entity = buildEntity(point, request, httpMethod); + + Object result; + try { + result = point.proceed(); + entity.setStatusCode(resolveSuccessCode(result)); + fillResponse(entity, result, DEFAULT_MAX_RESPONSE_SIZE); + return result; + } catch (Throwable e) { + entity.setStatusCode(resolveErrorCode(e)); + entity.setErrorMsg(buildErrorMsg(e)); + throw e; + } finally { + entity.setCostTime(System.currentTimeMillis() - start); + sysOperationLogService.saveAsync(entity); + } + } + + private SysOperationLog buildEntity(ProceedingJoinPoint point, HttpServletRequest request, String httpMethod) { + SysOperationLog e = new SysOperationLog(); + e.setCreateTime(LocalDateTime.now()); + + // 用户信息(ControllerUserContextAspect Order 1 已填充) + e.setUserId(UserContext.getUserId()); + e.setUserName(StringUtils.hasText(UserContext.getUserName()) ? UserContext.getUserName() : "anonymous"); + e.setSchId(UserContext.getSchId()); + e.setRequestIp(StringUtils.hasText(UserContext.getIp()) ? UserContext.getIp() : IpUtils.getClientIp()); + + String uri = request != null ? request.getRequestURI() : ""; + String methodName = point.getSignature().getName(); + e.setRequestUri(uri); + e.setRequestMethod(httpMethod); + e.setClassMethod(point.getTarget().getClass().getName() + "." + methodName); + e.setLogLevel(resolveLevel(httpMethod)); + e.setOperationType(resolveOperationType(httpMethod, methodName)); + if (request != null) { + e.setUserAgent(request.getHeader("User-Agent")); + } + e.setRequestParams(truncate(buildRequestParams(point, request), MAX_PARAMS)); + return e; + } + + private String buildRequestParams(ProceedingJoinPoint point, HttpServletRequest request) { + try { + if (request != null && "GET".equalsIgnoreCase(request.getMethod())) { + String qs = request.getQueryString(); + if (StringUtils.hasText(qs)) { + return qs; + } + } + Object[] args = point.getArgs(); + if (args == null || args.length == 0) { + return ""; + } + List filtered = new ArrayList<>(); + for (Object arg : args) { + if (arg == null) { + continue; + } + if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse + || arg instanceof HttpSession || arg instanceof MultipartFile + || arg instanceof MultipartFile[]) { + continue; + } + // BoaUser 等平台对象跳过序列化(无 toString 意义且可能循环引用) + if (arg.getClass().getName().startsWith("com.yunhai.platform.")) { + continue; + } + filtered.add(arg); + } + return filtered.isEmpty() ? "" : JSON.toJSONString(filtered); + } catch (Exception ex) { + return ""; + } + } + + private void fillResponse(SysOperationLog e, Object result, int max) { + if (result == null) { + return; + } + try { + String resp = JSON.toJSONString(result); + if (resp.length() > max) { + resp = resp.substring(0, max) + "...[截断]"; + } + e.setResponseData(GzipUtil.compress(resp)); + } catch (Exception ex) { + log.warn("审计日志响应序列化失败, uri={}", e.getRequestUri()); + } + } + + /** 成功路径:Controller 返回 ApiResponse/ApiResult 时取其 code,其余视为 SUCCESS(0) */ + private int resolveSuccessCode(Object result) { + if (result instanceof ApiResponse) { + return ((ApiResponse) result).getCode(); + } + if (result instanceof ApiResult) { + return ((ApiResult) result).getCode(); + } + return ErrorCode.SUCCESS.getCode(); + } + + /** 异常路径:从业务异常提取错误码,兜底 INTERNAL_SERVER_ERROR */ + private int resolveErrorCode(Throwable e) { + if (e instanceof DException) { + IErrorCode ec = ((DException) e).getCode(); + if (ec != null) { + return ec.getCode(); + } + } + return ErrorCode.INTERNAL_SERVER_ERROR.getCode(); + } + + private HttpServletRequest getRequest() { + ServletRequestAttributes a = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return a != null ? a.getRequest() : null; + } + + private String buildErrorMsg(Throwable e) { + String msg = e.getMessage(); + String head = (msg == null || msg.trim().isEmpty()) ? e.getClass().getName() : msg; + return truncate(head + System.lineSeparator() + ExceptionUtils.getStackTrace(e), MAX_ERROR_MSG); + } + + private String truncate(String s, int max) { + return s != null && s.length() > max ? s.substring(0, max) : s; + } + + /** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */ + private int resolveLevel(String httpMethod) { + if ("DELETE".equalsIgnoreCase(httpMethod)) { + return 1; + } + if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod)) { + return 2; + } + return 3; + } + + private String resolveOperationType(String httpMethod, String methodName) { + String lower = methodName == null ? "" : methodName.toLowerCase(); + if ("DELETE".equalsIgnoreCase(httpMethod) || lower.startsWith("delete") || lower.startsWith("del")) { + return "DELETE"; + } + if (lower.startsWith("create") || lower.startsWith("add")) { + return "CREATE"; + } + if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod) + || lower.startsWith("edit") || lower.startsWith("update") || lower.startsWith("set")) { + return "UPDATE"; + } + return "QUERY"; + } +} diff --git a/src/main/java/com/yunhai/profession/common/listener/BaseAuditListener.java b/src/main/java/com/yunhai/profession/common/listener/BaseAuditListener.java new file mode 100644 index 0000000..3c76222 --- /dev/null +++ b/src/main/java/com/yunhai/profession/common/listener/BaseAuditListener.java @@ -0,0 +1,47 @@ +package com.yunhai.profession.common.listener; + +import com.yunhai.profession.entity.BaseEntity; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import org.springframework.stereotype.Component; + +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import java.time.LocalDateTime; + +/** + * JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(平台当前登录人)与时间戳。 + * 对应 {@link BaseEntity} 的 createBy / updateBy / createTime / updateTime 字段。 + * 当前登录人取自基础平台 {@link UserContextHolder}。 + */ +@Component +public class BaseAuditListener { + + @PrePersist + public void prePersist(BaseEntity entity) { + LocalDateTime now = LocalDateTime.now(); + entity.setCreateTime(now); + entity.setUpdateTime(now); + String account = currentAccount(); + if (account != null) { + if (entity.getCreateBy() == null) { + entity.setCreateBy(account); + } + entity.setUpdateBy(account); + } + } + + @PreUpdate + public void preUpdate(BaseEntity entity) { + entity.setUpdateTime(LocalDateTime.now()); + String account = currentAccount(); + if (account != null) { + entity.setUpdateBy(account); + } + } + + private String currentAccount() { + LoginUser user = UserContextHolder.get(); + return user == null ? null : user.getLoginName(); + } +} diff --git a/src/main/java/com/yunhai/profession/common/validation/ValidationGroups.java b/src/main/java/com/yunhai/profession/common/validation/ValidationGroups.java new file mode 100644 index 0000000..9a56ac8 --- /dev/null +++ b/src/main/java/com/yunhai/profession/common/validation/ValidationGroups.java @@ -0,0 +1,10 @@ +package com.yunhai.profession.common.validation; + +/** + * JSR-303 分组校验标记接口(对齐 UserBase 规范)。 + * Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。 + */ +public interface ValidationGroups { + interface Create {} + interface Update {} +} diff --git a/src/main/java/com/yunhai/profession/config/Config.java b/src/main/java/com/yunhai/profession/config/Config.java new file mode 100644 index 0000000..0b40d1c --- /dev/null +++ b/src/main/java/com/yunhai/profession/config/Config.java @@ -0,0 +1,11 @@ +package com.yunhai.profession.config; + +/** + * 全局路径常量。与 UserBase 一致:API 统一挂在 /{platform}/ 之下(platform = api / manage / mobiapi)。 + */ +public enum Config { + Inst; + + public static final String API_PATH = "/{platform}"; // manage / api / mobiapi,前面有/后面无/,拼接子路径时用"/"分隔 + public static final String OPEN_API_PATH = "/oapi"; +} diff --git a/src/main/java/com/yunhai/profession/config/JacksonConfig.java b/src/main/java/com/yunhai/profession/config/JacksonConfig.java new file mode 100644 index 0000000..e0ab577 --- /dev/null +++ b/src/main/java/com/yunhai/profession/config/JacksonConfig.java @@ -0,0 +1,35 @@ +package com.yunhai.profession.config; + +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; + +import java.time.format.DateTimeFormatter; + +/** + * Jackson 时间序列化全局约定(对齐旧 .NET 版 "yyyy-MM-dd HH:mm:ss"): + * 接口出入参中的 LocalDateTime/LocalDate 统一使用该格式,避免 ISO 的 "T" 分隔符 + * (如 2026-09-01T16:38:40)直接暴露给前端表格。 + */ +@Configuration +public class JacksonConfig { + + private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { + return builder -> builder + .serializers( + new LocalDateTimeSerializer(DATE_TIME), + new LocalDateSerializer(DATE)) + .deserializers( + new LocalDateTimeDeserializer(DATE_TIME), + new LocalDateDeserializer(DATE)); + } +} diff --git a/src/main/java/com/yunhai/profession/config/SwaggerConfig.java b/src/main/java/com/yunhai/profession/config/SwaggerConfig.java new file mode 100644 index 0000000..30fb9c1 --- /dev/null +++ b/src/main/java/com/yunhai/profession/config/SwaggerConfig.java @@ -0,0 +1,22 @@ +package com.yunhai.profession.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 professionOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("教师专业发展接口文档") + .description("教师专业发展子应用:栏目配置、教案资料发布、评论互动、积分统计(springdoc-openapi)") + .version("1.0")); + } +} diff --git a/src/main/java/com/yunhai/profession/controller/api/BaseApiController.java b/src/main/java/com/yunhai/profession/controller/api/BaseApiController.java new file mode 100644 index 0000000..6243b56 --- /dev/null +++ b/src/main/java/com/yunhai/profession/controller/api/BaseApiController.java @@ -0,0 +1,153 @@ +package com.yunhai.profession.controller.api; + +import com.yunhai.profession.config.Config; +import com.yunhai.profession.entity.ProfessionRecord; +import com.yunhai.profession.model.UserSysRole; +import com.yunhai.profession.model.file.UpFileResultVo; +import com.yunhai.profession.repository.ProfessionRecordRepository; +import com.yunhai.profession.service.IFileService; +import com.yunhai.profession.service.IUserCenterService; +import com.yunhai.profession.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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 平台代理域接口(base/)。 + *

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

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/base/") +public class BaseApiController { + + private static final Logger log = LoggerFactory.getLogger(BaseApiController.class); + + @Autowired + private IUserCenterService userCenterService; + + @Autowired + private IFileService fileService; + + @Autowired + private ProfessionRecordRepository recordRepository; + + // ============================ FileUpload ============================ + + /** + * 附件上传(单文件)。 + * 返回: { fileid, host, fileurl, name, type, size } + */ + @PostMapping("FileUpload") + public UpFileResultVo fileUpload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, "profession"); + } + + // ============================ GetUserInfo ============================ + + /** + * 获取当前登录用户信息(教师端,无家长角色)。 + * 返回 {id, name, role: "teacher", profilePicture, school: {id, name}, currentRole} + */ + @GetMapping("GetUserInfo") + public Map getUserInfo(BoaUser authUserWithDept) { + // 开发联调兜底:无 CAS 会话时从 UserContext(currentrole)取身份;生产 CAS 下 BoaUser 非空 + Integer uid = authUserWithDept != null && authUserWithDept.getId() != null + ? authUserWithDept.getId() : UserContext.getUserId(); + Integer schId = schIdOf(authUserWithDept); + String teacherName = uid != null ? teacherNameOf(uid) : ""; + Map result = new LinkedHashMap<>(); + result.put("id", uid); + result.put("name", authUserWithDept != null && authUserWithDept.getName() != null + ? authUserWithDept.getName() : teacherName); + result.put("role", "teacher"); + result.put("profilePicture", ""); + Map school = new LinkedHashMap<>(); + if (authUserWithDept != null && authUserWithDept.getSchool() != null) { + school.put("id", authUserWithDept.getSchool().getId()); + school.put("name", authUserWithDept.getSchool().getName()); + } else if (schId != null) { + school.put("id", schId); + school.put("name", "测试学校"); + } + result.put("school", school); + UserSysRole role = new UserSysRole(); + role.setSchId(schId); + role.setUserId(uid); + if (authUserWithDept != null && authUserWithDept.getRoleIds() != null && !authUserWithDept.getRoleIds().isEmpty()) { + role.setRoleId(authUserWithDept.getRoleIds().get(0)); + } + Integer childId = UserContext.getChildId(); + role.setChildId(childId == null ? 0 : childId); + result.put("currentRole", role); + return result; + } + + // ============================ GetUserMod ============================ + + /** + * 获取当前用户已授权的模块列表(左侧菜单树)。 + *

数据从基础平台用户中心透传(BoaModule.id 即 modId); + * modId 分配需与基础平台菜单树一致(见 重构任务计划.md 任务5)。

+ */ + @GetMapping("GetUserMod") + public List> getUserMod(BoaUser authUserWithDept) { + Integer schId = schIdOf(authUserWithDept); + Integer uid = authUserWithDept != null && authUserWithDept.getId() != null + ? authUserWithDept.getId() : UserContext.getUserId(); + return userCenterService.getUserModuleTree(uid, schId); + } + + // ============================ GetSchoolAll ============================ + + /** + * 获取当前用户学校组及教师(树形结构,选人数据源)。 + */ + @GetMapping("GetSchoolAll") + public List> getSchoolAll(BoaUser authUserWithDept) { + return userCenterService.getSchoolAllTree(schIdOf(authUserWithDept)); + } + + // ============================ 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 != null && user.getSchool() != null) { + return user.getSchool().getId(); + } + return UserContext.getSchId(); + } + + /** 开发兜底:无 CAS 会话时从记录冗余姓名反查教师名 */ + private String teacherNameOf(Integer uid) { + if (uid == null) { + return ""; + } + return recordRepository.findTopByTeacherIdOrderByIdDesc(uid) + .map(ProfessionRecord::getTeacherName) + .orElse(""); + } +} diff --git a/src/main/java/com/yunhai/profession/controller/api/FileController.java b/src/main/java/com/yunhai/profession/controller/api/FileController.java new file mode 100644 index 0000000..5e4214f --- /dev/null +++ b/src/main/java/com/yunhai/profession/controller/api/FileController.java @@ -0,0 +1,74 @@ +package com.yunhai.profession.controller.api; + +import com.yunhai.profession.config.Config; +import com.yunhai.profession.model.file.UpFileResultVo; +import com.yunhai.profession.model.file.UpFileVo; +import com.yunhai.profession.service.IFileService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 附件域接口(file/)。附件统一挂 topicType=activity。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/file/") +public class FileController { + + private static final String TOPIC_TYPE = "profession"; + + @Autowired + private IFileService fileService; + + /** 附件上传(单文件),返回 { fileid, host, fileurl, name, type, size } */ + @PostMapping("Upload") + public UpFileResultVo upload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, TOPIC_TYPE); + } + + /** 图片上传(多文件) */ + @PostMapping("UploadImage") + public List uploadImage(@RequestParam("file") MultipartFile[] files) { + return fileService.uploadImages(files, TOPIC_TYPE); + } + + /** 文件下载(浏览器直接 location.href 跳转) */ + @GetMapping("Down") + public void down(@RequestParam("id") Integer id, HttpServletResponse response) { + fileService.download(id, response); + } + + /** 文件在线预览(inline 输出) */ + @GetMapping("Preview") + public void preview(@RequestParam("id") Integer id, + @RequestParam(value = "type", required = false) Integer type, + HttpServletResponse response) { + fileService.preview(id, response); + } + + /** 删除文件 */ + @PostMapping("Delete") + public void delete(@RequestParam("fileId") Integer fileId) { + fileService.delete(fileId); + } + + /** 查询某活动下的附件列表 */ + @GetMapping("List") + public List list(@RequestParam("topicId") Integer topicId) { + return fileService.listByTopic(TOPIC_TYPE, topicId); + } + + /** 批量绑定附件到活动 */ + @PostMapping("Bind") + public void bind(@RequestParam("fileIds") List fileIds, + @RequestParam("topicId") Integer topicId) { + fileService.bindToTopic(fileIds, topicId); + } +} diff --git a/src/main/java/com/yunhai/profession/controller/api/LogController.java b/src/main/java/com/yunhai/profession/controller/api/LogController.java new file mode 100644 index 0000000..7d9c8a5 --- /dev/null +++ b/src/main/java/com/yunhai/profession/controller/api/LogController.java @@ -0,0 +1,32 @@ +package com.yunhai.profession.controller.api; + +import com.yunhai.profession.config.Config; +import com.yunhai.profession.entity.SysOperationLog; +import com.yunhai.profession.model.BasePageQuery; +import com.yunhai.profession.model.BasePageResult; +import com.yunhai.profession.service.ISysOperationLogService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 审计日志查询接口(log/)。 + *

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

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/log/") +public class LogController { + + @Autowired + private ISysOperationLogService logService; + + /** 分页查询审计日志(responseData 不返回) */ + @GetMapping("Page") + public BasePageResult page(BasePageQuery query, + @RequestParam(value = "operationType", required = false) String operationType, + @RequestParam(value = "statusCode", required = false) Integer statusCode) { + return logService.page(query, operationType, statusCode); + } +} diff --git a/src/main/java/com/yunhai/profession/controller/api/MouduleController.java b/src/main/java/com/yunhai/profession/controller/api/MouduleController.java new file mode 100644 index 0000000..4b6a9a5 --- /dev/null +++ b/src/main/java/com/yunhai/profession/controller/api/MouduleController.java @@ -0,0 +1,168 @@ +package com.yunhai.profession.controller.api; + +import com.yunhai.profession.config.Config; +import com.yunhai.profession.service.IMouduleService; +import com.yunhai.profession.utils.ProfessionContext; +import com.yunhai.platform.utils.model.BoaUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 栏目配置域接口(moudule/):栏目、上级栏目、自定义字段。 + *

对齐历史后台 ProfessionOld 契约(方法名沿用),返回裸对象由平台包装 {code,msg,data}。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/moudule/") +public class MouduleController { + + @Autowired + private IMouduleService mouduleService; + + /** 区局学校ID(>0 时该校账号可建全区下发栏目),见 application.yml profession.district-sch-id */ + @Value("${profession.district-sch-id:0}") + private Integer districtSchId; + + // ==================== 栏目 ==================== + + /** 栏目列表(含只读标记、篇数) */ + @GetMapping("MouduleList") + public List> mouduleList(BoaUser authUserWithDept) { + return mouduleService.listModules(currentSchId(authUserWithDept)); + } + + /** 级联栏目树(前台) */ + @GetMapping("ModuleTree") + public List> moduleTree(BoaUser authUserWithDept) { + return mouduleService.moduleTree(currentSchId(authUserWithDept)); + } + + /** 我的栏目(前台左栏) */ + @GetMapping("MyModuleList") + public List> myModuleList(BoaUser authUserWithDept, + @RequestParam(value = "userid", required = false) Integer userid) { + return mouduleService.myModuleList(currentSchId(authUserWithDept), userid); + } + + /** 新建栏目 */ + @PostMapping("CreateMoudule") + public Integer createMoudule(BoaUser authUserWithDept, @RequestParam Map params) { + return mouduleService.createModule(currentSchId(authUserWithDept), isDistrict(currentSchId(authUserWithDept)), params); + } + + /** 编辑栏目 */ + @PostMapping("EditMoudule") + public void editMoudule(BoaUser authUserWithDept, @RequestParam Map params) { + mouduleService.editModule(currentSchId(authUserWithDept), isDistrict(currentSchId(authUserWithDept)), params); + } + + /** 删除栏目(ids CSV) */ + @GetMapping("DeleteMoudule") + public void deleteMoudule(BoaUser authUserWithDept, @RequestParam("ids") String ids) { + mouduleService.deleteModules(currentSchId(authUserWithDept), ids); + } + + /** 批量排序 + 改名 + 勾选 + 上级栏目一次提交(对齐老 OrderBy 含 TopModuleTypes) */ + @GetMapping("OrderBy") + public void orderBy(BoaUser authUserWithDept, + @RequestParam(value = "StrID", required = false) String strIds, + @RequestParam(value = "StrSort", required = false) String strSort, + @RequestParam(value = "namestr", required = false) String names, + @RequestParam(value = "Checkstrs", required = false) String checks, + @RequestParam(value = "TopModuleTypes", required = false) String topModuleTypes) { + mouduleService.orderModules(currentSchId(authUserWithDept), strIds, strSort, names, checks, topModuleTypes); + } + + /** 栏目动态字段定义(发布表单渲染;对齐老 Getdatelist) */ + @GetMapping("Getdatelist") + public Map getdatelist(BoaUser authUserWithDept, @RequestParam("SerID") Integer serId) { + return mouduleService.moduleFieldList(currentSchId(authUserWithDept), serId); + } + + // ==================== 上级栏目 ==================== + + @GetMapping("TopMouduleList") + public List> topMouduleList(BoaUser authUserWithDept, + @RequestParam(value = "searchname", required = false) String searchName, + @RequestParam(value = "IsUsing", required = false) Integer isUsing) { + return mouduleService.listTopModules(currentSchId(authUserWithDept), searchName, isUsing); + } + + @PostMapping("CreateTopMoudule") + public Integer createTopMoudule(BoaUser authUserWithDept, @RequestParam("MoudleName") String name) { + return mouduleService.createTopModule(currentSchId(authUserWithDept), name); + } + + @PostMapping("EditTopMoudule") + public void editTopMoudule(BoaUser authUserWithDept, + @RequestParam("SerID") Integer serId, + @RequestParam("MoudleName") String name) { + mouduleService.editTopModule(currentSchId(authUserWithDept), serId, name); + } + + @GetMapping("DeleteTopMoudule") + public void deleteTopMoudule(BoaUser authUserWithDept, @RequestParam("ids") String ids) { + mouduleService.deleteTopModules(currentSchId(authUserWithDept), ids); + } + + @GetMapping("TopMouduleOrderBy") + public void topMouduleOrderBy(BoaUser authUserWithDept, @RequestParam("StrID") String strIds) { + mouduleService.orderTopModules(currentSchId(authUserWithDept), strIds); + } + + @GetMapping("TopMouduleIsUsing") + public void topMouduleIsUsing(BoaUser authUserWithDept, + @RequestParam("SerID") Integer serId, + @RequestParam("IsUsing") Integer isUsing) { + mouduleService.topModuleIsUsing(currentSchId(authUserWithDept), serId, isUsing); + } + + // ==================== 自定义字段 ==================== + + /** 字段分页(PageIndex 从 1 起) */ + @GetMapping("FieldList") + public Map fieldList(BoaUser authUserWithDept, + @RequestParam(value = "FieldType", required = false) String fieldType, + @RequestParam(value = "PageIndex", defaultValue = "1") int pageIndex, + @RequestParam(value = "PageSize", defaultValue = "10") int pageSize) { + return mouduleService.pageFields(currentSchId(authUserWithDept), fieldType, pageIndex, pageSize); + } + + /** 全部字段(栏目绑定选择用) */ + @GetMapping("BindFieldList") + public List> bindFieldList(BoaUser authUserWithDept) { + return mouduleService.listFields(currentSchId(authUserWithDept)); + } + + @PostMapping("FieldtypeCreate") + public Integer fieldtypeCreate(BoaUser authUserWithDept, @RequestParam Map params) { + return mouduleService.createField(currentSchId(authUserWithDept), params); + } + + @PostMapping("FieldtypeEdit") + public void fieldtypeEdit(BoaUser authUserWithDept, @RequestParam Map params) { + mouduleService.editField(currentSchId(authUserWithDept), params); + } + + @GetMapping("DeleteField") + public void deleteField(BoaUser authUserWithDept, @RequestParam("ids") String ids) { + mouduleService.deleteFields(currentSchId(authUserWithDept), ids); + } + + // ==================== 内部方法 ==================== + + private Integer currentSchId(BoaUser user) { + return ProfessionContext.currentSchId(user); + } + + private boolean isDistrict(Integer schId) { + return districtSchId != null && districtSchId > 0 && districtSchId.equals(schId); + } +} diff --git a/src/main/java/com/yunhai/profession/controller/api/PointsController.java b/src/main/java/com/yunhai/profession/controller/api/PointsController.java new file mode 100644 index 0000000..f3cf9ef --- /dev/null +++ b/src/main/java/com/yunhai/profession/controller/api/PointsController.java @@ -0,0 +1,109 @@ +package com.yunhai.profession.controller.api; + +import com.yunhai.profession.config.Config; +import com.yunhai.profession.service.IPointsService; +import com.yunhai.profession.utils.ProfessionContext; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 积分与学期设置域接口(points/):全局积分、积分指标树、学期设置。 + *

对齐历史后台契约(pointstoOne/pointsEdit/TreePointsList/TermSendMessage 系列沿用)。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/points/") +public class PointsController { + + @Autowired + private IPointsService pointsService; + + // ==================== 全局积分 ==================== + + /** 全局默认积分(对齐老 pointstoOne) */ + @GetMapping("pointstoOne") + public Map globalPoints(BoaUser authUserWithDept) { + return pointsService.globalPoints(currentSchId(authUserWithDept)); + } + + /** 修改全局默认积分(对齐老 pointsEdit) */ + @PostMapping("pointsEdit") + public void pointsEdit(BoaUser authUserWithDept, + @RequestParam(value = "SendpointsNum", required = false) Integer sendPointsNum, + @RequestParam(value = "CommpointsNum", required = false) Integer commPointsNum) { + pointsService.globalPointsEdit(currentSchId(authUserWithDept), sendPointsNum, commPointsNum); + } + + // ==================== 积分指标树 ==================== + + /** 指标树(zTree 结构) */ + @GetMapping("TreePointsList") + public List> treePointsList(BoaUser authUserWithDept) { + return pointsService.pointsTree(currentSchId(authUserWithDept)); + } + + /** 积分来源下拉 */ + @GetMapping("SoursePointsList") + public List> soursePointsList() { + return pointsService.pointsSourceList(); + } + + /** 节点详情 */ + @GetMapping("PointsSettingtoOne") + public Map pointsSettingtoOne(BoaUser authUserWithDept, @RequestParam("SerID") Integer serId) { + return pointsService.pointsDetail(currentSchId(authUserWithDept), serId); + } + + /** 新增/编辑节点(SerID 空 = 新增) */ + @PostMapping("PointsSettingEdit") + public Integer pointsSettingEdit(BoaUser authUserWithDept, @RequestParam Map params) { + return pointsService.pointsEdit(currentSchId(authUserWithDept), params); + } + + /** 删除节点(ids CSV,连带子节点) */ + @GetMapping("DeletePointsSetting") + public void deletePointsSetting(BoaUser authUserWithDept, @RequestParam("ids") String ids) { + pointsService.deletePoints(currentSchId(authUserWithDept), ids); + } + + // ==================== 学期设置 ==================== + + /** 学期列表 */ + @GetMapping("TermList") + public List> termList(BoaUser authUserWithDept) { + return pointsService.termList(currentSchId(authUserWithDept)); + } + + /** 学期分页(对齐老 TermSendMessageList) */ + @GetMapping("TermSendMessageList") + public Map termSendMessageList(BoaUser authUserWithDept, + @RequestParam(value = "PageIndex", defaultValue = "1") int pageIndex, + @RequestParam(value = "PageSize", defaultValue = "10") int pageSize) { + return pointsService.termPage(currentSchId(authUserWithDept), pageIndex, pageSize); + } + + /** 学期详情 */ + @GetMapping("TermSendMessageDetail") + public Map termSendMessageDetail(BoaUser authUserWithDept, @RequestParam("SerID") Integer serId) { + return pointsService.termDetail(currentSchId(authUserWithDept), serId); + } + + /** 学期编辑(SerID 空 = 新增) */ + @PostMapping("TermSendMessageEdit") + public Integer termSendMessageEdit(BoaUser authUserWithDept, @RequestParam Map params) { + return pointsService.termEdit(currentSchId(authUserWithDept), params); + } + + // ==================== 内部方法 ==================== + + private Integer currentSchId(BoaUser user) { + return ProfessionContext.currentSchId(user); + } +} diff --git a/src/main/java/com/yunhai/profession/controller/api/ProfessionController.java b/src/main/java/com/yunhai/profession/controller/api/ProfessionController.java new file mode 100644 index 0000000..bffee4e --- /dev/null +++ b/src/main/java/com/yunhai/profession/controller/api/ProfessionController.java @@ -0,0 +1,309 @@ +package com.yunhai.profession.controller.api; + +import com.yunhai.profession.config.Config; +import com.yunhai.profession.service.IAreaService; +import com.yunhai.profession.service.IProfessionService; +import com.yunhai.profession.service.IStatsService; +import com.yunhai.profession.utils.ProfessionContext; +import com.yunhai.profession.utils.ProfessionDicts; +import com.yunhai.platform.utils.model.BoaUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 专业发展记录域接口(profession/):记录(教案/学习资料)、评论、足迹、积分、排行、字典。 + *

对齐历史前台 ProfessionPageOld 契约(方法名沿用)。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/profession/") +public class ProfessionController { + + @Autowired + private IProfessionService professionService; + + @Autowired + private IStatsService statsService; + + @Autowired + private IAreaService areaService; + + @Value("${profession.district-sch-id:0}") + private Integer districtSchId; + + // ==================== 记录 ==================== + + /** 发布记录(教师) */ + @PostMapping("Create") + public Integer create(BoaUser authUserWithDept, @RequestParam Map params) { + return professionService.createRecord(currentSchId(authUserWithDept), + teacherId(authUserWithDept), teacherName(authUserWithDept), params); + } + + /** 编辑记录(仅作者) */ + @PostMapping("Edit") + public void edit(BoaUser authUserWithDept, @RequestParam Map params) { + professionService.editRecord(currentSchId(authUserWithDept), teacherId(authUserWithDept), params); + } + + /** 删除记录(ids CSV;作者或超管) */ + @GetMapping("Delete") + public void delete(BoaUser authUserWithDept, @RequestParam("ids") String ids) { + professionService.deleteRecords(currentSchId(authUserWithDept), teacherId(authUserWithDept), + ProfessionContext.isSuperAdmin(), ids); + } + + /** 后台明细分页(时间/关键词/栏目/教师/学科过滤) */ + @GetMapping("ProfessionList") + public Map professionList(BoaUser authUserWithDept, + @RequestParam(value = "mouid", required = false) Integer mouid, + @RequestParam(value = "userid", required = false) Integer userid, + @RequestParam(value = "grade", required = false) String grade, + @RequestParam(value = "subid", required = false) String subid, + @RequestParam(value = "searchname", required = false) String searchname, + @RequestParam(value = "starttime", required = false) String starttime, + @RequestParam(value = "endtime", required = false) String endtime, + @RequestParam(value = "PageIndex", defaultValue = "1") int pageIndex, + @RequestParam(value = "PageSize", defaultValue = "10") int pageSize) { + return professionService.pageRecords(currentSchId(authUserWithDept), mouid, userid, null, subid, grade, searchname, + starttime, endtime, pageIndex, pageSize, false); + } + + /** 记录详情(阅读数 +1,含字段定义/附件/点赞态) */ + @GetMapping("ProfessionDetail") + public Map professionDetail(BoaUser authUserWithDept, @RequestParam("SerID") Integer serId) { + return professionService.recordDetail(currentSchId(authUserWithDept), serId, teacherId(authUserWithDept)); + } + + /** 某栏目文章分页(前台栏目页,附带同校其他栏目) */ + @GetMapping("ModuleRecordList") + public Map moduleRecordList(BoaUser authUserWithDept, + @RequestParam("mouid") Integer mouid, + @RequestParam(value = "userid", required = false) Integer userid, + @RequestParam(value = "grouid", required = false) Integer grouid, + @RequestParam(value = "grade", required = false) String grade, + @RequestParam(value = "subject", required = false) String subject, + @RequestParam(value = "searchname", required = false) String searchname, + @RequestParam(value = "starttime", required = false) String starttime, + @RequestParam(value = "endtime", required = false) String endtime, + @RequestParam(value = "PageIndex", defaultValue = "1") int pageIndex, + @RequestParam(value = "PageSize", defaultValue = "10") int pageSize) { + return professionService.pageRecords(currentSchId(authUserWithDept), mouid, userid, grouid, subject, grade, + searchname, starttime, endtime, pageIndex, pageSize, true); + } + + /** 某教师某栏目文章分页(个人空间) */ + @GetMapping("MyModuleRecordList") + public Map myModuleRecordList(BoaUser authUserWithDept, + @RequestParam("mouid") Integer mouid, + @RequestParam("userid") Integer userid, + @RequestParam(value = "grade", required = false) String grade, + @RequestParam(value = "searchname", required = false) String searchname, + @RequestParam(value = "PageIndex", defaultValue = "1") int pageIndex, + @RequestParam(value = "PageSize", defaultValue = "10") int pageSize) { + return professionService.pageRecords(currentSchId(authUserWithDept), mouid, userid, null, null, grade, searchname, + null, null, pageIndex, pageSize, false); + } + + /** 首页各栏目最新记录块 */ + @GetMapping("HomeModuleRecords") + public List> homeModuleRecords(BoaUser authUserWithDept, + @RequestParam(value = "size", defaultValue = "6") int size) { + return professionService.homeModuleRecords(currentSchId(authUserWithDept), size); + } + + /** 个人空间按年时间轴 */ + @GetMapping("MyTimeline") + public List> myTimeline(BoaUser authUserWithDept, + @RequestParam("userid") Integer userid) { + return professionService.myTimeline(currentSchId(authUserWithDept), userid); + } + + // ==================== 评论 ==================== + + @PostMapping("DiscussCreate") + public Integer discussCreate(BoaUser authUserWithDept, + @RequestParam("SID") Integer sid, + @RequestParam("Contents") String contents) { + return professionService.createDiscuss(currentSchId(authUserWithDept), teacherId(authUserWithDept), + teacherName(authUserWithDept), sid, contents); + } + + @PostMapping("DiscussEdit") + public void discussEdit(BoaUser authUserWithDept, + @RequestParam("SerID") Integer serId, + @RequestParam("Contents") String contents) { + professionService.editDiscuss(currentSchId(authUserWithDept), teacherId(authUserWithDept), serId, contents); + } + + @GetMapping("DiscussDel") + public void discussDel(BoaUser authUserWithDept, @RequestParam("id") Integer id) { + professionService.deleteDiscuss(currentSchId(authUserWithDept), teacherId(authUserWithDept), + ProfessionContext.isSuperAdmin(), id); + } + + @GetMapping("DiscussList") + public List> discussList(BoaUser authUserWithDept, @RequestParam("sid") Integer sid) { + return professionService.discussList(currentSchId(authUserWithDept), teacherId(authUserWithDept), + ProfessionContext.isSuperAdmin(), sid); + } + + // ==================== 足迹 ==================== + + @PostMapping("VisitCreate") + public void visitCreate(BoaUser authUserWithDept, @RequestParam("v_typeid") Integer recordId) { + professionService.visitCreate(currentSchId(authUserWithDept), teacherId(authUserWithDept), + teacherName(authUserWithDept), recordId); + } + + @GetMapping("VisitList") + public List> visitList(BoaUser authUserWithDept, + @RequestParam(value = "pid", required = false) Integer recordId, + @RequestParam(value = "userid", required = false) Integer authorId, + @RequestParam(value = "limit", defaultValue = "20") int limit) { + return professionService.visitList(recordId, authorId, limit); + } + + // ==================== 点赞 ==================== + + /** 点赞/取消点赞(toggle),返回 {liked, likeCount} */ + @PostMapping("LikeCreate") + public Map likeCreate(BoaUser authUserWithDept, @RequestParam("recordId") Integer recordId) { + return professionService.likeCreate(currentSchId(authUserWithDept), teacherId(authUserWithDept), + teacherName(authUserWithDept), recordId); + } + + // ==================== 积分/排行/字典 ==================== + + /** 我的积分(传 userid = 查看该教师空间,姓名由服务端按其记录回填) */ + @GetMapping("MyPoints") + public Map myPoints(BoaUser authUserWithDept, + @RequestParam(value = "userid", required = false) Integer userid) { + Integer teacherId = userid != null ? userid : teacherId(authUserWithDept); + // 带 userid 查他人时不能回填登录人姓名,否则空间侧栏显示当前登录人 + String teacherName = userid != null ? null : teacherName(authUserWithDept); + return professionService.myPoints(currentSchId(authUserWithDept), teacherId, teacherName); + } + + /** 本校发布排行(前 N) */ + @GetMapping("ReleaseRanking") + public List> releaseRanking(BoaUser authUserWithDept, + @RequestParam(value = "top", defaultValue = "10") int top) { + return professionService.releaseRanking(currentSchId(authUserWithDept), top); + } + + /** 首页刊头统计(累计记录/活跃教师/发展栏目/本周新增) */ + @GetMapping("HomeStats") + public Map homeStats(BoaUser authUserWithDept) { + return professionService.homeStats(currentSchId(authUserWithDept)); + } + + /** 积分排行(发布+评论合计,前 N) */ + @GetMapping("PointsRank") + public List> pointsRank(BoaUser authUserWithDept, + @RequestParam(value = "top", defaultValue = "8") int top) { + return professionService.pointsRank(currentSchId(authUserWithDept), top); + } + + /** 个人空间聚合(姓名/积分/累计记录/被访问/栏目篇数/本月概览) */ + @GetMapping("SpaceOverview") + public Map spaceOverview(BoaUser authUserWithDept, + @RequestParam(value = "userid", required = false) Integer userid) { + Integer uid = userid != null ? userid : ProfessionContext.teacherId(authUserWithDept); + return professionService.spaceOverview(currentSchId(authUserWithDept), uid); + } + + /** 学科组树(对接班级管理教师分组 getTeaCustomGroupList:组带 groupTypeName/teacherCount/发布总数,叶子=教师带个人发布数; + * mouid 可选——发展首页不传=全部栏目合计,栏目页传当前栏目=篇数按该栏目统计) */ + @GetMapping("SubjectGroupTree") + public List> subjectGroupTree(BoaUser authUserWithDept, + @RequestParam(value = "mouid", required = false) Integer mouid) { + return professionService.subjectGroupTree(currentSchId(authUserWithDept), mouid); + } + + /** 全区发布排行(超管/区局) */ + @GetMapping("AreaReleaseRanking") + public List> areaReleaseRanking(BoaUser authUserWithDept, + @RequestParam(value = "top", defaultValue = "10") int top) { + requireCrossSchool(currentSchId(authUserWithDept)); + Map data = statsService.schoolTeacherStat(null, 0, null, null); + @SuppressWarnings("unchecked") + List> rows = (List>) data.get("rows"); + rows.sort((a, b) -> Long.compare(((Number) b.getOrDefault("recordCount", 0)).longValue(), + ((Number) a.getOrDefault("recordCount", 0)).longValue())); + return rows.subList(0, Math.min(Math.max(top, 1), rows.size())); + } + + /** 年级段字典(对齐老 GetGradeList) */ + @GetMapping("GetGradeList") + public List> gradeList() { + return ProfessionDicts.gradeList(); + } + + /** 学科字典([{subId,subName}],subId 对齐班级管理 SUB01 编码) */ + @GetMapping("GetSubList") + public List> subjectList() { + return ProfessionDicts.subjectList(); + } + + /** 行政区划子级(省市级联懒加载,parentCode 不传默认取省;对齐自定义字段 province_city) */ + @GetMapping("GetAreaList") + public List> areaList( + @RequestParam(value = "parentCode", required = false) String parentCode) { + return areaService.listChildren(parentCode); + } + + /** 行政区划树(省→市→区县,按 level 裁剪;前端 Cascader 一次性取数用) */ + @GetMapping("GetAreaTree") + public List> areaTree( + @RequestParam(value = "level", defaultValue = "3") Integer level) { + return areaService.tree(level); + } + + /** 行政区划编码路径转名称(详情回显翻译,如 ["440000","440300"] → "广东省/深圳市") */ + @GetMapping("AreaNamePath") + public String areaNamePath(@RequestParam("codes") String codes) { + return areaService.namePath(codes); + } + + /** 是否区局/教育局账号(对齐老 GetSchGroupJuGuid) */ + @GetMapping("IsDistrictAccount") + public Map isDistrictAccount(BoaUser authUserWithDept) { + Map result = new LinkedHashMap<>(); + result.put("district", ProfessionContext.isSuperAdmin() + || isDistrict(currentSchId(authUserWithDept))); + return result; + } + + // ==================== 内部方法 ==================== + + private void requireCrossSchool(Integer schId) { + if (!ProfessionContext.isSuperAdmin() && !isDistrict(schId)) { + throw new IllegalArgumentException("仅超管或区局账号可访问跨校数据"); + } + } + + private boolean isDistrict(Integer schId) { + return districtSchId != null && districtSchId > 0 && districtSchId.equals(schId); + } + + private Integer currentSchId(BoaUser user) { + return ProfessionContext.currentSchId(user); + } + + private Integer teacherId(BoaUser user) { + return ProfessionContext.teacherId(user); + } + + private String teacherName(BoaUser user) { + return ProfessionContext.teacherName(user); + } +} diff --git a/src/main/java/com/yunhai/profession/controller/api/StatsController.java b/src/main/java/com/yunhai/profession/controller/api/StatsController.java new file mode 100644 index 0000000..ca35d0c --- /dev/null +++ b/src/main/java/com/yunhai/profession/controller/api/StatsController.java @@ -0,0 +1,283 @@ +package com.yunhai.profession.controller.api; + +import com.yunhai.profession.config.Config; +import com.yunhai.profession.service.IStatsService; +import com.yunhai.profession.utils.ProfessionContext; +import com.yunhai.platform.utils.model.BoaUser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 统计域接口(stats/):校级 5 类统计 + 明细 + 导出 + 附件打包 + 局端跨校统计。 + *

校级统计限本校;局端接口(School 前缀/GetSchool 系)需超管或区局账号。

+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/stats/") +public class StatsController { + + @Autowired + private IStatsService statsService; + + @Value("${profession.district-sch-id:0}") + private Integer districtSchId; + + // ==================== 校级统计 ==================== + + /** 教师个人统计(动态列) */ + @GetMapping("TeacherStat") + public Map teacherStat(BoaUser authUserWithDept, + @RequestParam(value = "type", defaultValue = "0") Integer type, + @RequestParam(value = "starttime", required = false) String starttime, + @RequestParam(value = "endtime", required = false) String endtime, + @RequestParam(value = "SearchName", required = false) String searchName) { + return statsService.teacherStat(currentSchId(authUserWithDept), type, starttime, endtime, searchName); + } + + /** 栏目统计 */ + @GetMapping("ModuleStat") + public Map moduleStat(BoaUser authUserWithDept, + @RequestParam(value = "type", defaultValue = "0") Integer type, + @RequestParam(value = "starttime", required = false) String starttime, + @RequestParam(value = "endtime", required = false) String endtime, + @RequestParam(value = "SearchName", required = false) String searchName) { + return statsService.moduleStat(currentSchId(authUserWithDept), type, starttime, endtime, searchName); + } + + /** 学科统计(Grade 可选) */ + @GetMapping("SubjectStat") + public Map subjectStat(BoaUser authUserWithDept, + @RequestParam(value = "type", defaultValue = "0") Integer type, + @RequestParam(value = "Grade", required = false) String grade, + @RequestParam(value = "starttime", required = false) String starttime, + @RequestParam(value = "endtime", required = false) String endtime) { + return statsService.subjectStat(currentSchId(authUserWithDept), type, starttime, endtime, grade); + } + + /** 评论统计 */ + @GetMapping("DiscussStat") + public Map discussStat(BoaUser authUserWithDept, + @RequestParam(value = "type", defaultValue = "0") Integer type, + @RequestParam(value = "starttime", required = false) String starttime, + @RequestParam(value = "endtime", required = false) String endtime, + @RequestParam(value = "SearchName", required = false) String searchName) { + return statsService.discussStat(currentSchId(authUserWithDept), type, starttime, endtime, searchName); + } + + /** 积分统计(动态列) */ + @GetMapping("PointsStat") + public Map pointsStat(BoaUser authUserWithDept, + @RequestParam(value = "type", defaultValue = "0") Integer type, + @RequestParam(value = "starttime", required = false) String starttime, + @RequestParam(value = "endtime", required = false) String endtime, + @RequestParam(value = "SearchName", required = false) String searchName) { + return statsService.pointsStat(currentSchId(authUserWithDept), type, starttime, endtime, searchName); + } + + /** 明细分页 */ + @GetMapping("StatDetail") + public Map statDetail(BoaUser authUserWithDept, + @RequestParam(value = "type", defaultValue = "0") Integer type, + @RequestParam(value = "starttime", required = false) String starttime, + @RequestParam(value = "endtime", required = false) String endtime, + @RequestParam(value = "SearchName", required = false) String searchName, + @RequestParam(value = "mouid", required = false) Integer mouid, + @RequestParam(value = "userid", required = false) Integer userid, + @RequestParam(value = "subid", required = false) String subid, + @RequestParam(value = "PageIndex", defaultValue = "1") int pageIndex, + @RequestParam(value = "PageSize", defaultValue = "10") int pageSize) { + return statsService.statDetail(currentSchId(authUserWithDept), type, starttime, endtime, searchName, + mouid, userid, subid, pageIndex, pageSize); + } + + // ==================== 导出 / 打包 ==================== + + /** Excel 导出:1 教师 / 2 栏目 / 3 学科 / 4 评论 / 5 积分(对齐老 Exceldaochu1~5) */ + @GetMapping("Exceldaochu1") + public void exceldaochu1(BoaUser authUserWithDept, + @RequestParam(value = "type", defaultValue = "0") Integer type, + @RequestParam(value = "starttime", required = false) String starttime, + @RequestParam(value = "endtime", required = false) String endtime, + @RequestParam(value = "SearchName", required = false) String searchName, + HttpServletResponse response) { + statsService.exportExcel(currentSchId(authUserWithDept), 1, type, starttime, endtime, searchName, null, response); + } + + @GetMapping("Exceldaochu2") + public void exceldaochu2(BoaUser authUserWithDept, + @RequestParam(value = "type", defaultValue = "0") Integer type, + @RequestParam(value = "starttime", required = false) String starttime, + @RequestParam(value = "endtime", required = false) String endtime, + @RequestParam(value = "SearchName", required = false) String searchName, + HttpServletResponse response) { + statsService.exportExcel(currentSchId(authUserWithDept), 2, type, starttime, endtime, searchName, null, response); + } + + @GetMapping("Exceldaochu3") + public void exceldaochu3(BoaUser authUserWithDept, + @RequestParam(value = "type", defaultValue = "0") Integer type, + @RequestParam(value = "Grade", required = false) String grade, + @RequestParam(value = "starttime", required = false) String starttime, + @RequestParam(value = "endtime", required = false) String endtime, + HttpServletResponse response) { + statsService.exportExcel(currentSchId(authUserWithDept), 3, type, starttime, endtime, null, grade, response); + } + + @GetMapping("Exceldaochu4") + public void exceldaochu4(BoaUser authUserWithDept, + @RequestParam(value = "type", defaultValue = "0") Integer type, + @RequestParam(value = "starttime", required = false) String starttime, + @RequestParam(value = "endtime", required = false) String endtime, + @RequestParam(value = "SearchName", required = false) String searchName, + HttpServletResponse response) { + statsService.exportExcel(currentSchId(authUserWithDept), 4, type, starttime, endtime, searchName, null, response); + } + + @GetMapping("Exceldaochu5") + public void exceldaochu5(BoaUser authUserWithDept, + @RequestParam(value = "type", defaultValue = "0") Integer type, + @RequestParam(value = "starttime", required = false) String starttime, + @RequestParam(value = "endtime", required = false) String endtime, + @RequestParam(value = "SearchName", required = false) String searchName, + HttpServletResponse response) { + statsService.exportExcel(currentSchId(authUserWithDept), 5, type, starttime, endtime, searchName, null, response); + } + + /** 明细附件打包下载(zip,对齐老 CompressFileDown) */ + @GetMapping("CompressFileDown") + public void compressFileDown(BoaUser authUserWithDept, + @RequestParam(value = "type", defaultValue = "0") Integer type, + @RequestParam(value = "starttime", required = false) String starttime, + @RequestParam(value = "endtime", required = false) String endtime, + @RequestParam(value = "SearchName", required = false) String searchName, + @RequestParam(value = "mouid", required = false) Integer mouid, + @RequestParam(value = "userid", required = false) Integer userid, + @RequestParam(value = "subid", required = false) String subid, + HttpServletResponse response) { + statsService.attachmentZipDown(currentSchId(authUserWithDept), type, starttime, endtime, searchName, + mouid, userid, subid, response); + } + + /** 自定义统计区间默认值(对齐老 GetTime) */ + @GetMapping("GetTime") + public Map getTime() { + return statsService.defaultRange(); + } + + // ==================== 局端跨校统计(超管/区局) ==================== + + /** 学校总览(记录+评论按校分组,对齐 StatisticsMap 归并) */ + @GetMapping("StatisticsMap") + public Map statisticsMap(BoaUser authUserWithDept, + @RequestParam(value = "rqtype", defaultValue = "0") Integer rqtype, + @RequestParam(value = "KSSJ", required = false) String kssj, + @RequestParam(value = "JSSJ", required = false) String jssj) { + requireCrossSchool(currentSchId(authUserWithDept)); + return statsService.schoolOverview(rqtype, kssj, jssj); + } + + /** 按校记录数列表(对齐 GetSchoolType) */ + @GetMapping("GetSchoolType") + public List> getSchoolType(BoaUser authUserWithDept, + @RequestParam(value = "rqtype", defaultValue = "0") Integer rqtype, + @RequestParam(value = "KSSJ", required = false) String kssj, + @RequestParam(value = "JSSJ", required = false) String jssj) { + requireCrossSchool(currentSchId(authUserWithDept)); + Map overview = statsService.schoolOverview(rqtype, kssj, jssj); + @SuppressWarnings("unchecked") + List> schools = (List>) overview.get("schools"); + List> rows = new ArrayList<>(); + for (Map s : schools) { + Map row = new LinkedHashMap<>(); + row.put("ID", s.get("schId")); + row.put("Count", s.get("recordCount")); + rows.add(row); + } + return rows; + } + + /** 学校×教师统计(schId 空 = 全部学校;对齐 GetSchoolTeaList) */ + @GetMapping("GetSchoolTeaList") + public Map getSchoolTeaList(BoaUser authUserWithDept, + @RequestParam(value = "SchSerID", required = false) Integer schSerId, + @RequestParam(value = "rqtype", defaultValue = "0") Integer rqtype, + @RequestParam(value = "KSSJ", required = false) String kssj, + @RequestParam(value = "JSSJ", required = false) String jssj) { + requireCrossSchool(currentSchId(authUserWithDept)); + return statsService.schoolTeacherStat(schSerId, rqtype, kssj, jssj); + } + + /** 学校×栏目统计(对齐 GetSchoolMoudels) */ + @GetMapping("GetSchoolMoudels") + public Map getSchoolMoudels(BoaUser authUserWithDept, + @RequestParam(value = "SchSerID", required = false) Integer schSerId, + @RequestParam(value = "rqtype", defaultValue = "0") Integer rqtype, + @RequestParam(value = "KSSJ", required = false) String kssj, + @RequestParam(value = "JSSJ", required = false) String jssj) { + requireCrossSchool(currentSchId(authUserWithDept)); + return statsService.schoolModuleStat(schSerId, rqtype, kssj, jssj); + } + + /** 学校×学科统计(对齐 GetSchoolTeaSubMap/SubList) */ + @GetMapping("GetSchoolTeaSubList") + public Map getSchoolTeaSubList(BoaUser authUserWithDept, + @RequestParam(value = "SchSerID", required = false) Integer schSerId, + @RequestParam(value = "rqtype", defaultValue = "0") Integer rqtype, + @RequestParam(value = "KSSJ", required = false) String kssj, + @RequestParam(value = "JSSJ", required = false) String jssj) { + requireCrossSchool(currentSchId(authUserWithDept)); + return statsService.schoolSubjectStat(schSerId, rqtype, kssj, jssj); + } + + /** 学校×教师积分统计(对齐 GetSchoolTeaJFList) */ + @GetMapping("GetSchoolTeaJFList") + public Map getSchoolTeaJFList(BoaUser authUserWithDept, + @RequestParam(value = "SchSerID", required = false) Integer schSerId, + @RequestParam(value = "rqtype", defaultValue = "0") Integer rqtype, + @RequestParam(value = "KSSJ", required = false) String kssj, + @RequestParam(value = "JSSJ", required = false) String jssj) { + requireCrossSchool(currentSchId(authUserWithDept)); + return statsService.schoolPointsStat(schSerId, rqtype, kssj, jssj); + } + + /** 统计维度下拉(对齐 StatisticsMoudels:0个人/1模块/2学科/3评论/4积分) */ + @GetMapping("StatisticsMoudels") + public List> statisticsMoudels() { + List> list = new ArrayList<>(); + list.add(dim(0, "个人")); + list.add(dim(1, "模块")); + list.add(dim(2, "学科")); + list.add(dim(3, "评论")); + list.add(dim(4, "积分")); + return list; + } + + // ==================== 内部方法 ==================== + + private Map dim(int value, String text) { + Map m = new LinkedHashMap<>(); + m.put("Value", value); + m.put("Text", text); + return m; + } + + private void requireCrossSchool(Integer schId) { + boolean district = districtSchId != null && districtSchId > 0 && districtSchId.equals(schId); + if (!ProfessionContext.isSuperAdmin() && !district) { + throw new IllegalArgumentException("仅超管或区局账号可访问跨校统计"); + } + } + + private Integer currentSchId(BoaUser user) { + return ProfessionContext.currentSchId(user); + } +} diff --git a/src/main/java/com/yunhai/profession/entity/BaseEntity.java b/src/main/java/com/yunhai/profession/entity/BaseEntity.java new file mode 100644 index 0000000..94d6715 --- /dev/null +++ b/src/main/java/com/yunhai/profession/entity/BaseEntity.java @@ -0,0 +1,49 @@ +package com.yunhai.profession.entity; + +import com.yunhai.profession.common.listener.BaseAuditListener; +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.*; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link BaseAuditListener} 自动填充)。 + */ +@MappedSuperclass +@Getter +@Setter +@EntityListeners(BaseAuditListener.class) +public abstract class BaseEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + /** + * 创建人登录账号 + */ + @Column(name = "create_by", updatable = false) + private String createBy; + + /** + * 创建时间 + */ + @Column(name = "create_time", updatable = false) + private LocalDateTime createTime; + + /** + * 更新人登录账号 + */ + @Column(name = "update_by") + private String updateBy; + + /** + * 更新时间 + */ + @Column(name = "update_time") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/yunhai/profession/entity/PointsConfig.java b/src/main/java/com/yunhai/profession/entity/PointsConfig.java new file mode 100644 index 0000000..5da524c --- /dev/null +++ b/src/main/java/com/yunhai/profession/entity/PointsConfig.java @@ -0,0 +1,33 @@ +package com.yunhai.profession.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 全局积分默认配置(每校一行,懒创建),对齐历史契约 pointstoOne/pointsEdit: + * SendpointsNum(发布积分数)/CommpointsNum(评论积分数)。 + */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "profession_points_config") +public class PointsConfig extends BaseEntity { + + /** 归属学校(每校唯一) */ + @Column(name = "sch_id", nullable = false, unique = true) + private Integer schId; + + /** 默认发布积分数 */ + @Column(name = "send_points_num", nullable = false) + private Integer sendPointsNum = 0; + + /** 默认评论积分数 */ + @Column(name = "comm_points_num", nullable = false) + private Integer commPointsNum = 0; +} diff --git a/src/main/java/com/yunhai/profession/entity/PointsSetting.java b/src/main/java/com/yunhai/profession/entity/PointsSetting.java new file mode 100644 index 0000000..ef47451 --- /dev/null +++ b/src/main/java/com/yunhai/profession/entity/PointsSetting.java @@ -0,0 +1,77 @@ +package com.yunhai.profession.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 积分指标树节点实体,对齐历史契约 PointsSettingtoOne/PointsSettingEdit: + * ParentID/Type(1按比例上级加分/2封顶)/Sendpoints/Commpoints/SoursePoints 等。 + */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "profession_points_setting") +public class PointsSetting extends BaseEntity { + + /** 归属学校(多租户隔离) */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 父节点ID(0/空 = 根节点) */ + @Column(name = "parent_id", nullable = false) + private Integer parentId = 0; + + /** 节点类型:1=按比例上级加分 / 2=封顶 */ + @Column(name = "type", nullable = false) + private Integer type = 2; + + /** 节点名称 */ + @Column(name = "name", length = 100, nullable = false) + private String name; + + /** 发布积分 */ + @Column(name = "send_points", nullable = false) + private Integer sendPoints = 0; + + /** 是否加成上级(Type=1) */ + @Column(name = "is_send_upper", nullable = false) + private Integer isSendUpper = 0; + + /** 上级发布加分(Type=1) */ + @Column(name = "upper_send_points") + private Integer upperSendPoints; + + /** 评论积分 */ + @Column(name = "comm_points", nullable = false) + private Integer commPoints = 0; + + /** 评论是否加成上级(Type=1) */ + @Column(name = "is_comm_upper", nullable = false) + private Integer isCommUpper = 0; + + /** 上级评论加分(Type=1) */ + @Column(name = "upper_comm_points") + private Integer upperCommPoints; + + /** 发布积分封顶(Type=2) */ + @Column(name = "end_send_points") + private Integer endSendPoints; + + /** 评论积分封顶(Type=2) */ + @Column(name = "end_comm_points") + private Integer endCommPoints; + + /** 积分来源串(CSV,对齐 SoursePoints) */ + @Column(name = "sourse_points", length = 500) + private String soursePoints; + + /** 积分来源ID串(CSV,对齐 SoursePointid) */ + @Column(name = "sourse_point_id", length = 500) + private String soursePointId; +} diff --git a/src/main/java/com/yunhai/profession/entity/ProfessionArea.java b/src/main/java/com/yunhai/profession/entity/ProfessionArea.java new file mode 100644 index 0000000..07d920e --- /dev/null +++ b/src/main/java/com/yunhai/profession/entity/ProfessionArea.java @@ -0,0 +1,48 @@ +package com.yunhai.profession.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; + +/** + * 行政区划(省 / 市 / 区县)字典实体,供自定义字段「省市级联(province_city)」取数。 + *

编码采用 GB/T 2260(6 位):省 2 位有效(补 0000,如 440000)、市 4 位有效(440300)、 + * 区县 6 位(440305);直辖市的市级节点用「市辖区」占位(code 为 XX0100)。

+ *

注意:本表是行政区划字典,与项目里的 districtSchId(区级教育局账号)无关,勿混用。

+ *

层级 level:1 省 / 2 市 / 3 区县;parent_code 为上级 code(省为 "0")。

+ */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "profession_area", indexes = { + @Index(name = "idx_pa_parent", columnList = "parent_code"), + @Index(name = "idx_pa_level", columnList = "level") +}) +public class ProfessionArea extends BaseEntity { + + /** 区划编码(GB/T 2260,6 位,唯一) */ + @Column(name = "code", length = 12, nullable = false, unique = true) + private String code; + + /** 区划名称(如:广东省 / 深圳市 / 南山区) */ + @Column(name = "name", length = 64, nullable = false) + private String name; + + /** 上级区划编码(省级固定为 "0") */ + @Column(name = "parent_code", length = 12, nullable = false) + private String parentCode; + + /** 层级:1 省 / 2 市 / 3 区县 */ + @Column(name = "level", nullable = false) + private Integer level; + + /** 排序号(小在前,一般等于 code 升序) */ + @Column(name = "sort_order", nullable = false) + private Integer sortOrder = 0; +} diff --git a/src/main/java/com/yunhai/profession/entity/ProfessionDiscuss.java b/src/main/java/com/yunhai/profession/entity/ProfessionDiscuss.java new file mode 100644 index 0000000..af5d57d --- /dev/null +++ b/src/main/java/com/yunhai/profession/entity/ProfessionDiscuss.java @@ -0,0 +1,45 @@ +package com.yunhai.profession.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 记录评论实体,对齐历史契约 Discuss:SID(记录id)/TeaName/Contents(≤500)/EditDate。 + *

只存评论教师 id,不保存账号。

+ */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "profession_discuss") +public class ProfessionDiscuss extends BaseEntity { + + /** 归属学校(多租户隔离) */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 所属记录ID */ + @Column(name = "record_id", nullable = false) + private Integer recordId; + + /** 评论教师ID(平台用户 id,非账号) */ + @Column(name = "teacher_id", nullable = false) + private Integer teacherId; + + /** 评论教师姓名(展示冗余) */ + @Column(name = "teacher_name", length = 64) + private String teacherName; + + /** 评论内容(≤500 字) */ + @Column(name = "contents", length = 500, nullable = false) + private String contents; + + /** 评论获得的积分(写入时点计算) */ + @Column(name = "comm_points", nullable = false) + private Integer commPoints = 0; +} diff --git a/src/main/java/com/yunhai/profession/entity/ProfessionField.java b/src/main/java/com/yunhai/profession/entity/ProfessionField.java new file mode 100644 index 0000000..90cf8eb --- /dev/null +++ b/src/main/java/com/yunhai/profession/entity/ProfessionField.java @@ -0,0 +1,61 @@ +package com.yunhai.profession.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 自定义字段实体,对齐历史契约 FieldList 行: + * FieldType(text/number/date/radio/checkbox/select)/FieldName/FieldIFread/FieldDefault/Fieldrownum/FieldMaxlength/FieldExteadtext。 + */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "profession_field") +public class ProfessionField extends BaseEntity { + + /** 归属学校(多租户隔离) */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 字段类型:text/number/date/radio/checkbox/select */ + @Column(name = "field_type", length = 20, nullable = false) + private String fieldType; + + /** 字段显示名 */ + @Column(name = "field_name", length = 100, nullable = false) + private String fieldName; + + /** 排序号(小在前) */ + @Column(name = "field_sort", nullable = false) + private Integer fieldSort = 0; + + /** 是否必填(0 否 / 1 是,对齐老契约 FieldIFread) */ + @Column(name = "if_required", nullable = false) + private Integer ifRequired = 0; + + /** 默认值(radio/checkbox 存选项值;date 可存 "today" 表示默认当天) */ + @Column(name = "default_value", length = 255) + private String defaultValue; + + /** 文本域行数(text 多行时生效) */ + @Column(name = "row_num") + private Integer rowNum; + + /** 最小长度 */ + @Column(name = "min_length") + private Integer minLength; + + /** 最大长度 */ + @Column(name = "max_length") + private Integer maxLength; + + /** 选项 JSON(radio/checkbox/select 用):[{"Name":"选项名","fleid":"值","ifcheck":1}] */ + @Column(name = "ext_text", columnDefinition = "TEXT") + private String extText; +} diff --git a/src/main/java/com/yunhai/profession/entity/ProfessionLike.java b/src/main/java/com/yunhai/profession/entity/ProfessionLike.java new file mode 100644 index 0000000..09d473a --- /dev/null +++ b/src/main/java/com/yunhai/profession/entity/ProfessionLike.java @@ -0,0 +1,38 @@ +package com.yunhai.profession.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; + +/** + * 记录点赞实体(一人一记录一赞,重复请求由唯一约束兜底)。 + */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "profession_like", + uniqueConstraints = @UniqueConstraint(name = "uk_like_record_teacher", columnNames = {"record_id", "teacher_id"})) +public class ProfessionLike extends BaseEntity { + + /** 归属学校(多租户隔离) */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 记录ID */ + @Column(name = "record_id", nullable = false) + private Integer recordId; + + /** 点赞教师ID(平台用户 id,非账号) */ + @Column(name = "teacher_id", nullable = false) + private Integer teacherId; + + /** 点赞教师姓名(展示冗余) */ + @Column(name = "teacher_name", length = 64) + private String teacherName; +} diff --git a/src/main/java/com/yunhai/profession/entity/ProfessionModule.java b/src/main/java/com/yunhai/profession/entity/ProfessionModule.java new file mode 100644 index 0000000..3157dc9 --- /dev/null +++ b/src/main/java/com/yunhai/profession/entity/ProfessionModule.java @@ -0,0 +1,67 @@ +package com.yunhai.profession.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 栏目(业务模块)实体,对齐历史契约 MouduleList 行: + * SchSerID(归属校)/userSchSerID(查看者校)/MoudleName/TopModuleType/SendpointsNum/CommpointsNum/OpenStrid/IsCheck。 + *

sch_id 即创建方(区局账号创建的栏目 sch_id 为区局);is_shared=1 时对全部学校可见, + * 查看方通过 ownerSchId != 当前 schId 判定只读(对齐老契约 SchSerID != userSchSerID)。

+ */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "profession_module") +public class ProfessionModule extends BaseEntity { + + /** 归属学校(创建方;多租户隔离关键字段) */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 是否全区下发(0 仅本校 / 1 全区学校可见) */ + @Column(name = "is_shared", nullable = false) + private Integer isShared = 0; + + /** 栏目名称 */ + @Column(name = "name", length = 100, nullable = false) + private String name; + + /** 上级栏目ID(0/空 = 无) */ + @Column(name = "top_module_id") + private Integer topModuleId; + + /** 绑定的自定义字段ID串(CSV,如 "1,5,8",对齐老契约 ModuleAuthority) */ + @Column(name = "field_ids", length = 1000) + private String fieldIds; + + /** 发布积分(空/0 = 使用全局默认) */ + @Column(name = "send_points") + private Integer sendPoints; + + /** 评论积分(空/0 = 使用全局默认) */ + @Column(name = "comm_points") + private Integer commPoints; + + /** 开放用户ID串(CSV,对齐老契约 OpenStrid;空 = 不限) */ + @Column(name = "open_user_ids", length = 2000) + private String openUserIds; + + /** 备注 */ + @Column(name = "remark", length = 500) + private String remark; + + /** 是否启用勾选(0 停用 / 1 启用,对齐老契约 IsCheck) */ + @Column(name = "is_check", nullable = false) + private Integer isCheck = 1; + + /** 排序号(小在前) */ + @Column(name = "sort_order", nullable = false) + private Integer sortOrder = 0; +} diff --git a/src/main/java/com/yunhai/profession/entity/ProfessionRecord.java b/src/main/java/com/yunhai/profession/entity/ProfessionRecord.java new file mode 100644 index 0000000..60e33f8 --- /dev/null +++ b/src/main/java/com/yunhai/profession/entity/ProfessionRecord.java @@ -0,0 +1,82 @@ +package com.yunhai.profession.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 专业发展记录(教案/学习资料条目)实体,对齐历史契约 Profession: + * Protitle/pro_grade/pro_sub/procIFopen/ProRemark/ProContents/moudleid/TeaName/proUserid/DiscussCount/ReadCount。 + *

只存教师 id(teacher_id = 平台用户 id),不保存账号;teacher_name 仅为展示冗余。

+ */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "profession_record") +public class ProfessionRecord extends BaseEntity { + + /** 归属学校(多租户隔离) */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 所属栏目ID */ + @Column(name = "module_id", nullable = false) + private Integer moduleId; + + /** 标题 */ + @Column(name = "title", length = 200, nullable = false) + private String title; + + /** 年级段值(字典 value) */ + @Column(name = "grade", length = 20) + private String grade; + + /** 年级段名(展示冗余) */ + @Column(name = "grade_name", length = 50) + private String gradeName; + + /** 学科值(字典 value) */ + @Column(name = "subject", length = 20) + private String subject; + + /** 学科名(展示冗余) */ + @Column(name = "subject_name", length = 50) + private String subjectName; + + /** 是否公开(0 私密 / 1 公开,对齐老契约 procIFopen) */ + @Column(name = "if_open", nullable = false) + private Integer ifOpen = 1; + + /** 富文本正文(对齐老契约 ProRemark) */ + @Column(name = "remark", columnDefinition = "LONGTEXT") + private String remark; + + /** 动态字段值 JSON(对齐老契约 ProContents:Fieldlist=[{Fieldvalue:字段id, Fieldtext:值}]) */ + @Column(name = "contents", columnDefinition = "LONGTEXT") + private String contents; + + /** 作者教师ID(平台用户 id,非账号) */ + @Column(name = "teacher_id", nullable = false) + private Integer teacherId; + + /** 作者教师姓名(展示冗余) */ + @Column(name = "teacher_name", length = 64) + private String teacherName; + + /** 阅读数 */ + @Column(name = "read_count", nullable = false) + private Integer readCount = 0; + + /** 点赞数(冗余计数,与 profession_like 行数同步维护) */ + @Column(name = "like_count", nullable = false) + private Integer likeCount = 0; + + /** 发布时获得的积分(写入时点计算,统计直接 SUM) */ + @Column(name = "send_points", nullable = false) + private Integer sendPoints = 0; +} diff --git a/src/main/java/com/yunhai/profession/entity/ProfessionVisit.java b/src/main/java/com/yunhai/profession/entity/ProfessionVisit.java new file mode 100644 index 0000000..b2994ba --- /dev/null +++ b/src/main/java/com/yunhai/profession/entity/ProfessionVisit.java @@ -0,0 +1,44 @@ +package com.yunhai.profession.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 浏览足迹实体,对齐历史契约 VisitCreate(v_type/v_typeid/v_userid)与 ProfessionVisitList。 + */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "profession_visit") +public class ProfessionVisit extends BaseEntity { + + /** 归属学校(多租户隔离) */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 栏目ID(对齐 v_type) */ + @Column(name = "module_id") + private Integer moduleId; + + /** 记录ID(对齐 v_typeid;看个人空间时可为空) */ + @Column(name = "record_id") + private Integer recordId; + + /** 被访问教师ID(记录作者,对齐 v_userid) */ + @Column(name = "author_id", nullable = false) + private Integer authorId; + + /** 访客教师ID(平台用户 id,非账号) */ + @Column(name = "visitor_id", nullable = false) + private Integer visitorId; + + /** 访客姓名(展示冗余) */ + @Column(name = "visitor_name", length = 64) + private String visitorName; +} diff --git a/src/main/java/com/yunhai/profession/entity/SysOperationLog.java b/src/main/java/com/yunhai/profession/entity/SysOperationLog.java new file mode 100644 index 0000000..5364326 --- /dev/null +++ b/src/main/java/com/yunhai/profession/entity/SysOperationLog.java @@ -0,0 +1,84 @@ +package com.yunhai.profession.entity; + +import lombok.Data; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import java.io.Serializable; +import java.time.LocalDateTime; + +/** + * 系统审计日志实体(移植自 UserclassService SysOperationLog)。 + * 全量记录 controller 请求:用户、URI、参数、响应(GZIP)、耗时、异常。 + * 索引由 resources/sql/sys_operation_log.sql 创建,本实体不含 @Table(indexes), + * 避免 ddl-auto=update 启动时重复建索引告警。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "sys_operation_log") +public class SysOperationLog implements Serializable { + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */ + @Column(name = "log_level", nullable = false, columnDefinition = "TINYINT") + private Integer logLevel; + + @Column(name = "operation_type", nullable = false, length = 16) + private String operationType; + + @Column(name = "user_id") + private Integer userId; + + @Column(name = "user_name", length = 64) + private String userName; + + @Column(name = "sch_id") + private Integer schId; + + @Column(name = "request_ip", nullable = false, length = 64) + private String requestIp; + + @Column(name = "request_uri", nullable = false, length = 512) + private String requestUri; + + @Column(name = "request_method", nullable = false, length = 16) + private String requestMethod; + + @Column(name = "class_method", nullable = false, length = 512) + private String classMethod; + + @Column(name = "request_params", columnDefinition = "TEXT") + private String requestParams; + + /** GZIP 压缩后的响应 JSON(不参与 JSON 序列化) */ + @Column(name = "response_data", columnDefinition = "MEDIUMBLOB") + private byte[] responseData; + + @Column(name = "status_code", nullable = false) + private Integer statusCode; + + /** 耗时 ms */ + @Column(name = "cost_time", nullable = false) + private Long costTime; + + @Column(name = "user_agent", length = 512) + private String userAgent; + + @Column(name = "error_msg", columnDefinition = "TEXT") + private String errorMsg; + + @CreationTimestamp + @Column(name = "create_time", nullable = false, updatable = false, columnDefinition = "DATETIME(3)") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/yunhai/profession/entity/TermSetting.java b/src/main/java/com/yunhai/profession/entity/TermSetting.java new file mode 100644 index 0000000..325cc10 --- /dev/null +++ b/src/main/java/com/yunhai/profession/entity/TermSetting.java @@ -0,0 +1,55 @@ +package com.yunhai.profession.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 学期设置实体,对齐历史契约 TermSendMessage: + * SchoolYear/Termtype/TermStartTime/TermEndTime/SendTime/QualifiedLine/IsOpen。 + */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "profession_term_setting") +public class TermSetting extends BaseEntity { + + /** 归属学校(多租户隔离) */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 学年(如 2026) */ + @Column(name = "school_year", length = 20, nullable = false) + private String schoolYear; + + /** 学期序(1 上学期 / 2 下学期) */ + @Column(name = "term_type", nullable = false) + private Integer termType = 1; + + /** 学期开始时间 */ + @Column(name = "term_start_time") + private LocalDateTime termStartTime; + + /** 学期结束时间 */ + @Column(name = "term_end_time") + private LocalDateTime termEndTime; + + /** 推送提醒时间(结束前 3 天) */ + @Column(name = "send_time") + private LocalDateTime sendTime; + + /** 合格线(积分) */ + @Column(name = "qualified_line", precision = 10, scale = 2) + private BigDecimal qualifiedLine; + + /** 是否开启(0 关 / 1 开) */ + @Column(name = "is_open", nullable = false) + private Integer isOpen = 1; +} diff --git a/src/main/java/com/yunhai/profession/entity/TopModule.java b/src/main/java/com/yunhai/profession/entity/TopModule.java new file mode 100644 index 0000000..9538838 --- /dev/null +++ b/src/main/java/com/yunhai/profession/entity/TopModule.java @@ -0,0 +1,36 @@ +package com.yunhai.profession.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 上级栏目(一级栏目)实体,对齐历史契约 TopMoude:SerID/MoudleName/IsUsing/排序。 + */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "profession_top_module") +public class TopModule extends BaseEntity { + + /** 归属学校(多租户隔离;区局账号创建时为区局 schId) */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 栏目名称 */ + @Column(name = "name", length = 100, nullable = false) + private String name; + + /** 是否启用(0 停用 / 1 启用) */ + @Column(name = "is_using", nullable = false) + private Integer isUsing = 1; + + /** 排序号(小在前) */ + @Column(name = "sort_order", nullable = false) + private Integer sortOrder = 0; +} diff --git a/src/main/java/com/yunhai/profession/entity/UpFile.java b/src/main/java/com/yunhai/profession/entity/UpFile.java new file mode 100644 index 0000000..081e440 --- /dev/null +++ b/src/main/java/com/yunhai/profession/entity/UpFile.java @@ -0,0 +1,44 @@ +package com.yunhai.profession.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; + +/** + * 附件元信息实体(按前端 UpFileVo 契约重建)。 + */ +@NoArgsConstructor +@Getter +@Setter +@Entity +@Table(name = "up_file") +public class UpFile extends BaseEntity { + + /** 主题类型(如 "profession") */ + @Column(name = "topic_type", length = 50) + private String topicType; + + /** 主题ID(活动ID;未绑定时为空) */ + @Column(name = "topic_id") + private Integer topicId; + + /** 原始文件名 */ + @Column(name = "file_true_name", length = 255) + private String fileTrueName; + + /** 存储相对路径 */ + @Column(name = "file_name", length = 255) + private String fileName; + + /** 文件大小(字节) */ + @Column(name = "file_size") + private Integer fileSize; + + /** 扩展名(小写) */ + @Column(name = "file_type", length = 50) + private String fileType; +} diff --git a/src/main/java/com/yunhai/profession/enums/UserType.java b/src/main/java/com/yunhai/profession/enums/UserType.java new file mode 100644 index 0000000..7a5d027 --- /dev/null +++ b/src/main/java/com/yunhai/profession/enums/UserType.java @@ -0,0 +1,20 @@ +package com.yunhai.profession.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 用户类型(与基础平台 BoaUser.type 对齐)。 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public enum UserType { + SUPER_ADMINISTRATOR(1, "超级管理员"), + SCHOOL_ADMIN(2, "学校管理员"), + REGULAR_USER(3, "普通用户"); + + private Integer id; + private String name; +} diff --git a/src/main/java/com/yunhai/profession/model/BasePageQuery.java b/src/main/java/com/yunhai/profession/model/BasePageQuery.java new file mode 100644 index 0000000..ea60fb6 --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/BasePageQuery.java @@ -0,0 +1,14 @@ +package com.yunhai.profession.model; + +import lombok.Data; + +/** + * 统一分页查询入参。pageIndex 从 1 起。 + */ +@Data +public class BasePageQuery { + private int pageIndex = 1; + private int pageSize = 10; + /** 通用模糊搜索关键词 */ + private String keyword; +} diff --git a/src/main/java/com/yunhai/profession/model/BasePageResult.java b/src/main/java/com/yunhai/profession/model/BasePageResult.java new file mode 100644 index 0000000..c81ed19 --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/BasePageResult.java @@ -0,0 +1,33 @@ +package com.yunhai.profession.model; + +import lombok.Data; + +import java.util.List; + +/** + * 统一分页响应外壳。 + */ +@Data +public class BasePageResult { + /** 总记录数 */ + private int total; + /** 当前页数据 */ + private List content; + /** 当前页码(从 1 起) */ + private int pageIndex; + /** 每页条数 */ + private int pageSize; + /** 总页数 */ + private int totalPages; + /** 是否还有下一页 */ + private boolean hasNext; + + public BasePageResult(List content, int total, int pageIndex, int pageSize, int totalPages, boolean hasNext) { + this.content = content; + this.total = total; + this.pageIndex = pageIndex; + this.pageSize = pageSize; + this.totalPages = totalPages; + this.hasNext = hasNext; + } +} diff --git a/src/main/java/com/yunhai/profession/model/SchTeaList.java b/src/main/java/com/yunhai/profession/model/SchTeaList.java new file mode 100644 index 0000000..63ada86 --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/SchTeaList.java @@ -0,0 +1,106 @@ +package com.yunhai.profession.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * 班级管理接口-教师(bschclass {@code /oapi/DbClass/GetSchteaList} 响应项)。 + * 对应 C# BestSchool.Entity.Model.SchTeaList。 + */ +public class SchTeaList { + + /** 学校 GUID(SchSerID) */ + @JsonProperty("SchSerID") + private String schSerID; + + @JsonProperty("TeaTel") + private String teaTel; + + /** 教师姓名 */ + @JsonProperty("TeaName") + private String teaName; + + /** 教师用户ID */ + @JsonProperty("UserTeaSerID") + private Integer userTeaSerID; + + @JsonProperty("TeaIDCard") + private String teaIDCard; + + /** 教师工号 */ + @JsonProperty("TeaNo") + private String teaNo; + + /** 教师所属组ID列表(兼容旧字段 GroupIds) */ + @JsonProperty("GroupIds") + private List groupIds; + + /** 教师所属组ID(接口现行字段 GroupID,单个) */ + @JsonProperty("GroupID") + private Integer groupId; + + public Integer getGroupId() { + return groupId; + } + + public void setGroupId(Integer groupId) { + this.groupId = groupId; + } + + public String getSchSerID() { + return schSerID; + } + + public void setSchSerID(String schSerID) { + this.schSerID = schSerID; + } + + public String getTeaTel() { + return teaTel; + } + + public void setTeaTel(String teaTel) { + this.teaTel = teaTel; + } + + public String getTeaName() { + return teaName; + } + + public void setTeaName(String teaName) { + this.teaName = teaName; + } + + public Integer getUserTeaSerID() { + return userTeaSerID; + } + + public void setUserTeaSerID(Integer userTeaSerID) { + this.userTeaSerID = userTeaSerID; + } + + public String getTeaIDCard() { + return teaIDCard; + } + + public void setTeaIDCard(String teaIDCard) { + this.teaIDCard = teaIDCard; + } + + public String getTeaNo() { + return teaNo; + } + + public void setTeaNo(String teaNo) { + this.teaNo = teaNo; + } + + public List getGroupIds() { + return groupIds; + } + + public void setGroupIds(List groupIds) { + this.groupIds = groupIds; + } +} diff --git a/src/main/java/com/yunhai/profession/model/UserSysRole.java b/src/main/java/com/yunhai/profession/model/UserSysRole.java new file mode 100644 index 0000000..e39c580 --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/UserSysRole.java @@ -0,0 +1,29 @@ +package com.yunhai.profession.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 用户系统角色上下文(来自请求头 currentrole,URL 编码 JSON:{childId, roleId, schId, userId})。 + * 家长端 childId 即当前孩子(学生)ID,0 代表主账号操作。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class UserSysRole { + + /** 子账号/孩子ID,0=主账号 */ + private Integer childId; + + /** 角色ID */ + private Integer roleId; + + /** 学校ID(数据隔离关键字段) */ + private Integer schId; + + /** 用户ID(部分平台版本携带) */ + private Integer userId; +} diff --git a/src/main/java/com/yunhai/profession/model/bsch/SchClassDto.java b/src/main/java/com/yunhai/profession/model/bsch/SchClassDto.java new file mode 100644 index 0000000..641744c --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/bsch/SchClassDto.java @@ -0,0 +1,29 @@ +package com.yunhai.profession.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 班级 DTO(/oapi/VSchClass/GetVClassList,SchClass 视图)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchClassDto { + + /** 班级业务ID(sch_class.class_id,scopeType=班级 的 scopeId;学生 ClassID 指向它) */ + @JsonProperty("ClassID") + private Integer classId; + + /** 班级名称 */ + @JsonProperty("ClassName") + private String className; + + /** 年级ID(= gradeId) */ + @JsonProperty("Levell") + private Integer gradeId; + + /** 排序号 */ + @JsonProperty("sx") + private Integer sort; +} diff --git a/src/main/java/com/yunhai/profession/model/bsch/SchClassMasterDto.java b/src/main/java/com/yunhai/profession/model/bsch/SchClassMasterDto.java new file mode 100644 index 0000000..d2efb6f --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/bsch/SchClassMasterDto.java @@ -0,0 +1,46 @@ +package com.yunhai.profession.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +/** + * bschclass 班级 DTO(/oapi/sch/getSchClassList,含班主任)。 + * 上游 OapiClassVO 无 @JsonProperty 注解,JSON 字段为 camelCase; + * 班级名优先取 name,缺失回退 cname。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchClassMasterDto { + + /** 班级业务ID(sch_class.class_id) */ + private Integer classId; + + /** 班级名称 */ + private String name; + + /** 班级组名称(name 缺失时的回退) */ + private String cname; + + /** 年级ID(班级显示名「年级名+班级名」的对应关系) */ + private Integer gradeId; + + /** 年级名称(如「高一」;本接口已携带,免再查年级表) */ + private String gradeName; + + /** 班主任ID(用户中心教师ID) */ + private Integer firstTeacherId; + + /** 班主任姓名 */ + private String firstTeacherName; + + /** 副班主任ID */ + private Integer secondTeacherId; + + /** 是否毕业班 */ + private Boolean graduate; + + /** 班级名称(name 优先,回退 cname) */ + public String className() { + return name != null && !name.isEmpty() ? name : cname; + } +} diff --git a/src/main/java/com/yunhai/profession/model/bsch/SchGradeDto.java b/src/main/java/com/yunhai/profession/model/bsch/SchGradeDto.java new file mode 100644 index 0000000..23bdd6b --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/bsch/SchGradeDto.java @@ -0,0 +1,25 @@ +package com.yunhai.profession.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 年级 DTO(/oapi/Schoolen/GetSchLenLevellist,BaseSchsystemGrade)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchGradeDto { + + /** 年级ID(=gradeId,scopeType=年级 的 scopeId;班级 Levell 指向它) */ + @JsonProperty("Id") + private Integer id; + + /** 年级名称(gradeNickname,如「初一」) */ + @JsonProperty("Name") + private String name; + + /** 所属学段(学制)ID */ + @JsonProperty("OwnerId") + private Integer ownerId; +} diff --git a/src/main/java/com/yunhai/profession/model/bsch/SchInfoDto.java b/src/main/java/com/yunhai/profession/model/bsch/SchInfoDto.java new file mode 100644 index 0000000..12412e9 --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/bsch/SchInfoDto.java @@ -0,0 +1,25 @@ +package com.yunhai.profession.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 学校信息 DTO(/oapi/SchInfo/GetUserSchFirst)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchInfoDto { + + /** 学校 GUID(bschclass 侧 SchSerId) */ + @JsonProperty("SchSerId") + private String schSerId; + + /** 用户中心学校组ID(与 CampusNotice schId 同域) */ + @JsonProperty("UserSchSerId") + private Integer userSchSerId; + + /** 学校名称 */ + @JsonProperty("SchName") + private String schName; +} diff --git a/src/main/java/com/yunhai/profession/model/bsch/SchStudentDto.java b/src/main/java/com/yunhai/profession/model/bsch/SchStudentDto.java new file mode 100644 index 0000000..939587b --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/bsch/SchStudentDto.java @@ -0,0 +1,35 @@ +package com.yunhai.profession.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 学生 DTO(/oapi/OAStu/*,SchStudent 视图)。 + * account 仅为上游接口契约字段(兼容反序列化),本系统落库/身份映射不再使用; + * 学生身份以 stuId(UserStuSerID,= 平台用户ID)+ stuName 承载。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchStudentDto { + + /** 学员登录账号(上游契约字段,仅兼容保留,不落库) */ + @JsonProperty("Account") + private String account; + + /** 学生ID */ + @JsonProperty("UserStuSerID") + private Integer stuId; + + /** 学生姓名 */ + @JsonProperty("StuName") + private String stuName; + + /** 所在班级ID(sch_class.class_id) */ + @JsonProperty("ClassID") + private Integer classId; + + /** 所在组ID */ + @JsonProperty("GroupID") + private Integer groupId; +} diff --git a/src/main/java/com/yunhai/profession/model/bsch/SchSubjectDto.java b/src/main/java/com/yunhai/profession/model/bsch/SchSubjectDto.java new file mode 100644 index 0000000..7703100 --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/bsch/SchSubjectDto.java @@ -0,0 +1,29 @@ +package com.yunhai.profession.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 学校学科 DTO(/oapi/SubInfo/GetSchSublist)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchSubjectDto { + + /** 学校 GUID(bschclass 侧 SchSerId) */ + @JsonProperty("SchSerID") + private String schSerId; + + /** 学科ID(学科编码;字典无编码时为字典ID字符串) */ + @JsonProperty("SubID") + private String subId; + + /** 学科名称 */ + @JsonProperty("SubName") + private String subName; + + /** 排序号 */ + @JsonProperty("No") + private Integer no; +} diff --git a/src/main/java/com/yunhai/profession/model/bsch/SchSystemDto.java b/src/main/java/com/yunhai/profession/model/bsch/SchSystemDto.java new file mode 100644 index 0000000..19b0927 --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/bsch/SchSystemDto.java @@ -0,0 +1,25 @@ +package com.yunhai.profession.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * bschclass 学段(学制)DTO(/oapi/Schoolen/GetSchLenlist,BaseSchsystem)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchSystemDto { + + /** 学制ID(scopeType=学部 的 scopeId) */ + @JsonProperty("Id") + private Integer id; + + /** 学段名称(如「小学部」「初中部」) */ + @JsonProperty("Name") + private String name; + + /** 学制类型 */ + @JsonProperty("Type") + private Integer type; +} diff --git a/src/main/java/com/yunhai/profession/model/bsch/SchTeachDto.java b/src/main/java/com/yunhai/profession/model/bsch/SchTeachDto.java new file mode 100644 index 0000000..46d11e3 --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/bsch/SchTeachDto.java @@ -0,0 +1,50 @@ +package com.yunhai.profession.model.bsch; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.Date; + +/** + * bschclass 任教关系 DTO(/oapi/TeachInfo/*,sch_class_teach 视图)。 + * 一条记录 = 某学校某班级中某教师任教某学科。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchTeachDto { + + /** 任教记录ID(sch_class_teach.id) */ + @JsonProperty("SerID") + private Integer serId; + + /** 学校 GUID(bschclass 侧 SchSerId) */ + @JsonProperty("SchSerID") + private String schSerId; + + /** 教师ID(= 平台用户ID) */ + @JsonProperty("UserTeaSerID") + private Integer teaId; + + /** 学科ID(学科编码) */ + @JsonProperty("SubID") + private String subId; + + /** 班级业务ID(sch_class.class_id) */ + @JsonProperty("ClassID") + private Integer classId; + + /** 班级ID(与 ClassID 同值,上游兼容字段) */ + @JsonProperty("UserClassID") + private Integer userClassId; + + /** + * 创建时间。bschclass 返回 "yyyy-MM-dd HH:mm:ss"(空格分隔、非 ISO), + * ClassService 的独立 RestTemplate 不带全局 spring.jackson.date-format, + * 必须在注解上指定格式,否则反序列化抛 InvalidFormatException(任教获取失败的根因)。 + */ + @JsonProperty("CreationTime") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date creationTime; +} diff --git a/src/main/java/com/yunhai/profession/model/bsch/SchTreeNodeDto.java b/src/main/java/com/yunhai/profession/model/bsch/SchTreeNodeDto.java new file mode 100644 index 0000000..91d8b0e --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/bsch/SchTreeNodeDto.java @@ -0,0 +1,41 @@ +package com.yunhai.profession.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * bschclass 组织树节点 DTO(/oapi/SchGroup/GetTeaTree、GetStuTree,TeaTreeVO 视图)。 + * type=1 组节点(学校/学段/年级/班级、教师组),type=2 人员叶子(学生/教师)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class SchTreeNodeDto { + + /** 节点ID(学校/学段/年级/班级为对应业务ID,叶子为学生 stuId 或教师 teaId) */ + private Integer id; + + /** 节点名称 */ + private String name; + + /** 父节点ID(根节点为 0) */ + private Integer pid; + + /** 排序号(组取 sort,叶子固定 0) */ + private Integer no; + + /** 1=组节点,2=人员叶子 */ + private Integer type; + + /** 是否父节点(组 true,叶子 false) */ + @JsonProperty("isParent") + private Boolean isParent; + + /** 头像(当前固定空串) */ + private String photo; + + /** 子节点(叶子为 null,组节点非 null) */ + private List children; +} diff --git a/src/main/java/com/yunhai/profession/model/bsch/TeacherCustomGroupDto.java b/src/main/java/com/yunhai/profession/model/bsch/TeacherCustomGroupDto.java new file mode 100644 index 0000000..ead7c56 --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/bsch/TeacherCustomGroupDto.java @@ -0,0 +1,59 @@ +package com.yunhai.profession.model.bsch; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.List; + +/** + * bschclass 教师分组 DTO(/oapi/sch/getTeaCustomGroupList 响应项,对应 OapiGroupVO)。 + * 组/组员/组长合一:组长 relationType=1、组员 relationType=2(members 已排除组长)。 + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class TeacherCustomGroupDto { + + /** 组ID(sch_teacher_custom_group.id) */ + private Integer groupId; + + /** 组名称 */ + private String groupName; + + /** 组类型(1年级组 2教研组 3备课组) */ + private Integer groupType; + + /** 组类型名称 */ + private String groupTypeName; + + /** 学科集合 */ + private List subjects; + + /** 年级集合 */ + private List grades; + + /** 组员集合(不含组长) */ + private List members; + + /** 组长集合 */ + private List leaders; + + /** 通用 id/name 项(对应 bschclass SelectIntVO/SelectStrVO) */ + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class IdName { + + private Object id; + + private String name; + + /** 学科编码为字符串、年级/教师为数字,统一按字符串取用 */ + public String idStr() { + return id == null ? null : String.valueOf(id); + } + + /** 教师/年级项 id 为数字,按整数取用 */ + public Integer idInt() { + return id instanceof Number ? ((Number) id).intValue() : null; + } + } +} diff --git a/src/main/java/com/yunhai/profession/model/file/UpFileResultVo.java b/src/main/java/com/yunhai/profession/model/file/UpFileResultVo.java new file mode 100644 index 0000000..82641ff --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/file/UpFileResultVo.java @@ -0,0 +1,16 @@ +package com.yunhai.profession.model.file; + +import lombok.Data; + +/** + * 文件上传结果(对齐前端 UpFileResultVo,含旧字段 fileid/host/fileurl)。 + */ +@Data +public class UpFileResultVo { + private Integer fileid; + private String name; + private String fileurl; + private String host; + private String type; + private Integer size; +} diff --git a/src/main/java/com/yunhai/profession/model/file/UpFileVo.java b/src/main/java/com/yunhai/profession/model/file/UpFileVo.java new file mode 100644 index 0000000..586b508 --- /dev/null +++ b/src/main/java/com/yunhai/profession/model/file/UpFileVo.java @@ -0,0 +1,25 @@ +package com.yunhai.profession.model.file; + +import com.yunhai.profession.entity.UpFile; +import lombok.Data; +import org.springframework.beans.BeanUtils; + +/** + * 附件视图对象(对齐前端 UpFileVo)。 + */ +@Data +public class UpFileVo { + private Integer id; + private String fileTrueName; + private String fileName; + private Integer fileSize; + private String fileType; + private Integer topicId; + private String topicType; + + public static UpFileVo from(UpFile entity) { + UpFileVo vo = new UpFileVo(); + BeanUtils.copyProperties(entity, vo); + return vo; + } +} diff --git a/src/main/java/com/yunhai/profession/repository/PointsConfigRepository.java b/src/main/java/com/yunhai/profession/repository/PointsConfigRepository.java new file mode 100644 index 0000000..2311687 --- /dev/null +++ b/src/main/java/com/yunhai/profession/repository/PointsConfigRepository.java @@ -0,0 +1,14 @@ +package com.yunhai.profession.repository; + +import com.yunhai.profession.entity.PointsConfig; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * 全局积分默认配置仓储。 + */ +public interface PointsConfigRepository extends JpaRepository { + + Optional findBySchId(Integer schId); +} diff --git a/src/main/java/com/yunhai/profession/repository/PointsSettingRepository.java b/src/main/java/com/yunhai/profession/repository/PointsSettingRepository.java new file mode 100644 index 0000000..795cf8a --- /dev/null +++ b/src/main/java/com/yunhai/profession/repository/PointsSettingRepository.java @@ -0,0 +1,16 @@ +package com.yunhai.profession.repository; + +import com.yunhai.profession.entity.PointsSetting; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +/** + * 积分指标树仓储。 + */ +public interface PointsSettingRepository extends JpaRepository { + + List findBySchIdOrderByIdAsc(Integer schId); + + List findByIdIn(List ids); +} diff --git a/src/main/java/com/yunhai/profession/repository/ProfessionAreaRepository.java b/src/main/java/com/yunhai/profession/repository/ProfessionAreaRepository.java new file mode 100644 index 0000000..8ce97b8 --- /dev/null +++ b/src/main/java/com/yunhai/profession/repository/ProfessionAreaRepository.java @@ -0,0 +1,22 @@ +package com.yunhai.profession.repository; + +import com.yunhai.profession.entity.ProfessionArea; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; + +/** + * 行政区划字典仓储。 + */ +public interface ProfessionAreaRepository extends JpaRepository { + + /** 某上级下的子级区划(省传 "0"),按排序号升序 */ + List findByParentCodeOrderBySortOrderAscCodeAsc(String parentCode); + + /** 按层级查(1 省 / 2 市 / 3 区县) */ + List findByLevelOrderBySortOrderAscCodeAsc(Integer level); + + /** 按 code 批量反查(回显翻译用) */ + List findByCodeIn(Collection codes); +} diff --git a/src/main/java/com/yunhai/profession/repository/ProfessionDiscussRepository.java b/src/main/java/com/yunhai/profession/repository/ProfessionDiscussRepository.java new file mode 100644 index 0000000..526fc18 --- /dev/null +++ b/src/main/java/com/yunhai/profession/repository/ProfessionDiscussRepository.java @@ -0,0 +1,78 @@ +package com.yunhai.profession.repository; + +import com.yunhai.profession.entity.ProfessionDiscuss; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 记录评论仓储。时间边界参数传 null 表示不限。 + */ +public interface ProfessionDiscussRepository extends JpaRepository { + + List findByRecordIdOrderByCreateTimeAsc(Integer recordId); + + long deleteByRecordId(Integer recordId); + + // ==================== 校级聚合 ==================== + + /** 按记录聚合评论数 */ + @Query("select d.recordId, count(d) from ProfessionDiscuss d where d.schId = :schId " + + "and (:start is null or d.createTime >= :start) and (:end is null or d.createTime < :end) " + + "group by d.recordId") + List countGroupByRecord(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** 按评论教师聚合:教师id、姓名、评论数 */ + @Query("select d.teacherId, d.teacherName, count(d) from ProfessionDiscuss d where d.schId = :schId " + + "and (:start is null or d.createTime >= :start) and (:end is null or d.createTime < :end) " + + "group by d.teacherId, d.teacherName") + List countGroupByTeacher(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** 评论积分按教师聚合 */ + @Query("select d.teacherId, d.teacherName, sum(d.commPoints) from ProfessionDiscuss d where d.schId = :schId " + + "and (:start is null or d.createTime >= :start) and (:end is null or d.createTime < :end) " + + "group by d.teacherId, d.teacherName") + List sumPointsGroupByTeacher(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + // ==================== 局端跨校聚合 ==================== + + /** 按学校聚合评论数 */ + @Query("select d.schId, count(d) from ProfessionDiscuss d " + + "where (:start is null or d.createTime >= :start) and (:end is null or d.createTime < :end) " + + "group by d.schId") + List countGroupBySchool(@Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** 按学校+教师聚合评论数 */ + @Query("select d.schId, d.teacherId, d.teacherName, count(d) from ProfessionDiscuss d " + + "where (:schId is null or d.schId = :schId) and (:start is null or d.createTime >= :start) and (:end is null or d.createTime < :end) " + + "group by d.schId, d.teacherId, d.teacherName") + List countGroupBySchoolTeacher(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** 评论积分按学校+教师聚合 */ + @Query("select d.schId, d.teacherId, d.teacherName, sum(d.commPoints) from ProfessionDiscuss d " + + "where (:schId is null or d.schId = :schId) and (:start is null or d.createTime >= :start) and (:end is null or d.createTime < :end) " + + "group by d.schId, d.teacherId, d.teacherName") + List sumPointsGroupBySchoolTeacher(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + // ===== 教师维度聚合(个人空间概览) ===== + + /** 教师在时间段内的评论数 */ + @Query("select count(d) from ProfessionDiscuss d where d.schId = :schId and d.teacherId = :uid " + + "and (:start is null or d.createTime >= :start) and (:end is null or d.createTime < :end)") + long countByTeacherRange(@Param("schId") Integer schId, @Param("uid") Integer uid, + @Param("start") LocalDateTime start, @Param("end") LocalDateTime end); +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/profession/repository/ProfessionFieldRepository.java b/src/main/java/com/yunhai/profession/repository/ProfessionFieldRepository.java new file mode 100644 index 0000000..e8b0fb1 --- /dev/null +++ b/src/main/java/com/yunhai/profession/repository/ProfessionFieldRepository.java @@ -0,0 +1,21 @@ +package com.yunhai.profession.repository; + +import com.yunhai.profession.entity.ProfessionField; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import java.util.List; + +/** + * 自定义字段仓储。 + */ +public interface ProfessionFieldRepository extends JpaRepository, JpaSpecificationExecutor { + + List findBySchIdOrderByFieldSortAscIdAsc(Integer schId); + + List findByIdInAndSchId(List ids, Integer schId); + + Page findBySchId(Integer schId, Pageable pageable); +} diff --git a/src/main/java/com/yunhai/profession/repository/ProfessionLikeRepository.java b/src/main/java/com/yunhai/profession/repository/ProfessionLikeRepository.java new file mode 100644 index 0000000..5f60e9a --- /dev/null +++ b/src/main/java/com/yunhai/profession/repository/ProfessionLikeRepository.java @@ -0,0 +1,14 @@ +package com.yunhai.profession.repository; + +import com.yunhai.profession.entity.ProfessionLike; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +/** + * 记录点赞仓储。 + */ +public interface ProfessionLikeRepository extends JpaRepository { + + Optional findByRecordIdAndTeacherId(Integer recordId, Integer teacherId); +} diff --git a/src/main/java/com/yunhai/profession/repository/ProfessionModuleRepository.java b/src/main/java/com/yunhai/profession/repository/ProfessionModuleRepository.java new file mode 100644 index 0000000..93bc31b --- /dev/null +++ b/src/main/java/com/yunhai/profession/repository/ProfessionModuleRepository.java @@ -0,0 +1,26 @@ +package com.yunhai.profession.repository; + +import com.yunhai.profession.entity.ProfessionModule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * 栏目仓储。 + */ +public interface ProfessionModuleRepository extends JpaRepository { + + List findBySchIdOrderBySortOrderAscIdAsc(Integer schId); + + /** 本校栏目 + 全区下发栏目,按排序号(对齐老契约 SchSerID=userSchSerID ∪ 区局下发) */ + @Query("select m from ProfessionModule m where (m.schId = :schId or m.isShared = 1) and m.isCheck >= 0 order by m.sortOrder asc, m.id asc") + List findVisible(@Param("schId") Integer schId); + + /** 启用中的可见栏目(前台用) */ + @Query("select m from ProfessionModule m where (m.schId = :schId or m.isShared = 1) and m.isCheck = 1 order by m.sortOrder asc, m.id asc") + List findVisibleEnabled(@Param("schId") Integer schId); + + List findByIdIn(List ids); +} diff --git a/src/main/java/com/yunhai/profession/repository/ProfessionRecordRepository.java b/src/main/java/com/yunhai/profession/repository/ProfessionRecordRepository.java new file mode 100644 index 0000000..cf0435a --- /dev/null +++ b/src/main/java/com/yunhai/profession/repository/ProfessionRecordRepository.java @@ -0,0 +1,157 @@ +package com.yunhai.profession.repository; + +import com.yunhai.profession.entity.ProfessionRecord; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 专业发展记录仓储。时间边界参数传 null 表示不限(:start is null 写法 Hibernate 支持)。 + */ +public interface ProfessionRecordRepository extends JpaRepository, JpaSpecificationExecutor { + + Page findBySchId(Integer schId, Pageable pageable); + + List findByIdIn(List ids); + + /** 教师任一记录(开发兜底反查姓名) */ + java.util.Optional findTopByTeacherIdOrderByIdDesc(Integer teacherId); + + /** 阅读数 +1 */ + @Modifying + @Query("update ProfessionRecord r set r.readCount = r.readCount + 1 where r.id = :id") + void increaseReadCount(@Param("id") Integer id); + + /** 点赞数增减(delta = +1 点赞 / -1 取消) */ + @Modifying + @Query("update ProfessionRecord r set r.likeCount = r.likeCount + :delta where r.id = :id") + void increaseLikeCount(@Param("id") Integer id, @Param("delta") int delta); + + /** 直查点赞数(绕开一级缓存,增减后取最新值) */ + @Query("select r.likeCount from ProfessionRecord r where r.id = :id") + Integer findLikeCount(@Param("id") Integer id); + + // ==================== 校级聚合(时间范围可选) ==================== + + /** 按教师聚合:教师id、姓名、记录数 */ + @Query("select r.teacherId, r.teacherName, count(r) from ProfessionRecord r " + + "where r.schId = :schId and (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end) " + + "group by r.teacherId, r.teacherName") + List countGroupByTeacher(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** 按栏目聚合:栏目id、记录数 */ + @Query("select r.moduleId, count(r) from ProfessionRecord r " + + "where r.schId = :schId and (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end) " + + "group by r.moduleId") + List countGroupByModule(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** 按学科聚合:学科值、记录数 */ + @Query("select r.subject, r.subjectName, count(r) from ProfessionRecord r " + + "where r.schId = :schId and (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end) " + + "group by r.subject, r.subjectName") + List countGroupBySubject(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** 发布积分按教师聚合 */ + @Query("select r.teacherId, r.teacherName, sum(r.sendPoints) from ProfessionRecord r " + + "where r.schId = :schId and (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end) " + + "group by r.teacherId, r.teacherName") + List sumPointsGroupByTeacher(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** 发布积分按栏目聚合 */ + @Query("select r.moduleId, sum(r.sendPoints) from ProfessionRecord r " + + "where r.schId = :schId and (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end) " + + "group by r.moduleId") + List sumPointsGroupByModule(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** 按教师×栏目聚合记录数(个人统计动态列) */ + @Query("select r.teacherId, r.teacherName, r.moduleId, count(r) from ProfessionRecord r " + + "where r.schId = :schId and (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end) " + + "group by r.teacherId, r.teacherName, r.moduleId") + List countGroupByTeacherModule(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** 按教师×栏目聚合发布积分(积分统计动态列) */ + @Query("select r.teacherId, r.teacherName, r.moduleId, sum(r.sendPoints) from ProfessionRecord r " + + "where r.schId = :schId and (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end) " + + "group by r.teacherId, r.teacherName, r.moduleId") + List sumPointsGroupByTeacherModule(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + // ==================== 局端跨校聚合(schId 不限) ==================== + + /** 按学校聚合记录数 */ + @Query("select r.schId, count(r) from ProfessionRecord r " + + "where (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end) " + + "group by r.schId") + List countGroupBySchool(@Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** 按学校+教师聚合记录数 */ + @Query("select r.schId, r.teacherId, r.teacherName, count(r) from ProfessionRecord r " + + "where (:schId is null or r.schId = :schId) and (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end) " + + "group by r.schId, r.teacherId, r.teacherName") + List countGroupBySchoolTeacher(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** 按学校+栏目聚合记录数 */ + @Query("select r.schId, r.moduleId, count(r) from ProfessionRecord r " + + "where (:schId is null or r.schId = :schId) and (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end) " + + "group by r.schId, r.moduleId") + List countGroupBySchoolModule(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** 按学校+学科聚合记录数 */ + @Query("select r.schId, r.subject, r.subjectName, count(r) from ProfessionRecord r " + + "where (:schId is null or r.schId = :schId) and (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end) " + + "group by r.schId, r.subject, r.subjectName") + List countGroupBySchoolSubject(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** 发布积分按学校+教师聚合 */ + @Query("select r.schId, r.teacherId, r.teacherName, sum(r.sendPoints) from ProfessionRecord r " + + "where (:schId is null or r.schId = :schId) and (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end) " + + "group by r.schId, r.teacherId, r.teacherName") + List sumPointsGroupBySchoolTeacher(@Param("schId") Integer schId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + // ===== 教师维度聚合(个人空间概览) ===== + + /** 教师在时间段内的记录数 */ + @Query("select count(r) from ProfessionRecord r where r.schId = :schId and r.teacherId = :uid " + + "and (:start is null or r.createTime >= :start) and (:end is null or r.createTime < :end)") + long countByTeacherRange(@Param("schId") Integer schId, @Param("uid") Integer uid, + @Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + + /** 教师记录的阅读总量 */ + @Query("select coalesce(sum(r.readCount), 0) from ProfessionRecord r " + + "where r.schId = :schId and r.teacherId = :uid") + long sumReadsByTeacher(@Param("schId") Integer schId, @Param("uid") Integer uid); + + /** 教师记录的获赞总量 */ + @Query("select coalesce(sum(r.likeCount), 0) from ProfessionRecord r " + + "where r.schId = :schId and r.teacherId = :uid") + long sumLikesByTeacher(@Param("schId") Integer schId, @Param("uid") Integer uid); +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/profession/repository/ProfessionVisitRepository.java b/src/main/java/com/yunhai/profession/repository/ProfessionVisitRepository.java new file mode 100644 index 0000000..28464be --- /dev/null +++ b/src/main/java/com/yunhai/profession/repository/ProfessionVisitRepository.java @@ -0,0 +1,35 @@ +package com.yunhai.profession.repository; + +import com.yunhai.profession.entity.ProfessionVisit; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * 浏览足迹仓储。 + */ +public interface ProfessionVisitRepository extends JpaRepository { + + /** 最近访客(同一访客去重由调用方处理) */ + List findByRecordIdOrderByCreateTimeDesc(Integer recordId, Pageable pageable); + + /** 个人空间最近访客(条数由 Pageable 传入,同一访客去重由调用方处理) */ + List findByAuthorIdOrderByCreateTimeDesc(Integer authorId, Pageable pageable); + + List findByRecordId(Integer recordId); + + // ===== 聚合计数(个人空间概览) ===== + + /** 被访问总次数(作为被访问者) */ + long countByAuthorId(Integer authorId); + + /** 时间段内被访问次数(start/end 传 null 表示不限) */ + @Query("select count(v) from ProfessionVisit v where v.authorId = :uid " + + "and (:start is null or v.createTime >= :start) and (:end is null or v.createTime < :end)") + long countVisitsByAuthor(@Param("uid") Integer uid, + @Param("start") java.time.LocalDateTime start, + @Param("end") java.time.LocalDateTime end); +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/profession/repository/SysOperationLogRepository.java b/src/main/java/com/yunhai/profession/repository/SysOperationLogRepository.java new file mode 100644 index 0000000..498251e --- /dev/null +++ b/src/main/java/com/yunhai/profession/repository/SysOperationLogRepository.java @@ -0,0 +1,12 @@ +package com.yunhai.profession.repository; + +import com.yunhai.profession.entity.SysOperationLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +/** + * 系统审计日志仓储。 + */ +public interface SysOperationLogRepository + extends JpaRepository, JpaSpecificationExecutor { +} diff --git a/src/main/java/com/yunhai/profession/repository/TermSettingRepository.java b/src/main/java/com/yunhai/profession/repository/TermSettingRepository.java new file mode 100644 index 0000000..912828d --- /dev/null +++ b/src/main/java/com/yunhai/profession/repository/TermSettingRepository.java @@ -0,0 +1,18 @@ +package com.yunhai.profession.repository; + +import com.yunhai.profession.entity.TermSetting; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +/** + * 学期设置仓储。 + */ +public interface TermSettingRepository extends JpaRepository { + + List findBySchIdOrderBySchoolYearDescTermTypeAsc(Integer schId); + + Page findBySchId(Integer schId, Pageable pageable); +} diff --git a/src/main/java/com/yunhai/profession/repository/TopModuleRepository.java b/src/main/java/com/yunhai/profession/repository/TopModuleRepository.java new file mode 100644 index 0000000..ef93abf --- /dev/null +++ b/src/main/java/com/yunhai/profession/repository/TopModuleRepository.java @@ -0,0 +1,14 @@ +package com.yunhai.profession.repository; + +import com.yunhai.profession.entity.TopModule; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +/** + * 上级栏目仓储。 + */ +public interface TopModuleRepository extends JpaRepository { + + List findBySchIdOrderBySortOrderAscIdAsc(Integer schId); +} diff --git a/src/main/java/com/yunhai/profession/repository/UpFileRepository.java b/src/main/java/com/yunhai/profession/repository/UpFileRepository.java new file mode 100644 index 0000000..3d0dfff --- /dev/null +++ b/src/main/java/com/yunhai/profession/repository/UpFileRepository.java @@ -0,0 +1,16 @@ +package com.yunhai.profession.repository; + +import com.yunhai.profession.entity.UpFile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +/** + * 附件元信息仓储。 + */ +public interface UpFileRepository extends JpaRepository { + + List findByTopicTypeAndTopicId(String topicType, Integer topicId); + + List findByIdIn(List ids); +} diff --git a/src/main/java/com/yunhai/profession/service/IAreaService.java b/src/main/java/com/yunhai/profession/service/IAreaService.java new file mode 100644 index 0000000..8bc55b7 --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/IAreaService.java @@ -0,0 +1,35 @@ +package com.yunhai.profession.service; + +import java.util.List; +import java.util.Map; + +/** + * 行政区划字典域:供自定义字段类型 province_city(省市级联)取数。 + *

数据源为 profession_area 表(GB/T 2260),非多租户数据(全校共用一份)。

+ */ +public interface IAreaService { + + /** + * 某上级下的子级区划;parentCode 为空时返回全部省份。 + * + * @param parentCode 上级区划编码(省级传 "0" 或留空) + * @return [{value:code, label:name, level, parentCode}] + */ + List> listChildren(String parentCode); + + /** + * 区划树(省 → 市 → 区县),供前端 Cascader 一次性取数。 + * + * @param level 需要展开到几级:2=省市 / 3=省市区(默认 3) + * @return [{value, label, level, children:[...]}] + */ + List> tree(Integer level); + + /** + * 编码路径转名称(详情回显翻译)。 + * + * @param codesCSV 编码串,如 "440000,440300" + * @return 名称串,如 "广东省/深圳市";查不到的编码原样保留 + */ + String namePath(String codesCSV); +} diff --git a/src/main/java/com/yunhai/profession/service/IClassService.java b/src/main/java/com/yunhai/profession/service/IClassService.java new file mode 100644 index 0000000..43b5e11 --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/IClassService.java @@ -0,0 +1,124 @@ +package com.yunhai.profession.service; + +import com.yunhai.profession.model.SchTeaList; +import com.yunhai.profession.model.bsch.*; + +import java.util.List; + +/** + * 班级管理(bschclass)开放接口客户端门面。 + *

基址取配置 {@code yh.open.bschclass}(/oapi/** 无需应用签名,独立 RestTemplate 直连)。 + * 推送范围树、圈人、身份快照统一经本门面取数,不散落 RestTemplate 调用(改造方案 2.0)。 + */ +public interface IClassService { + + /** + * 获取指定学校全部教师(含所属组 GroupIds)。 + * 对应 bschclass 开放接口:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}} + * + * @param schId 学校ID(即用户当前身份的 schId) + */ + List getSchTeaList(int schId); + + /** + * 用户中心学校ID → bschclass 学校信息(SchSerId / 学校名)。 + * 对应:GET {@code {bschclass}/oapi/SchInfo/GetUserSchFirst?UserSchID={schId}} + */ + SchInfoDto getSchFirst(int schId); + + /** + * 学校学段(学制)列表:小学部 / 初中部…。 + * 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenlist?SchSerID=} + */ + List getSchLenList(String schSerId); + + /** + * 学校年级列表(Id=gradeId、Name=gradeNickname、OwnerId=学段id)。 + * 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenLevellist?SchSerID=} + */ + List getSchGradeList(String schSerId); + + /** + * 学校全部在读班级(ClassID/ClassName/Levell=gradeId),ScopeTree 一次取数客户端组装。 + * 对应:GET {@code {bschclass}/oapi/VSchClass/GetVClassList?SchSerID=} + */ + List getSchClassList(String schSerId); + + /** + * 按校全部学生(圈人:全校范围展开)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetSchStulist?schserid=} + */ + List getSchStuList(String schSerId); + + /** + * 按年级直查学生(圈人:年级范围免逐班聚合;bschclass 侧补缺接口)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetGradeStulist?SchSerID=&GradeId=} + */ + List getGradeStuList(String schSerId, Integer gradeId); + + /** + * 按学段直查学生(圈人:学部范围一键展开;bschclass 侧补缺接口)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetSubStulist?SchSerID=&SchSystemId=} + */ + List getSubStuList(String schSerId, Integer schSystemId); + + /** + * 按班级批量查学生(圈人:班级范围一次多班)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetClaArrStulist?ClassIDs=1,2,3} + */ + List getClaArrStuList(List classIds); + + /** + * 按学生ID批量查(圈人:指定学员范围)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetStuIdslist?stuids=1,2,3} + */ + List getStuIdsList(List stuIds); + + /** + * 学校学生树:学校→学段→年级→班级→学生(单元素列表,根为学校节点)。 + * 节点契约:组节点 type=1、isParent=true、children 非 null;学生叶子 type=2、isParent=false、children=null。 + * 对应:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?SchSerID=} + */ + List getTeaTree(Integer schId, boolean toUser); + + List getStuTree(Integer schId, boolean toUser); + + /** + * 学校学科列表(含学科编码、名称、排序号)。 + * 对应:GET {@code {bschclass}/oapi/SubInfo/GetSchSublist?SchID=} + */ + List getSchSubList(String schSerId); + + /** + * 指定教师的全部任教(校验教师归属该校;SchID 过滤学校、UserTeaSerID 过滤教师)。 + * 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchTeachlist?SchID=&UserTeaSerID=} + */ + List getSchTeachList(String schSerId, Integer teaId); + + /** + * 指定班级的全部任教(班级维度:该班所有教师与学科)。 + * 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchClaTeachlist?ClassID=} + */ + List getSchClaTeachList(Integer classId); + + /** + * 学校全部任教(全校所有教师的班级任教关系)。 + * 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchTeachlist?SchID=} + */ + List getSchAllTeachList(String schSerId); + + /** + * 学校全部班级(含班主任 firstTeacherId / 副班主任 secondTeacherId,不含毕业班)。 + * 请假模块用于识别教师所带班级(班主任 = 审批/登记数据范围)。 + * 对应:GET {@code {bschclass}/oapi/sch/getSchClassList?schId=}(schId 为用户中心学校ID) + */ + List getSchClassMasterList(Integer schId); + + /** + * 学校教师分组列表(组/组员/组长合一;组员teaId=用户中心ID,可与记录 teacher_id 直接关联)。 + * 发展首页/栏目列表左侧学科组卡片取数:组教师人数、组类型、教师发布数量。 + * 对应:GET {@code {bschclass}/oapi/sch/getTeaCustomGroupList?schId=&groupType=} + * (groupType:1年级组 2教研组 3备课组,不传返回全部类型) + */ + List getTeaCustomGroupList(int schId, Integer groupType); +} diff --git a/src/main/java/com/yunhai/profession/service/IFileService.java b/src/main/java/com/yunhai/profession/service/IFileService.java new file mode 100644 index 0000000..1c19b2f --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/IFileService.java @@ -0,0 +1,94 @@ +package com.yunhai.profession.service; + +import com.yunhai.profession.entity.UpFile; +import com.yunhai.profession.model.file.UpFileVo; +import com.yunhai.profession.model.file.UpFileResultVo; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 文件上传/下载/删除业务接口。 + */ +public interface IFileService { + /** + * 上传单个文件。 + * + * @param file 上传的文件 + * @param appType 应用类型(如 "profession") + * @param topicId 关联主题ID(可为空,通知保存后再绑定) + * @return 文件视图对象 + */ + UpFileVo upload(MultipartFile file, String appType, Integer topicId); + + /** + * 将已上传但未绑定主题的附件绑定到指定通知。 + * + * @param fileIds 文件记录ID列表 + * @param topicId 通知ID + */ + void bindToTopic(List fileIds, Integer topicId); + + /** + * 查询某主题下的附件列表。 + * + * @param appType 应用类型 + * @param topicId 主题ID + * @return 附件列表 + */ + List listByTopic(String appType, Integer topicId); + + /** + * 下载文件。 + * + * @param fileId 文件记录ID + * @param response HTTP 响应 + */ + void download(Integer fileId, HttpServletResponse response); + + /** + * 在线预览文件(Content-Disposition: inline,按扩展名输出真实 Content-Type, + * 供前端 open-file-viewer 取 Blob / video 播放)。 + * + * @param fileId 文件记录ID + * @param response HTTP 响应 + */ + void preview(Integer fileId, HttpServletResponse response); + + /** + * 删除文件(同时删除物理文件和数据库记录)。 + * + * @param fileId 文件记录ID + */ + void delete(Integer fileId); + + /** + * 打包下载多个附件为 ZIP。 + * + * @param files 附件记录列表(调用方按 fileIds 查好) + * @param zipName 压缩包显示名(自动追加 .zip) + * @param response HTTP 响应 + */ + void downloadZip(List files, String zipName, HttpServletResponse response); + + /** + * 按主题类型和主题ID打包下载附件为 ZIP(Controller 便捷方法,内部自行查附件列表)。 + * + * @param topicType 主题类型(如 "profession") + * @param topicId 主题ID + * @param zipName 压缩包显示名 + * @param response HTTP 响应 + */ + void downloadZipByTopic(String topicType, Integer topicId, String zipName, HttpServletResponse response); + + /** + * 批量上传图片(循环上传,返回每个文件的视图对象列表)。 + */ + List uploadImages(MultipartFile[] files, String appType); + + /** + * 上传单个文件并返回前端兼容的结果 VO(含 fileid/name/fileurl/host/type/size)。 + */ + UpFileResultVo uploadToResult(MultipartFile file, String appType); +} diff --git a/src/main/java/com/yunhai/profession/service/IMouduleService.java b/src/main/java/com/yunhai/profession/service/IMouduleService.java new file mode 100644 index 0000000..a6cf316 --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/IMouduleService.java @@ -0,0 +1,67 @@ +package com.yunhai.profession.service; + +import java.util.List; +import java.util.Map; + +/** + * 栏目配置域业务接口:上级栏目、栏目、自定义字段(对齐历史后台 ProfessionOld 契约)。 + */ +public interface IMouduleService { + + // ==================== 栏目 ==================== + + /** 后台栏目列表(含 ownerSchId/currentSchId 判只读、recordCount) */ + List> listModules(Integer currentSchId); + + /** 前台级联栏目树:上级栏目 → 栏目,附篇数 */ + List> moduleTree(Integer currentSchId); + + /** 前台某教师可见栏目(含篇数) */ + List> myModuleList(Integer currentSchId, Integer teacherId); + + /** 新建栏目(区局账号可设 isShared) */ + Integer createModule(Integer currentSchId, boolean districtAccount, Map req); + + /** 编辑栏目(仅归属校可改) */ + void editModule(Integer currentSchId, boolean districtAccount, Map req); + + /** 删除栏目(ids CSV,仅归属校) */ + void deleteModules(Integer currentSchId, String ids); + + /** 批量排序 + 改名 + 勾选一次提交(对齐老 OrderBy) */ + void orderModules(Integer currentSchId, String strIds, String strSorts, String names, String checks); + + /** 批量排序 + 改名 + 勾选 + 上级栏目一次提交(对齐老 OrderBy 含 TopModuleTypes) */ + void orderModules(Integer currentSchId, String strIds, String strSorts, String names, String checks, String topModuleTypes); + + /** 栏目动态字段定义(发布表单渲染用,对齐老 Getdatelist):栏目信息 + fields[ischeck] */ + Map moduleFieldList(Integer currentSchId, Integer moduleId); + + // ==================== 上级栏目 ==================== + + List> listTopModules(Integer currentSchId, String searchName, Integer isUsing); + + Integer createTopModule(Integer currentSchId, String name); + + void editTopModule(Integer currentSchId, Integer id, String name); + + void deleteTopModules(Integer currentSchId, String ids); + + void orderTopModules(Integer currentSchId, String strIds); + + void topModuleIsUsing(Integer currentSchId, Integer id, Integer isUsing); + + // ==================== 自定义字段 ==================== + + /** 字段分页(fieldType 过滤 + 分页) */ + Map pageFields(Integer currentSchId, String fieldType, int pageIndex, int pageSize); + + /** 全部字段(绑定选择用) */ + List> listFields(Integer currentSchId); + + Integer createField(Integer currentSchId, Map req); + + void editField(Integer currentSchId, Map req); + + void deleteFields(Integer currentSchId, String ids); +} diff --git a/src/main/java/com/yunhai/profession/service/IPointsService.java b/src/main/java/com/yunhai/profession/service/IPointsService.java new file mode 100644 index 0000000..0277d7d --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/IPointsService.java @@ -0,0 +1,55 @@ +package com.yunhai.profession.service; + +import com.yunhai.profession.entity.ProfessionModule; + +import java.util.List; +import java.util.Map; + +/** + * 积分与学期设置域业务接口:全局积分、积分指标树、学期设置(对齐历史 Points/Term 契约)。 + */ +public interface IPointsService { + + // ==================== 全局积分 ==================== + + /** 全局默认积分(无则懒创建默认行) */ + Map globalPoints(Integer schId); + + /** 修改全局默认积分 */ + void globalPointsEdit(Integer schId, Integer sendPointsNum, Integer commPointsNum); + + // ==================== 积分计算(发布/评论时点) ==================== + + /** 发布积分:栏目覆盖 → 全局默认 */ + int resolveSendPoints(Integer schId, ProfessionModule module); + + /** 评论积分:栏目覆盖 → 全局默认 */ + int resolveCommPoints(Integer schId, ProfessionModule module); + + // ==================== 积分指标树 ==================== + + /** 指标树(zTree 结构 id/pid/name/children) */ + List> pointsTree(Integer schId); + + /** 积分来源下拉(静态) */ + List> pointsSourceList(); + + /** 节点详情 */ + Map pointsDetail(Integer schId, Integer id); + + /** 新增/编辑节点(id 空 = 新增) */ + Integer pointsEdit(Integer schId, Map req); + + /** 删除节点(ids CSV,连带子节点) */ + void deletePoints(Integer schId, String ids); + + // ==================== 学期设置 ==================== + + List> termList(Integer schId); + + Map termPage(Integer schId, int pageIndex, int pageSize); + + Map termDetail(Integer schId, Integer id); + + Integer termEdit(Integer schId, Map req); +} diff --git a/src/main/java/com/yunhai/profession/service/IProfessionService.java b/src/main/java/com/yunhai/profession/service/IProfessionService.java new file mode 100644 index 0000000..9a3d9c1 --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/IProfessionService.java @@ -0,0 +1,87 @@ +package com.yunhai.profession.service; + +import java.util.List; +import java.util.Map; + +/** + * 专业发展记录域业务接口:记录(教案/学习资料)、评论、足迹、积分展示、排行 + * (对齐历史前台 ProfessionPageOld + 后台明细契约)。 + */ +public interface IProfessionService { + + // ==================== 记录 ==================== + + /** 教师发布记录,返回记录 id(写入时点计算发布积分) */ + Integer createRecord(Integer schId, Integer teacherId, String teacherName, Map req); + + /** 编辑记录(仅作者) */ + void editRecord(Integer schId, Integer teacherId, Map req); + + /** 删除记录(ids CSV;仅作者或超管) */ + void deleteRecords(Integer schId, Integer teacherId, boolean superAdmin, String ids); + + /** + * 记录分页列表。 + * + * @param teacherId 按教师过滤(可空) + * @param groupId 按教师分组过滤(可空;组员=组长+组员教师集合) + * @param withOtherModules true 时附带同校其他启用栏目(对齐老契约 OtherData) + */ + Map pageRecords(Integer schId, Integer moduleId, Integer teacherId, Integer groupId, + String subject, String grade, + String keyword, String startTime, String endTime, + int pageIndex, int pageSize, boolean withOtherModules); + + /** 记录详情(阅读数 +1;含栏目绑定字段定义、附件、动态字段值原文、点赞数与当前用户点赞态) */ + Map recordDetail(Integer schId, Integer recordId, Integer currentUserId); + + /** 首页各栏目最新记录块 */ + List> homeModuleRecords(Integer schId, int sizePerModule); + + /** 个人空间按年时间轴 */ + List> myTimeline(Integer schId, Integer teacherId); + + // ==================== 评论 ==================== + + Integer createDiscuss(Integer schId, Integer teacherId, String teacherName, Integer recordId, String contents); + + void editDiscuss(Integer schId, Integer teacherId, Integer discussId, String contents); + + void deleteDiscuss(Integer schId, Integer teacherId, boolean superAdmin, Integer discussId); + + /** 评论列表(canManage=本人/记录作者/超管) */ + List> discussList(Integer schId, Integer currentUserId, boolean superAdmin, Integer recordId); + + // ==================== 足迹 ==================== + + /** 记录浏览足迹(同时阅读数 +1) */ + void visitCreate(Integer schId, Integer visitorId, String visitorName, Integer recordId); + + /** 最近访客:recordId 优先,否则看个人空间 */ + List> visitList(Integer recordId, Integer authorId, int limit); + + // ==================== 点赞 ==================== + + /** 点赞/取消点赞(toggle),返回 {liked, likeCount} */ + Map likeCreate(Integer schId, Integer teacherId, String teacherName, Integer recordId); + + // ==================== 积分/排行 ==================== + + /** 我的积分(发布 + 评论) */ + Map myPoints(Integer schId, Integer teacherId, String teacherName); + + /** 发布排行(默认前 10) */ + List> releaseRanking(Integer schId, Integer topN); + + /** 学科组树(用户中心组树 + 教师发布篇数;组节点 count=子树合计,叶子=教师;moduleId 非空时篇数只统计该栏目) */ + List> subjectGroupTree(Integer schId, Integer moduleId); + + /** 首页刊头统计:累计记录 / 活跃教师 / 发展栏目 / 本周新增(按学校) */ + Map homeStats(Integer schId); + + /** 积分排行(发布+评论合计,前 N) */ + List> pointsRank(Integer schId, Integer topN); + + /** 个人空间聚合:姓名/积分/累计记录/获赞/被访问/各栏目篇数/本月概览(发布/评论/阅读/访客) */ + Map spaceOverview(Integer schId, Integer userid); +} diff --git a/src/main/java/com/yunhai/profession/service/IStatsService.java b/src/main/java/com/yunhai/profession/service/IStatsService.java new file mode 100644 index 0000000..cc115e4 --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/IStatsService.java @@ -0,0 +1,59 @@ +package com.yunhai.profession.service; + +import javax.servlet.http.HttpServletResponse; +import java.util.Map; + +/** + * 统计域业务接口:校级 5 类统计 + 明细 + 导出 + 附件打包 + 局端跨校统计。 + *

时间维度 type:0 累计 / 1 今日 / 2 本周 / 3 本月 / 4 本学期 / 5 自定义(对齐老契约)。

+ */ +public interface IStatsService { + + /** 教师个人统计(动态列 = 各栏目记录数 + 合计 + 排名) */ + Map teacherStat(Integer schId, Integer type, String start, String end, String searchName); + + /** 模块统计(各栏目记录数) */ + Map moduleStat(Integer schId, Integer type, String start, String end, String searchName); + + /** 学科组统计(grade 可选过滤) */ + Map subjectStat(Integer schId, Integer type, String start, String end, String grade); + + /** 评论统计(按记录) */ + Map discussStat(Integer schId, Integer type, String start, String end, String searchName); + + /** 积分统计(动态列 = 各栏目发布积分;评论积分单列) */ + Map pointsStat(Integer schId, Integer type, String start, String end, String searchName); + + /** 明细分页(mouid/userid/subid 过滤) */ + Map statDetail(Integer schId, Integer type, String start, String end, String searchName, + Integer moduleId, Integer teacherId, String subject, + int pageIndex, int pageSize); + + /** Excel 导出(excelType 1 教师/2 模块/3 学科/4 评论/5 积分,对齐老 Exceldaochu1~5) */ + void exportExcel(Integer schId, int excelType, Integer type, String start, String end, + String searchName, String grade, HttpServletResponse response); + + /** 明细附件打包下载(zip) */ + void attachmentZipDown(Integer schId, Integer type, String start, String end, String searchName, + Integer moduleId, Integer teacherId, String subject, HttpServletResponse response); + + /** 自定义统计区间的默认值(对齐老 GetTime) */ + Map defaultRange(); + + // ==================== 局端跨校(需超管/区局) ==================== + + /** 学校总览(记录数 + 评论数按校分组,对齐 StatisticsMap/GetSchoolType 归并) */ + Map schoolOverview(Integer type, String start, String end); + + /** 学校×教师统计(schId 空 = 全部学校) */ + Map schoolTeacherStat(Integer schId, Integer type, String start, String end); + + /** 学校×栏目统计 */ + Map schoolModuleStat(Integer schId, Integer type, String start, String end); + + /** 学校×学科统计 */ + Map schoolSubjectStat(Integer schId, Integer type, String start, String end); + + /** 学校×教师积分统计(发布 + 评论合计) */ + Map schoolPointsStat(Integer schId, Integer type, String start, String end); +} diff --git a/src/main/java/com/yunhai/profession/service/ISysOperationLogService.java b/src/main/java/com/yunhai/profession/service/ISysOperationLogService.java new file mode 100644 index 0000000..c1abb7a --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/ISysOperationLogService.java @@ -0,0 +1,17 @@ +package com.yunhai.profession.service; + +import com.yunhai.profession.entity.SysOperationLog; +import com.yunhai.profession.model.BasePageQuery; +import com.yunhai.profession.model.BasePageResult; + +/** + * 系统审计日志业务接口:异步落库 + 分页查询。 + */ +public interface ISysOperationLogService { + + /** 异步保存(失败仅记应用日志,不影响业务请求) */ + void saveAsync(SysOperationLog entity); + + /** 分页查询(responseData 不返回) */ + BasePageResult page(BasePageQuery query, String operationType, Integer statusCode); +} diff --git a/src/main/java/com/yunhai/profession/service/IUserCenterService.java b/src/main/java/com/yunhai/profession/service/IUserCenterService.java new file mode 100644 index 0000000..d3054bd --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/IUserCenterService.java @@ -0,0 +1,51 @@ +package com.yunhai.profession.service; + +import java.util.List; +import java.util.Map; + +/** + * 用户中心门面服务(对齐 UserBase {@code UserCenterService} 模式)。 + *

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

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

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

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

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

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

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

+ * + * @param userId 用户ID + * @param schId 学校ID(可为 null) + * @return 模块树(顶层 List,含 children 嵌套) + */ + List> getUserModuleTree(Integer userId, Integer schId); +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/profession/service/impl/AreaService.java b/src/main/java/com/yunhai/profession/service/impl/AreaService.java new file mode 100644 index 0000000..ea43781 --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/impl/AreaService.java @@ -0,0 +1,124 @@ +package com.yunhai.profession.service.impl; + +import com.yunhai.profession.entity.ProfessionArea; +import com.yunhai.profession.repository.ProfessionAreaRepository; +import com.yunhai.profession.service.IAreaService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 行政区划字典业务实现。 + *

三级数据量约 3400 行,整树在内存组装(一次 findAll 足够,无需递归查库)。 + * 若后续数据量增大,可改为按 parentCode 懒加载(listChildren 已是懒加载语义)。

+ */ +@Service +public class AreaService implements IAreaService { + + /** 省级节点的固定上级编码 */ + private static final String ROOT_PARENT = "0"; + + @Autowired + private ProfessionAreaRepository areaRepository; + + @Override + public List> listChildren(String parentCode) { + String parent = StringUtils.hasText(parentCode) ? parentCode.trim() : ROOT_PARENT; + List> rows = new ArrayList<>(); + for (ProfessionArea a : areaRepository.findByParentCodeOrderBySortOrderAscCodeAsc(parent)) { + rows.add(node(a)); + } + return rows; + } + + @Override + public List> tree(Integer level) { + int maxLevel = (level == null || level < 1) ? 3 : Math.min(level, 3); + List all = areaRepository.findByLevelOrderBySortOrderAscCodeAsc(1); + // 省 + List> root = new ArrayList<>(); + if (maxLevel < 2) { + for (ProfessionArea a : all) { + root.add(node(a)); + } + return root; + } + // 市:按 parentCode 分组 + Map> cityByProvince = groupBy(areaRepository.findByLevelOrderBySortOrderAscCodeAsc(2)); + // 区县:按 parentCode 分组 + Map> districtByCity = maxLevel >= 3 + ? groupBy(areaRepository.findByLevelOrderBySortOrderAscCodeAsc(3)) + : new HashMap<>(); + + for (ProfessionArea p : all) { + Map pNode = node(p); + List> cities = new ArrayList<>(); + for (ProfessionArea c : cityByProvince.getOrDefault(p.getCode(), new ArrayList<>())) { + Map cNode = node(c); + if (maxLevel >= 3) { + List> districts = new ArrayList<>(); + for (ProfessionArea d : districtByCity.getOrDefault(c.getCode(), new ArrayList<>())) { + districts.add(node(d)); + } + cNode.put("children", districts); + } + cities.add(cNode); + } + pNode.put("children", cities); + root.add(pNode); + } + return root; + } + + @Override + public String namePath(String codesCSV) { + if (!StringUtils.hasText(codesCSV)) { + return ""; + } + List codes = new ArrayList<>(); + for (String s : codesCSV.split(",")) { + String t = s.trim(); + if (!t.isEmpty()) { + codes.add(t); + } + } + if (codes.isEmpty()) { + return ""; + } + Map nameByCode = new HashMap<>(); + for (ProfessionArea a : areaRepository.findByCodeIn(new HashSet<>(codes))) { + nameByCode.put(a.getCode(), a.getName()); + } + List names = new ArrayList<>(); + for (String c : codes) { + names.add(nameByCode.getOrDefault(c, c)); + } + return String.join("/", names); + } + + // ==================== 内部方法 ==================== + + private static Map node(ProfessionArea a) { + Map m = new LinkedHashMap<>(); + m.put("value", a.getCode()); + m.put("label", a.getName()); + m.put("level", a.getLevel()); + m.put("parentCode", a.getParentCode()); + return m; + } + + private static Map> groupBy(List list) { + Map> map = new HashMap<>(); + for (ProfessionArea a : list) { + map.computeIfAbsent(a.getParentCode(), k -> new ArrayList<>()).add(a); + } + return map; + } +} diff --git a/src/main/java/com/yunhai/profession/service/impl/ClassService.java b/src/main/java/com/yunhai/profession/service/impl/ClassService.java new file mode 100644 index 0000000..ae516fa --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/impl/ClassService.java @@ -0,0 +1,221 @@ +package com.yunhai.profession.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.profession.model.SchTeaList; +import com.yunhai.profession.model.bsch.*; +import com.yunhai.profession.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.*; +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.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 班级管理(bschclass)开放接口实现。 + * bschclass 为开放接口(无需应用签名),使用独立 RestTemplate 直连。 + *

推送范围树/圈人/身份快照统一经本门面(改造方案 2.0): + * 学段 GetSchLenlist、年级 GetSchLenLevellist、班级 GetVClassList、 + * 学生 GetSchStulist / GetGradeStulist(补缺) / GetSubStulist(补缺) / GetClaArrStulist / GetStuIdslist、 + * 学生树 SchGroup/GetStuTree。 + */ +@Service +public class ClassService implements IClassService { + + private static final Logger log = LoggerFactory.getLogger(ClassService.class); + + /** 班级管理服务基址,如 http://base.yunhaixingtong.com/bschclass */ + @Value("${yh.open.bschclass:}") + private String bschClassHost; + + private final RestTemplate restTemplate; + + public ClassService() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(10000); + this.restTemplate = new RestTemplate(factory); + } + + @Override + public List getSchTeaList(int schId) { + return exchangeList(bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId, + new ParameterizedTypeReference>() { + }, "获取教师数据", "schId=" + schId); + } + + @Override + public SchInfoDto getSchFirst(int schId) { + requireHost(); + String url = bschClassHost + "/oapi/SchInfo/GetUserSchFirst?UserSchID=" + schId; + try { + ResponseEntity resp = restTemplate.getForEntity(URI.create(url), SchInfoDto.class); + return resp.getBody(); + } catch (RestClientException e) { + log.error("[getSchFirst] 请求班级管理接口失败, url={}", url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取学校信息失败"); + } + } + + @Override + public List getSchLenList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenlist?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学段列表", "schSerId=" + schSerId); + } + + @Override + public List getSchGradeList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenLevellist?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取年级列表", "schSerId=" + schSerId); + } + + @Override + public List getSchClassList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/VSchClass/GetVClassList?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取班级列表", "schSerId=" + schSerId); + } + + @Override + public List getSchStuList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/OAStu/GetSchStulist?schserid=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学校学生", "schSerId=" + schSerId); + } + + @Override + public List getGradeStuList(String schSerId, Integer gradeId) { + return exchangeList(bschClassHost + "/oapi/OAStu/GetGradeStulist?SchSerID=" + schSerId + "&GradeId=" + gradeId, + new ParameterizedTypeReference>() { + }, "获取年级学生", "schSerId=" + schSerId + ", gradeId=" + gradeId); + } + + @Override + public List getSubStuList(String schSerId, Integer schSystemId) { + return exchangeList(bschClassHost + "/oapi/OAStu/GetSubStulist?SchSerID=" + schSerId + "&SchSystemId=" + schSystemId, + new ParameterizedTypeReference>() { + }, "获取学段学生", "schSerId=" + schSerId + ", schSystemId=" + schSystemId); + } + + @Override + public List getClaArrStuList(List classIds) { + String csv = joinIds(classIds); + return exchangeList(bschClassHost + "/oapi/OAStu/GetClaArrStulist?ClassIDs=" + csv, + new ParameterizedTypeReference>() { + }, "获取班级学生", "classIds=" + csv); + } + + @Override + public List getStuIdsList(List stuIds) { + String csv = joinIds(stuIds); + return exchangeList(bschClassHost + "/oapi/OAStu/GetStuIdslist?stuids=" + csv, + new ParameterizedTypeReference>() { + }, "获取指定学生", "stuIds=" + csv); + } + + @Override + public List getStuTree(Integer schId,boolean toUser) { + return exchangeList(bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId+"&toUser="+toUser, + new ParameterizedTypeReference>() { + }, "获取学生树", "schId=" + schId); + } + + @Override + public List getTeaTree(Integer schId,boolean toUser) { + return exchangeList(bschClassHost + "/oapi/SchGroup/GetTeaTree?schId=" + schId+"&toUser="+toUser, + new ParameterizedTypeReference>() { + }, "获取教师树", "schId=" + schId); + } + + @Override + public List getSchSubList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/SubInfo/GetSchSublist?SchID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学校学科", "schSerId=" + schSerId); + } + + @Override + public List getSchTeachList(String schSerId, Integer teaId) { + return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchTeachlist?SchID=" + schSerId + "&UserTeaSerID=" + teaId, + new ParameterizedTypeReference>() { + }, "获取教师任教", "schSerId=" + schSerId + ", teaId=" + teaId); + } + + @Override + public List getSchClaTeachList(Integer classId) { + return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchClaTeachlist?ClassID=" + classId, + new ParameterizedTypeReference>() { + }, "获取班级任教", "classId=" + classId); + } + + @Override + public List getSchAllTeachList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchTeachlist?SchID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学校全部任教", "schSerId=" + schSerId); + } + + @Override + public List getSchClassMasterList(Integer schId) { + return exchangeList(bschClassHost + "/oapi/sch/getSchClassList?schId=" + schId, + new ParameterizedTypeReference>() { + }, "获取学校班级(含班主任)", "schId=" + schId); + } + + @Override + public List getTeaCustomGroupList(int schId, Integer groupType) { + String url = bschClassHost + "/oapi/sch/getTeaCustomGroupList?schId=" + schId + + (groupType == null ? "" : "&groupType=" + groupType); + return exchangeList(url, new ParameterizedTypeReference>() { + }, "获取教师分组列表", "schId=" + schId + ", groupType=" + groupType); + } + + + + + + // ============================ 通用调用 ============================ + + private List exchangeList(String url, ParameterizedTypeReference> type, + String action, String arg) { + requireHost(); + try { + // GET + Accept: application/json,保持与既有 GetSchteaList 调用一致 + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + ResponseEntity> resp = restTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(headers), type); + List list = resp.getBody(); + log.info("[bschclass] {} 完成, {} 返回 {} 条", action, arg, list == null ? 0 : list.size()); + return list != null ? list : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[bschclass] {} 失败, {}, url={}", action, arg, url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, action + "失败"); + } + } + + private void requireHost() { + if (!StringUtils.hasText(bschClassHost)) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)"); + } + } + + private String joinIds(List ids) { + if (ids == null || ids.isEmpty()) { + return ""; + } + return ids.stream().map(String::valueOf).collect(Collectors.joining(",")); + } +} diff --git a/src/main/java/com/yunhai/profession/service/impl/FileService.java b/src/main/java/com/yunhai/profession/service/impl/FileService.java new file mode 100644 index 0000000..0e2f6e2 --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/impl/FileService.java @@ -0,0 +1,508 @@ +package com.yunhai.profession.service.impl; + +import com.yunhai.profession.entity.UpFile; +import com.yunhai.profession.model.file.UpFileVo; +import com.yunhai.profession.model.file.UpFileResultVo; +import com.yunhai.profession.repository.UpFileRepository; +import com.yunhai.profession.service.IFileService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 文件上传/下载/删除业务实现。 + *

+ * 存储路径规则:{uploadPath}/{appType}/{yyyyMMdd}/{uuid}.{ext} + * 与 C# UpLoadFile.FileSave 逻辑等价,但去除分片上传(预览走本服务转 PDF,见 preview)。 + */ +@Service +public class FileService implements IFileService { + + private static final Logger log = LoggerFactory.getLogger(FileService.class); + + @Autowired + private UpFileRepository upFileRepository; + + @Value("${file.upload-path}") + private String uploadPath; + + private static final String DEFAULT_APP_TYPE = "profession"; + + /** Office 类型扩展名:预览前需转 PDF(浏览器无法内联渲染 msword 等原始格式) */ + private static final Set OFFICE_EXTS = new HashSet<>(Arrays.asList( + "doc", "docx", "wps", "xls", "xlsx", "et", "ppt", "pptx", "dps")); + + /** Office→PDF 全局锁:COM 自动化不宜并发,转换一次后复用同名 .pdf 缓存 */ + private static final Object PDF_CONVERT_LOCK = new Object(); + + private static volatile Path wpsScript; + + // ============================ 上传 ============================ + + @Override + @Transactional + public UpFileVo upload(MultipartFile file, String appType, Integer topicId) { + if (file == null || file.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不得为空"); + } + String originalName = file.getOriginalFilename(); + if (!StringUtils.hasText(originalName)) { + throw new BizException(ErrorCode.PARAM_ERROR, "文件名为空"); + } + // 提取后缀 + String ext = ""; + int dotIdx = originalName.lastIndexOf('.'); + if (dotIdx >= 0) { + ext = originalName.substring(dotIdx + 1).toLowerCase(); + } + + // 构造存储子路径:{appType}/{yyyyMMdd}/{uuid}.{ext} + String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String savedName = UUID.randomUUID().toString().replace("-", "") + (ext.isEmpty() ? "" : "." + ext); + String relativePath = (appType == null ? DEFAULT_APP_TYPE : appType) + "/" + dateDir + "/" + savedName; + + // 物理写入 + Path fullPath = Paths.get(uploadPath, relativePath); + try { + Files.createDirectories(fullPath.getParent()); + file.transferTo(fullPath.toFile()); + } catch (Exception e) { + log.error("[upload] 文件保存失败: {}", fullPath, e); + throw new BizException(ErrorCode.ERROR, "文件保存失败", e); + } + + // 入库 + UpFile entity = new UpFile(); + entity.setTopicType(appType == null ? DEFAULT_APP_TYPE : appType); + entity.setTopicId(topicId); + entity.setFileTrueName(originalName); + entity.setFileName(relativePath); + entity.setFileSize((int) file.getSize()); + entity.setFileType(ext); + upFileRepository.save(entity); + + return toVo(entity); + } + + // ============================ 绑定主题 ============================ + + @Override + @Transactional + public void bindToTopic(List fileIds, Integer topicId) { + if (fileIds == null || fileIds.isEmpty() || topicId == null) { + return; + } + for (Integer fileId : fileIds) { + upFileRepository.findById(fileId).ifPresent(f -> { + f.setTopicId(topicId); + upFileRepository.save(f); + }); + } + } + + // ============================ 查询附件列表 ============================ + + @Override + public List listByTopic(String appType, Integer topicId) { + String type = appType == null ? DEFAULT_APP_TYPE : appType; + return upFileRepository.findByTopicTypeAndTopicId(type, topicId).stream() + .map(this::toVo) + .collect(Collectors.toList()); + } + + // ============================ 下载 ============================ + + @Override + public void download(Integer fileId, HttpServletResponse response) { + writeFile(fileId, response, false); + } + + @Override + public void preview(Integer fileId, HttpServletResponse response) { + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId)); + Path fullPath = Paths.get(uploadPath, file.getFileName()); + if (!Files.exists(fullPath)) { + throw new BizException(ErrorCode.FORBIDDEN, "物理文件不存在: " + file.getFileName()); + } + if (OFFICE_EXTS.contains(extOf(file.getFileName()))) { + // Office 原始格式浏览器无法内联渲染:转 PDF 后输出(与老系统 Aspose 转 PDF 链路对齐) + Path pdfPath = siblingPdf(fullPath); + try { + ensureOfficePdf(fullPath, pdfPath); + streamFile(pdfPath, "application/pdf", pdfDisplayName(file.getFileTrueName()), response); + } catch (Exception e) { + log.warn("[preview] Office 转 PDF 失败,输出提示页: {}", file.getFileName(), e); + writeUnsupportedHtml(response); + } + return; + } + writeFile(fileId, response, true); + } + + // ============================ Office 转 PDF ============================ + + /** 转换并缓存:{uuid}.doc → 同目录 {uuid}.pdf,已存在直接复用 */ + private void ensureOfficePdf(Path officePath, Path pdfPath) throws Exception { + synchronized (PDF_CONVERT_LOCK) { + if (Files.exists(pdfPath) && Files.size(pdfPath) > 0) { + return; + } + Files.deleteIfExists(pdfPath); + runWpsConvert(officePath, pdfPath); + if (!Files.exists(pdfPath) || Files.size(pdfPath) == 0) { + throw new IllegalStateException("转换未产出 PDF: " + pdfPath); + } + } + } + + /** + * 通过 PowerShell 调用 WPS 的 Office COM 接口导出 PDF(本机/服务器需安装 WPS, + * ProgID:KWPS=文字 KET=表格 KWPP=演示,API 兼容 MS Office)。 + * 未安装 WPS 或转换失败时由调用方降级为提示页。 + */ + private static final String WPS_CONVERT_SCRIPT = + "param([string]$Src,[string]$Dst)\n" + + "$ErrorActionPreference='Stop'\n" + + "$ext=[IO.Path]::GetExtension($Src).ToLowerInvariant()\n" + + "$app=$null; $doc=$null\n" + + "try {\n" + + " if($ext -eq '.doc' -or $ext -eq '.docx' -or $ext -eq '.wps') {\n" + + " $app=New-Object -ComObject KWPS.Application\n" + + " $doc=$app.Documents.Open($Src,$false,$true)\n" + + " try { $doc.ExportAsFixedFormat($Dst,17) } catch { $doc.SaveAs2($Dst,17) }\n" + + " } elseif($ext -eq '.xls' -or $ext -eq '.xlsx' -or $ext -eq '.et') {\n" + + " $app=New-Object -ComObject KET.Application\n" + + " $doc=$app.Workbooks.Open($Src,0,$true)\n" + + " $doc.ExportAsFixedFormat(0,$Dst)\n" + + " } elseif($ext -eq '.ppt' -or $ext -eq '.pptx' -or $ext -eq '.dps') {\n" + + " $app=New-Object -ComObject KWPP.Application\n" + + " $doc=$app.Presentations.Open($Src,$true,$false,$false)\n" + + " $doc.SaveAs($Dst,32)\n" + + " } else { throw ('unsupported ext: '+$ext) }\n" + + " if(-not (Test-Path -LiteralPath $Dst)) { throw 'pdf not produced' }\n" + + " exit 0\n" + + "} finally {\n" + + " if($doc){ try{ $doc.Close($false) }catch{} }\n" + + " if($app){ try{ $app.Quit() }catch{} }\n" + + "}\n"; + + private Path wpsScript() throws Exception { + Path script = wpsScript; + if (script != null && Files.exists(script)) { + return script; + } + synchronized (PDF_CONVERT_LOCK) { + if (wpsScript == null || !Files.exists(wpsScript)) { + Path f = Files.createTempFile("wps2pdf", ".ps1"); + Files.write(f, WPS_CONVERT_SCRIPT.getBytes(StandardCharsets.US_ASCII)); + f.toFile().deleteOnExit(); + wpsScript = f; + } + return wpsScript; + } + } + + private void runWpsConvert(Path officePath, Path pdfPath) throws Exception { + Path script = wpsScript(); + ProcessBuilder pb = new ProcessBuilder("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-File", script.toString(), "-Src", officePath.toString(), "-Dst", pdfPath.toString()); + pb.redirectErrorStream(true); + Process p = pb.start(); + StringBuilder output = new StringBuilder(); + Thread drain = new Thread(() -> { + try (BufferedReader r = new BufferedReader( + new InputStreamReader(p.getInputStream(), Charset.defaultCharset()))) { + String line; + while ((line = r.readLine()) != null) { + output.append(line).append('\n'); + } + } catch (Exception ignored) { + } + }); + drain.setDaemon(true); + drain.start(); + if (!p.waitFor(120, TimeUnit.SECONDS)) { + p.destroyForcibly(); + throw new IllegalStateException("WPS 转换超时: " + officePath); + } + drain.join(3000); + if (p.exitValue() != 0) { + throw new IllegalStateException("WPS 转换失败 exit=" + p.exitValue() + " output=" + output); + } + } + + private String extOf(String fileName) { + String n = String.valueOf(fileName).toLowerCase(); + int dot = n.lastIndexOf('.'); + return dot >= 0 ? n.substring(dot + 1) : ""; + } + + private Path siblingPdf(Path officePath) { + String name = officePath.getFileName().toString(); + int dot = name.lastIndexOf('.'); + String base = dot > 0 ? name.substring(0, dot) : name; + return officePath.resolveSibling(base + ".pdf"); + } + + private String pdfDisplayName(String trueName) { + String n = String.valueOf(trueName); + int dot = n.lastIndexOf('.'); + return (dot > 0 ? n.substring(0, dot) : n) + ".pdf"; + } + + /** 内联输出物理文件(预览用) */ + private void streamFile(Path path, String contentType, String displayName, HttpServletResponse response) { + try (InputStream is = Files.newInputStream(path); + OutputStream os = response.getOutputStream()) { + String encodedName = URLEncoder.encode(displayName, "UTF-8").replaceAll("\\+", "%20"); + response.setContentType(contentType); + response.setHeader("Content-Disposition", "inline; filename=\"" + encodedName + "\""); + response.setContentLengthLong(Files.size(path)); + byte[] buffer = new byte[8192]; + int len; + while ((len = is.read(buffer)) != -1) { + os.write(buffer, 0, len); + } + os.flush(); + } catch (Exception e) { + log.error("[streamFile] 文件输出失败: {}", path, e); + throw new BizException(ErrorCode.ERROR, "文件下载失败", e); + } + } + + /** 转换不可用时返回给 iframe 的提示页,避免整块空白 */ + private void writeUnsupportedHtml(HttpServletResponse response) { + try { + response.setContentType("text/html;charset=UTF-8"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write( + "" + + "" + + "该文件暂不支持在线预览,请点击右上角\u201c下载\u201d按钮查看"); + response.getWriter().flush(); + } catch (Exception e) { + log.warn("[preview] 提示页输出失败", e); + } + } + + /** + * 文件输出。inline=true 时 Content-Disposition 用 inline 并按扩展名输出真实 Content-Type + * (浏览器/前端组件可直接渲染),否则 attachment 强制下载。 + */ + private void writeFile(Integer fileId, HttpServletResponse response, boolean inline) { + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId)); + Path fullPath = Paths.get(uploadPath, file.getFileName()); + if (!Files.exists(fullPath)) { + throw new BizException(ErrorCode.FORBIDDEN, "物理文件不存在: " + file.getFileName()); + } + try (FileInputStream fis = new FileInputStream(fullPath.toFile()); + OutputStream os = response.getOutputStream()) { + String encodedName = URLEncoder.encode(file.getFileTrueName(), "UTF-8") + .replaceAll("\\+", "%20"); + if (inline) { + response.setContentType(resolveContentType(file.getFileName())); + response.setHeader("Content-Disposition", "inline; filename=\"" + encodedName + "\""); + } else { + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedName + "\""); + } + response.setContentLengthLong(file.getFileSize()); + byte[] buffer = new byte[4096]; + int len; + while ((len = fis.read(buffer)) != -1) { + os.write(buffer, 0, len); + } + os.flush(); + } catch (Exception e) { + log.error("[writeFile] 文件输出失败: {}", file.getFileName(), e); + throw new BizException(ErrorCode.ERROR, "文件下载失败", e); + } + } + + /** + * 按扩展名推断 Content-Type(JDK 自带表 + 常见办公/图片格式兜底),未知类型回退流格式。 + */ + private String resolveContentType(String fileName) { + String name = String.valueOf(fileName).toLowerCase(); + Map table = new HashMap<>(); + table.put("pdf", "application/pdf"); + table.put("png", "image/png"); + table.put("jpg", "image/jpeg"); + table.put("jpeg", "image/jpeg"); + table.put("gif", "image/gif"); + table.put("bmp", "image/bmp"); + table.put("webp", "image/webp"); + table.put("svg", "image/svg+xml"); + table.put("txt", "text/plain;charset=UTF-8"); + table.put("mp4", "video/mp4"); + table.put("webm", "video/webm"); + table.put("ogg", "video/ogg"); + table.put("doc", "application/msword"); + table.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + table.put("xls", "application/vnd.ms-excel"); + table.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + table.put("ppt", "application/vnd.ms-powerpoint"); + table.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"); + String type = table.get(name.substring(name.lastIndexOf('.') + 1)); + if (type != null) { + return type; + } + String guessed = URLConnection.guessContentTypeFromName(name); + return guessed != null ? guessed : "application/octet-stream"; + } + + // ============================ 删除 ============================ + + @Override + @Transactional + public void delete(Integer fileId) { + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId)); + // 删物理文件(连同同名预览 PDF 缓存) + Path fullPath = Paths.get(uploadPath, file.getFileName()); + try { + Files.deleteIfExists(fullPath); + Files.deleteIfExists(siblingPdf(fullPath)); + } catch (Exception e) { + log.warn("[delete] 物理文件删除失败: {}(数据库记录仍会删除)", fullPath, e); + } + // 删数据库记录 + upFileRepository.delete(file); + } + + // ============================ 打包下载 ZIP ============================ + + @Override + public void downloadZip(List files, String zipName, HttpServletResponse response) { + if (files == null || files.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载"); + } + try { + response.setContentType("application/zip"); + String name = URLEncoder.encode(zipName == null ? "附件" : zipName, "UTF-8") + .replaceAll("\\+", "%20"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + name + ".zip\""); + try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) { + Set used = new HashSet<>(); + for (UpFile f : files) { + Path p = Paths.get(uploadPath, f.getFileName()); + if (!Files.exists(p)) { + log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName()); + continue; + } + String entryName = sanitizeEntryName(f.getFileTrueName()); + if (used.contains(entryName)) { + entryName = f.getId() + "_" + entryName; + } + used.add(entryName); + zos.putNextEntry(new ZipEntry(entryName)); + Files.copy(p, zos); + zos.closeEntry(); + } + } + } catch (Exception e) { + log.error("[downloadZip] 打包下载失败", e); + throw new BizException(ErrorCode.ERROR, "打包下载失败", e); + } + } + + /** + * 净化 ZIP 条目名:去除路径分隔符等非法字符,防止 Zip Slip。 + */ + private String sanitizeEntryName(String name) { + if (name == null || name.trim().isEmpty()) { + return "file"; + } + return name.replaceAll("[\\\\/:*?\"<>|]", "_"); + } + + // ============================ 按主题打包下载 ============================ + + @Override + public void downloadZipByTopic(String topicType, Integer topicId, String zipName, + HttpServletResponse response) { + String type = topicType == null ? DEFAULT_APP_TYPE : topicType; + List files = upFileRepository.findByTopicTypeAndTopicId(type, topicId); + downloadZip(files, zipName, response); + } + + // ============================ 批量上传图片 ============================ + + @Override + @Transactional + public List uploadImages(MultipartFile[] files, String appType) { + List results = new ArrayList<>(); + if (files == null || files.length == 0) { + return results; + } + String type = appType != null ? appType : DEFAULT_APP_TYPE; + for (MultipartFile file : files) { + results.add(upload(file, type, null)); + } + return results; + } + + // ============================ 上传(返回前端兼容 VO) ============================ + + @Override + @Transactional + public UpFileResultVo uploadToResult(MultipartFile file, String appType) { + UpFileVo vo = upload(file, appType, null); + UpFileResultVo result = new UpFileResultVo(); + result.setFileid(vo.getId()); + result.setName(vo.getFileTrueName()); + result.setFileurl(vo.getFileName()); + result.setHost(""); + result.setType(vo.getFileType()); + result.setSize(vo.getFileSize()); + return result; + } + + // ============================ 转换 ============================ + + private UpFileVo toVo(UpFile entity) { + UpFileVo vo = new UpFileVo(); + BeanUtils.copyProperties(entity, vo); + return vo; + } +} diff --git a/src/main/java/com/yunhai/profession/service/impl/MouduleService.java b/src/main/java/com/yunhai/profession/service/impl/MouduleService.java new file mode 100644 index 0000000..46b501b --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/impl/MouduleService.java @@ -0,0 +1,521 @@ +package com.yunhai.profession.service.impl; + +import com.yunhai.profession.entity.ProfessionField; +import com.yunhai.profession.entity.ProfessionModule; +import com.yunhai.profession.entity.ProfessionRecord; +import com.yunhai.profession.entity.TopModule; +import com.yunhai.profession.repository.ProfessionFieldRepository; +import com.yunhai.profession.repository.ProfessionModuleRepository; +import com.yunhai.profession.repository.ProfessionRecordRepository; +import com.yunhai.profession.repository.TopModuleRepository; +import com.yunhai.profession.service.IMouduleService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import javax.persistence.criteria.Predicate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 栏目配置域业务实现。schId 归属校判定只读:ownerSchId != currentSchId 的行(区局下发)不可改。 + */ +@Service +public class MouduleService implements IMouduleService { + + @Autowired + private ProfessionModuleRepository moduleRepository; + + @Autowired + private TopModuleRepository topModuleRepository; + + @Autowired + private ProfessionFieldRepository fieldRepository; + + @Autowired + private ProfessionRecordRepository recordRepository; + + // ==================== 栏目 ==================== + + @Override + public List> listModules(Integer currentSchId) { + Map countByModule = recordCountMap(currentSchId); + List> rows = new ArrayList<>(); + for (ProfessionModule m : moduleRepository.findVisible(currentSchId)) { + Map row = new LinkedHashMap<>(); + row.put("id", m.getId()); + row.put("name", m.getName()); + row.put("topModuleId", m.getTopModuleId()); + row.put("ownerSchId", m.getSchId()); + row.put("currentSchId", currentSchId); + row.put("readonly", !m.getSchId().equals(currentSchId)); + row.put("isShared", m.getIsShared()); + row.put("fieldIds", m.getFieldIds()); + row.put("sendPoints", m.getSendPoints()); + row.put("commPoints", m.getCommPoints()); + row.put("openUserIds", m.getOpenUserIds()); + row.put("remark", m.getRemark()); + row.put("isCheck", m.getIsCheck()); + row.put("sortOrder", m.getSortOrder()); + row.put("recordCount", countByModule.getOrDefault(m.getId(), 0L)); + rows.add(row); + } + return rows; + } + + @Override + public List> moduleTree(Integer currentSchId) { + Map countByModule = recordCountMap(currentSchId); + List modules = moduleRepository.findVisibleEnabled(currentSchId); + Map>> byTop = new HashMap<>(); + List> roots = new ArrayList<>(); + for (ProfessionModule m : modules) { + Map node = new LinkedHashMap<>(); + node.put("id", m.getId()); + node.put("name", m.getName()); + node.put("topModuleId", m.getTopModuleId()); + node.put("ownerSchId", m.getSchId()); + node.put("recordCount", countByModule.getOrDefault(m.getId(), 0L)); + Integer top = m.getTopModuleId() == null ? 0 : m.getTopModuleId(); + if (top == 0) { + roots.add(node); + } else { + byTop.computeIfAbsent(top, k -> new ArrayList<>()).add(node); + } + } + List tops = topModuleRepository.findBySchIdOrderBySortOrderAscIdAsc(currentSchId); + List> tree = new ArrayList<>(); + for (TopModule t : tops) { + List> children = byTop.get(t.getId()); + if (children == null || children.isEmpty()) { + continue; + } + Map node = new LinkedHashMap<>(); + node.put("id", t.getId()); + node.put("name", t.getName()); + node.put("isUsing", t.getIsUsing()); + node.put("children", children); + tree.add(node); + } + // 没挂上级栏目的启用栏目兜底放到根层级 + tree.addAll(roots); + return tree; + } + + @Override + public List> myModuleList(Integer currentSchId, Integer teacherId) { + List> rows = new ArrayList<>(); + if (teacherId != null) { + // 指定教师:各启用栏目下该教师的篇数(含 0,对齐老 MouduleMyList) + Map countByModule = new HashMap<>(); + for (Object[] row : recordRepository.countGroupByTeacherModule(currentSchId, null, null)) { + if (teacherId.equals(row[0])) { + countByModule.put((Integer) row[2], ((Number) row[3]).longValue()); + } + } + for (ProfessionModule m : moduleRepository.findVisibleEnabled(currentSchId)) { + Map row = new LinkedHashMap<>(); + row.put("id", m.getId()); + row.put("name", m.getName()); + row.put("recordCount", countByModule.getOrDefault(m.getId(), 0L)); + rows.add(row); + } + return rows; + } + Map countByModule = recordCountMap(currentSchId); + for (ProfessionModule m : moduleRepository.findVisibleEnabled(currentSchId)) { + Map row = new LinkedHashMap<>(); + row.put("id", m.getId()); + row.put("name", m.getName()); + row.put("recordCount", countByModule.getOrDefault(m.getId(), 0L)); + rows.add(row); + } + return rows; + } + + @Override + @Transactional + public Integer createModule(Integer currentSchId, boolean districtAccount, Map req) { + ProfessionModule m = new ProfessionModule(); + m.setSchId(currentSchId); + applyModuleRequest(m, req, districtAccount); + return moduleRepository.save(m).getId(); + } + + @Override + @Transactional + public void editModule(Integer currentSchId, boolean districtAccount, Map req) { + Integer id = intOf(req.get("id")); + ProfessionModule m = moduleRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("栏目不存在: " + id)); + if (!m.getSchId().equals(currentSchId)) { + throw new IllegalArgumentException("区局下发栏目只读,不可编辑"); + } + applyModuleRequest(m, req, districtAccount); + moduleRepository.save(m); + } + + @Override + @Transactional + public void deleteModules(Integer currentSchId, String ids) { + for (Integer id : csvToInt(ids)) { + moduleRepository.findById(id).ifPresent(m -> { + if (!m.getSchId().equals(currentSchId)) { + return; + } + moduleRepository.delete(m); + }); + } + } + + @Override + @Transactional + public void orderModules(Integer currentSchId, String strIds, String strSorts, String names, String checks) { + orderModules(currentSchId, strIds, strSorts, names, checks, null); + } + + @Override + @Transactional + public void orderModules(Integer currentSchId, String strIds, String strSorts, String names, String checks, String topModuleTypes) { + List idList = csvToInt(strIds); + List sortList = splitCsv(strSorts); + List nameList = splitCsv(names); + List checkList = splitCsv(checks); + List topList = splitCsv(topModuleTypes); + for (int i = 0; i < idList.size(); i++) { + Integer id = idList.get(i); + final int idx = i; + moduleRepository.findById(id).ifPresent(m -> { + if (!m.getSchId().equals(currentSchId)) { + return; + } + m.setSortOrder(idx + 1); + if (idx < nameList.size() && StringUtils.hasText(nameList.get(idx))) { + m.setName(nameList.get(idx)); + } + if (idx < checkList.size() && StringUtils.hasText(checkList.get(idx))) { + m.setIsCheck("true".equalsIgnoreCase(checkList.get(idx)) || "1".equals(checkList.get(idx)) ? 1 : 0); + } + if (idx < topList.size() && StringUtils.hasText(topList.get(idx))) { + try { + m.setTopModuleId(Integer.valueOf(topList.get(idx).trim())); + } catch (NumberFormatException ignore) { + // 非法片段保持原值 + } + } + moduleRepository.save(m); + }); + } + if (sortList.size() != idList.size()) { + // 预留:strSorts 与 id 数不一致时以提交顺序为准 + } + } + + @Override + public Map moduleFieldList(Integer currentSchId, Integer moduleId) { + ProfessionModule m = moduleRepository.findById(moduleId) + .orElseThrow(() -> new IllegalArgumentException("栏目不存在: " + moduleId)); + if (!m.getSchId().equals(currentSchId) && !Integer.valueOf(1).equals(m.getIsShared())) { + throw new IllegalArgumentException("无权访问该栏目"); + } + List boundIds = csvToInt(m.getFieldIds()); + Map result = new LinkedHashMap<>(); + result.put("id", m.getId()); + result.put("name", m.getName()); + result.put("topModuleId", m.getTopModuleId()); + result.put("sendPoints", m.getSendPoints()); + result.put("commPoints", m.getCommPoints()); + result.put("openUserIds", m.getOpenUserIds()); + result.put("remark", m.getRemark()); + result.put("fieldIds", m.getFieldIds()); + List> fields = new ArrayList<>(); + for (ProfessionField f : fieldRepository.findBySchIdOrderByFieldSortAscIdAsc(m.getSchId())) { + fields.add(fieldRow(f, boundIds.contains(f.getId()))); + } + result.put("fields", fields); + return result; + } + + // ==================== 上级栏目 ==================== + + @Override + public List> listTopModules(Integer currentSchId, String searchName, Integer isUsing) { + List> rows = new ArrayList<>(); + for (TopModule t : topModuleRepository.findBySchIdOrderBySortOrderAscIdAsc(currentSchId)) { + if (StringUtils.hasText(searchName) && !t.getName().contains(searchName)) { + continue; + } + if (isUsing != null && !isUsing.equals(t.getIsUsing())) { + continue; + } + Map row = new LinkedHashMap<>(); + row.put("id", t.getId()); + row.put("name", t.getName()); + row.put("isUsing", t.getIsUsing()); + row.put("sortOrder", t.getSortOrder()); + rows.add(row); + } + return rows; + } + + @Override + @Transactional + public Integer createTopModule(Integer currentSchId, String name) { + TopModule t = new TopModule(); + t.setSchId(currentSchId); + t.setName(name); + t.setSortOrder(topModuleRepository.findBySchIdOrderBySortOrderAscIdAsc(currentSchId).size() + 1); + return topModuleRepository.save(t).getId(); + } + + @Override + @Transactional + public void editTopModule(Integer currentSchId, Integer id, String name) { + TopModule t = topModuleRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("上级栏目不存在: " + id)); + if (!t.getSchId().equals(currentSchId)) { + throw new IllegalArgumentException("只允许修改本校上级栏目"); + } + t.setName(name); + topModuleRepository.save(t); + } + + @Override + @Transactional + public void deleteTopModules(Integer currentSchId, String ids) { + for (Integer id : csvToInt(ids)) { + topModuleRepository.findById(id).ifPresent(t -> { + if (t.getSchId().equals(currentSchId)) { + topModuleRepository.delete(t); + } + }); + } + } + + @Override + @Transactional + public void orderTopModules(Integer currentSchId, String strIds) { + List idList = csvToInt(strIds); + for (int i = 0; i < idList.size(); i++) { + final int idx = i; + topModuleRepository.findById(idList.get(i)).ifPresent(t -> { + if (t.getSchId().equals(currentSchId)) { + t.setSortOrder(idx + 1); + topModuleRepository.save(t); + } + }); + } + } + + @Override + @Transactional + public void topModuleIsUsing(Integer currentSchId, Integer id, Integer isUsing) { + TopModule t = topModuleRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("上级栏目不存在: " + id)); + if (!t.getSchId().equals(currentSchId)) { + throw new IllegalArgumentException("只允许修改本校上级栏目"); + } + t.setIsUsing(isUsing != null && isUsing == 1 ? 1 : 0); + topModuleRepository.save(t); + } + + // ==================== 自定义字段 ==================== + + @Override + public Map pageFields(Integer currentSchId, String fieldType, int pageIndex, int pageSize) { + Specification spec = (root, cq, cb) -> { + List ps = new ArrayList<>(); + ps.add(cb.equal(root.get("schId"), currentSchId)); + if (StringUtils.hasText(fieldType)) { + ps.add(cb.equal(root.get("fieldType"), fieldType)); + } + return cb.and(ps.toArray(new Predicate[0])); + }; + Page page = fieldRepository.findAll(spec, + PageRequest.of(Math.max(pageIndex - 1, 0), pageSize <= 0 ? 10 : pageSize, + Sort.by(Sort.Direction.ASC, "fieldSort", "id"))); + List> rows = new ArrayList<>(); + for (ProfessionField f : page.getContent()) { + rows.add(fieldRow(f, null)); + } + Map result = new LinkedHashMap<>(); + result.put("total", page.getTotalElements()); + result.put("rows", rows); + return result; + } + + @Override + public List> listFields(Integer currentSchId) { + List> rows = new ArrayList<>(); + for (ProfessionField f : fieldRepository.findBySchIdOrderByFieldSortAscIdAsc(currentSchId)) { + rows.add(fieldRow(f, null)); + } + return rows; + } + + @Override + @Transactional + public Integer createField(Integer currentSchId, Map req) { + ProfessionField f = new ProfessionField(); + f.setSchId(currentSchId); + applyFieldRequest(f, req); + return fieldRepository.save(f).getId(); + } + + @Override + @Transactional + public void editField(Integer currentSchId, Map req) { + Integer id = intOf(req.get("id")); + ProfessionField f = fieldRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("字段不存在: " + id)); + if (!f.getSchId().equals(currentSchId)) { + throw new IllegalArgumentException("只允许修改本校字段"); + } + applyFieldRequest(f, req); + fieldRepository.save(f); + } + + @Override + @Transactional + public void deleteFields(Integer currentSchId, String ids) { + for (Integer id : csvToInt(ids)) { + fieldRepository.findById(id).ifPresent(f -> { + if (f.getSchId().equals(currentSchId)) { + fieldRepository.delete(f); + } + }); + } + } + + // ==================== 内部方法 ==================== + + private void applyModuleRequest(ProfessionModule m, Map req, boolean districtAccount) { + String name = strOf(req.get("name")); + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("栏目名称不能为空"); + } + m.setName(name); + m.setTopModuleId(intOf(req.get("topModuleId"))); + m.setFieldIds(strOf(req.get("fieldIds"))); + m.setSendPoints(intOf(req.get("sendPoints"))); + m.setCommPoints(intOf(req.get("commPoints"))); + m.setOpenUserIds(strOf(req.get("openUserIds"))); + m.setRemark(strOf(req.get("remark"))); + m.setIsCheck(intOf(req.get("isCheck")) == null ? 1 : intOf(req.get("isCheck")) == 0 ? 0 : 1); + Integer sortOrder = intOf(req.get("sortOrder")); + m.setSortOrder(sortOrder == null ? m.getSortOrder() == null ? 0 : m.getSortOrder() : sortOrder); + Integer isShared = intOf(req.get("isShared")); + // 仅区局账号允许全区下发 + m.setIsShared(districtAccount && isShared != null && isShared == 1 ? 1 : 0); + } + + private void applyFieldRequest(ProfessionField f, Map req) { + String name = strOf(req.get("fieldName")); + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("字段名称不能为空"); + } + String type = strOf(req.get("fieldType")); + if (!StringUtils.hasText(type)) { + type = "text"; + } + f.setFieldName(name); + f.setFieldType(type); + Integer sort = intOf(req.get("fieldSort")); + f.setFieldSort(sort == null ? 0 : sort); + Integer required = intOf(req.get("ifRequired")); + f.setIfRequired(required != null && required == 1 ? 1 : 0); + f.setDefaultValue(strOf(req.get("defaultValue"))); + f.setRowNum(intOf(req.get("rowNum"))); + f.setMinLength(intOf(req.get("minLength"))); + f.setMaxLength(intOf(req.get("maxLength"))); + f.setExtText(strOf(req.get("extText"))); + } + + private Map fieldRow(ProfessionField f, Boolean checked) { + Map row = new LinkedHashMap<>(); + row.put("id", f.getId()); + row.put("fieldType", f.getFieldType()); + row.put("fieldName", f.getFieldName()); + row.put("fieldSort", f.getFieldSort()); + row.put("ifRequired", f.getIfRequired()); + row.put("defaultValue", f.getDefaultValue()); + row.put("rowNum", f.getRowNum()); + row.put("minLength", f.getMinLength()); + row.put("maxLength", f.getMaxLength()); + row.put("extText", f.getExtText()); + row.put("createTime", f.getCreateTime()); + if (checked != null) { + row.put("isCheck", checked); + } + return row; + } + + private Map recordCountMap(Integer schId) { + Map map = new HashMap<>(); + for (Object[] row : recordRepository.countGroupByModule(schId, null, null)) { + map.put((Integer) row[0], (Long) row[1]); + } + return map; + } + + private static List csvToInt(String csv) { + List list = new ArrayList<>(); + if (StringUtils.hasText(csv)) { + for (String s : csv.split(",")) { + String t = s.trim(); + if (t.isEmpty()) { + continue; + } + try { + list.add(Integer.valueOf(t)); + } catch (NumberFormatException ignore) { + } + } + } + return list; + } + + private static List splitCsv(String csv) { + List list = new ArrayList<>(); + if (StringUtils.hasText(csv)) { + for (String s : csv.split(",")) { + list.add(s.trim()); + } + } + return list; + } + + private static Integer intOf(Object v) { + if (v == null) { + return null; + } + if (v instanceof Number) { + return ((Number) v).intValue(); + } + String s = String.valueOf(v).trim(); + if (s.isEmpty()) { + return null; + } + try { + return Integer.valueOf(s); + } catch (NumberFormatException e) { + return null; + } + } + + private static String strOf(Object v) { + if (v == null) { + return null; + } + String s = String.valueOf(v).trim(); + return s.isEmpty() ? null : s; + } +} diff --git a/src/main/java/com/yunhai/profession/service/impl/PointsService.java b/src/main/java/com/yunhai/profession/service/impl/PointsService.java new file mode 100644 index 0000000..addf54c --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/impl/PointsService.java @@ -0,0 +1,383 @@ +package com.yunhai.profession.service.impl; + +import com.yunhai.profession.entity.PointsConfig; +import com.yunhai.profession.entity.PointsSetting; +import com.yunhai.profession.entity.ProfessionModule; +import com.yunhai.profession.entity.TermSetting; +import com.yunhai.profession.repository.PointsConfigRepository; +import com.yunhai.profession.repository.PointsSettingRepository; +import com.yunhai.profession.repository.TermSettingRepository; +import com.yunhai.profession.service.IPointsService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 积分与学期设置域业务实现。 + *

积分规则首版:发布/评论时点取"栏目覆盖值 → 全局默认值"; + * 指标树(Type1 比例加分 / Type2 封顶)提供存储与基础应用,深度规则联调时补齐。

+ */ +@Service +public class PointsService implements IPointsService { + + @Autowired + private PointsConfigRepository pointsConfigRepository; + + @Autowired + private PointsSettingRepository pointsSettingRepository; + + @Autowired + private TermSettingRepository termSettingRepository; + + // ==================== 全局积分 ==================== + + @Override + @Transactional + public Map globalPoints(Integer schId) { + PointsConfig config = getOrCreateConfig(schId); + Map row = new LinkedHashMap<>(); + row.put("id", config.getId()); + row.put("schId", config.getSchId()); + row.put("sendPointsNum", config.getSendPointsNum()); + row.put("commPointsNum", config.getCommPointsNum()); + return row; + } + + @Override + @Transactional + public void globalPointsEdit(Integer schId, Integer sendPointsNum, Integer commPointsNum) { + PointsConfig config = getOrCreateConfig(schId); + if (sendPointsNum != null) { + config.setSendPointsNum(Math.max(sendPointsNum, 0)); + } + if (commPointsNum != null) { + config.setCommPointsNum(Math.max(commPointsNum, 0)); + } + pointsConfigRepository.save(config); + } + + // ==================== 积分计算 ==================== + + @Override + public int resolveSendPoints(Integer schId, ProfessionModule module) { + if (module != null && module.getSendPoints() != null && module.getSendPoints() > 0) { + return module.getSendPoints(); + } + return getOrCreateConfig(schId).getSendPointsNum(); + } + + @Override + public int resolveCommPoints(Integer schId, ProfessionModule module) { + if (module != null && module.getCommPoints() != null && module.getCommPoints() > 0) { + return module.getCommPoints(); + } + return getOrCreateConfig(schId).getCommPointsNum(); + } + + // ==================== 积分指标树 ==================== + + @Override + public List> pointsTree(Integer schId) { + List all = pointsSettingRepository.findBySchIdOrderByIdAsc(schId); + Map>> byParent = new HashMap<>(); + List> roots = new ArrayList<>(); + for (PointsSetting p : all) { + Map node = new LinkedHashMap<>(); + node.put("id", p.getId()); + node.put("pid", p.getParentId() == null ? 0 : p.getParentId()); + node.put("name", p.getName()); + node.put("type", p.getType()); + Integer pid = p.getParentId() == null ? 0 : p.getParentId(); + if (pid == 0) { + roots.add(node); + } else { + byParent.computeIfAbsent(pid, k -> new ArrayList<>()).add(node); + } + } + for (Map root : roots) { + List> children = byParent.get(((Number) root.get("id")).intValue()); + if (children != null) { + root.put("children", children); + } + } + return roots; + } + + @Override + public List> pointsSourceList() { + List> list = new ArrayList<>(); + Map s1 = new LinkedHashMap<>(); + s1.put("id", 1); + s1.put("name", "发布"); + list.add(s1); + Map s2 = new LinkedHashMap<>(); + s2.put("id", 2); + s2.put("name", "评论"); + list.add(s2); + return list; + } + + @Override + public Map pointsDetail(Integer schId, Integer id) { + PointsSetting p = pointsSettingRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("积分指标不存在: " + id)); + if (!p.getSchId().equals(schId)) { + throw new IllegalArgumentException("无权访问该积分指标"); + } + Map row = new LinkedHashMap<>(); + row.put("id", p.getId()); + row.put("parentId", p.getParentId()); + row.put("type", p.getType()); + row.put("name", p.getName()); + row.put("sendPoints", p.getSendPoints()); + row.put("isSendUpper", p.getIsSendUpper()); + row.put("upperSendPoints", p.getUpperSendPoints()); + row.put("commPoints", p.getCommPoints()); + row.put("isCommUpper", p.getIsCommUpper()); + row.put("upperCommPoints", p.getUpperCommPoints()); + row.put("endSendPoints", p.getEndSendPoints()); + row.put("endCommPoints", p.getEndCommPoints()); + row.put("soursePoints", p.getSoursePoints()); + row.put("soursePointId", p.getSoursePointId()); + return row; + } + + @Override + @Transactional + public Integer pointsEdit(Integer schId, Map req) { + Integer id = intOf(req.get("id")); + PointsSetting p; + if (id == null) { + p = new PointsSetting(); + p.setSchId(schId); + } else { + p = pointsSettingRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("积分指标不存在: " + id)); + if (!p.getSchId().equals(schId)) { + throw new IllegalArgumentException("无权修改该积分指标"); + } + } + Integer parentId = intOf(req.get("parentId")); + p.setParentId(parentId == null ? 0 : parentId); + p.setName(strOf(req.get("name"))); + Integer type = intOf(req.get("type")); + p.setType(type == null ? 2 : type); + p.setSendPoints(nz(intOf(req.get("sendPoints")))); + p.setCommPoints(nz(intOf(req.get("commPoints")))); + p.setSoursePoints(strOf(req.get("soursePoints"))); + p.setSoursePointId(strOf(req.get("soursePointId"))); + if (p.getType() == 1) { + p.setIsSendUpper(eq1(intOf(req.get("isSendUpper")))); + p.setUpperSendPoints(intOf(req.get("upperSendPoints"))); + p.setIsCommUpper(eq1(intOf(req.get("isCommUpper")))); + p.setUpperCommPoints(intOf(req.get("upperCommPoints"))); + p.setEndSendPoints(null); + p.setEndCommPoints(null); + } else { + p.setIsSendUpper(0); + p.setIsCommUpper(0); + p.setUpperSendPoints(null); + p.setUpperCommPoints(null); + p.setEndSendPoints(intOf(req.get("endSendPoints"))); + p.setEndCommPoints(intOf(req.get("endCommPoints"))); + } + return pointsSettingRepository.save(p).getId(); + } + + @Override + @Transactional + public void deletePoints(Integer schId, String ids) { + List all = pointsSettingRepository.findBySchIdOrderByIdAsc(schId); + for (Integer id : csvToInt(ids)) { + deleteNode(all, schId, id); + } + } + + private void deleteNode(List all, Integer schId, Integer id) { + for (PointsSetting p : new ArrayList<>(all)) { + if (p.getParentId() != null && p.getParentId().equals(id)) { + deleteNode(all, schId, p.getId()); + } + } + pointsSettingRepository.findById(id).ifPresent(p -> { + if (p.getSchId().equals(schId)) { + pointsSettingRepository.delete(p); + } + }); + } + + // ==================== 学期设置 ==================== + + @Override + public List> termList(Integer schId) { + List> rows = new ArrayList<>(); + for (TermSetting t : termSettingRepository.findBySchIdOrderBySchoolYearDescTermTypeAsc(schId)) { + rows.add(termRow(t)); + } + return rows; + } + + @Override + public Map termPage(Integer schId, int pageIndex, int pageSize) { + Page page = termSettingRepository.findBySchId(schId, + PageRequest.of(Math.max(pageIndex - 1, 0), pageSize <= 0 ? 10 : pageSize, + Sort.by(Sort.Direction.DESC, "schoolYear", "termType"))); + List> rows = new ArrayList<>(); + for (TermSetting t : page.getContent()) { + rows.add(termRow(t)); + } + Map result = new LinkedHashMap<>(); + result.put("total", page.getTotalElements()); + result.put("rows", rows); + return result; + } + + @Override + public Map termDetail(Integer schId, Integer id) { + TermSetting t = termSettingRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("学期设置不存在: " + id)); + if (!t.getSchId().equals(schId)) { + throw new IllegalArgumentException("无权访问该学期设置"); + } + return termRow(t); + } + + @Override + @Transactional + public Integer termEdit(Integer schId, Map req) { + Integer id = intOf(req.get("id")); + TermSetting t; + if (id == null) { + t = new TermSetting(); + t.setSchId(schId); + } else { + t = termSettingRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("学期设置不存在: " + id)); + if (!t.getSchId().equals(schId)) { + throw new IllegalArgumentException("无权修改该学期设置"); + } + } + String year = strOf(req.get("schoolYear")); + if (!StringUtils.hasText(year)) { + throw new IllegalArgumentException("学年不能为空"); + } + t.setSchoolYear(year); + t.setTermType(nz(intOf(req.get("termType"))) == 2 ? 2 : 1); + t.setTermStartTime(dateOf(req.get("termStartTime"))); + t.setTermEndTime(dateOf(req.get("termEndTime"))); + t.setSendTime(dateOf(req.get("sendTime"))); + String line = strOf(req.get("qualifiedLine")); + t.setQualifiedLine(StringUtils.hasText(line) ? new BigDecimal(line) : null); + t.setIsOpen(eq1(intOf(req.get("isOpen")))); + return termSettingRepository.save(t).getId(); + } + + // ==================== 内部方法 ==================== + + @Transactional + protected PointsConfig getOrCreateConfig(Integer schId) { + final Integer schIdFinal = schId == null ? 0 : schId; + return pointsConfigRepository.findBySchId(schIdFinal).orElseGet(() -> { + PointsConfig c = new PointsConfig(); + c.setSchId(schIdFinal); + c.setSendPointsNum(0); + c.setCommPointsNum(0); + return pointsConfigRepository.save(c); + }); + } + + private Map termRow(TermSetting t) { + Map row = new LinkedHashMap<>(); + row.put("id", t.getId()); + row.put("schoolYear", t.getSchoolYear()); + row.put("termType", t.getTermType()); + row.put("termStartTime", t.getTermStartTime()); + row.put("termEndTime", t.getTermEndTime()); + row.put("sendTime", t.getSendTime()); + row.put("qualifiedLine", t.getQualifiedLine()); + row.put("isOpen", t.getIsOpen()); + return row; + } + + private static LocalDateTime dateOf(Object v) { + if (v == null) { + return null; + } + String s = String.valueOf(v).trim(); + if (s.isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(s.replace(" ", "T")); + } catch (Exception e) { + try { + return java.time.LocalDate.parse(s).atStartOfDay(); + } catch (Exception e2) { + return null; + } + } + } + + private static int nz(Integer v) { + return v == null ? 0 : v; + } + + private static int eq1(Integer v) { + return v != null && v == 1 ? 1 : 0; + } + + private static Integer intOf(Object v) { + if (v == null) { + return null; + } + if (v instanceof Number) { + return ((Number) v).intValue(); + } + String s = String.valueOf(v).trim(); + if (s.isEmpty()) { + return null; + } + try { + return Integer.valueOf(s); + } catch (NumberFormatException e) { + return null; + } + } + + private static String strOf(Object v) { + if (v == null) { + return null; + } + String s = String.valueOf(v).trim(); + return s.isEmpty() ? null : s; + } + + private static List csvToInt(String csv) { + List list = new ArrayList<>(); + if (StringUtils.hasText(csv)) { + for (String s : csv.split(",")) { + String t = s.trim(); + if (t.isEmpty()) { + continue; + } + try { + list.add(Integer.valueOf(t)); + } catch (NumberFormatException ignore) { + } + } + } + return list; + } +} diff --git a/src/main/java/com/yunhai/profession/service/impl/ProfessionService.java b/src/main/java/com/yunhai/profession/service/impl/ProfessionService.java new file mode 100644 index 0000000..a85052c --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/impl/ProfessionService.java @@ -0,0 +1,928 @@ +package com.yunhai.profession.service.impl; + +import com.yunhai.profession.entity.ProfessionDiscuss; +import com.yunhai.profession.entity.ProfessionField; +import com.yunhai.profession.entity.ProfessionLike; +import com.yunhai.profession.entity.ProfessionModule; +import com.yunhai.profession.entity.ProfessionRecord; +import com.yunhai.profession.entity.ProfessionVisit; +import com.yunhai.profession.entity.UpFile; +import com.yunhai.profession.model.bsch.TeacherCustomGroupDto; +import com.yunhai.profession.repository.ProfessionDiscussRepository; +import com.yunhai.profession.repository.ProfessionFieldRepository; +import com.yunhai.profession.repository.ProfessionLikeRepository; +import com.yunhai.profession.repository.ProfessionModuleRepository; +import com.yunhai.profession.repository.ProfessionRecordRepository; +import com.yunhai.profession.repository.ProfessionVisitRepository; +import com.yunhai.profession.repository.UpFileRepository; +import com.yunhai.profession.service.IFileService; +import com.yunhai.profession.service.IPointsService; +import com.yunhai.profession.service.IProfessionService; +import com.yunhai.profession.service.IClassService; +import com.yunhai.profession.utils.ProfessionContext; +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.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import javax.persistence.criteria.Predicate; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 专业发展记录域业务实现。 + *

身份约定:记录/评论/足迹只存教师 id(teacher_id),姓名为展示冗余,不存账号。

+ */ +@Service +public class ProfessionService implements IProfessionService { + + private static final String TOPIC_TYPE = "profession"; + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private static final Logger log = LoggerFactory.getLogger(ProfessionService.class); + + @Autowired + private ProfessionRecordRepository recordRepository; + + @Autowired + private ProfessionDiscussRepository discussRepository; + + @Autowired + private ProfessionVisitRepository visitRepository; + + @Autowired + private ProfessionLikeRepository likeRepository; + + @Autowired + private ProfessionModuleRepository moduleRepository; + + @Autowired + private ProfessionFieldRepository fieldRepository; + + @Autowired + private UpFileRepository upFileRepository; + + @Autowired + private IFileService fileService; + + @Autowired + private IPointsService pointsService; + + @Autowired + private com.yunhai.profession.service.IUserCenterService userCenterService; + + @Autowired + private IClassService classService; + + // ==================== 记录 ==================== + + @Override + @Transactional + public Integer createRecord(Integer schId, Integer teacherId, String teacherName, Map req) { + Integer moduleId = intOf(req.get("moduleId")); + if (moduleId == null) { + moduleId = intOf(req.get("moudleid")); + } + if (moduleId == null) { + throw new IllegalArgumentException("所属栏目不能为空"); + } + final Integer moduleIdFinal = moduleId; + ProfessionModule module = moduleRepository.findById(moduleIdFinal) + .orElseThrow(() -> new IllegalArgumentException("栏目不存在: " + moduleIdFinal)); + if (!module.getSchId().equals(schId) && !Integer.valueOf(1).equals(module.getIsShared())) { + throw new IllegalArgumentException("无权向该栏目发布"); + } + ProfessionRecord r = new ProfessionRecord(); + r.setSchId(schId); + r.setModuleId(moduleId); + applyRecordRequest(r, req); + r.setTeacherId(teacherId); + // 开发兜底名(teacher-xxx)替换为该教师历史记录冗余的真实姓名(首发的教师沿用原名) + if (!StringUtils.hasText(teacherName) || teacherName.startsWith("teacher-")) { + teacherName = recordRepository.findTopByTeacherIdOrderByIdDesc(teacherId) + .map(ProfessionRecord::getTeacherName).orElse(teacherName); + } + r.setTeacherName(teacherName); + r.setSendPoints(pointsService.resolveSendPoints(schId, module)); + ProfessionRecord saved = recordRepository.save(r); + bindFiles(req.get("fileIds"), saved.getId()); + return saved.getId(); + } + + @Override + @Transactional + public void editRecord(Integer schId, Integer teacherId, Map req) { + Integer id = intOf(req.get("id")); + if (id == null) { + id = intOf(req.get("serId")); + } + final Integer idFinal = id; + ProfessionRecord r = recordRepository.findById(idFinal) + .orElseThrow(() -> new IllegalArgumentException("记录不存在: " + idFinal)); + if (!r.getSchId().equals(schId)) { + throw new IllegalArgumentException("无权编辑该记录"); + } + if (!r.getTeacherId().equals(teacherId)) { + throw new IllegalArgumentException("仅作者可编辑"); + } + applyRecordRequest(r, req); + recordRepository.save(r); + bindFiles(req.get("fileIds"), r.getId()); + } + + @Override + @Transactional + public void deleteRecords(Integer schId, Integer teacherId, boolean superAdmin, String ids) { + for (Integer id : csvToInt(ids)) { + recordRepository.findById(id).ifPresent(r -> { + if (!r.getSchId().equals(schId) && !superAdmin) { + return; + } + if (!r.getTeacherId().equals(teacherId) && !superAdmin) { + return; + } + discussRepository.deleteByRecordId(r.getId()); + recordRepository.delete(r); + }); + } + } + + @Override + public Map pageRecords(Integer schId, Integer moduleId, Integer teacherId, Integer groupId, + String subject, String grade, + String keyword, String startTime, String endTime, + int pageIndex, int pageSize, boolean withOtherModules) { + Specification spec = recordSpec(schId, moduleId, teacherId, + groupId != null ? groupTeacherIds(schId, groupId) : null, + subject, grade, keyword, startTime, endTime); + Page page = recordRepository.findAll(spec, + PageRequest.of(Math.max(pageIndex - 1, 0), pageSize <= 0 ? 10 : pageSize, + Sort.by(Sort.Direction.DESC, "createTime", "id"))); + Map discussCount = discussCountMap(schId); + Map moduleName = moduleNameMap(schId); + List> rows = new ArrayList<>(); + for (ProfessionRecord r : page.getContent()) { + rows.add(recordRow(r, moduleName.get(r.getModuleId()), discussCount.getOrDefault(r.getId(), 0L))); + } + Map result = new LinkedHashMap<>(); + result.put("total", page.getTotalElements()); + result.put("rows", rows); + if (withOtherModules) { + List> others = new ArrayList<>(); + for (ProfessionModule m : moduleRepository.findVisibleEnabled(schId)) { + if (moduleId != null && m.getId().equals(moduleId)) { + continue; + } + Map row = new LinkedHashMap<>(); + row.put("id", m.getId()); + row.put("name", m.getName()); + others.add(row); + } + result.put("otherModules", others); + } + return result; + } + + @Override + @Transactional + public Map recordDetail(Integer schId, Integer recordId, Integer currentUserId) { + ProfessionRecord r = recordRepository.findById(recordId) + .orElseThrow(() -> new IllegalArgumentException("记录不存在: " + recordId)); + if (!r.getSchId().equals(schId)) { + throw new IllegalArgumentException("无权查看该记录"); + } + recordRepository.increaseReadCount(recordId); + Map row = recordRow(r, moduleNameMap(schId).get(r.getModuleId()), null); + row.put("remark", r.getRemark()); + row.put("contents", r.getContents()); + // 点赞数与当前用户点赞态 + row.put("likeCount", r.getLikeCount()); + row.put("liked", currentUserId != null + && likeRepository.findByRecordIdAndTeacherId(recordId, currentUserId).isPresent()); + // 栏目绑定字段定义(发布表单同源,详情渲染动态字段) + ProfessionModule module = moduleRepository.findById(r.getModuleId()).orElse(null); + List> fields = new ArrayList<>(); + if (module != null && StringUtils.hasText(module.getFieldIds())) { + List boundIds = ProfessionContext.csvToIntList(module.getFieldIds()); + if (!boundIds.isEmpty()) { + for (ProfessionField f : fieldRepository.findAllById(boundIds)) { + Map fr = new LinkedHashMap<>(); + fr.put("id", f.getId()); + fr.put("fieldType", f.getFieldType()); + fr.put("fieldName", f.getFieldName()); + fr.put("ifRequired", f.getIfRequired()); + fr.put("extText", f.getExtText()); + fields.add(fr); + } + } + } + row.put("fields", fields); + List> files = new ArrayList<>(); + for (UpFile f : upFileRepository.findByTopicTypeAndTopicId(TOPIC_TYPE, recordId)) { + Map fr = new LinkedHashMap<>(); + fr.put("fileid", f.getId()); + fr.put("fileTrueName", f.getFileTrueName()); + fr.put("fileType", f.getFileType()); + files.add(fr); + } + row.put("attachments", files); + row.put("discussCount", discussRepository.findByRecordIdOrderByCreateTimeAsc(recordId).size()); + return row; + } + + @Override + public List> homeModuleRecords(Integer schId, int sizePerModule) { + Map countByModule = new HashMap<>(); + for (Object[] row : recordRepository.countGroupByModule(schId, null, null)) { + countByModule.put((Integer) row[0], (Long) row[1]); + } + List> blocks = new ArrayList<>(); + for (ProfessionModule m : moduleRepository.findVisibleEnabled(schId)) { + Page page = recordRepository.findAll( + recordSpec(schId, m.getId(), null, null, null, null, null, null, null), + PageRequest.of(0, Math.max(sizePerModule, 1), Sort.by(Sort.Direction.DESC, "createTime", "id"))); + List> items = new ArrayList<>(); + for (ProfessionRecord r : page.getContent()) { + Map item = new LinkedHashMap<>(); + item.put("id", r.getId()); + item.put("title", r.getTitle()); + item.put("teacherId", r.getTeacherId()); + item.put("teacherName", r.getTeacherName()); + item.put("createTime", r.getCreateTime()); + items.add(item); + } + Map block = new LinkedHashMap<>(); + block.put("id", m.getId()); + block.put("name", m.getName()); + block.put("recordCount", countByModule.getOrDefault(m.getId(), 0L)); + block.put("itemlist", items); + blocks.add(block); + } + return blocks; + } + + @Override + public List> myTimeline(Integer schId, Integer teacherId) { + Specification spec = recordSpec(schId, null, teacherId, null, null, null, null, null, null); + List records = recordRepository.findAll(spec, + PageRequest.of(0, 500, Sort.by(Sort.Direction.DESC, "createTime", "id"))).getContent(); + Map moduleName = moduleNameMap(schId); + Map> byYear = new LinkedHashMap<>(); + for (ProfessionRecord r : records) { + LocalDateTime t = r.getCreateTime(); + if (t == null) { + continue; + } + int year = t.getYear(); + Map yearNode = byYear.computeIfAbsent(year, k -> { + Map n = new LinkedHashMap<>(); + n.put("year", year); + n.put("itemlist", new ArrayList>()); + return n; + }); + Map item = new LinkedHashMap<>(); + item.put("id", r.getId()); + item.put("moduleId", r.getModuleId()); + item.put("moduleName", moduleName.get(r.getModuleId())); + item.put("title", r.getTitle()); + item.put("remark", r.getRemark()); + item.put("year", year); + item.put("month", t.getMonthValue()); + item.put("day", t.getDayOfMonth()); + item.put("createTime", r.getCreateTime()); + ((List>) yearNode.get("itemlist")).add(item); + } + return new ArrayList<>(byYear.values()); + } + + // ==================== 评论 ==================== + + @Override + @Transactional + public Integer createDiscuss(Integer schId, Integer teacherId, String teacherName, Integer recordId, String contents) { + if (!StringUtils.hasText(contents)) { + throw new IllegalArgumentException("评论内容不能为空"); + } + if (contents.length() > 500) { + throw new IllegalArgumentException("评论不能超过 500 字"); + } + ProfessionRecord r = recordRepository.findById(recordId) + .orElseThrow(() -> new IllegalArgumentException("记录不存在: " + recordId)); + // 开发兜底名(teacher-xxx)替换为该教师记录冗余的真实姓名 + if (!StringUtils.hasText(teacherName) || teacherName.startsWith("teacher-")) { + teacherName = recordRepository.findTopByTeacherIdOrderByIdDesc(teacherId) + .map(ProfessionRecord::getTeacherName).orElse(teacherName); + } + ProfessionDiscuss d = new ProfessionDiscuss(); + d.setSchId(schId); + d.setRecordId(recordId); + d.setTeacherId(teacherId); + d.setTeacherName(teacherName); + d.setContents(contents); + d.setCommPoints(pointsService.resolveCommPoints(schId, + moduleRepository.findById(r.getModuleId()).orElse(null))); + return discussRepository.save(d).getId(); + } + + @Override + @Transactional + public void editDiscuss(Integer schId, Integer teacherId, Integer discussId, String contents) { + ProfessionDiscuss d = discussRepository.findById(discussId) + .orElseThrow(() -> new IllegalArgumentException("评论不存在: " + discussId)); + if (!d.getTeacherId().equals(teacherId)) { + throw new IllegalArgumentException("仅本人可编辑评论"); + } + if (!StringUtils.hasText(contents) || contents.length() > 500) { + throw new IllegalArgumentException("评论需为 1~500 字"); + } + d.setContents(contents); + discussRepository.save(d); + } + + @Override + @Transactional + public void deleteDiscuss(Integer schId, Integer teacherId, boolean superAdmin, Integer discussId) { + discussRepository.findById(discussId).ifPresent(d -> { + if (!d.getSchId().equals(schId) && !superAdmin) { + return; + } + if (!d.getTeacherId().equals(teacherId) && !superAdmin) { + return; + } + discussRepository.delete(d); + }); + } + + @Override + public List> discussList(Integer schId, Integer currentUserId, boolean superAdmin, Integer recordId) { + List> rows = new ArrayList<>(); + for (ProfessionDiscuss d : discussRepository.findByRecordIdOrderByCreateTimeAsc(recordId)) { + Map row = new LinkedHashMap<>(); + row.put("id", d.getId()); + row.put("recordId", d.getRecordId()); + row.put("teacherId", d.getTeacherId()); + row.put("teacherName", d.getTeacherName()); + row.put("contents", d.getContents()); + row.put("createTime", d.getCreateTime()); + row.put("canManage", superAdmin + || (d.getTeacherId() != null && d.getTeacherId().equals(currentUserId))); + rows.add(row); + } + return rows; + } + + // ==================== 足迹 ==================== + + @Override + @Transactional + public void visitCreate(Integer schId, Integer visitorId, String visitorName, Integer recordId) { + ProfessionVisit v = new ProfessionVisit(); + v.setSchId(schId); + // 开发兜底名(teacher-xxx)替换为该教师记录冗余的真实姓名 + if (!StringUtils.hasText(visitorName) || visitorName.startsWith("teacher-")) { + visitorName = recordRepository.findTopByTeacherIdOrderByIdDesc(visitorId) + .map(ProfessionRecord::getTeacherName).orElse(visitorName); + } + ProfessionRecord r = recordRepository.findById(recordId).orElse(null); + if (r != null) { + v.setModuleId(r.getModuleId()); + v.setAuthorId(r.getTeacherId()); + recordRepository.increaseReadCount(recordId); + } else { + v.setAuthorId(visitorId); + } + v.setRecordId(recordId); + v.setVisitorId(visitorId); + v.setVisitorName(visitorName); + visitRepository.save(v); + } + + @Override + public List> visitList(Integer recordId, Integer authorId, int limit) { + List visits; + Pageable top = PageRequest.of(0, Math.min(Math.max(limit, 1), 50)); + if (recordId != null) { + visits = visitRepository.findByRecordIdOrderByCreateTimeDesc(recordId, top); + } else if (authorId != null) { + visits = visitRepository.findByAuthorIdOrderByCreateTimeDesc(authorId, top); + } else { + visits = new ArrayList<>(); + } + Map> distinct = new LinkedHashMap<>(); + for (ProfessionVisit v : visits) { + if (distinct.containsKey(v.getVisitorId())) { + continue; + } + Map row = new LinkedHashMap<>(); + row.put("visitorId", v.getVisitorId()); + row.put("visitorName", v.getVisitorName()); + row.put("visitTime", v.getCreateTime()); + distinct.put(v.getVisitorId(), row); + } + return new ArrayList<>(distinct.values()); + } + + // ==================== 点赞 ==================== + + @Override + @Transactional + public Map likeCreate(Integer schId, Integer teacherId, String teacherName, Integer recordId) { + ProfessionRecord r = recordRepository.findById(recordId) + .orElseThrow(() -> new IllegalArgumentException("记录不存在: " + recordId)); + if (!r.getSchId().equals(schId)) { + throw new IllegalArgumentException("无权操作该记录"); + } + // 开发兜底名(teacher-xxx)替换为记录冗余的真实姓名 + String likerName = teacherName; + if (likerName == null || likerName.isEmpty() || likerName.startsWith("teacher-")) { + likerName = recordRepository.findTopByTeacherIdOrderByIdDesc(teacherId) + .map(ProfessionRecord::getTeacherName).orElse(likerName); + } + Map result = new LinkedHashMap<>(); + ProfessionLike existed = likeRepository.findByRecordIdAndTeacherId(recordId, teacherId).orElse(null); + if (existed != null) { + // 已赞 → 取消 + likeRepository.delete(existed); + recordRepository.increaseLikeCount(recordId, -1); + result.put("liked", false); + } else { + ProfessionLike like = new ProfessionLike(); + like.setSchId(schId); + like.setRecordId(recordId); + like.setTeacherId(teacherId); + like.setTeacherName(likerName); + likeRepository.save(like); + recordRepository.increaseLikeCount(recordId, 1); + result.put("liked", true); + } + // 增减后直查最新计数(findById 会命中一级缓存旧值) + result.put("likeCount", recordRepository.findLikeCount(recordId)); + return result; + } + + // ==================== 积分/排行 ==================== + + @Override + public Map myPoints(Integer schId, Integer teacherId, String teacherName) { + long sendPoints = 0; + long commPoints = 0; + for (Object[] row : recordRepository.sumPointsGroupByTeacher(schId, null, null)) { + if (teacherId.equals(row[0])) { + sendPoints = row[2] == null ? 0 : ((Number) row[2]).longValue(); + // 开发兜底名(teacher-xxx)用记录冗余的真实姓名覆盖 + if (!StringUtils.hasText(teacherName) || teacherName.startsWith("teacher-")) { + teacherName = String.valueOf(row[1]); + } + } + } + for (Object[] row : discussRepository.sumPointsGroupByTeacher(schId, null, null)) { + if (teacherId.equals(row[0])) { + commPoints = row[2] == null ? 0 : ((Number) row[2]).longValue(); + } + } + Map result = new LinkedHashMap<>(); + result.put("teacherId", teacherId); + result.put("teacherName", teacherName); + result.put("sendPoints", sendPoints); + result.put("commPoints", commPoints); + result.put("pointsnum", sendPoints + commPoints); + return result; + } + + @Override + public List> releaseRanking(Integer schId, Integer topN) { + List> rows = new ArrayList<>(); + List data = recordRepository.countGroupByTeacher(schId, null, null); + data.sort((a, b) -> Long.compare(((Number) b[2]).longValue(), ((Number) a[2]).longValue())); + int n = topN == null || topN <= 0 ? 10 : topN; + for (Object[] row : data) { + if (rows.size() >= n) { + break; + } + Map item = new LinkedHashMap<>(); + item.put("teacherId", row[0]); + item.put("teacherName", row[1]); + item.put("recordCount", ((Number) row[2]).longValue()); + rows.add(item); + } + return rows; + } + + @Override + public List> subjectGroupTree(Integer schId, Integer moduleId) { + if (schId == null) { + return new ArrayList<>(); + } + // 1) 班级管理教师分组(组/组员/组长合一;组员 teaId=用户中心ID,与记录 teacher_id 同一 id 体系) + List groups; + try { + groups = classService.getTeaCustomGroupList(schId, null); + } catch (Exception e) { + // 班级管理不可用时不阻塞首页 + log.warn("[subjectGroupTree] 获取教师分组失败, schId={}", schId, e); + return new ArrayList<>(); + } + // 2) 教师发布篇数:传 moduleId 只统计该栏目(栏目页组树计数与列表一致),否则全部栏目合计(发展首页) + Map countByTeacher = new HashMap<>(); + if (moduleId == null) { + for (Object[] row : recordRepository.countGroupByTeacher(schId, null, null)) { + countByTeacher.put((Integer) row[0], ((Number) row[2]).longValue()); + } + } else { + for (Object[] row : recordRepository.countGroupByTeacherModule(schId, null, null)) { + if (moduleId.equals(row[2])) { + countByTeacher.put((Integer) row[0], ((Number) row[3]).longValue()); + } + } + } + // 3) 组节点:组类型 + 组教师人数 + 组内发布总数;叶子=教师(count=个人篇数,组长在前) + List> roots = new ArrayList<>(); + for (TeacherCustomGroupDto group : groups) { + Map node = new LinkedHashMap<>(); + node.put("id", group.getGroupId()); + node.put("name", group.getGroupName()); + node.put("groupType", group.getGroupType()); + node.put("groupTypeName", group.getGroupTypeName()); + List> children = new ArrayList<>(); + Set teacherIds = new HashSet<>(); + long total = 0; + List teachers = new ArrayList<>(); + if (group.getLeaders() != null) { + teachers.addAll(group.getLeaders()); + } + if (group.getMembers() != null) { + teachers.addAll(group.getMembers()); + } + for (TeacherCustomGroupDto.IdName t : teachers) { + Integer teaId = t.idInt(); + if (teaId == null || !teacherIds.add(teaId)) { + continue; + } + long count = countByTeacher.getOrDefault(teaId, 0L); + Map leaf = new LinkedHashMap<>(); + leaf.put("id", teaId); + leaf.put("name", t.getName()); + leaf.put("count", count); + leaf.put("teacher", true); + children.add(leaf); + total += count; + } + node.put("teacherCount", teacherIds.size()); + node.put("count", total); + node.put("teacher", false); + node.put("children", children); + roots.add(node); + } + // 4) 同类型组排一起(1年级组 2教研组 3备课组,空类型最后);稳定排序保留 bschclass 组内 sort 序 + roots.sort(Comparator.comparing( + (Map n) -> (Integer) n.get("groupType"), + Comparator.nullsLast(Comparator.naturalOrder()))); + return roots; + } + + // ==================== 内部方法 ==================== + + private Map recordRow(ProfessionRecord r, String moduleName, Long discussCount) { + Map row = new LinkedHashMap<>(); + row.put("id", r.getId()); + row.put("moduleId", r.getModuleId()); + row.put("moduleName", moduleName); + row.put("title", r.getTitle()); + row.put("grade", r.getGrade()); + row.put("gradeName", r.getGradeName()); + row.put("subject", r.getSubject()); + row.put("subjectName", r.getSubjectName()); + row.put("ifOpen", r.getIfOpen()); + row.put("teacherId", r.getTeacherId()); + row.put("teacherName", r.getTeacherName()); + row.put("readCount", r.getReadCount()); + row.put("likeCount", r.getLikeCount()); + if (discussCount != null) { + row.put("discussCount", discussCount); + } + row.put("sendPoints", r.getSendPoints()); + row.put("createTime", r.getCreateTime()); + return row; + } + + private void applyRecordRequest(ProfessionRecord r, Map req) { + String title = strOf(req.get("title")); + if (title == null) { + title = strOf(req.get("protitle")); + } + if (!StringUtils.hasText(title)) { + throw new IllegalArgumentException("标题不能为空"); + } + r.setTitle(title); + String grade = strOf(req.get("grade")); + r.setGrade(grade); + r.setGradeName(grade == null ? null : com.yunhai.profession.utils.ProfessionDicts.gradeName(grade)); + String subject = strOf(req.get("subject")); + r.setSubject(subject); + r.setSubjectName(subject == null ? null : com.yunhai.profession.utils.ProfessionDicts.subjectName(subject)); + Integer ifOpen = intOf(req.get("ifOpen")); + r.setIfOpen(ifOpen == null ? 1 : (ifOpen == 0 ? 0 : 1)); + r.setRemark(strOf(req.get("remark"))); + r.setContents(strOf(req.get("contents"))); + } + + private void bindFiles(Object fileIds, Integer recordId) { + List ids = new ArrayList<>(); + if (fileIds instanceof List) { + for (Object o : (List) fileIds) { + Integer v = intOf(o); + if (v != null) { + ids.add(v); + } + } + } else if (fileIds != null) { + ids = csvToInt(String.valueOf(fileIds)); + } + if (!ids.isEmpty()) { + fileService.bindToTopic(ids, recordId); + } + } + + /** + * 教师分组成员教师 id(组长+组员,去重保序);组不存在或班级管理不可用时返回空集合(查询结果为空)。 + */ + private List groupTeacherIds(Integer schId, Integer groupId) { + List ids = new ArrayList<>(); + if (schId == null || groupId == null) { + return ids; + } + try { + for (TeacherCustomGroupDto group : classService.getTeaCustomGroupList(schId, null)) { + if (!groupId.equals(group.getGroupId())) { + continue; + } + Set seen = new LinkedHashSet<>(); + collectGroupTeacher(group.getLeaders(), seen); + collectGroupTeacher(group.getMembers(), seen); + ids.addAll(seen); + break; + } + } catch (Exception e) { + log.warn("[groupTeacherIds] 获取教师分组失败, schId={}, groupId={}", schId, groupId, e); + } + return ids; + } + + private void collectGroupTeacher(List teachers, Set out) { + if (teachers == null) { + return; + } + for (TeacherCustomGroupDto.IdName t : teachers) { + Integer id = t.idInt(); + if (id != null) { + out.add(id); + } + } + } + + private Specification recordSpec(Integer schId, Integer moduleId, Integer teacherId, + Collection teacherIds, + String subject, String grade, String keyword, + String startTime, String endTime) { + final LocalDateTime start = parseStart(startTime); + final LocalDateTime end = parseEnd(endTime); + return (root, cq, cb) -> { + List ps = new ArrayList<>(); + ps.add(cb.equal(root.get("schId"), schId)); + if (moduleId != null) { + ps.add(cb.equal(root.get("moduleId"), moduleId)); + } + if (teacherId != null) { + ps.add(cb.equal(root.get("teacherId"), teacherId)); + } + if (teacherIds != null) { + // 组过滤:空集合(组不存在/组服务不可用)时恒假,避免退化为不过滤 + ps.add(teacherIds.isEmpty() ? cb.disjunction() : root.get("teacherId").in(teacherIds)); + } + if (StringUtils.hasText(subject)) { + ps.add(cb.equal(root.get("subject"), subject)); + } + if (StringUtils.hasText(grade)) { + ps.add(cb.equal(root.get("grade"), grade)); + } + if (StringUtils.hasText(keyword)) { + String kw = "%" + keyword.trim() + "%"; + // 按标题 / 作者姓名联合模糊匹配(前台检索框契约) + ps.add(cb.or(cb.like(root.get("title"), kw), cb.like(root.get("teacherName"), kw))); + } + if (start != null) { + ps.add(cb.greaterThanOrEqualTo(root.get("createTime"), start)); + } + if (end != null) { + ps.add(cb.lessThan(root.get("createTime"), end)); + } + return cb.and(ps.toArray(new Predicate[0])); + }; + } + + private Map discussCountMap(Integer schId) { + Map map = new HashMap<>(); + for (Object[] row : discussRepository.countGroupByRecord(schId, null, null)) { + map.put((Integer) row[0], (Long) row[1]); + } + return map; + } + + private Map moduleNameMap(Integer schId) { + Map map = new HashMap<>(); + for (ProfessionModule m : moduleRepository.findVisible(schId)) { + map.put(m.getId(), m.getName()); + } + return map; + } + + private static LocalDateTime parseStart(String s) { + if (!StringUtils.hasText(s)) { + return null; + } + try { + return LocalDate.parse(s.trim().substring(0, 10), DATE_FMT).atStartOfDay(); + } catch (Exception e) { + return null; + } + } + + private static LocalDateTime parseEnd(String s) { + if (!StringUtils.hasText(s)) { + return null; + } + try { + return LocalDate.parse(s.trim().substring(0, 10), DATE_FMT).plusDays(1).atStartOfDay(); + } catch (Exception e) { + return null; + } + } + + private static List csvToInt(String csv) { + List list = new ArrayList<>(); + if (StringUtils.hasText(csv)) { + for (String s : csv.split(",")) { + String t = s.trim(); + if (t.isEmpty()) { + continue; + } + try { + list.add(Integer.valueOf(t)); + } catch (NumberFormatException ignore) { + } + } + } + return list; + } + + private static Integer intOf(Object v) { + if (v == null) { + return null; + } + if (v instanceof Number) { + return ((Number) v).intValue(); + } + String s = String.valueOf(v).trim(); + if (s.isEmpty()) { + return null; + } + try { + return Integer.valueOf(s); + } catch (NumberFormatException e) { + return null; + } + } + + private static String strOf(Object v) { + if (v == null) { + return null; + } + String s = String.valueOf(v).trim(); + return s.isEmpty() ? null : s; + } + + @Override + public Map homeStats(Integer schId) { + Map m = new LinkedHashMap<>(); + long recordTotal = recordRepository.count((root, cq, cb) -> cb.equal(root.get("schId"), schId)); + long teacherTotal = recordRepository.countGroupByTeacher(schId, null, null).size(); + long moduleTotal = moduleRepository.findVisibleEnabled(schId).size(); + LocalDateTime weekAgo = LocalDateTime.now().minusDays(7); + long weekNew = recordRepository.count((root, cq, cb) -> { + List ps = new ArrayList<>(); + ps.add(cb.equal(root.get("schId"), schId)); + ps.add(cb.greaterThanOrEqualTo(root.get("createTime"), weekAgo)); + return cb.and(ps.toArray(new javax.persistence.criteria.Predicate[0])); + }); + m.put("recordTotal", recordTotal); + m.put("teacherTotal", teacherTotal); + m.put("moduleTotal", moduleTotal); + m.put("weekNew", weekNew); + return m; + } + + @Override + public List> pointsRank(Integer schId, Integer topN) { + Map> byTeacher = new LinkedHashMap<>(); + for (Object[] row : recordRepository.sumPointsGroupByTeacher(schId, null, null)) { + Map r = new LinkedHashMap<>(); + r.put("teacherId", row[0]); + r.put("teacherName", row[1]); + r.put("points", row[2] == null ? 0L : ((Number) row[2]).longValue()); + byTeacher.put((Integer) row[0], r); + } + for (Object[] row : discussRepository.sumPointsGroupByTeacher(schId, null, null)) { + Map r = byTeacher.get((Integer) row[0]); + if (r == null) { + r = new LinkedHashMap<>(); + r.put("teacherId", row[0]); + r.put("teacherName", row[1]); + r.put("points", 0L); + byTeacher.put((Integer) row[0], r); + } + long add = row[2] == null ? 0L : ((Number) row[2]).longValue(); + r.put("points", ((Number) r.get("points")).longValue() + add); + } + List> rows = new ArrayList<>(byTeacher.values()); + rows.sort((a, b) -> Long.compare(((Number) b.get("points")).longValue(), ((Number) a.get("points")).longValue())); + int n = topN == null || topN <= 0 ? 8 : topN; + return rows.size() > n ? new ArrayList<>(rows.subList(0, n)) : rows; + } + + @Override + public Map spaceOverview(Integer schId, Integer userid) { + Map m = new LinkedHashMap<>(); + // 姓名 + 积分 + String name = null; + long points = 0; + for (Object[] row : recordRepository.sumPointsGroupByTeacher(schId, null, null)) { + if (userid.equals(row[0])) { + name = String.valueOf(row[1]); + points = row[2] == null ? 0 : ((Number) row[2]).longValue(); + } + } + for (Object[] row : discussRepository.sumPointsGroupByTeacher(schId, null, null)) { + if (userid.equals(row[0])) { + if (name == null) name = String.valueOf(row[1]); + points += row[2] == null ? 0 : ((Number) row[2]).longValue(); + } + } + m.put("teacherId", userid); + m.put("teacherName", name == null ? "" : name); + m.put("points", points); + // 累计记录 / 被访问 + long recordTotal = 0; + for (Object[] row : recordRepository.countGroupByTeacher(schId, null, null)) { + if (userid.equals(row[0])) recordTotal = ((Number) row[2]).longValue(); + } + m.put("recordTotal", recordTotal); + m.put("likeTotal", recordRepository.sumLikesByTeacher(schId, userid)); + m.put("visitTotal", visitRepository.countByAuthorId(userid)); + // 各栏目篇数(启用栏目,含 0) + Map byModule = new HashMap<>(); + for (Object[] row : recordRepository.countGroupByTeacherModule(schId, null, null)) { + if (userid.equals(row[0])) byModule.put((Integer) row[2], ((Number) row[3]).longValue()); + } + List> moduleCounts = new ArrayList<>(); + for (ProfessionModule mod : moduleRepository.findVisibleEnabled(schId)) { + Map r = new LinkedHashMap<>(); + r.put("id", mod.getId()); + r.put("name", mod.getName()); + r.put("count", byModule.getOrDefault(mod.getId(), 0L)); + moduleCounts.add(r); + } + m.put("moduleCounts", moduleCounts); + // 本月概览 + YearMonth ym = YearMonth.now(); + LocalDateTime start = ym.atDay(1).atStartOfDay(); + LocalDateTime end = start.plusMonths(1); + Map month = new LinkedHashMap<>(); + month.put("publish", recordRepository.countByTeacherRange(schId, userid, start, end)); + month.put("comments", discussRepository.countByTeacherRange(schId, userid, start, end)); + month.put("reads", recordRepository.sumReadsByTeacher(schId, userid)); + month.put("visitors", visitRepository.countVisitsByAuthor(userid, start, end)); + m.put("month", month); + return m; + } + + // ==================== 聚合辅助 ==================== +} diff --git a/src/main/java/com/yunhai/profession/service/impl/StatsService.java b/src/main/java/com/yunhai/profession/service/impl/StatsService.java new file mode 100644 index 0000000..f47e290 --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/impl/StatsService.java @@ -0,0 +1,680 @@ +package com.yunhai.profession.service.impl; + +import com.alibaba.excel.EasyExcel; +import com.yunhai.profession.entity.ProfessionModule; +import com.yunhai.profession.entity.ProfessionRecord; +import com.yunhai.profession.entity.TermSetting; +import com.yunhai.profession.entity.UpFile; +import com.yunhai.profession.repository.ProfessionDiscussRepository; +import com.yunhai.profession.repository.ProfessionModuleRepository; +import com.yunhai.profession.repository.ProfessionRecordRepository; +import com.yunhai.profession.repository.TermSettingRepository; +import com.yunhai.profession.repository.UpFileRepository; +import com.yunhai.profession.service.IFileService; +import com.yunhai.profession.service.IStatsService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import javax.persistence.criteria.Predicate; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 统计域业务实现。 + *

校级统计基于分组聚合查询;明细/导出基于动态 Specification; + * 局端跨校统计按 schId 分组(仅超管/区局账号调用,Controller 侧校验)。

+ */ +@Service +public class StatsService implements IStatsService { + + private static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Autowired + private ProfessionRecordRepository recordRepository; + + @Autowired + private ProfessionDiscussRepository discussRepository; + + @Autowired + private ProfessionModuleRepository moduleRepository; + + @Autowired + private TermSettingRepository termSettingRepository; + + @Autowired + private UpFileRepository upFileRepository; + + @Autowired + private IFileService fileService; + + // ==================== 校级统计 ==================== + + @Override + public Map teacherStat(Integer schId, Integer type, String start, String end, String searchName) { + LocalDateTime[] range = resolveRange(type, start, end, schId); + List modules = moduleRepository.findVisible(schId); + List> columns = new ArrayList<>(); + for (ProfessionModule m : modules) { + Map c = new LinkedHashMap<>(); + c.put("id", m.getId()); + c.put("name", m.getName()); + columns.add(c); + } + Map> byTeacher = new LinkedHashMap<>(); + for (Object[] row : recordRepository.countGroupByTeacherModule(schId, range[0], range[1])) { + Integer teacherId = (Integer) row[0]; + Map r = byTeacher.computeIfAbsent(teacherId, k -> { + Map n = new LinkedHashMap<>(); + n.put("teacherId", teacherId); + n.put("teacherName", String.valueOf(row[1])); + n.put("moduleCounts", new LinkedHashMap()); + n.put("total", 0L); + return n; + }); + long cnt = ((Number) row[3]).longValue(); + @SuppressWarnings("unchecked") + Map counts = (Map) r.get("moduleCounts"); + counts.put((Integer) row[2], cnt); + r.put("total", ((Number) r.get("total")).longValue() + cnt); + } + List> rows = filterTeachers(byTeacher, searchName); + Map result = new LinkedHashMap<>(); + result.put("columns", columns); + result.put("rows", rows); + result.put("total", rows.size()); + return result; + } + + @Override + public Map moduleStat(Integer schId, Integer type, String start, String end, String searchName) { + LocalDateTime[] range = resolveRange(type, start, end, schId); + Map names = moduleNameMap(schId); + List> rows = new ArrayList<>(); + long total = 0; + for (Object[] row : recordRepository.countGroupByModule(schId, range[0], range[1])) { + Integer moduleId = (Integer) row[0]; + String name = names.getOrDefault(moduleId, "栏目" + moduleId); + if (StringUtils.hasText(searchName) && !name.contains(searchName)) { + continue; + } + long cnt = ((Number) row[1]).longValue(); + total += cnt; + Map r = new LinkedHashMap<>(); + r.put("id", moduleId); + r.put("name", name); + r.put("count", cnt); + rows.add(r); + } + rows.sort(Comparator.comparingLong(r -> -((Number) r.get("count")).longValue())); + Map result = new LinkedHashMap<>(); + result.put("rows", rows); + result.put("total", total); + return result; + } + + @Override + public Map subjectStat(Integer schId, Integer type, String start, String end, String grade) { + LocalDateTime[] range = resolveRange(type, start, end, schId); + List> rows = new ArrayList<>(); + if (StringUtils.hasText(grade)) { + Map bySubject = new LinkedHashMap<>(); + for (ProfessionRecord r : recordRepository.findAll( + recordSpec(schId, null, null, null, null, grade, range[0], range[1]))) { + String key = r.getSubject() == null ? "-" : r.getSubject(); + bySubject.merge(key, 1L, Long::sum); + } + for (Map.Entry en : bySubject.entrySet()) { + Map r = new LinkedHashMap<>(); + r.put("subject", en.getKey()); + r.put("subjectName", com.yunhai.profession.utils.ProfessionDicts.subjectName(en.getKey())); + r.put("count", en.getValue()); + rows.add(r); + } + } else { + for (Object[] row : recordRepository.countGroupBySubject(schId, range[0], range[1])) { + Map r = new LinkedHashMap<>(); + r.put("subject", row[0]); + r.put("subjectName", row[1] == null ? String.valueOf(row[0]) : String.valueOf(row[1])); + r.put("count", ((Number) row[2]).longValue()); + rows.add(r); + } + } + rows.sort(Comparator.comparingLong(r -> -((Number) r.get("count")).longValue())); + Map result = new LinkedHashMap<>(); + result.put("rows", rows); + result.put("total", rows.stream().mapToLong(r -> ((Number) r.get("count")).longValue()).sum()); + return result; + } + + @Override + public Map discussStat(Integer schId, Integer type, String start, String end, String searchName) { + LocalDateTime[] range = resolveRange(type, start, end, schId); + List recordIds = new ArrayList<>(); + Map counts = new LinkedHashMap<>(); + for (Object[] row : discussRepository.countGroupByRecord(schId, range[0], range[1])) { + recordIds.add((Integer) row[0]); + counts.put((Integer) row[0], ((Number) row[1]).longValue()); + } + Map records = new HashMap<>(); + if (!recordIds.isEmpty()) { + for (ProfessionRecord r : recordRepository.findByIdIn(recordIds)) { + records.put(r.getId(), r); + } + } + List> rows = new ArrayList<>(); + for (Map.Entry en : counts.entrySet()) { + ProfessionRecord r = records.get(en.getKey()); + if (r == null) { + continue; + } + if (StringUtils.hasText(searchName) && (r.getTitle() == null || !r.getTitle().contains(searchName)) + && (r.getTeacherName() == null || !r.getTeacherName().contains(searchName))) { + continue; + } + Map row = new LinkedHashMap<>(); + row.put("id", r.getId()); + row.put("title", r.getTitle()); + row.put("teacherId", r.getTeacherId()); + row.put("teacherName", r.getTeacherName()); + row.put("discussCount", en.getValue()); + rows.add(row); + } + rows.sort(Comparator.comparingLong(r -> -((Number) r.get("discussCount")).longValue())); + Map result = new LinkedHashMap<>(); + result.put("rows", rows); + result.put("total", rows.stream().mapToLong(r -> ((Number) r.get("discussCount")).longValue()).sum()); + return result; + } + + @Override + public Map pointsStat(Integer schId, Integer type, String start, String end, String searchName) { + LocalDateTime[] range = resolveRange(type, start, end, schId); + List modules = moduleRepository.findVisible(schId); + List> columns = new ArrayList<>(); + for (ProfessionModule m : modules) { + Map c = new LinkedHashMap<>(); + c.put("id", m.getId()); + c.put("name", m.getName()); + columns.add(c); + } + Map> byTeacher = new LinkedHashMap<>(); + for (Object[] row : recordRepository.sumPointsGroupByTeacherModule(schId, range[0], range[1])) { + Integer teacherId = (Integer) row[0]; + Map r = byTeacher.computeIfAbsent(teacherId, k -> newTeacherPointsRow(teacherId, String.valueOf(row[1]))); + long pts = row[3] == null ? 0 : ((Number) row[3]).longValue(); + @SuppressWarnings("unchecked") + Map counts = (Map) r.get("modulePoints"); + counts.put((Integer) row[2], pts); + r.put("sendPoints", ((Number) r.get("sendPoints")).longValue() + pts); + } + for (Object[] row : discussRepository.sumPointsGroupByTeacher(schId, range[0], range[1])) { + Integer teacherId = (Integer) row[0]; + Map r = byTeacher.computeIfAbsent(teacherId, k -> newTeacherPointsRow(teacherId, String.valueOf(row[1]))); + long pts = row[2] == null ? 0 : ((Number) row[2]).longValue(); + r.put("commPoints", pts); + } + List> rows = new ArrayList<>(); + for (Map r : byTeacher.values()) { + long total = ((Number) r.get("sendPoints")).longValue() + ((Number) r.get("commPoints")).longValue(); + r.put("total", total); + if (StringUtils.hasText(searchName) + && (r.get("teacherName") == null || !String.valueOf(r.get("teacherName")).contains(searchName))) { + continue; + } + rows.add(r); + } + rows.sort(Comparator.comparingLong(r -> -((Number) r.get("total")).longValue())); + Map result = new LinkedHashMap<>(); + result.put("columns", columns); + result.put("rows", rows); + result.put("total", rows.size()); + return result; + } + + @Override + public Map statDetail(Integer schId, Integer type, String start, String end, String searchName, + Integer moduleId, Integer teacherId, String subject, + int pageIndex, int pageSize) { + LocalDateTime[] range = resolveRange(type, start, end, schId); + Specification spec = recordSpec(schId, moduleId, teacherId, + StringUtils.hasText(subject) ? subject : null, + StringUtils.hasText(searchName) ? searchName : null, null, range[0], range[1]); + Page page = recordRepository.findAll(spec, + PageRequest.of(Math.max(pageIndex - 1, 0), pageSize <= 0 ? 10 : pageSize, + Sort.by(Sort.Direction.DESC, "createTime", "id"))); + Map names = moduleNameMap(schId); + List> rows = new ArrayList<>(); + for (ProfessionRecord r : page.getContent()) { + Map row = new LinkedHashMap<>(); + row.put("id", r.getId()); + row.put("moduleId", r.getModuleId()); + row.put("moduleName", names.get(r.getModuleId())); + row.put("title", r.getTitle()); + row.put("gradeName", r.getGradeName()); + row.put("subjectName", r.getSubjectName()); + row.put("teacherId", r.getTeacherId()); + row.put("teacherName", r.getTeacherName()); + row.put("readCount", r.getReadCount()); + row.put("createTime", r.getCreateTime()); + rows.add(row); + } + Map result = new LinkedHashMap<>(); + result.put("total", page.getTotalElements()); + result.put("rows", rows); + return result; + } + + // ==================== 导出 / 打包 ==================== + + @Override + public void exportExcel(Integer schId, int excelType, Integer type, String start, String end, + String searchName, String grade, HttpServletResponse response) { + List> head = new ArrayList<>(); + List> rows = new ArrayList<>(); + switch (excelType) { + case 1: { + Map data = teacherStat(schId, type, start, end, searchName); + head.add(row("排名", "教师", "合计")); + for (Map c : castColumns(data)) { + head.get(0).add(String.valueOf(c.get("name"))); + } + int rank = 1; + for (Map r : castRows(data)) { + List line = new ArrayList<>(); + line.add(rank++); + line.add(r.get("teacherName")); + line.add(r.get("total")); + @SuppressWarnings("unchecked") + Map counts = (Map) r.get("moduleCounts"); + for (Map c : castColumns(data)) { + Integer moduleId = ((Number) c.get("id")).intValue(); + line.add(counts.getOrDefault(moduleId, 0L)); + } + rows.add(line); + } + break; + } + case 2: { + head.add(row("栏目", "记录数")); + Map data = moduleStat(schId, type, start, end, searchName); + for (Map r : castRows(data)) { + rows.add(row(r.get("name"), r.get("count"))); + } + break; + } + case 3: { + head.add(row("学科", "记录数")); + Map data = subjectStat(schId, type, start, end, grade); + for (Map r : castRows(data)) { + rows.add(row(r.get("subjectName"), r.get("count"))); + } + break; + } + case 4: { + head.add(row("标题", "教师", "评论数")); + Map data = discussStat(schId, type, start, end, searchName); + for (Map r : castRows(data)) { + rows.add(row(r.get("title"), r.get("teacherName"), r.get("discussCount"))); + } + break; + } + default: { + Map data = pointsStat(schId, type, start, end, searchName); + head.add(row("排名", "教师", "发布积分", "评论积分", "合计")); + for (Map c : castColumns(data)) { + head.get(0).add(String.valueOf(c.get("name"))); + } + int rank = 1; + for (Map r : castRows(data)) { + List line = new ArrayList<>(); + line.add(rank++); + line.add(r.get("teacherName")); + line.add(r.get("sendPoints")); + line.add(r.get("commPoints")); + line.add(r.get("total")); + @SuppressWarnings("unchecked") + Map points = (Map) r.get("modulePoints"); + for (Map c : castColumns(data)) { + Integer moduleId = ((Number) c.get("id")).intValue(); + line.add(points.getOrDefault(moduleId, 0L)); + } + rows.add(line); + } + break; + } + } + try { + String fileName = URLEncoder.encode(statName(excelType) + ".xlsx", "UTF-8"); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf-8"); + response.setHeader("Content-Disposition", "attachment; filename=" + fileName); + EasyExcel.write(response.getOutputStream()).head(head).sheet(statName(excelType)).doWrite(rows); + } catch (IOException e) { + throw new IllegalStateException("导出失败: " + e.getMessage(), e); + } + } + + @Override + public void attachmentZipDown(Integer schId, Integer type, String start, String end, String searchName, + Integer moduleId, Integer teacherId, String subject, HttpServletResponse response) { + LocalDateTime[] range = resolveRange(type, start, end, schId); + Specification spec = recordSpec(schId, moduleId, teacherId, + StringUtils.hasText(subject) ? subject : null, + StringUtils.hasText(searchName) ? searchName : null, null, range[0], range[1]); + List records = recordRepository.findAll(spec, + PageRequest.of(0, 500, Sort.by(Sort.Direction.DESC, "createTime"))).getContent(); + List files = new ArrayList<>(); + for (ProfessionRecord r : records) { + files.addAll(upFileRepository.findByTopicTypeAndTopicId("profession", r.getId())); + } + if (files.isEmpty()) { + throw new IllegalArgumentException("筛选结果没有可下载的附件"); + } + fileService.downloadZip(files, "专业发展附件", response); + } + + @Override + public Map defaultRange() { + LocalDate today = LocalDate.now(); + Map result = new LinkedHashMap<>(); + result.put("startTime", today.minusMonths(1).format(DAY_FMT)); + result.put("endTime", today.format(DAY_FMT)); + return result; + } + + // ==================== 局端跨校统计 ==================== + + @Override + public Map schoolOverview(Integer type, String start, String end) { + LocalDateTime[] range = resolveRange(type, start, end, null); + Map bySchool = new LinkedHashMap<>(); + for (Object[] row : recordRepository.countGroupBySchool(range[0], range[1])) { + bySchool.computeIfAbsent((Integer) row[0], k -> new long[2])[0] = ((Number) row[1]).longValue(); + } + for (Object[] row : discussRepository.countGroupBySchool(range[0], range[1])) { + bySchool.computeIfAbsent((Integer) row[0], k -> new long[2])[1] = ((Number) row[1]).longValue(); + } + long zrs = 0; + long totalDiscusses = 0; + List> schools = new ArrayList<>(); + for (Map.Entry en : bySchool.entrySet()) { + zrs += en.getValue()[0]; + totalDiscusses += en.getValue()[1]; + Map r = new LinkedHashMap<>(); + r.put("schId", en.getKey()); + r.put("recordCount", en.getValue()[0]); + r.put("discussCount", en.getValue()[1]); + schools.add(r); + } + Map result = new LinkedHashMap<>(); + result.put("zrs", zrs); + result.put("totalDiscusses", totalDiscusses); + result.put("schools", schools); + return result; + } + + @Override + public Map schoolTeacherStat(Integer schId, Integer type, String start, String end) { + LocalDateTime[] range = resolveRange(type, start, end, schId); + Map> rows = new LinkedHashMap<>(); + for (Object[] row : recordRepository.countGroupBySchoolTeacher(schId, range[0], range[1])) { + String key = row[0] + "-" + row[1]; + Map r = rows.computeIfAbsent(key, k -> { + Map n = new LinkedHashMap<>(); + n.put("schId", row[0]); + n.put("teacherId", row[1]); + n.put("teacherName", row[2]); + n.put("recordCount", 0L); + n.put("discussCount", 0L); + return n; + }); + r.put("recordCount", ((Number) row[3]).longValue()); + } + for (Object[] row : discussRepository.countGroupBySchoolTeacher(schId, range[0], range[1])) { + Map r = rows.get(row[0] + "-" + row[1]); + if (r != null) { + r.put("discussCount", ((Number) row[3]).longValue()); + } + } + Map result = new LinkedHashMap<>(); + result.put("rows", new ArrayList<>(rows.values())); + return result; + } + + @Override + public Map schoolModuleStat(Integer schId, Integer type, String start, String end) { + LocalDateTime[] range = resolveRange(type, start, end, schId); + List> rows = new ArrayList<>(); + for (Object[] row : recordRepository.countGroupBySchoolModule(schId, range[0], range[1])) { + Map r = new LinkedHashMap<>(); + r.put("schId", row[0]); + r.put("moduleId", row[1]); + r.put("count", ((Number) row[2]).longValue()); + rows.add(r); + } + Map result = new LinkedHashMap<>(); + result.put("rows", rows); + return result; + } + + @Override + public Map schoolSubjectStat(Integer schId, Integer type, String start, String end) { + LocalDateTime[] range = resolveRange(type, start, end, schId); + List> rows = new ArrayList<>(); + for (Object[] row : recordRepository.countGroupBySchoolSubject(schId, range[0], range[1])) { + Map r = new LinkedHashMap<>(); + r.put("schId", row[0]); + r.put("subject", row[1]); + r.put("subjectName", row[2] == null ? String.valueOf(row[1]) : String.valueOf(row[2])); + r.put("count", ((Number) row[3]).longValue()); + rows.add(r); + } + Map result = new LinkedHashMap<>(); + result.put("rows", rows); + return result; + } + + @Override + public Map schoolPointsStat(Integer schId, Integer type, String start, String end) { + LocalDateTime[] range = resolveRange(type, start, end, schId); + Map> rows = new LinkedHashMap<>(); + for (Object[] row : recordRepository.sumPointsGroupBySchoolTeacher(schId, range[0], range[1])) { + String key = row[0] + "-" + row[1]; + Map r = rows.computeIfAbsent(key, k -> { + Map n = new LinkedHashMap<>(); + n.put("schId", row[0]); + n.put("teacherId", row[1]); + n.put("teacherName", row[2]); + n.put("sendPoints", 0L); + n.put("commPoints", 0L); + return n; + }); + r.put("sendPoints", row[3] == null ? 0L : ((Number) row[3]).longValue()); + } + for (Object[] row : discussRepository.sumPointsGroupBySchoolTeacher(schId, range[0], range[1])) { + Map r = rows.get(row[0] + "-" + row[1]); + if (r != null) { + r.put("commPoints", row[3] == null ? 0L : ((Number) row[3]).longValue()); + } + } + List> list = new ArrayList<>(rows.values()); + for (Map r : list) { + r.put("total", ((Number) r.get("sendPoints")).longValue() + ((Number) r.get("commPoints")).longValue()); + } + list.sort(Comparator.comparingLong(r -> -((Number) r.get("total")).longValue())); + Map result = new LinkedHashMap<>(); + result.put("rows", list); + return result; + } + + // ==================== 内部方法 ==================== + + /** + * 时间维度解析:0 累计 / 1 今日 / 2 本周 / 3 本月 / 4 本学期(取学期设置,缺省自然年)/ 5 自定义。 + */ + private LocalDateTime[] resolveRange(Integer type, String start, String end, Integer schId) { + int t = type == null ? 0 : type; + LocalDateTime now = LocalDateTime.now(); + LocalDate today = now.toLocalDate(); + switch (t) { + case 1: + return new LocalDateTime[]{today.atStartOfDay(), today.plusDays(1).atStartOfDay()}; + case 2: + return new LocalDateTime[]{today.with(DayOfWeek.MONDAY).atStartOfDay(), + today.with(DayOfWeek.MONDAY).plusWeeks(1).atStartOfDay()}; + case 3: + return new LocalDateTime[]{today.withDayOfMonth(1).atStartOfDay(), + today.withDayOfMonth(1).plusMonths(1).atStartOfDay()}; + case 4: + LocalDateTime[] semester = semesterRange(schId, now); + if (semester != null) { + return semester; + } + return new LocalDateTime[]{today.withDayOfYear(1).atStartOfDay(), null}; + case 5: + return new LocalDateTime[]{parseDay(start, true), parseDay(end, false)}; + default: + return new LocalDateTime[]{null, null}; + } + } + + private LocalDateTime[] semesterRange(Integer schId, LocalDateTime now) { + List terms = schId == null ? new ArrayList<>() + : termSettingRepository.findBySchIdOrderBySchoolYearDescTermTypeAsc(schId); + for (TermSetting t : terms) { + if (t.getTermStartTime() != null && t.getTermEndTime() != null + && !now.isBefore(t.getTermStartTime()) && !now.isAfter(t.getTermEndTime())) { + return new LocalDateTime[]{t.getTermStartTime(), t.getTermEndTime()}; + } + } + return null; + } + + private LocalDateTime parseDay(String s, boolean startOfDay) { + if (!StringUtils.hasText(s)) { + return null; + } + try { + LocalDate d = LocalDate.parse(s.trim().substring(0, 10), DAY_FMT); + return startOfDay ? d.atStartOfDay() : d.plusDays(1).atStartOfDay(); + } catch (Exception e) { + return null; + } + } + + private Specification recordSpec(Integer schId, Integer moduleId, Integer teacherId, + String subject, String keyword, String grade, + LocalDateTime start, LocalDateTime end) { + return (root, cq, cb) -> { + List ps = new ArrayList<>(); + ps.add(cb.equal(root.get("schId"), schId)); + if (moduleId != null) { + ps.add(cb.equal(root.get("moduleId"), moduleId)); + } + if (teacherId != null) { + ps.add(cb.equal(root.get("teacherId"), teacherId)); + } + if (StringUtils.hasText(subject)) { + ps.add(cb.equal(root.get("subject"), subject)); + } + if (StringUtils.hasText(grade)) { + ps.add(cb.equal(root.get("grade"), grade)); + } + if (StringUtils.hasText(keyword)) { + ps.add(cb.like(root.get("title"), "%" + keyword.trim() + "%")); + } + if (start != null) { + ps.add(cb.greaterThanOrEqualTo(root.get("createTime"), start)); + } + if (end != null) { + ps.add(cb.lessThan(root.get("createTime"), end)); + } + return cb.and(ps.toArray(new Predicate[0])); + }; + } + + private List> filterTeachers(Map> byTeacher, String searchName) { + List> rows = new ArrayList<>(); + for (Map r : byTeacher.values()) { + if (StringUtils.hasText(searchName) + && (r.get("teacherName") == null || !String.valueOf(r.get("teacherName")).contains(searchName))) { + continue; + } + rows.add(r); + } + rows.sort(Comparator.comparingLong(r -> -((Number) r.get("total")).longValue())); + int rank = 1; + for (Map r : rows) { + r.put("rank", rank++); + } + return rows; + } + + private Map newTeacherPointsRow(Integer teacherId, String teacherName) { + Map n = new LinkedHashMap<>(); + n.put("teacherId", teacherId); + n.put("teacherName", teacherName); + n.put("modulePoints", new LinkedHashMap()); + n.put("sendPoints", 0L); + n.put("commPoints", 0L); + return n; + } + + private Map moduleNameMap(Integer schId) { + Map map = new HashMap<>(); + for (ProfessionModule m : moduleRepository.findVisible(schId)) { + map.put(m.getId(), m.getName()); + } + return map; + } + + @SuppressWarnings("unchecked") + private List> castRows(Map data) { + return (List>) data.get("rows"); + } + + @SuppressWarnings("unchecked") + private List> castColumns(Map data) { + Object columns = data.get("columns"); + return columns == null ? new ArrayList<>() : (List>) columns; + } + + private static String statName(int excelType) { + switch (excelType) { + case 1: + return "教师个人统计"; + case 2: + return "栏目统计"; + case 3: + return "学科统计"; + case 4: + return "评论统计"; + default: + return "积分统计"; + } + } + + private static List row(String... cells) { + return new ArrayList<>(java.util.Arrays.asList(cells)); + } + + private static List row(Object... cells) { + return new ArrayList<>(java.util.Arrays.asList(cells)); + } +} diff --git a/src/main/java/com/yunhai/profession/service/impl/SysOperationLogService.java b/src/main/java/com/yunhai/profession/service/impl/SysOperationLogService.java new file mode 100644 index 0000000..376315d --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/impl/SysOperationLogService.java @@ -0,0 +1,73 @@ +package com.yunhai.profession.service.impl; + +import com.yunhai.profession.aspect.SysOperationLogAspect; +import com.yunhai.profession.entity.SysOperationLog; +import com.yunhai.profession.model.BasePageQuery; +import com.yunhai.profession.model.BasePageResult; +import com.yunhai.profession.repository.SysOperationLogRepository; +import com.yunhai.profession.service.ISysOperationLogService; +import com.yunhai.profession.utils.PageUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import javax.persistence.criteria.Predicate; +import java.util.ArrayList; +import java.util.List; + +/** + * 系统审计日志业务实现:@Async 异步落库,失败仅记日志不抛出。 + */ +@Service +public class SysOperationLogService implements ISysOperationLogService { + + private static final Logger log = LoggerFactory.getLogger(SysOperationLogService.class); + + @Autowired + private SysOperationLogRepository repository; + + @Override + @Async + public void saveAsync(SysOperationLog entity) { + // 不加 @Transactional:save() 单条自原子;若外层事务在异常后提交 rollback-only + // 会抛 UnexpectedRollbackException 逃出 try/catch,被异步线程兜底处理器当成未捕获异常 + try { + repository.save(entity); + } catch (Exception e) { + log.warn("[saveAsync] 审计日志落库失败, uri={}", entity == null ? null : entity.getRequestUri()); + } + } + + @Override + public BasePageResult page(BasePageQuery query, String operationType, Integer statusCode) { + Specification spec = (root, cq, cb) -> { + List ps = new ArrayList<>(); + if (StringUtils.hasText(operationType)) { + ps.add(cb.equal(root.get("operationType"), operationType)); + } + if (statusCode != null) { + ps.add(cb.equal(root.get("statusCode"), statusCode)); + } + if (StringUtils.hasText(query.getKeyword())) { + String kw = "%" + query.getKeyword().trim() + "%"; + Predicate uriLike = cb.like(root.get("requestUri"), kw); + Predicate nameLike = cb.like(root.get("userName"), kw); + Predicate methodLike = cb.like(root.get("classMethod"), kw); + ps.add(cb.or(uriLike, nameLike, methodLike)); + } + return cb.and(ps.toArray(new Predicate[0])); + }; + Pageable pageable = PageUtils.toPageable(query, Sort.by(Sort.Direction.DESC, "createTime")); + Page page = repository.findAll(spec, pageable); + page.getContent().forEach(e -> e.setResponseData(null)); + return PageUtils.toResult(page); + } +} diff --git a/src/main/java/com/yunhai/profession/service/impl/UserCenterService.java b/src/main/java/com/yunhai/profession/service/impl/UserCenterService.java new file mode 100644 index 0000000..daa23ce --- /dev/null +++ b/src/main/java/com/yunhai/profession/service/impl/UserCenterService.java @@ -0,0 +1,306 @@ +package com.yunhai.profession.service.impl; + +import com.yunhai.profession.model.SchTeaList; +import com.yunhai.profession.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.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.stream.Collectors; + +/** + * 用户中心门面服务实现。 + *

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

+ */ +@Service +public class UserCenterService implements IUserCenterService { + + private static final Logger log = LoggerFactory.getLogger(UserCenterService.class); + + /** 学生组(UserBase GroupTypeEnum.STUDENT_GROUP),组树中剔除 */ + private static final int GROUP_TYPE_STUDENT = 8; + + /** 班级管理服务基址(yh.open.bschclass) */ + @Value("${yh.open.bschclass:}") + private String bschClassHost; + + private final RestTemplate restTemplate; + + { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(10000); + this.restTemplate = new RestTemplate(factory); + } + + /** 拉取学校全部教师(bschclass /oapi/DbClass/GetSchteaList) */ + private List getSchTeaList(Integer schId) { + if (!StringUtils.hasText(bschClassHost)) { + log.warn("[getSchTeaList] 班级管理接口未配置(yh.open.bschclass)"); + return Collections.emptyList(); + } + String url = bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId; + try { + ResponseEntity> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>() { + }); + List list = resp.getBody(); + log.info("[getSchTeaList] schId={}, 获取教师 {} 人", schId, list == null ? 0 : list.size()); + return list != null ? list : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[getSchTeaList] 请求班级管理接口失败, schId={}, url={}", schId, url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取教师数据失败"); + } + } + + // ============================ 学校组织树 ============================ + + @Override + public List> getSchoolAllTree(Integer schId) { + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + + // 1. userbase 组树(整棵学校子树),剔除学生组 + List> flatNodes = new ArrayList<>(); + BoaGroup root = YschBaseOpenApi.getGroupById(schId, true); + if (root == null) { + log.warn("[getSchoolAll] 用户中心未找到学校组, schId={}", schId); + return Collections.emptyList(); + } + flattenGroup(root, 0, flatNodes); + + // 2. bschclass 全部教师,按 GroupIds 挂到对应组下;无有效组的兜底挂学校根节点 + Set treeGroupIds = new HashSet<>(); + for (Map node : flatNodes) { + treeGroupIds.add((Integer) node.get("id")); + } + Map teacherMap = new LinkedHashMap<>(); + Map> teacherPids = new LinkedHashMap<>(); + List teachers = getSchTeaList(schId); + for (SchTeaList tea : teachers) { + if (tea.getUserTeaSerID() == null) { + continue; + } + // 同一教师多行(多组/接口重复数据)合并,按 (教师, 组) 去重 + teacherMap.putIfAbsent(tea.getUserTeaSerID(), tea); + // 接口现行字段为单个 GroupID,兼容旧字段 GroupIds + List teaGroupIds = tea.getGroupIds(); + if ((teaGroupIds == null || teaGroupIds.isEmpty()) && tea.getGroupId() != null) { + teaGroupIds = Collections.singletonList(tea.getGroupId()); + } + if (teaGroupIds != null) { + for (Integer gid : teaGroupIds) { + if (gid != null && treeGroupIds.contains(gid)) { + teacherPids.computeIfAbsent(tea.getUserTeaSerID(), k -> new HashSet<>()).add(gid); + } + } + } + } + for (Map.Entry entry : teacherMap.entrySet()) { + int teaId = entry.getKey(); + SchTeaList tea = entry.getValue(); + Set pids = teacherPids.get(teaId); + if (pids == null || pids.isEmpty()) { + // 教师所属组均被剔除(如学生组)或无组信息,挂学校根节点 + pids = new HashSet<>(Collections.singletonList(treeGroupIds.contains(schId) ? schId : 0)); + } + for (Integer pid : pids) { + Map node = new LinkedHashMap<>(); + node.put("id", teaId); + node.put("name", tea.getTeaName() != null ? tea.getTeaName() : ""); + node.put("pid", pid); + node.put("no", tea.getTeaNo() != null ? tea.getTeaNo() : 0); + node.put("type", 2); + node.put("isParent", false); + node.put("photo", ""); + flatNodes.add(node); + } + } + + // 3. 扁平节点递归组装为树 + return formatUserTree(flatNodes, 0); + } + + /** + * 将 BoaGroup 子树扁平化为组节点列表(type=1);学生组(type=8)整棵剔除。 + * isParent=true 标识组节点,前端 ztree 据此渲染为文件夹。 + */ + private void flattenGroup(BoaGroup group, int pid, List> nodes) { + if (group == null || group.getId() == null) { + return; + } + if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) { + return; + } + Map node = new LinkedHashMap<>(); + node.put("id", group.getId()); + node.put("name", group.getName() != null ? group.getName() : ""); + node.put("pid", pid); + node.put("no", group.getSort() != null ? group.getSort() : 0); + node.put("type", 1); + node.put("isParent", true); + node.put("photo", ""); + nodes.add(node); + + if (group.getChildren() != null) { + for (BoaGroup child : group.getChildren()) { + flattenGroup(child, group.getId(), nodes); + } + } + } + + /** + * 递归构建用户树(对应 C# HttpUserApi.FormatUserTree)。 + * 根层取 pid=0 或父节点不在列表中的节点;type=1 且无子节点的空组剔除。 + */ + private List> formatUserTree(List> treeList, int pid) { + List> data; + if (pid == 0) { + Set allIds = new HashSet<>(); + for (Map n : treeList) { + allIds.add((Integer) n.get("id")); + } + data = new ArrayList<>(); + for (Map n : treeList) { + int p = (Integer) n.get("pid"); + if (p == 0 || !allIds.contains(p)) { + data.add(n); + } + } + } else { + data = new ArrayList<>(); + for (Map n : treeList) { + if ((Integer) n.get("pid") == pid) { + data.add(n); + } + } + } + + List> result = new ArrayList<>(); + for (Map item : data) { + int itemId = (Integer) item.get("id"); + int itemType = (Integer) item.get("type"); + + List> children = formatUserTree(treeList, itemId); + + // type=1(组) 且无子节点 → 不加入(C# 逻辑) + if (itemType == 1 && children.isEmpty()) { + continue; + } + + Map node = new LinkedHashMap<>(item); + // 仅组节点携带 children;叶子不带该字段,避免 ztree 误判 isParent + if (itemType == 1) { + node.put("children", children); + } + result.add(node); + } + + // 按 no 排序(数字优先,无法解析的排最后) + result.sort(Comparator.comparingInt(n -> { + Object no = n.get("no"); + if (no instanceof Integer) { + return (Integer) no; + } + if (no instanceof String) { + try { + return Integer.parseInt((String) no); + } catch (NumberFormatException e) { + return 9999; + } + } + return 9999; + })); + + return result; + } + + // ============================ 组内用户 ============================ + + @Override + public List> getGroupUser(Integer gid, Integer role) { + if (gid == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "组ID不得为空"); + } + log.info("[getGroupUser] gid={}, role={}, 用户中心组内用户开放接口尚未接入", gid, role); + // 显式报错而非静默返回空列表:原空桩会让前端误以为组内无用户 + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "组内用户查询接口暂未接入用户中心,请联系管理员配置后重试"); + } + + // ============================ 用户模块树 ============================ + + @Override + public List> getUserModuleTree(Integer userId, Integer schId) { + if (userId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空"); + } + List modules = YschBaseOpenApi.getUserModule(userId, schId); + if (modules == null || modules.isEmpty()) { + return Collections.emptyList(); + } + // 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望 + return buildModuleTree(modules); + } + + /** + * 将扁平模块列表组装为树(对应原 NoticeController.buildModuleTree)。 + * 顶层取 parentId=0 或父节点不在列表中的节点,子节点按 orderNumber 排序。 + */ + private List> buildModuleTree(List modules) { + Map moduleMap = modules.stream() + .collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a)); + + // 每个模块节点(含 children 占位) + Map> nodeMap = new LinkedHashMap<>(); + for (BoaModule m : modules) { + Map node = new LinkedHashMap<>(); + node.put("id", m.getId()); + node.put("modId", m.getId()); + node.put("name", m.getName()); + node.put("parentId", m.getParentId() != null ? m.getParentId() : 0); + node.put("page", m.getPath() != null ? m.getPath() : ""); + node.put("level", m.getLevel() != null ? m.getLevel() : 0); + node.put("icon", m.getIcon() != null ? m.getIcon() : ""); + node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0); + node.put("usable", true); + node.put("children", new ArrayList<>()); + nodeMap.put(m.getId(), node); + } + + // 挂父子关系 + List> roots = new ArrayList<>(); + for (BoaModule m : modules) { + Map node = nodeMap.get(m.getId()); + int parentId = m.getParentId() != null ? m.getParentId() : 0; + if (parentId == 0 || !moduleMap.containsKey(parentId)) { + roots.add(node); + } else { + @SuppressWarnings("unchecked") + List> children = (List>) nodeMap.get(parentId).get("children"); + children.add(node); + } + } + + // 保持 SDK(用户中心)返回顺序,不再按 orderNumber 重排 + return roots; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/profession/utils/GzipUtil.java b/src/main/java/com/yunhai/profession/utils/GzipUtil.java new file mode 100644 index 0000000..cada437 --- /dev/null +++ b/src/main/java/com/yunhai/profession/utils/GzipUtil.java @@ -0,0 +1,27 @@ +package com.yunhai.profession.utils; + +import java.io.ByteArrayOutputStream; +import java.util.zip.GZIPOutputStream; + +/** + * GZIP 压缩工具(审计日志响应体压缩落库用)。 + */ +public class GzipUtil { + + private GzipUtil() { + } + + public static byte[] compress(String data) { + if (data == null || data.isEmpty()) { + return new byte[0]; + } + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); + GZIPOutputStream gzip = new GZIPOutputStream(bos)) { + gzip.write(data.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + gzip.finish(); + return bos.toByteArray(); + } catch (Exception e) { + return new byte[0]; + } + } +} diff --git a/src/main/java/com/yunhai/profession/utils/IpUtils.java b/src/main/java/com/yunhai/profession/utils/IpUtils.java new file mode 100644 index 0000000..9a0c965 --- /dev/null +++ b/src/main/java/com/yunhai/profession/utils/IpUtils.java @@ -0,0 +1,44 @@ +package com.yunhai.profession.utils; + +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.servlet.http.HttpServletRequest; + +/** + * 客户端 IP 解析(移植自 UserclassService OtherUtil.getClientIp)。 + * 按 X-Forwarded-For 等代理头链路取真实 IP,多级代理取第一个。 + */ +public class IpUtils { + + private IpUtils() { + } + + public static String getClientIp() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return "unknown"; + } + HttpServletRequest request = attributes.getRequest(); + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_CLIENT_IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } +} diff --git a/src/main/java/com/yunhai/profession/utils/PageUtils.java b/src/main/java/com/yunhai/profession/utils/PageUtils.java new file mode 100644 index 0000000..c3567ba --- /dev/null +++ b/src/main/java/com/yunhai/profession/utils/PageUtils.java @@ -0,0 +1,37 @@ +package com.yunhai.profession.utils; + +import com.yunhai.profession.model.BasePageQuery; +import com.yunhai.profession.model.BasePageResult; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +/** + * 分页辅助类:BasePageQuery <-> Pageable,Page <-> BasePageResult。 + */ +public class PageUtils { + + private PageUtils() { + } + + public static Pageable toPageable(BasePageQuery query, Sort sort) { + int page = Math.max(query.getPageIndex() - 1, 0); + int size = Math.max(query.getPageSize(), 1); + if (sort == null) { + sort = Sort.unsorted(); + } + return PageRequest.of(page, size, sort); + } + + public static BasePageResult toResult(Page page) { + return new BasePageResult( + page.getContent(), + (int) page.getTotalElements(), + page.getNumber() + 1, + page.getSize(), + page.getTotalPages(), + page.hasNext() + ); + } +} diff --git a/src/main/java/com/yunhai/profession/utils/ProfessionContext.java b/src/main/java/com/yunhai/profession/utils/ProfessionContext.java new file mode 100644 index 0000000..b2c1f74 --- /dev/null +++ b/src/main/java/com/yunhai/profession/utils/ProfessionContext.java @@ -0,0 +1,79 @@ +package com.yunhai.profession.utils; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.profession.enums.UserType; + +/** + * 当前请求上下文取值助手:统一从 BoaUser / UserContext 取当前学校、教师身份。 + *

业务实体一律存教师 id(平台用户 id),不存账号。

+ */ +public class ProfessionContext { + + private ProfessionContext() { + } + + /** 当前学校(BoaUser 优先,回退 UserContext;超管为 0) */ + public static Integer currentSchId(BoaUser user) { + if (user != null && user.getSchool() != null && user.getSchool().getId() != null) { + return user.getSchool().getId(); + } + return UserContext.getSchId(); + } + + /** 当前教师 id(平台用户 id) */ + public static Integer teacherId(BoaUser user) { + if (user != null && user.getId() != null) { + return user.getId(); + } + return UserContext.getUserId(); + } + + /** 当前教师姓名 */ + public static String teacherName(BoaUser user) { + if (user != null && user.getName() != null) { + return user.getName(); + } + String name = UserContext.getUserName(); + return name == null ? "" : name; + } + + /** 是否超级管理员(type=1,schId=0,可跨校统计) */ + public static boolean isSuperAdmin() { + Integer type = UserContext.getType(); + return UserType.SUPER_ADMINISTRATOR.getId().equals(type); + } + + /** CSV 转整数列表("1,2,3" → [1,2,3];空安全) */ + public static java.util.List csvToIntList(String csv) { + java.util.List list = new java.util.ArrayList<>(); + if (csv == null || csv.trim().isEmpty()) { + return list; + } + for (String s : csv.split(",")) { + String t = s.trim(); + if (!t.isEmpty()) { + try { + list.add(Integer.valueOf(t)); + } catch (NumberFormatException ignore) { + // 忽略非法片段 + } + } + } + return list; + } + + /** 整数列表转 CSV */ + public static String intListToCsv(java.util.List list) { + if (list == null || list.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (Integer v : list) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(v); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/yunhai/profession/utils/ProfessionDicts.java b/src/main/java/com/yunhai/profession/utils/ProfessionDicts.java new file mode 100644 index 0000000..c8a6329 --- /dev/null +++ b/src/main/java/com/yunhai/profession/utils/ProfessionDicts.java @@ -0,0 +1,80 @@ +package com.yunhai.profession.utils; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 年级段 / 学科静态字典(老系统取自平台 Common/reviewprocess/GetGradeList、GetSubList, + * 重构后由本服务直接返回,value 稳定、label 展示)。 + */ +public class ProfessionDicts { + + private ProfessionDicts() { + } + + /** 年级段 [{value,label}](全学段,对齐老平台 GetGradeList 按校范围的超集) */ + public static List> gradeList() { + List> list = new ArrayList<>(); + list.add(item("1", "一年级")); + list.add(item("2", "二年级")); + list.add(item("3", "三年级")); + list.add(item("4", "四年级")); + list.add(item("5", "五年级")); + list.add(item("6", "六年级")); + list.add(item("7", "预备")); + list.add(item("8", "初一")); + list.add(item("9", "初二")); + list.add(item("10", "初三")); + list.add(item("11", "高一")); + list.add(item("12", "高二")); + list.add(item("13", "高三")); + return list; + } + + /** 学科 [{subId,subName}],subId 对齐班级管理 sch_dic_subject 编码(SUB01=语文…) */ + public static List> subjectList() { + String[][] subjects = { + {"01", "语文"}, {"02", "数学"}, {"03", "英语"}, {"04", "物理"}, + {"05", "化学"}, {"06", "道德与法治"}, {"07", "历史"}, {"08", "地理"}, + {"09", "生物"}, {"10", "音乐"}, {"11", "体育与健康"}, {"12", "美术"}, + {"13", "信息技术"}, {"14", "劳动"}, {"99", "其他"}}; + List> list = new ArrayList<>(); + for (String[] s : subjects) { + Map m = new LinkedHashMap<>(); + m.put("subId", "SUB" + s[0]); + m.put("subName", s[1]); + list.add(m); + } + return list; + } + + /** 按 value 取年级段名 */ + public static String gradeName(String value) { + for (Map it : gradeList()) { + if (it.get("value").equals(value)) { + return String.valueOf(it.get("label")); + } + } + return value; + } + + /** 按 value 取学科名(兼容新旧编码:SUB01 与 01) */ + public static String subjectName(String value) { + String code = value == null ? null : (value.startsWith("SUB") ? value.substring(3) : value); + for (Map it : subjectList()) { + if (String.valueOf(it.get("subId")).equals("SUB" + code)) { + return String.valueOf(it.get("subName")); + } + } + return value; + } + + private static Map item(String value, String label) { + Map m = new LinkedHashMap<>(); + m.put("value", value); + m.put("label", label); + return m; + } +} diff --git a/src/main/java/com/yunhai/profession/utils/UserContext.java b/src/main/java/com/yunhai/profession/utils/UserContext.java new file mode 100644 index 0000000..dc7c2a3 --- /dev/null +++ b/src/main/java/com/yunhai/profession/utils/UserContext.java @@ -0,0 +1,140 @@ +package com.yunhai.profession.utils; + +import lombok.Value; + +/** + * 当前登录用户上下文(ThreadLocal 静态持有器)。 + *

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

+ */ +public class UserContext { + private static final ThreadLocal USER_ID = new ThreadLocal<>(); + private static final ThreadLocal USER_NAME = new ThreadLocal<>(); + private static final ThreadLocal ROLE_ID = new ThreadLocal<>(); + private static final ThreadLocal SCH_ID = new ThreadLocal<>(); + private static final ThreadLocal IP = new ThreadLocal<>(); + private static final ThreadLocal TYPE = new ThreadLocal<>(); + + /** 当前角色切换上下文(来自 currentrole header),家长场景 childId 即学生ID */ + private static final ThreadLocal CHILD_ID = new ThreadLocal<>(); + + public static void setUserId(Integer userId) { + USER_ID.set(userId); + } + + public static Integer getUserId() { + return USER_ID.get(); + } + + public static void setUserName(String userName) { + USER_NAME.set(userName); + } + + public static String getUserName() { + return USER_NAME.get(); + } + + public static void setRoleId(Integer roleId) { + ROLE_ID.set(roleId); + } + + public static Integer getRoleId() { + return ROLE_ID.get(); + } + + public static void setSchId(Integer schId) { + SCH_ID.set(schId); + } + + public static Integer getSchId() { + return SCH_ID.get(); + } + + public static void setIp(String ip) { + IP.set(ip); + } + + public static String getIp() { + return IP.get(); + } + + public static void setType(Integer type) { + TYPE.set(type); + } + + public static Integer getType() { + return TYPE.get(); + } + + public static void setChildId(Integer childId) { + CHILD_ID.set(childId); + } + + /** 家长端当前孩子(学生)ID;主账号/教师为 0 或 null */ + public static Integer getChildId() { + return CHILD_ID.get(); + } + + /** 用户信息快照(用于异步任务传递) */ + @Value + public static class UserInfo { + Integer userId; + String userName; + Integer roleId; + Integer schId; + String ip; + Integer type; + Integer childId; + } + + public static UserInfo getCurrentUser() { + return new UserInfo( + USER_ID.get(), + USER_NAME.get(), + ROLE_ID.get(), + SCH_ID.get(), + IP.get(), + TYPE.get(), + CHILD_ID.get() + ); + } + + public static void setUserInfo(UserInfo info) { + if (info == null) { + return; + } + USER_ID.set(info.getUserId()); + USER_NAME.set(info.getUserName()); + ROLE_ID.set(info.getRoleId()); + SCH_ID.set(info.getSchId()); + IP.set(info.getIp()); + TYPE.set(info.getType()); + CHILD_ID.set(info.getChildId()); + } + + /** + * 把当前用户快照带入子线程执行异步任务,执行完 clear。 + */ + public static Runnable wrap(Runnable task) { + UserInfo info = getCurrentUser(); + return () -> { + try { + setUserInfo(info); + task.run(); + } finally { + clear(); + } + }; + } + + public static void clear() { + USER_ID.remove(); + USER_NAME.remove(); + ROLE_ID.remove(); + SCH_ID.remove(); + IP.remove(); + TYPE.remove(); + CHILD_ID.remove(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..ac14f43 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,82 @@ +server: + port: 8107 + +spring: + mvc: + date-format: yyyy-MM-dd HH:mm:ss + jackson: + time-zone: GMT+8 + date-format: yyyy-MM-dd HH:mm:ss + + datasource: + # characterEncoding=UTF-8 + connectionCollation=utf8mb4_* 保证连接层按 utf8mb4 传输, + # 避免中文写入 latin1 列时报 SQL 1366 Incorrect string value + url: jdbc:mysql://localhost:3306/profession?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true + username: root + password: 123123 + driver-class-name: com.mysql.cj.jdbc.Driver + min-idle: 10 + max-active: 30 + #jpa + jpa: + database: MySQL + show-sql: true + hibernate: + # 开发期使用 update 自动建表;生产环境设置成 none + # 注意:update 建表不带 CHARSET,表字符集继承库默认值, + # 务必保证建库时用 CREATE DATABASE profession DEFAULT CHARACTER SET utf8mb4; + ddl-auto: update + # MySQL8Dialect 会输出 ENGINE=InnoDB DEFAULT CHARSET=utf8mb4(MySQL5InnoDBDialect 不会) + database-platform: org.hibernate.dialect.MySQL8Dialect + properties: + hibernate.order_by.default_null_ordering: last + open-in-view: true + + #文件上传 + servlet: + multipart: + max-file-size: 100MB + max-request-size: 100MB + + #本地关闭 rabbit + rabbitmq: + listener: + direct: + auto-startup: false + simple: + auto-startup: false + +# 文件上传存储路径(Docker 部署时与 volume 挂载一致) +file: + upload-path: D:/data/upload + +# 教师专业发展应用配置 +profession: + # 区局(教育局)学校ID:>0 时该校账号可创建全区下发栏目、访问局端跨校统计;0=未配置 + district-sch-id: 0 + +# 短信模块配置 +sms: + enabled: true + module-id: profession + +# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) +yh: + sso: + server-url-prefix: http://sso.yunhaixingtong.com + client-host-url: http://localhost:8107 + allow-headers: all + allow-origins: all + url-pattern: /api/* + mobi-api-pattern: /mobiapi/* + app-auth: + enabled: true + # 本应用自身身份(客户端签名用) + app-key: 100014 + app-secret: 785cc282bbc64b8496392cc2395590c8 + open: + enabled: true + base-host: http://base.yunhaixingtong.com + user-center-url: http://base.yunhaixingtong.com/userbase + # 班级管理(bschclass)服务基址 + bschclass: http://base.yunhaixingtong.com/userclass diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..50f1a6e --- /dev/null +++ b/src/main/resources/bootstrap.yml @@ -0,0 +1,33 @@ +spring: + application: + name: profession + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + cloud: + nacos: + config: + enabled: ${NACOS_CONFIG_ENABLED:false} + server-addr: ${NACOS_ADDR:nacos.yunhaixingtong.com:8848} + namespace: ${SPRING_CLOUD_NACOS_CONFIG_NAMESPACE:6196f0de-a28d-4894-8f10-e2982b57c6bc} + # 应用自身配置所在分组 + group: OA_APP + # 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension},即 profession.yaml + file-extension: yaml + encode: UTF-8 + # nacos 不可达时不强制退出,回退到本地 application.yml 兜底 + fail-fast: false + # 只读账号 + username: ${SPRING_CLOUD_NACOS_USERNAME:yh-reader} + password: ${SPRING_CLOUD_NACOS_PASSWORD:123456} + # 共享公共配置:public 空间下的 common.properties + shared-configs: + # ① 1 号(最低优先级):public 命名空间(namespace="" 固定写空字符串)的 application-default.yaml + - data-id: application-default.yaml + group: DEFAULT_GROUP + namespace: "" # ⭐ 固定 = "",强制指向 Nacos "public" 命名空间,不读上面的全局 prod namespace + refresh: false + + # ② 2 号(中等优先级):prod 命名空间(不写 namespace = 继承顶部全局 prod namespace id)的 application-infra.yaml + - data-id: application-infra.yaml + group: DEFAULT_GROUP + refresh: false diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..20612de --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,35 @@ + + + + + + + + + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET} + + + + + + + /app/logs/app.log + + /app/logs/app.%d{yyyy-MM-dd}.log + 30 + 5GB + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + + + + + + + + + + + \ No newline at end of file