commit
build / build-push (push) Successful in 47s

This commit is contained in:
wanghu
2026-09-30 14:43:18 +08:00
parent 8583574e01
commit 3b3eeb9f87
90 changed files with 8633 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
target/
.idea/
*.iml
*.log
.DS_Store
+55
View File
@@ -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
+9
View File
@@ -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
+204
View File
@@ -0,0 +1,204 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.yunhai</groupId>
<artifactId>profession</artifactId>
<version>0.0.1</version>
<name>Profession</name>
<description>教师专业发展子应用</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencyManagement>
<!-- 基础平台版本管理(含 yh-* 架包与 spring-boot-dependencies 对齐) -->
<dependencies>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-platform-dependencies</artifactId>
<version>1.0.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Spring Cloud Alibaba(Nacos 配置中心,对齐 Boot 2.7) -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2021.0.5.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- 仅引入 Spring Security 的加密模块(BCryptPasswordEncoder),不引入整套安全框架 -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- AOP(UserContext 填充切面 + 审计日志切面) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- Boot 2.4+ 默认不再处理 bootstrap.yml,需显式引入以继续从 Nacos 拉取配置 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
<version>3.1.8</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JSON 序列化(fastjson,主类开启安全模式) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.80</version>
</dependency>
<!-- Swagger / OpenAPI 文档(springdoc,兼容 Boot 2.7) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.7.0</version>
</dependency>
<!-- 基础平台全家桶(聚合 starter):认证(CAS/会话/SSO)+ 应用间签名鉴权 + 用户中心静态门面
+ 统一响应 + actuator 健康探活,一行带齐(模块构成见 BasePlatform/后端项目知识库.md)。
约定:标准业务服务一律引聚合 starter;平台能力提供方(如 UserBase)按需单引。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-spring-boot-starter</artifactId>
</dependency>
<!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。
版本 3.5.1 已在本地 .m2 仓库。 -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.5.1</version>
</dependency>
<!-- Excel 导入导出(EasyExcel 3.x,注解驱动、流式写入内存友好) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.2</version>
</dependency>
<!-- 不引 spring-boot-starter-data-redis / cache:代码零使用,data-redis 会自动装配
RedisHealthIndicator,连不上 redis 会把 /actuator/health 整体拉成 DOWN(503)。 -->
</dependencies>
<build>
<finalName>profession</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- 关闭增量编译,避免 Windows 下 maven-status 状态文件被锁导致
"Error while storing the mojo status / 拒绝访问" -->
<useIncrementalCompilation>false</useIncrementalCompilation>
</configuration>
</plugin>
<!-- 打可执行 fat jar(Docker 镜像直接 java -jar 启动) -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.yunhai.profession.ProfessionApplication</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>nexus-public</id>
<name>bsch/nexus public</name>
<url>http://nexus.yunhaixingtong.com/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
<!--
ci 构建档案:将构建产物重定向到 IDE(IntelliJ/Eclipse)索引目录之外,
避免 IDE 后台进程持续锁定 target/maven-status/.../createdFiles.lst 导致
"Error while storing the mojo status / 拒绝访问"。
输出目录需从未被 IDE 打开过。常规本地构建无需激活此 profile。
-->
<profiles>
<profile>
<id>ci</id>
<build>
<directory>C:/notice-build/profession</directory>
</build>
</profile>
</profiles>
</project>
@@ -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);
}
}
@@ -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)。
* <p>@Order(1) 先于审计日志切面(Order 3)执行,保证日志能取到 UserContext。
* 取用户优先级:方法参数 BoaUser → BoaUserContext 静态持有者 → request attribute;
* 角色切换信息从 currentrole(Header/Query,URL 编码 JSON)解析。</p>
*/
@Aspect
@Component
@Order(1)
public class ControllerUserContextAspect {
private static final Logger log = LoggerFactory.getLogger(ControllerUserContextAspect.class);
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 外部认证框架把解析好的 BoaUser 放进 request attribute 的候选 key */
private static final List<String> BOA_USER_ATTR_KEYS = Arrays.asList(
"boaUser", "boauser", "currentUser", "user", "authUser", "loginUser"
);
@Around("execution(* com.yunhai.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());
}
}
@@ -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)。
* <p>全量拦截 controller 包所有方法,不依赖注解;排除日志查询控制器自身(防递归);
* 用户信息来自 {@link ControllerUserContextAspect}(Order 1)填充的 UserContext;
* 响应体 GZIP 压缩落库,落库走 @Async 不阻塞请求。</p>
*/
@Aspect
@Component
@Order(3)
public class SysOperationLogAspect {
private static final Logger log = LoggerFactory.getLogger(SysOperationLogAspect.class);
/** 排除的控制器(日志查询自身,防递归刷屏) */
private static final List<String> EXCLUDE_CONTROLLERS = java.util.Arrays.asList(
"com.yunhai.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<Object> filtered = new ArrayList<>();
for (Object arg : args) {
if (arg == null) {
continue;
}
if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse
|| arg instanceof HttpSession || arg instanceof MultipartFile
|| arg instanceof MultipartFile[]) {
continue;
}
// BoaUser 等平台对象跳过序列化(无 toString 意义且可能循环引用)
if (arg.getClass().getName().startsWith("com.yunhai.platform.")) {
continue;
}
filtered.add(arg);
}
return filtered.isEmpty() ? "" : JSON.toJSONString(filtered);
} catch (Exception ex) {
return "";
}
}
private void fillResponse(SysOperationLog e, Object result, int max) {
if (result == null) {
return;
}
try {
String resp = JSON.toJSONString(result);
if (resp.length() > max) {
resp = resp.substring(0, max) + "...[截断]";
}
e.setResponseData(GzipUtil.compress(resp));
} catch (Exception ex) {
log.warn("审计日志响应序列化失败, uri={}", e.getRequestUri());
}
}
/** 成功路径:Controller 返回 ApiResponse/ApiResult 时取其 code,其余视为 SUCCESS(0) */
private int resolveSuccessCode(Object result) {
if (result instanceof ApiResponse) {
return ((ApiResponse<?>) result).getCode();
}
if (result instanceof ApiResult) {
return ((ApiResult<?>) result).getCode();
}
return ErrorCode.SUCCESS.getCode();
}
/** 异常路径:从业务异常提取错误码,兜底 INTERNAL_SERVER_ERROR */
private int resolveErrorCode(Throwable e) {
if (e instanceof DException) {
IErrorCode ec = ((DException) e).getCode();
if (ec != null) {
return ec.getCode();
}
}
return ErrorCode.INTERNAL_SERVER_ERROR.getCode();
}
private HttpServletRequest getRequest() {
ServletRequestAttributes a = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return a != null ? a.getRequest() : null;
}
private String buildErrorMsg(Throwable e) {
String msg = e.getMessage();
String head = (msg == null || msg.trim().isEmpty()) ? e.getClass().getName() : msg;
return truncate(head + System.lineSeparator() + ExceptionUtils.getStackTrace(e), MAX_ERROR_MSG);
}
private String truncate(String s, int max) {
return s != null && s.length() > max ? s.substring(0, max) : s;
}
/** 1-重要(删除) 2-次要(新增/编辑) 3-一般(查询) */
private int resolveLevel(String httpMethod) {
if ("DELETE".equalsIgnoreCase(httpMethod)) {
return 1;
}
if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod)) {
return 2;
}
return 3;
}
private String resolveOperationType(String httpMethod, String methodName) {
String lower = methodName == null ? "" : methodName.toLowerCase();
if ("DELETE".equalsIgnoreCase(httpMethod) || lower.startsWith("delete") || lower.startsWith("del")) {
return "DELETE";
}
if (lower.startsWith("create") || lower.startsWith("add")) {
return "CREATE";
}
if ("POST".equalsIgnoreCase(httpMethod) || "PUT".equalsIgnoreCase(httpMethod)
|| lower.startsWith("edit") || lower.startsWith("update") || lower.startsWith("set")) {
return "UPDATE";
}
return "QUERY";
}
}
@@ -0,0 +1,47 @@
package com.yunhai.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();
}
}
@@ -0,0 +1,10 @@
package com.yunhai.profession.common.validation;
/**
* JSR-303 分组校验标记接口(对齐 UserBase 规范)。
* Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。
*/
public interface ValidationGroups {
interface Create {}
interface Update {}
}
@@ -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";
}
@@ -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));
}
}
@@ -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"));
}
}
@@ -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/)。
* <p>承载:FileUpload / GetUserInfo / GetUserMod / GetSchoolAll / Logout。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/base/")
public class BaseApiController {
private static final Logger log = LoggerFactory.getLogger(BaseApiController.class);
@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<String, Object> 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<String, Object> 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<String, Object> 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 ============================
/**
* 获取当前用户已授权的模块列表(左侧菜单树)。
* <p>数据从基础平台用户中心透传(BoaModule.id 即 modId);
* modId 分配需与基础平台菜单树一致(见 重构任务计划.md 任务5)。</p>
*/
@GetMapping("GetUserMod")
public List<Map<String, Object>> 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<Map<String, Object>> 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("");
}
}
@@ -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<UpFileVo> uploadImage(@RequestParam("file") MultipartFile[] files) {
return fileService.uploadImages(files, TOPIC_TYPE);
}
/** 文件下载(浏览器直接 location.href 跳转) */
@GetMapping("Down")
public void down(@RequestParam("id") Integer id, HttpServletResponse response) {
fileService.download(id, response);
}
/** 文件在线预览(inline 输出) */
@GetMapping("Preview")
public void preview(@RequestParam("id") Integer id,
@RequestParam(value = "type", required = false) Integer type,
HttpServletResponse response) {
fileService.preview(id, response);
}
/** 删除文件 */
@PostMapping("Delete")
public void delete(@RequestParam("fileId") Integer fileId) {
fileService.delete(fileId);
}
/** 查询某活动下的附件列表 */
@GetMapping("List")
public List<UpFileVo> list(@RequestParam("topicId") Integer topicId) {
return fileService.listByTopic(TOPIC_TYPE, topicId);
}
/** 批量绑定附件到活动 */
@PostMapping("Bind")
public void bind(@RequestParam("fileIds") List<Integer> fileIds,
@RequestParam("topicId") Integer topicId) {
fileService.bindToTopic(fileIds, topicId);
}
}
@@ -0,0 +1,32 @@
package com.yunhai.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/)。
* <p>本控制器被 SysOperationLogAspect 排除,避免日志查询自身再产生日志(防递归)。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/log/")
public class LogController {
@Autowired
private ISysOperationLogService logService;
/** 分页查询审计日志(responseData 不返回) */
@GetMapping("Page")
public BasePageResult<SysOperationLog> page(BasePageQuery query,
@RequestParam(value = "operationType", required = false) String operationType,
@RequestParam(value = "statusCode", required = false) Integer statusCode) {
return logService.page(query, operationType, statusCode);
}
}
@@ -0,0 +1,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/):栏目、上级栏目、自定义字段。
* <p>对齐历史后台 ProfessionOld 契约(方法名沿用),返回裸对象由平台包装 {code,msg,data}。</p>
*/
@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<Map<String, Object>> mouduleList(BoaUser authUserWithDept) {
return mouduleService.listModules(currentSchId(authUserWithDept));
}
/** 级联栏目树(前台) */
@GetMapping("ModuleTree")
public List<Map<String, Object>> moduleTree(BoaUser authUserWithDept) {
return mouduleService.moduleTree(currentSchId(authUserWithDept));
}
/** 我的栏目(前台左栏) */
@GetMapping("MyModuleList")
public List<Map<String, Object>> 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<String, Object> params) {
return mouduleService.createModule(currentSchId(authUserWithDept), isDistrict(currentSchId(authUserWithDept)), params);
}
/** 编辑栏目 */
@PostMapping("EditMoudule")
public void editMoudule(BoaUser authUserWithDept, @RequestParam Map<String, Object> 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<String, Object> getdatelist(BoaUser authUserWithDept, @RequestParam("SerID") Integer serId) {
return mouduleService.moduleFieldList(currentSchId(authUserWithDept), serId);
}
// ==================== 上级栏目 ====================
@GetMapping("TopMouduleList")
public List<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> bindFieldList(BoaUser authUserWithDept) {
return mouduleService.listFields(currentSchId(authUserWithDept));
}
@PostMapping("FieldtypeCreate")
public Integer fieldtypeCreate(BoaUser authUserWithDept, @RequestParam Map<String, Object> params) {
return mouduleService.createField(currentSchId(authUserWithDept), params);
}
@PostMapping("FieldtypeEdit")
public void fieldtypeEdit(BoaUser authUserWithDept, @RequestParam Map<String, Object> 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);
}
}
@@ -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/):全局积分、积分指标树、学期设置。
* <p>对齐历史后台契约(pointstoOne/pointsEdit/TreePointsList/TermSendMessage 系列沿用)。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/points/")
public class PointsController {
@Autowired
private IPointsService pointsService;
// ==================== 全局积分 ====================
/** 全局默认积分(对齐老 pointstoOne) */
@GetMapping("pointstoOne")
public Map<String, Object> 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<Map<String, Object>> treePointsList(BoaUser authUserWithDept) {
return pointsService.pointsTree(currentSchId(authUserWithDept));
}
/** 积分来源下拉 */
@GetMapping("SoursePointsList")
public List<Map<String, Object>> soursePointsList() {
return pointsService.pointsSourceList();
}
/** 节点详情 */
@GetMapping("PointsSettingtoOne")
public Map<String, Object> pointsSettingtoOne(BoaUser authUserWithDept, @RequestParam("SerID") Integer serId) {
return pointsService.pointsDetail(currentSchId(authUserWithDept), serId);
}
/** 新增/编辑节点(SerID 空 = 新增) */
@PostMapping("PointsSettingEdit")
public Integer pointsSettingEdit(BoaUser authUserWithDept, @RequestParam Map<String, Object> 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<Map<String, Object>> termList(BoaUser authUserWithDept) {
return pointsService.termList(currentSchId(authUserWithDept));
}
/** 学期分页(对齐老 TermSendMessageList) */
@GetMapping("TermSendMessageList")
public Map<String, Object> 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<String, Object> termSendMessageDetail(BoaUser authUserWithDept, @RequestParam("SerID") Integer serId) {
return pointsService.termDetail(currentSchId(authUserWithDept), serId);
}
/** 学期编辑(SerID 空 = 新增) */
@PostMapping("TermSendMessageEdit")
public Integer termSendMessageEdit(BoaUser authUserWithDept, @RequestParam Map<String, Object> params) {
return pointsService.termEdit(currentSchId(authUserWithDept), params);
}
// ==================== 内部方法 ====================
private Integer currentSchId(BoaUser user) {
return ProfessionContext.currentSchId(user);
}
}
@@ -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/):记录(教案/学习资料)、评论、足迹、积分、排行、字典。
* <p>对齐历史前台 ProfessionPageOld 契约(方法名沿用)。</p>
*/
@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<String, Object> params) {
return professionService.createRecord(currentSchId(authUserWithDept),
teacherId(authUserWithDept), teacherName(authUserWithDept), params);
}
/** 编辑记录(仅作者) */
@PostMapping("Edit")
public void edit(BoaUser authUserWithDept, @RequestParam Map<String, Object> 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<String, Object> 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<String, Object> professionDetail(BoaUser authUserWithDept, @RequestParam("SerID") Integer serId) {
return professionService.recordDetail(currentSchId(authUserWithDept), serId, teacherId(authUserWithDept));
}
/** 某栏目文章分页(前台栏目页,附带同校其他栏目) */
@GetMapping("ModuleRecordList")
public Map<String, Object> 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<String, Object> 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<Map<String, Object>> homeModuleRecords(BoaUser authUserWithDept,
@RequestParam(value = "size", defaultValue = "6") int size) {
return professionService.homeModuleRecords(currentSchId(authUserWithDept), size);
}
/** 个人空间按年时间轴 */
@GetMapping("MyTimeline")
public List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> likeCreate(BoaUser authUserWithDept, @RequestParam("recordId") Integer recordId) {
return professionService.likeCreate(currentSchId(authUserWithDept), teacherId(authUserWithDept),
teacherName(authUserWithDept), recordId);
}
// ==================== 积分/排行/字典 ====================
/** 我的积分(传 userid = 查看该教师空间,姓名由服务端按其记录回填) */
@GetMapping("MyPoints")
public Map<String, Object> 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<Map<String, Object>> releaseRanking(BoaUser authUserWithDept,
@RequestParam(value = "top", defaultValue = "10") int top) {
return professionService.releaseRanking(currentSchId(authUserWithDept), top);
}
/** 首页刊头统计(累计记录/活跃教师/发展栏目/本周新增) */
@GetMapping("HomeStats")
public Map<String, Object> homeStats(BoaUser authUserWithDept) {
return professionService.homeStats(currentSchId(authUserWithDept));
}
/** 积分排行(发布+评论合计,前 N) */
@GetMapping("PointsRank")
public List<Map<String, Object>> pointsRank(BoaUser authUserWithDept,
@RequestParam(value = "top", defaultValue = "8") int top) {
return professionService.pointsRank(currentSchId(authUserWithDept), top);
}
/** 个人空间聚合(姓名/积分/累计记录/被访问/栏目篇数/本月概览) */
@GetMapping("SpaceOverview")
public Map<String, Object> 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<Map<String, Object>> subjectGroupTree(BoaUser authUserWithDept,
@RequestParam(value = "mouid", required = false) Integer mouid) {
return professionService.subjectGroupTree(currentSchId(authUserWithDept), mouid);
}
/** 全区发布排行(超管/区局) */
@GetMapping("AreaReleaseRanking")
public List<Map<String, Object>> areaReleaseRanking(BoaUser authUserWithDept,
@RequestParam(value = "top", defaultValue = "10") int top) {
requireCrossSchool(currentSchId(authUserWithDept));
Map<String, Object> data = statsService.schoolTeacherStat(null, 0, null, null);
@SuppressWarnings("unchecked")
List<Map<String, Object>> rows = (List<Map<String, Object>>) 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<Map<String, Object>> gradeList() {
return ProfessionDicts.gradeList();
}
/** 学科字典([{subId,subName}],subId 对齐班级管理 SUB01 编码) */
@GetMapping("GetSubList")
public List<Map<String, Object>> subjectList() {
return ProfessionDicts.subjectList();
}
/** 行政区划子级(省市级联懒加载,parentCode 不传默认取省;对齐自定义字段 province_city) */
@GetMapping("GetAreaList")
public List<Map<String, Object>> areaList(
@RequestParam(value = "parentCode", required = false) String parentCode) {
return areaService.listChildren(parentCode);
}
/** 行政区划树(省→市→区县,按 level 裁剪;前端 Cascader 一次性取数用) */
@GetMapping("GetAreaTree")
public List<Map<String, Object>> 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<String, Object> isDistrictAccount(BoaUser authUserWithDept) {
Map<String, Object> 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);
}
}
@@ -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 类统计 + 明细 + 导出 + 附件打包 + 局端跨校统计。
* <p>校级统计限本校;局端接口(School 前缀/GetSchool 系)需超管或区局账号。</p>
*/
@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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> getTime() {
return statsService.defaultRange();
}
// ==================== 局端跨校统计(超管/区局) ====================
/** 学校总览(记录+评论按校分组,对齐 StatisticsMap 归并) */
@GetMapping("StatisticsMap")
public Map<String, Object> 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<Map<String, Object>> 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<String, Object> overview = statsService.schoolOverview(rqtype, kssj, jssj);
@SuppressWarnings("unchecked")
List<Map<String, Object>> schools = (List<Map<String, Object>>) overview.get("schools");
List<Map<String, Object>> rows = new ArrayList<>();
for (Map<String, Object> s : schools) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> statisticsMoudels() {
List<Map<String, Object>> 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<String, Object> dim(int value, String text) {
Map<String, Object> 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);
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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)」取数。
* <p>编码采用 GB/T 2260(6 位):省 2 位有效(补 0000,如 440000)、市 4 位有效(440300)、
* 区县 6 位(440305);直辖市的市级节点用「市辖区」占位(code 为 XX0100)。</p>
* <p>注意:本表是<b>行政区划</b>字典,与项目里的 districtSchId(区级教育局账号)无关,勿混用。</p>
* <p>层级 level:1 省 / 2 市 / 3 区县;parent_code 为上级 code(省为 "0")。</p>
*/
@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;
}
@@ -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。
* <p>只存评论教师 id,不保存账号。</p>
*/
@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;
}
@@ -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;
}
@@ -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;
}
@@ -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。
* <p>sch_id 即创建方(区局账号创建的栏目 sch_id 为区局);is_shared=1 时对全部学校可见,
* 查看方通过 ownerSchId != 当前 schId 判定只读(对齐老契约 SchSerID != userSchSerID)。</p>
*/
@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;
}
@@ -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。
* <p>只存教师 id(teacher_id = 平台用户 id),不保存账号;teacher_name 仅为展示冗余。</p>
*/
@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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,33 @@
package com.yunhai.profession.model;
import lombok.Data;
import java.util.List;
/**
* 统一分页响应外壳。
*/
@Data
public class BasePageResult<T> {
/** 总记录数 */
private int total;
/** 当前页数据 */
private List<T> content;
/** 当前页码(从 1 起) */
private int pageIndex;
/** 每页条数 */
private int pageSize;
/** 总页数 */
private int totalPages;
/** 是否还有下一页 */
private boolean hasNext;
public BasePageResult(List<T> content, int total, int pageIndex, int pageSize, int totalPages, boolean hasNext) {
this.content = content;
this.total = total;
this.pageIndex = pageIndex;
this.pageSize = pageSize;
this.totalPages = totalPages;
this.hasNext = hasNext;
}
}
@@ -0,0 +1,106 @@
package com.yunhai.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<Integer> groupIds;
/** 教师所属组ID(接口现行字段 GroupID,单个) */
@JsonProperty("GroupID")
private Integer groupId;
public Integer getGroupId() {
return groupId;
}
public void setGroupId(Integer groupId) {
this.groupId = groupId;
}
public String getSchSerID() {
return schSerID;
}
public void setSchSerID(String schSerID) {
this.schSerID = schSerID;
}
public String getTeaTel() {
return teaTel;
}
public void setTeaTel(String teaTel) {
this.teaTel = teaTel;
}
public String getTeaName() {
return teaName;
}
public void setTeaName(String teaName) {
this.teaName = teaName;
}
public Integer getUserTeaSerID() {
return userTeaSerID;
}
public void setUserTeaSerID(Integer userTeaSerID) {
this.userTeaSerID = userTeaSerID;
}
public String getTeaIDCard() {
return teaIDCard;
}
public void setTeaIDCard(String teaIDCard) {
this.teaIDCard = teaIDCard;
}
public String getTeaNo() {
return teaNo;
}
public void setTeaNo(String teaNo) {
this.teaNo = teaNo;
}
public List<Integer> getGroupIds() {
return groupIds;
}
public void setGroupIds(List<Integer> groupIds) {
this.groupIds = groupIds;
}
}
@@ -0,0 +1,29 @@
package com.yunhai.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;
}
@@ -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;
}
@@ -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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<SchTreeNodeDto> children;
}
@@ -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<IdName> subjects;
/** 年级集合 */
private List<IdName> grades;
/** 组员集合(不含组长) */
private List<IdName> members;
/** 组长集合 */
private List<IdName> 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;
}
}
}
@@ -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;
}
@@ -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;
}
}
@@ -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<PointsConfig, Integer> {
Optional<PointsConfig> findBySchId(Integer schId);
}
@@ -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<PointsSetting, Integer> {
List<PointsSetting> findBySchIdOrderByIdAsc(Integer schId);
List<PointsSetting> findByIdIn(List<Integer> ids);
}
@@ -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<ProfessionArea, Integer> {
/** 某上级下的子级区划(省传 "0"),按排序号升序 */
List<ProfessionArea> findByParentCodeOrderBySortOrderAscCodeAsc(String parentCode);
/** 按层级查(1 省 / 2 市 / 3 区县) */
List<ProfessionArea> findByLevelOrderBySortOrderAscCodeAsc(Integer level);
/** 按 code 批量反查(回显翻译用) */
List<ProfessionArea> findByCodeIn(Collection<String> codes);
}
@@ -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<ProfessionDiscuss, Integer> {
List<ProfessionDiscuss> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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);
}
@@ -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<ProfessionField, Integer>, JpaSpecificationExecutor<ProfessionField> {
List<ProfessionField> findBySchIdOrderByFieldSortAscIdAsc(Integer schId);
List<ProfessionField> findByIdInAndSchId(List<Integer> ids, Integer schId);
Page<ProfessionField> findBySchId(Integer schId, Pageable pageable);
}
@@ -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<ProfessionLike, Integer> {
Optional<ProfessionLike> findByRecordIdAndTeacherId(Integer recordId, Integer teacherId);
}
@@ -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<ProfessionModule, Integer> {
List<ProfessionModule> 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<ProfessionModule> 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<ProfessionModule> findVisibleEnabled(@Param("schId") Integer schId);
List<ProfessionModule> findByIdIn(List<Integer> ids);
}
@@ -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<ProfessionRecord, Integer>, JpaSpecificationExecutor<ProfessionRecord> {
Page<ProfessionRecord> findBySchId(Integer schId, Pageable pageable);
List<ProfessionRecord> findByIdIn(List<Integer> ids);
/** 教师任一记录(开发兜底反查姓名) */
java.util.Optional<ProfessionRecord> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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<Object[]> 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);
}
@@ -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<ProfessionVisit, Integer> {
/** 最近访客(同一访客去重由调用方处理) */
List<ProfessionVisit> findByRecordIdOrderByCreateTimeDesc(Integer recordId, Pageable pageable);
/** 个人空间最近访客(条数由 Pageable 传入,同一访客去重由调用方处理) */
List<ProfessionVisit> findByAuthorIdOrderByCreateTimeDesc(Integer authorId, Pageable pageable);
List<ProfessionVisit> 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);
}
@@ -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<SysOperationLog, Long>, JpaSpecificationExecutor<SysOperationLog> {
}
@@ -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<TermSetting, Integer> {
List<TermSetting> findBySchIdOrderBySchoolYearDescTermTypeAsc(Integer schId);
Page<TermSetting> findBySchId(Integer schId, Pageable pageable);
}
@@ -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<TopModule, Integer> {
List<TopModule> findBySchIdOrderBySortOrderAscIdAsc(Integer schId);
}
@@ -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<UpFile, Integer> {
List<UpFile> findByTopicTypeAndTopicId(String topicType, Integer topicId);
List<UpFile> findByIdIn(List<Integer> ids);
}
@@ -0,0 +1,35 @@
package com.yunhai.profession.service;
import java.util.List;
import java.util.Map;
/**
* 行政区划字典域:供自定义字段类型 province_city(省市级联)取数。
* <p>数据源为 profession_area 表(GB/T 2260),非多租户数据(全校共用一份)。</p>
*/
public interface IAreaService {
/**
* 某上级下的子级区划;parentCode 为空时返回全部省份。
*
* @param parentCode 上级区划编码(省级传 "0" 或留空)
* @return [{value:code, label:name, level, parentCode}]
*/
List<Map<String, Object>> listChildren(String parentCode);
/**
* 区划树(省 → 市 → 区县),供前端 Cascader 一次性取数。
*
* @param level 需要展开到几级:2=省市 / 3=省市区(默认 3)
* @return [{value, label, level, children:[...]}]
*/
List<Map<String, Object>> tree(Integer level);
/**
* 编码路径转名称(详情回显翻译)。
*
* @param codesCSV 编码串,如 "440000,440300"
* @return 名称串,如 "广东省/深圳市";查不到的编码原样保留
*/
String namePath(String codesCSV);
}
@@ -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)开放接口客户端门面。
* <p>基址取配置 {@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<SchTeaList> 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<SchSystemDto> getSchLenList(String schSerId);
/**
* 学校年级列表(Id=gradeId、Name=gradeNickname、OwnerId=学段id)。
* 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenLevellist?SchSerID=}
*/
List<SchGradeDto> getSchGradeList(String schSerId);
/**
* 学校全部在读班级(ClassID/ClassName/Levell=gradeId),ScopeTree 一次取数客户端组装。
* 对应:GET {@code {bschclass}/oapi/VSchClass/GetVClassList?SchSerID=}
*/
List<SchClassDto> getSchClassList(String schSerId);
/**
* 按校全部学生(圈人:全校范围展开)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetSchStulist?schserid=}
*/
List<SchStudentDto> getSchStuList(String schSerId);
/**
* 按年级直查学生(圈人:年级范围免逐班聚合;bschclass 侧补缺接口)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetGradeStulist?SchSerID=&GradeId=}
*/
List<SchStudentDto> getGradeStuList(String schSerId, Integer gradeId);
/**
* 按学段直查学生(圈人:学部范围一键展开;bschclass 侧补缺接口)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetSubStulist?SchSerID=&SchSystemId=}
*/
List<SchStudentDto> getSubStuList(String schSerId, Integer schSystemId);
/**
* 按班级批量查学生(圈人:班级范围一次多班)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetClaArrStulist?ClassIDs=1,2,3}
*/
List<SchStudentDto> getClaArrStuList(List<Integer> classIds);
/**
* 按学生ID批量查(圈人:指定学员范围)。
* 对应:GET {@code {bschclass}/oapi/OAStu/GetStuIdslist?stuids=1,2,3}
*/
List<SchStudentDto> getStuIdsList(List<Integer> stuIds);
/**
* 学校学生树:学校→学段→年级→班级→学生(单元素列表,根为学校节点)。
* 节点契约:组节点 type=1、isParent=true、children 非 null;学生叶子 type=2、isParent=false、children=null。
* 对应:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?SchSerID=}
*/
List<SchTreeNodeDto> getTeaTree(Integer schId, boolean toUser);
List<SchTreeNodeDto> getStuTree(Integer schId, boolean toUser);
/**
* 学校学科列表(含学科编码、名称、排序号)。
* 对应:GET {@code {bschclass}/oapi/SubInfo/GetSchSublist?SchID=}
*/
List<SchSubjectDto> getSchSubList(String schSerId);
/**
* 指定教师的全部任教(校验教师归属该校;SchID 过滤学校、UserTeaSerID 过滤教师)。
* 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchTeachlist?SchID=&UserTeaSerID=}
*/
List<SchTeachDto> getSchTeachList(String schSerId, Integer teaId);
/**
* 指定班级的全部任教(班级维度:该班所有教师与学科)。
* 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchClaTeachlist?ClassID=}
*/
List<SchTeachDto> getSchClaTeachList(Integer classId);
/**
* 学校全部任教(全校所有教师的班级任教关系)。
* 对应:GET {@code {bschclass}/oapi/TeachInfo/GetSchTeachlist?SchID=}
*/
List<SchTeachDto> getSchAllTeachList(String schSerId);
/**
* 学校全部班级(含班主任 firstTeacherId / 副班主任 secondTeacherId,不含毕业班)。
* 请假模块用于识别教师所带班级(班主任 = 审批/登记数据范围)。
* 对应:GET {@code {bschclass}/oapi/sch/getSchClassList?schId=}(schId 为用户中心学校ID)
*/
List<SchClassMasterDto> getSchClassMasterList(Integer schId);
/**
* 学校教师分组列表(组/组员/组长合一;组员teaId=用户中心ID,可与记录 teacher_id 直接关联)。
* 发展首页/栏目列表左侧学科组卡片取数:组教师人数、组类型、教师发布数量。
* 对应:GET {@code {bschclass}/oapi/sch/getTeaCustomGroupList?schId=&groupType=}
* (groupType:1年级组 2教研组 3备课组,不传返回全部类型)
*/
List<TeacherCustomGroupDto> getTeaCustomGroupList(int schId, Integer groupType);
}
@@ -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<Integer> fileIds, Integer topicId);
/**
* 查询某主题下的附件列表。
*
* @param appType 应用类型
* @param topicId 主题ID
* @return 附件列表
*/
List<UpFileVo> listByTopic(String appType, Integer topicId);
/**
* 下载文件。
*
* @param fileId 文件记录ID
* @param response HTTP 响应
*/
void download(Integer fileId, HttpServletResponse response);
/**
* 在线预览文件(Content-Disposition: inline,按扩展名输出真实 Content-Type,
* 供前端 open-file-viewer 取 Blob / video 播放)。
*
* @param fileId 文件记录ID
* @param response HTTP 响应
*/
void preview(Integer fileId, HttpServletResponse response);
/**
* 删除文件(同时删除物理文件和数据库记录)。
*
* @param fileId 文件记录ID
*/
void delete(Integer fileId);
/**
* 打包下载多个附件为 ZIP。
*
* @param files 附件记录列表(调用方按 fileIds 查好)
* @param zipName 压缩包显示名(自动追加 .zip)
* @param response HTTP 响应
*/
void downloadZip(List<UpFile> files, String zipName, HttpServletResponse response);
/**
* 按主题类型和主题ID打包下载附件为 ZIP(Controller 便捷方法,内部自行查附件列表)。
*
* @param topicType 主题类型(如 "profession")
* @param topicId 主题ID
* @param zipName 压缩包显示名
* @param response HTTP 响应
*/
void downloadZipByTopic(String topicType, Integer topicId, String zipName, HttpServletResponse response);
/**
* 批量上传图片(循环上传,返回每个文件的视图对象列表)。
*/
List<UpFileVo> uploadImages(MultipartFile[] files, String appType);
/**
* 上传单个文件并返回前端兼容的结果 VO(含 fileid/name/fileurl/host/type/size)。
*/
UpFileResultVo uploadToResult(MultipartFile file, String appType);
}
@@ -0,0 +1,67 @@
package com.yunhai.profession.service;
import java.util.List;
import java.util.Map;
/**
* 栏目配置域业务接口:上级栏目、栏目、自定义字段(对齐历史后台 ProfessionOld 契约)。
*/
public interface IMouduleService {
// ==================== 栏目 ====================
/** 后台栏目列表(含 ownerSchId/currentSchId 判只读、recordCount) */
List<Map<String, Object>> listModules(Integer currentSchId);
/** 前台级联栏目树:上级栏目 → 栏目,附篇数 */
List<Map<String, Object>> moduleTree(Integer currentSchId);
/** 前台某教师可见栏目(含篇数) */
List<Map<String, Object>> myModuleList(Integer currentSchId, Integer teacherId);
/** 新建栏目(区局账号可设 isShared) */
Integer createModule(Integer currentSchId, boolean districtAccount, Map<String, Object> req);
/** 编辑栏目(仅归属校可改) */
void editModule(Integer currentSchId, boolean districtAccount, Map<String, Object> 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<String, Object> moduleFieldList(Integer currentSchId, Integer moduleId);
// ==================== 上级栏目 ====================
List<Map<String, Object>> 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<String, Object> pageFields(Integer currentSchId, String fieldType, int pageIndex, int pageSize);
/** 全部字段(绑定选择用) */
List<Map<String, Object>> listFields(Integer currentSchId);
Integer createField(Integer currentSchId, Map<String, Object> req);
void editField(Integer currentSchId, Map<String, Object> req);
void deleteFields(Integer currentSchId, String ids);
}
@@ -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<String, Object> 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<Map<String, Object>> pointsTree(Integer schId);
/** 积分来源下拉(静态) */
List<Map<String, Object>> pointsSourceList();
/** 节点详情 */
Map<String, Object> pointsDetail(Integer schId, Integer id);
/** 新增/编辑节点(id 空 = 新增) */
Integer pointsEdit(Integer schId, Map<String, Object> req);
/** 删除节点(ids CSV,连带子节点) */
void deletePoints(Integer schId, String ids);
// ==================== 学期设置 ====================
List<Map<String, Object>> termList(Integer schId);
Map<String, Object> termPage(Integer schId, int pageIndex, int pageSize);
Map<String, Object> termDetail(Integer schId, Integer id);
Integer termEdit(Integer schId, Map<String, Object> req);
}
@@ -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<String, Object> req);
/** 编辑记录(仅作者) */
void editRecord(Integer schId, Integer teacherId, Map<String, Object> req);
/** 删除记录(ids CSV;仅作者或超管) */
void deleteRecords(Integer schId, Integer teacherId, boolean superAdmin, String ids);
/**
* 记录分页列表。
*
* @param teacherId 按教师过滤(可空)
* @param groupId 按教师分组过滤(可空;组员=组长+组员教师集合)
* @param withOtherModules true 时附带同校其他启用栏目(对齐老契约 OtherData)
*/
Map<String, Object> 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<String, Object> recordDetail(Integer schId, Integer recordId, Integer currentUserId);
/** 首页各栏目最新记录块 */
List<Map<String, Object>> homeModuleRecords(Integer schId, int sizePerModule);
/** 个人空间按年时间轴 */
List<Map<String, Object>> 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<Map<String, Object>> discussList(Integer schId, Integer currentUserId, boolean superAdmin, Integer recordId);
// ==================== 足迹 ====================
/** 记录浏览足迹(同时阅读数 +1) */
void visitCreate(Integer schId, Integer visitorId, String visitorName, Integer recordId);
/** 最近访客:recordId 优先,否则看个人空间 */
List<Map<String, Object>> visitList(Integer recordId, Integer authorId, int limit);
// ==================== 点赞 ====================
/** 点赞/取消点赞(toggle),返回 {liked, likeCount} */
Map<String, Object> likeCreate(Integer schId, Integer teacherId, String teacherName, Integer recordId);
// ==================== 积分/排行 ====================
/** 我的积分(发布 + 评论) */
Map<String, Object> myPoints(Integer schId, Integer teacherId, String teacherName);
/** 发布排行(默认前 10) */
List<Map<String, Object>> releaseRanking(Integer schId, Integer topN);
/** 学科组树(用户中心组树 + 教师发布篇数;组节点 count=子树合计,叶子=教师;moduleId 非空时篇数只统计该栏目) */
List<Map<String, Object>> subjectGroupTree(Integer schId, Integer moduleId);
/** 首页刊头统计:累计记录 / 活跃教师 / 发展栏目 / 本周新增(按学校) */
Map<String, Object> homeStats(Integer schId);
/** 积分排行(发布+评论合计,前 N) */
List<Map<String, Object>> pointsRank(Integer schId, Integer topN);
/** 个人空间聚合:姓名/积分/累计记录/获赞/被访问/各栏目篇数/本月概览(发布/评论/阅读/访客) */
Map<String, Object> spaceOverview(Integer schId, Integer userid);
}
@@ -0,0 +1,59 @@
package com.yunhai.profession.service;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
/**
* 统计域业务接口:校级 5 类统计 + 明细 + 导出 + 附件打包 + 局端跨校统计。
* <p>时间维度 type:0 累计 / 1 今日 / 2 本周 / 3 本月 / 4 本学期 / 5 自定义(对齐老契约)。</p>
*/
public interface IStatsService {
/** 教师个人统计(动态列 = 各栏目记录数 + 合计 + 排名) */
Map<String, Object> teacherStat(Integer schId, Integer type, String start, String end, String searchName);
/** 模块统计(各栏目记录数) */
Map<String, Object> moduleStat(Integer schId, Integer type, String start, String end, String searchName);
/** 学科组统计(grade 可选过滤) */
Map<String, Object> subjectStat(Integer schId, Integer type, String start, String end, String grade);
/** 评论统计(按记录) */
Map<String, Object> discussStat(Integer schId, Integer type, String start, String end, String searchName);
/** 积分统计(动态列 = 各栏目发布积分;评论积分单列) */
Map<String, Object> pointsStat(Integer schId, Integer type, String start, String end, String searchName);
/** 明细分页(mouid/userid/subid 过滤) */
Map<String, Object> 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<String, Object> defaultRange();
// ==================== 局端跨校(需超管/区局) ====================
/** 学校总览(记录数 + 评论数按校分组,对齐 StatisticsMap/GetSchoolType 归并) */
Map<String, Object> schoolOverview(Integer type, String start, String end);
/** 学校×教师统计(schId 空 = 全部学校) */
Map<String, Object> schoolTeacherStat(Integer schId, Integer type, String start, String end);
/** 学校×栏目统计 */
Map<String, Object> schoolModuleStat(Integer schId, Integer type, String start, String end);
/** 学校×学科统计 */
Map<String, Object> schoolSubjectStat(Integer schId, Integer type, String start, String end);
/** 学校×教师积分统计(发布 + 评论合计) */
Map<String, Object> schoolPointsStat(Integer schId, Integer type, String start, String end);
}
@@ -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<SysOperationLog> page(BasePageQuery query, String operationType, Integer statusCode);
}
@@ -0,0 +1,51 @@
package com.yunhai.profession.service;
import java.util.List;
import java.util.Map;
/**
* 用户中心门面服务(对齐 UserBase {@code UserCenterService} 模式)。
* <p>封装 {@code YschBaseOpenApi} / {@code OpenApiUserClient} 静态门面调用,
* 把原本散落在 Controller 的组树组装、教师挂组、成员解析等业务逻辑下沉到 Service 层,
* Controller 只保留"取参 → 调 Service → 返回"的薄壳。</p>
*/
public interface IUserCenterService {
/**
* 获取当前用户学校组及用户(树形结构)。
* <p>组装规则(从原 BaseApiController.getSchoolAll 下沉):</p>
* <ol>
* <li>userbase 读取当前学校整棵组树(getGroupById(schId, withSub=true)),剔除学生组(type=8)及其子组;</li>
* <li>bschclass 获取该校全部教师(/oapi/DbClass/GetSchteaList?Group=schId),按 GroupIds 挂到对应组下,
* 无有效组(如所属组全被剔除)的教师兜底挂学校根节点;</li>
* <li>扁平节点按 pid 递归组装为树(空组剔除,同 C# FormatUserTree)。</li>
* </ol>
*
* @param schId 学校ID(用户当前角色所属学校)
* @return 用户树形结构(组节点 type=1 / 教师节点 type=2)
*/
List<Map<String, Object>> getSchoolAllTree(Integer schId);
/**
* 获取指定组内用户(教师 + 管理员)。
* <p>对应前端 base/GetGroupUser。原实现为空桩返回空列表,现按 C# 语义
* (ITeainfoBLL.GetGroupTeainfo + IAdminfoBLL.GetGroupAdminfo)接入用户中心。
* 若用户中心不可用或未配置对应开放接口,则抛出带语义的异常(显式报错而非静默失能)。</p>
*
* @param gid 组ID
* @param role 角色(默认 2=教师;1=管理员等)
* @return 组内用户列表
*/
List<Map<String, Object>> getGroupUser(Integer gid, Integer role);
/**
* 获取当前用户可访问的模块树(左侧菜单)。
* <p>从基础平台拉取当前用户模块列表(含学校上下文),组装为前端 layout 期望的树结构
* ({@code modId/page/children}),对应前端 GetUserMod 接口。</p>
*
* @param userId 用户ID
* @param schId 学校ID(可为 null)
* @return 模块树(顶层 List,含 children 嵌套)
*/
List<Map<String, Object>> getUserModuleTree(Integer userId, Integer schId);
}
@@ -0,0 +1,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;
/**
* 行政区划字典业务实现。
* <p>三级数据量约 3400 行,整树在内存组装(一次 findAll 足够,无需递归查库)。
* 若后续数据量增大,可改为按 parentCode 懒加载(listChildren 已是懒加载语义)。</p>
*/
@Service
public class AreaService implements IAreaService {
/** 省级节点的固定上级编码 */
private static final String ROOT_PARENT = "0";
@Autowired
private ProfessionAreaRepository areaRepository;
@Override
public List<Map<String, Object>> listChildren(String parentCode) {
String parent = StringUtils.hasText(parentCode) ? parentCode.trim() : ROOT_PARENT;
List<Map<String, Object>> rows = new ArrayList<>();
for (ProfessionArea a : areaRepository.findByParentCodeOrderBySortOrderAscCodeAsc(parent)) {
rows.add(node(a));
}
return rows;
}
@Override
public List<Map<String, Object>> tree(Integer level) {
int maxLevel = (level == null || level < 1) ? 3 : Math.min(level, 3);
List<ProfessionArea> all = areaRepository.findByLevelOrderBySortOrderAscCodeAsc(1);
// 省
List<Map<String, Object>> root = new ArrayList<>();
if (maxLevel < 2) {
for (ProfessionArea a : all) {
root.add(node(a));
}
return root;
}
// 市:按 parentCode 分组
Map<String, List<ProfessionArea>> cityByProvince = groupBy(areaRepository.findByLevelOrderBySortOrderAscCodeAsc(2));
// 区县:按 parentCode 分组
Map<String, List<ProfessionArea>> districtByCity = maxLevel >= 3
? groupBy(areaRepository.findByLevelOrderBySortOrderAscCodeAsc(3))
: new HashMap<>();
for (ProfessionArea p : all) {
Map<String, Object> pNode = node(p);
List<Map<String, Object>> cities = new ArrayList<>();
for (ProfessionArea c : cityByProvince.getOrDefault(p.getCode(), new ArrayList<>())) {
Map<String, Object> cNode = node(c);
if (maxLevel >= 3) {
List<Map<String, Object>> 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<String> codes = new ArrayList<>();
for (String s : codesCSV.split(",")) {
String t = s.trim();
if (!t.isEmpty()) {
codes.add(t);
}
}
if (codes.isEmpty()) {
return "";
}
Map<String, String> nameByCode = new HashMap<>();
for (ProfessionArea a : areaRepository.findByCodeIn(new HashSet<>(codes))) {
nameByCode.put(a.getCode(), a.getName());
}
List<String> names = new ArrayList<>();
for (String c : codes) {
names.add(nameByCode.getOrDefault(c, c));
}
return String.join("/", names);
}
// ==================== 内部方法 ====================
private static Map<String, Object> node(ProfessionArea a) {
Map<String, Object> 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<String, List<ProfessionArea>> groupBy(List<ProfessionArea> list) {
Map<String, List<ProfessionArea>> map = new HashMap<>();
for (ProfessionArea a : list) {
map.computeIfAbsent(a.getParentCode(), k -> new ArrayList<>()).add(a);
}
return map;
}
}
@@ -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 直连。
* <p>推送范围树/圈人/身份快照统一经本门面(改造方案 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<SchTeaList> getSchTeaList(int schId) {
return exchangeList(bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId,
new ParameterizedTypeReference<List<SchTeaList>>() {
}, "获取教师数据", "schId=" + schId);
}
@Override
public SchInfoDto getSchFirst(int schId) {
requireHost();
String url = bschClassHost + "/oapi/SchInfo/GetUserSchFirst?UserSchID=" + schId;
try {
ResponseEntity<SchInfoDto> 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<SchSystemDto> getSchLenList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenlist?SchSerID=" + schSerId,
new ParameterizedTypeReference<List<SchSystemDto>>() {
}, "获取学段列表", "schSerId=" + schSerId);
}
@Override
public List<SchGradeDto> getSchGradeList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenLevellist?SchSerID=" + schSerId,
new ParameterizedTypeReference<List<SchGradeDto>>() {
}, "获取年级列表", "schSerId=" + schSerId);
}
@Override
public List<SchClassDto> getSchClassList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/VSchClass/GetVClassList?SchSerID=" + schSerId,
new ParameterizedTypeReference<List<SchClassDto>>() {
}, "获取班级列表", "schSerId=" + schSerId);
}
@Override
public List<SchStudentDto> getSchStuList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/OAStu/GetSchStulist?schserid=" + schSerId,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取学校学生", "schSerId=" + schSerId);
}
@Override
public List<SchStudentDto> getGradeStuList(String schSerId, Integer gradeId) {
return exchangeList(bschClassHost + "/oapi/OAStu/GetGradeStulist?SchSerID=" + schSerId + "&GradeId=" + gradeId,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取年级学生", "schSerId=" + schSerId + ", gradeId=" + gradeId);
}
@Override
public List<SchStudentDto> getSubStuList(String schSerId, Integer schSystemId) {
return exchangeList(bschClassHost + "/oapi/OAStu/GetSubStulist?SchSerID=" + schSerId + "&SchSystemId=" + schSystemId,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取学段学生", "schSerId=" + schSerId + ", schSystemId=" + schSystemId);
}
@Override
public List<SchStudentDto> getClaArrStuList(List<Integer> classIds) {
String csv = joinIds(classIds);
return exchangeList(bschClassHost + "/oapi/OAStu/GetClaArrStulist?ClassIDs=" + csv,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取班级学生", "classIds=" + csv);
}
@Override
public List<SchStudentDto> getStuIdsList(List<Integer> stuIds) {
String csv = joinIds(stuIds);
return exchangeList(bschClassHost + "/oapi/OAStu/GetStuIdslist?stuids=" + csv,
new ParameterizedTypeReference<List<SchStudentDto>>() {
}, "获取指定学生", "stuIds=" + csv);
}
@Override
public List<SchTreeNodeDto> getStuTree(Integer schId,boolean toUser) {
return exchangeList(bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId+"&toUser="+toUser,
new ParameterizedTypeReference<List<SchTreeNodeDto>>() {
}, "获取学生树", "schId=" + schId);
}
@Override
public List<SchTreeNodeDto> getTeaTree(Integer schId,boolean toUser) {
return exchangeList(bschClassHost + "/oapi/SchGroup/GetTeaTree?schId=" + schId+"&toUser="+toUser,
new ParameterizedTypeReference<List<SchTreeNodeDto>>() {
}, "获取教师树", "schId=" + schId);
}
@Override
public List<SchSubjectDto> getSchSubList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/SubInfo/GetSchSublist?SchID=" + schSerId,
new ParameterizedTypeReference<List<SchSubjectDto>>() {
}, "获取学校学科", "schSerId=" + schSerId);
}
@Override
public List<SchTeachDto> getSchTeachList(String schSerId, Integer teaId) {
return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchTeachlist?SchID=" + schSerId + "&UserTeaSerID=" + teaId,
new ParameterizedTypeReference<List<SchTeachDto>>() {
}, "获取教师任教", "schSerId=" + schSerId + ", teaId=" + teaId);
}
@Override
public List<SchTeachDto> getSchClaTeachList(Integer classId) {
return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchClaTeachlist?ClassID=" + classId,
new ParameterizedTypeReference<List<SchTeachDto>>() {
}, "获取班级任教", "classId=" + classId);
}
@Override
public List<SchTeachDto> getSchAllTeachList(String schSerId) {
return exchangeList(bschClassHost + "/oapi/TeachInfo/GetSchTeachlist?SchID=" + schSerId,
new ParameterizedTypeReference<List<SchTeachDto>>() {
}, "获取学校全部任教", "schSerId=" + schSerId);
}
@Override
public List<SchClassMasterDto> getSchClassMasterList(Integer schId) {
return exchangeList(bschClassHost + "/oapi/sch/getSchClassList?schId=" + schId,
new ParameterizedTypeReference<List<SchClassMasterDto>>() {
}, "获取学校班级(含班主任)", "schId=" + schId);
}
@Override
public List<TeacherCustomGroupDto> getTeaCustomGroupList(int schId, Integer groupType) {
String url = bschClassHost + "/oapi/sch/getTeaCustomGroupList?schId=" + schId
+ (groupType == null ? "" : "&groupType=" + groupType);
return exchangeList(url, new ParameterizedTypeReference<List<TeacherCustomGroupDto>>() {
}, "获取教师分组列表", "schId=" + schId + ", groupType=" + groupType);
}
// ============================ 通用调用 ============================
private <T> List<T> exchangeList(String url, ParameterizedTypeReference<List<T>> type,
String action, String arg) {
requireHost();
try {
// GET + Accept: application/json,保持与既有 GetSchteaList 调用一致
HttpHeaders headers = new HttpHeaders();
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
ResponseEntity<List<T>> resp = restTemplate.exchange(
url, HttpMethod.GET, new HttpEntity<>(headers), type);
List<T> 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<Integer> ids) {
if (ids == null || ids.isEmpty()) {
return "";
}
return ids.stream().map(String::valueOf).collect(Collectors.joining(","));
}
}
@@ -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;
/**
* 文件上传/下载/删除业务实现。
* <p>
* 存储路径规则:{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<String> 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<Integer> fileIds, Integer topicId) {
if (fileIds == null || fileIds.isEmpty() || topicId == null) {
return;
}
for (Integer fileId : fileIds) {
upFileRepository.findById(fileId).ifPresent(f -> {
f.setTopicId(topicId);
upFileRepository.save(f);
});
}
}
// ============================ 查询附件列表 ============================
@Override
public List<UpFileVo> listByTopic(String appType, Integer topicId) {
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(
"<!doctype html><html><head><meta charset=\"utf-8\">"
+ "<style>body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;"
+ "background:#f7f8fa;color:#8fa0b6;font:14px/1.8 'Microsoft YaHei',sans-serif}</style></head>"
+ "<body>该文件暂不支持在线预览,请点击右上角\u201c下载\u201d按钮查看</body></html>");
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<String, String> table = new HashMap<>();
table.put("pdf", "application/pdf");
table.put("png", "image/png");
table.put("jpg", "image/jpeg");
table.put("jpeg", "image/jpeg");
table.put("gif", "image/gif");
table.put("bmp", "image/bmp");
table.put("webp", "image/webp");
table.put("svg", "image/svg+xml");
table.put("txt", "text/plain;charset=UTF-8");
table.put("mp4", "video/mp4");
table.put("webm", "video/webm");
table.put("ogg", "video/ogg");
table.put("doc", "application/msword");
table.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
table.put("xls", "application/vnd.ms-excel");
table.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
table.put("ppt", "application/vnd.ms-powerpoint");
table.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation");
String type = table.get(name.substring(name.lastIndexOf('.') + 1));
if (type != null) {
return type;
}
String guessed = URLConnection.guessContentTypeFromName(name);
return guessed != null ? guessed : "application/octet-stream";
}
// ============================ 删除 ============================
@Override
@Transactional
public void delete(Integer fileId) {
UpFile file = upFileRepository.findById(fileId)
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId));
// 删物理文件(连同同名预览 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<UpFile> files, String zipName, HttpServletResponse response) {
if (files == null || files.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载");
}
try {
response.setContentType("application/zip");
String name = URLEncoder.encode(zipName == null ? "附件" : zipName, "UTF-8")
.replaceAll("\\+", "%20");
response.setHeader("Content-Disposition", "attachment; filename=\"" + name + ".zip\"");
try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) {
Set<String> used = new HashSet<>();
for (UpFile f : files) {
Path p = Paths.get(uploadPath, f.getFileName());
if (!Files.exists(p)) {
log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName());
continue;
}
String entryName = sanitizeEntryName(f.getFileTrueName());
if (used.contains(entryName)) {
entryName = f.getId() + "_" + entryName;
}
used.add(entryName);
zos.putNextEntry(new ZipEntry(entryName));
Files.copy(p, zos);
zos.closeEntry();
}
}
} catch (Exception e) {
log.error("[downloadZip] 打包下载失败", e);
throw new BizException(ErrorCode.ERROR, "打包下载失败", e);
}
}
/**
* 净化 ZIP 条目名:去除路径分隔符等非法字符,防止 Zip Slip。
*/
private String sanitizeEntryName(String name) {
if (name == null || name.trim().isEmpty()) {
return "file";
}
return name.replaceAll("[\\\\/:*?\"<>|]", "_");
}
// ============================ 按主题打包下载 ============================
@Override
public void downloadZipByTopic(String topicType, Integer topicId, String zipName,
HttpServletResponse response) {
String type = topicType == null ? DEFAULT_APP_TYPE : topicType;
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicId(type, topicId);
downloadZip(files, zipName, response);
}
// ============================ 批量上传图片 ============================
@Override
@Transactional
public List<UpFileVo> uploadImages(MultipartFile[] files, String appType) {
List<UpFileVo> results = new ArrayList<>();
if (files == null || files.length == 0) {
return results;
}
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;
}
}
@@ -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<Map<String, Object>> listModules(Integer currentSchId) {
Map<Integer, Long> countByModule = recordCountMap(currentSchId);
List<Map<String, Object>> rows = new ArrayList<>();
for (ProfessionModule m : moduleRepository.findVisible(currentSchId)) {
Map<String, Object> 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<Map<String, Object>> moduleTree(Integer currentSchId) {
Map<Integer, Long> countByModule = recordCountMap(currentSchId);
List<ProfessionModule> modules = moduleRepository.findVisibleEnabled(currentSchId);
Map<Integer, List<Map<String, Object>>> byTop = new HashMap<>();
List<Map<String, Object>> roots = new ArrayList<>();
for (ProfessionModule m : modules) {
Map<String, Object> 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<TopModule> tops = topModuleRepository.findBySchIdOrderBySortOrderAscIdAsc(currentSchId);
List<Map<String, Object>> tree = new ArrayList<>();
for (TopModule t : tops) {
List<Map<String, Object>> children = byTop.get(t.getId());
if (children == null || children.isEmpty()) {
continue;
}
Map<String, Object> 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<Map<String, Object>> myModuleList(Integer currentSchId, Integer teacherId) {
List<Map<String, Object>> rows = new ArrayList<>();
if (teacherId != null) {
// 指定教师:各启用栏目下该教师的篇数(含 0,对齐老 MouduleMyList)
Map<Integer, Long> 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<String, Object> 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<Integer, Long> countByModule = recordCountMap(currentSchId);
for (ProfessionModule m : moduleRepository.findVisibleEnabled(currentSchId)) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<Integer> idList = csvToInt(strIds);
List<String> sortList = splitCsv(strSorts);
List<String> nameList = splitCsv(names);
List<String> checkList = splitCsv(checks);
List<String> 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<String, Object> 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<Integer> boundIds = csvToInt(m.getFieldIds());
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> listTopModules(Integer currentSchId, String searchName, Integer isUsing) {
List<Map<String, Object>> 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<String, Object> 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<Integer> 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<String, Object> pageFields(Integer currentSchId, String fieldType, int pageIndex, int pageSize) {
Specification<ProfessionField> spec = (root, cq, cb) -> {
List<Predicate> 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<ProfessionField> page = fieldRepository.findAll(spec,
PageRequest.of(Math.max(pageIndex - 1, 0), pageSize <= 0 ? 10 : pageSize,
Sort.by(Sort.Direction.ASC, "fieldSort", "id")));
List<Map<String, Object>> rows = new ArrayList<>();
for (ProfessionField f : page.getContent()) {
rows.add(fieldRow(f, null));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", page.getTotalElements());
result.put("rows", rows);
return result;
}
@Override
public List<Map<String, Object>> listFields(Integer currentSchId) {
List<Map<String, Object>> rows = new ArrayList<>();
for (ProfessionField f : fieldRepository.findBySchIdOrderByFieldSortAscIdAsc(currentSchId)) {
rows.add(fieldRow(f, null));
}
return rows;
}
@Override
@Transactional
public Integer createField(Integer currentSchId, Map<String, Object> req) {
ProfessionField f = new ProfessionField();
f.setSchId(currentSchId);
applyFieldRequest(f, req);
return fieldRepository.save(f).getId();
}
@Override
@Transactional
public void editField(Integer currentSchId, Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> fieldRow(ProfessionField f, Boolean checked) {
Map<String, Object> 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<Integer, Long> recordCountMap(Integer schId) {
Map<Integer, Long> map = new HashMap<>();
for (Object[] row : recordRepository.countGroupByModule(schId, null, null)) {
map.put((Integer) row[0], (Long) row[1]);
}
return map;
}
private static List<Integer> csvToInt(String csv) {
List<Integer> 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<String> splitCsv(String csv) {
List<String> 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;
}
}
@@ -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;
/**
* 积分与学期设置域业务实现。
* <p>积分规则首版:发布/评论时点取"栏目覆盖值 → 全局默认值";
* 指标树(Type1 比例加分 / Type2 封顶)提供存储与基础应用,深度规则联调时补齐。</p>
*/
@Service
public class PointsService implements IPointsService {
@Autowired
private PointsConfigRepository pointsConfigRepository;
@Autowired
private PointsSettingRepository pointsSettingRepository;
@Autowired
private TermSettingRepository termSettingRepository;
// ==================== 全局积分 ====================
@Override
@Transactional
public Map<String, Object> globalPoints(Integer schId) {
PointsConfig config = getOrCreateConfig(schId);
Map<String, Object> 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<Map<String, Object>> pointsTree(Integer schId) {
List<PointsSetting> all = pointsSettingRepository.findBySchIdOrderByIdAsc(schId);
Map<Integer, List<Map<String, Object>>> byParent = new HashMap<>();
List<Map<String, Object>> roots = new ArrayList<>();
for (PointsSetting p : all) {
Map<String, Object> 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<String, Object> root : roots) {
List<Map<String, Object>> children = byParent.get(((Number) root.get("id")).intValue());
if (children != null) {
root.put("children", children);
}
}
return roots;
}
@Override
public List<Map<String, Object>> pointsSourceList() {
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> s1 = new LinkedHashMap<>();
s1.put("id", 1);
s1.put("name", "发布");
list.add(s1);
Map<String, Object> s2 = new LinkedHashMap<>();
s2.put("id", 2);
s2.put("name", "评论");
list.add(s2);
return list;
}
@Override
public Map<String, Object> pointsDetail(Integer schId, Integer id) {
PointsSetting p = pointsSettingRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("积分指标不存在: " + id));
if (!p.getSchId().equals(schId)) {
throw new IllegalArgumentException("无权访问该积分指标");
}
Map<String, Object> 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<String, Object> 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<PointsSetting> all = pointsSettingRepository.findBySchIdOrderByIdAsc(schId);
for (Integer id : csvToInt(ids)) {
deleteNode(all, schId, id);
}
}
private void deleteNode(List<PointsSetting> 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<Map<String, Object>> termList(Integer schId) {
List<Map<String, Object>> rows = new ArrayList<>();
for (TermSetting t : termSettingRepository.findBySchIdOrderBySchoolYearDescTermTypeAsc(schId)) {
rows.add(termRow(t));
}
return rows;
}
@Override
public Map<String, Object> termPage(Integer schId, int pageIndex, int pageSize) {
Page<TermSetting> page = termSettingRepository.findBySchId(schId,
PageRequest.of(Math.max(pageIndex - 1, 0), pageSize <= 0 ? 10 : pageSize,
Sort.by(Sort.Direction.DESC, "schoolYear", "termType")));
List<Map<String, Object>> rows = new ArrayList<>();
for (TermSetting t : page.getContent()) {
rows.add(termRow(t));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", page.getTotalElements());
result.put("rows", rows);
return result;
}
@Override
public Map<String, Object> 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<String, Object> 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<String, Object> termRow(TermSetting t) {
Map<String, Object> 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<Integer> csvToInt(String csv) {
List<Integer> 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;
}
}
@@ -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;
/**
* 专业发展记录域业务实现。
* <p>身份约定:记录/评论/足迹只存教师 id(teacher_id),姓名为展示冗余,不存账号。</p>
*/
@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<String, Object> 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<String, Object> 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<String, Object> 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<ProfessionRecord> spec = recordSpec(schId, moduleId, teacherId,
groupId != null ? groupTeacherIds(schId, groupId) : null,
subject, grade, keyword, startTime, endTime);
Page<ProfessionRecord> page = recordRepository.findAll(spec,
PageRequest.of(Math.max(pageIndex - 1, 0), pageSize <= 0 ? 10 : pageSize,
Sort.by(Sort.Direction.DESC, "createTime", "id")));
Map<Integer, Long> discussCount = discussCountMap(schId);
Map<Integer, String> moduleName = moduleNameMap(schId);
List<Map<String, Object>> rows = new ArrayList<>();
for (ProfessionRecord r : page.getContent()) {
rows.add(recordRow(r, moduleName.get(r.getModuleId()), discussCount.getOrDefault(r.getId(), 0L)));
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", page.getTotalElements());
result.put("rows", rows);
if (withOtherModules) {
List<Map<String, Object>> others = new ArrayList<>();
for (ProfessionModule m : moduleRepository.findVisibleEnabled(schId)) {
if (moduleId != null && m.getId().equals(moduleId)) {
continue;
}
Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> fields = new ArrayList<>();
if (module != null && StringUtils.hasText(module.getFieldIds())) {
List<Integer> boundIds = ProfessionContext.csvToIntList(module.getFieldIds());
if (!boundIds.isEmpty()) {
for (ProfessionField f : fieldRepository.findAllById(boundIds)) {
Map<String, Object> 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<Map<String, Object>> files = new ArrayList<>();
for (UpFile f : upFileRepository.findByTopicTypeAndTopicId(TOPIC_TYPE, recordId)) {
Map<String, Object> 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<Map<String, Object>> homeModuleRecords(Integer schId, int sizePerModule) {
Map<Integer, Long> countByModule = new HashMap<>();
for (Object[] row : recordRepository.countGroupByModule(schId, null, null)) {
countByModule.put((Integer) row[0], (Long) row[1]);
}
List<Map<String, Object>> blocks = new ArrayList<>();
for (ProfessionModule m : moduleRepository.findVisibleEnabled(schId)) {
Page<ProfessionRecord> 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<Map<String, Object>> items = new ArrayList<>();
for (ProfessionRecord r : page.getContent()) {
Map<String, Object> 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<String, Object> 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<Map<String, Object>> myTimeline(Integer schId, Integer teacherId) {
Specification<ProfessionRecord> spec = recordSpec(schId, null, teacherId, null, null, null, null, null, null);
List<ProfessionRecord> records = recordRepository.findAll(spec,
PageRequest.of(0, 500, Sort.by(Sort.Direction.DESC, "createTime", "id"))).getContent();
Map<Integer, String> moduleName = moduleNameMap(schId);
Map<Integer, Map<String, Object>> byYear = new LinkedHashMap<>();
for (ProfessionRecord r : records) {
LocalDateTime t = r.getCreateTime();
if (t == null) {
continue;
}
int year = t.getYear();
Map<String, Object> yearNode = byYear.computeIfAbsent(year, k -> {
Map<String, Object> n = new LinkedHashMap<>();
n.put("year", year);
n.put("itemlist", new ArrayList<Map<String, Object>>());
return n;
});
Map<String, Object> 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<Map<String, Object>>) 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<Map<String, Object>> discussList(Integer schId, Integer currentUserId, boolean superAdmin, Integer recordId) {
List<Map<String, Object>> rows = new ArrayList<>();
for (ProfessionDiscuss d : discussRepository.findByRecordIdOrderByCreateTimeAsc(recordId)) {
Map<String, Object> 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<Map<String, Object>> visitList(Integer recordId, Integer authorId, int limit) {
List<ProfessionVisit> 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<Integer, Map<String, Object>> distinct = new LinkedHashMap<>();
for (ProfessionVisit v : visits) {
if (distinct.containsKey(v.getVisitorId())) {
continue;
}
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> releaseRanking(Integer schId, Integer topN) {
List<Map<String, Object>> rows = new ArrayList<>();
List<Object[]> 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<String, Object> 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<Map<String, Object>> subjectGroupTree(Integer schId, Integer moduleId) {
if (schId == null) {
return new ArrayList<>();
}
// 1) 班级管理教师分组(组/组员/组长合一;组员 teaId=用户中心ID,与记录 teacher_id 同一 id 体系)
List<TeacherCustomGroupDto> groups;
try {
groups = classService.getTeaCustomGroupList(schId, null);
} catch (Exception e) {
// 班级管理不可用时不阻塞首页
log.warn("[subjectGroupTree] 获取教师分组失败, schId={}", schId, e);
return new ArrayList<>();
}
// 2) 教师发布篇数:传 moduleId 只统计该栏目(栏目页组树计数与列表一致),否则全部栏目合计(发展首页)
Map<Integer, Long> 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<Map<String, Object>> roots = new ArrayList<>();
for (TeacherCustomGroupDto group : groups) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", group.getGroupId());
node.put("name", group.getGroupName());
node.put("groupType", group.getGroupType());
node.put("groupTypeName", group.getGroupTypeName());
List<Map<String, Object>> children = new ArrayList<>();
Set<Integer> teacherIds = new HashSet<>();
long total = 0;
List<TeacherCustomGroupDto.IdName> 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<String, Object> 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<String, Object> n) -> (Integer) n.get("groupType"),
Comparator.nullsLast(Comparator.naturalOrder())));
return roots;
}
// ==================== 内部方法 ====================
private Map<String, Object> recordRow(ProfessionRecord r, String moduleName, Long discussCount) {
Map<String, Object> 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<String, Object> 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<Integer> 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<Integer> groupTeacherIds(Integer schId, Integer groupId) {
List<Integer> 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<Integer> 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<TeacherCustomGroupDto.IdName> teachers, Set<Integer> out) {
if (teachers == null) {
return;
}
for (TeacherCustomGroupDto.IdName t : teachers) {
Integer id = t.idInt();
if (id != null) {
out.add(id);
}
}
}
private Specification<ProfessionRecord> recordSpec(Integer schId, Integer moduleId, Integer teacherId,
Collection<Integer> 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<Predicate> 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<Integer, Long> discussCountMap(Integer schId) {
Map<Integer, Long> map = new HashMap<>();
for (Object[] row : discussRepository.countGroupByRecord(schId, null, null)) {
map.put((Integer) row[0], (Long) row[1]);
}
return map;
}
private Map<Integer, String> moduleNameMap(Integer schId) {
Map<Integer, String> 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<Integer> csvToInt(String csv) {
List<Integer> 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<String, Object> homeStats(Integer schId) {
Map<String, Object> 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<javax.persistence.criteria.Predicate> 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<Map<String, Object>> pointsRank(Integer schId, Integer topN) {
Map<Integer, Map<String, Object>> byTeacher = new LinkedHashMap<>();
for (Object[] row : recordRepository.sumPointsGroupByTeacher(schId, null, null)) {
Map<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String, Object> spaceOverview(Integer schId, Integer userid) {
Map<String, Object> 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<Integer, Long> 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<Map<String, Object>> moduleCounts = new ArrayList<>();
for (ProfessionModule mod : moduleRepository.findVisibleEnabled(schId)) {
Map<String, Object> 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<String, Object> 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;
}
// ==================== 聚合辅助 ====================
}
@@ -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;
/**
* 统计域业务实现。
* <p>校级统计基于分组聚合查询;明细/导出基于动态 Specification;
* 局端跨校统计按 schId 分组(仅超管/区局账号调用,Controller 侧校验)。</p>
*/
@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<String, Object> teacherStat(Integer schId, Integer type, String start, String end, String searchName) {
LocalDateTime[] range = resolveRange(type, start, end, schId);
List<ProfessionModule> modules = moduleRepository.findVisible(schId);
List<Map<String, Object>> columns = new ArrayList<>();
for (ProfessionModule m : modules) {
Map<String, Object> c = new LinkedHashMap<>();
c.put("id", m.getId());
c.put("name", m.getName());
columns.add(c);
}
Map<Integer, Map<String, Object>> byTeacher = new LinkedHashMap<>();
for (Object[] row : recordRepository.countGroupByTeacherModule(schId, range[0], range[1])) {
Integer teacherId = (Integer) row[0];
Map<String, Object> r = byTeacher.computeIfAbsent(teacherId, k -> {
Map<String, Object> n = new LinkedHashMap<>();
n.put("teacherId", teacherId);
n.put("teacherName", String.valueOf(row[1]));
n.put("moduleCounts", new LinkedHashMap<Integer, Long>());
n.put("total", 0L);
return n;
});
long cnt = ((Number) row[3]).longValue();
@SuppressWarnings("unchecked")
Map<Integer, Long> counts = (Map<Integer, Long>) r.get("moduleCounts");
counts.put((Integer) row[2], cnt);
r.put("total", ((Number) r.get("total")).longValue() + cnt);
}
List<Map<String, Object>> rows = filterTeachers(byTeacher, searchName);
Map<String, Object> result = new LinkedHashMap<>();
result.put("columns", columns);
result.put("rows", rows);
result.put("total", rows.size());
return result;
}
@Override
public Map<String, Object> moduleStat(Integer schId, Integer type, String start, String end, String searchName) {
LocalDateTime[] range = resolveRange(type, start, end, schId);
Map<Integer, String> names = moduleNameMap(schId);
List<Map<String, Object>> 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<String, Object> 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<String, Object> result = new LinkedHashMap<>();
result.put("rows", rows);
result.put("total", total);
return result;
}
@Override
public Map<String, Object> subjectStat(Integer schId, Integer type, String start, String end, String grade) {
LocalDateTime[] range = resolveRange(type, start, end, schId);
List<Map<String, Object>> rows = new ArrayList<>();
if (StringUtils.hasText(grade)) {
Map<String, Long> 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<String, Long> en : bySubject.entrySet()) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> discussStat(Integer schId, Integer type, String start, String end, String searchName) {
LocalDateTime[] range = resolveRange(type, start, end, schId);
List<Integer> recordIds = new ArrayList<>();
Map<Integer, Long> 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<Integer, ProfessionRecord> records = new HashMap<>();
if (!recordIds.isEmpty()) {
for (ProfessionRecord r : recordRepository.findByIdIn(recordIds)) {
records.put(r.getId(), r);
}
}
List<Map<String, Object>> rows = new ArrayList<>();
for (Map.Entry<Integer, Long> 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<String, Object> 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<String, Object> 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<String, Object> pointsStat(Integer schId, Integer type, String start, String end, String searchName) {
LocalDateTime[] range = resolveRange(type, start, end, schId);
List<ProfessionModule> modules = moduleRepository.findVisible(schId);
List<Map<String, Object>> columns = new ArrayList<>();
for (ProfessionModule m : modules) {
Map<String, Object> c = new LinkedHashMap<>();
c.put("id", m.getId());
c.put("name", m.getName());
columns.add(c);
}
Map<Integer, Map<String, Object>> byTeacher = new LinkedHashMap<>();
for (Object[] row : recordRepository.sumPointsGroupByTeacherModule(schId, range[0], range[1])) {
Integer teacherId = (Integer) row[0];
Map<String, Object> r = byTeacher.computeIfAbsent(teacherId, k -> newTeacherPointsRow(teacherId, String.valueOf(row[1])));
long pts = row[3] == null ? 0 : ((Number) row[3]).longValue();
@SuppressWarnings("unchecked")
Map<Integer, Long> counts = (Map<Integer, Long>) 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<String, Object> 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<Map<String, Object>> rows = new ArrayList<>();
for (Map<String, Object> 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<String, Object> result = new LinkedHashMap<>();
result.put("columns", columns);
result.put("rows", rows);
result.put("total", rows.size());
return result;
}
@Override
public Map<String, Object> 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<ProfessionRecord> spec = recordSpec(schId, moduleId, teacherId,
StringUtils.hasText(subject) ? subject : null,
StringUtils.hasText(searchName) ? searchName : null, null, range[0], range[1]);
Page<ProfessionRecord> page = recordRepository.findAll(spec,
PageRequest.of(Math.max(pageIndex - 1, 0), pageSize <= 0 ? 10 : pageSize,
Sort.by(Sort.Direction.DESC, "createTime", "id")));
Map<Integer, String> names = moduleNameMap(schId);
List<Map<String, Object>> rows = new ArrayList<>();
for (ProfessionRecord r : page.getContent()) {
Map<String, Object> 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<String, Object> 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<List<String>> head = new ArrayList<>();
List<List<Object>> rows = new ArrayList<>();
switch (excelType) {
case 1: {
Map<String, Object> data = teacherStat(schId, type, start, end, searchName);
head.add(row("排名", "教师", "合计"));
for (Map<String, Object> c : castColumns(data)) {
head.get(0).add(String.valueOf(c.get("name")));
}
int rank = 1;
for (Map<String, Object> r : castRows(data)) {
List<Object> line = new ArrayList<>();
line.add(rank++);
line.add(r.get("teacherName"));
line.add(r.get("total"));
@SuppressWarnings("unchecked")
Map<Integer, Long> counts = (Map<Integer, Long>) r.get("moduleCounts");
for (Map<String, Object> 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<String, Object> data = moduleStat(schId, type, start, end, searchName);
for (Map<String, Object> r : castRows(data)) {
rows.add(row(r.get("name"), r.get("count")));
}
break;
}
case 3: {
head.add(row("学科", "记录数"));
Map<String, Object> data = subjectStat(schId, type, start, end, grade);
for (Map<String, Object> r : castRows(data)) {
rows.add(row(r.get("subjectName"), r.get("count")));
}
break;
}
case 4: {
head.add(row("标题", "教师", "评论数"));
Map<String, Object> data = discussStat(schId, type, start, end, searchName);
for (Map<String, Object> r : castRows(data)) {
rows.add(row(r.get("title"), r.get("teacherName"), r.get("discussCount")));
}
break;
}
default: {
Map<String, Object> data = pointsStat(schId, type, start, end, searchName);
head.add(row("排名", "教师", "发布积分", "评论积分", "合计"));
for (Map<String, Object> c : castColumns(data)) {
head.get(0).add(String.valueOf(c.get("name")));
}
int rank = 1;
for (Map<String, Object> r : castRows(data)) {
List<Object> 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<Integer, Long> points = (Map<Integer, Long>) r.get("modulePoints");
for (Map<String, Object> 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<ProfessionRecord> spec = recordSpec(schId, moduleId, teacherId,
StringUtils.hasText(subject) ? subject : null,
StringUtils.hasText(searchName) ? searchName : null, null, range[0], range[1]);
List<ProfessionRecord> records = recordRepository.findAll(spec,
PageRequest.of(0, 500, Sort.by(Sort.Direction.DESC, "createTime"))).getContent();
List<UpFile> 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<String, Object> defaultRange() {
LocalDate today = LocalDate.now();
Map<String, Object> result = new LinkedHashMap<>();
result.put("startTime", today.minusMonths(1).format(DAY_FMT));
result.put("endTime", today.format(DAY_FMT));
return result;
}
// ==================== 局端跨校统计 ====================
@Override
public Map<String, Object> schoolOverview(Integer type, String start, String end) {
LocalDateTime[] range = resolveRange(type, start, end, null);
Map<Integer, long[]> 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<Map<String, Object>> schools = new ArrayList<>();
for (Map.Entry<Integer, long[]> en : bySchool.entrySet()) {
zrs += en.getValue()[0];
totalDiscusses += en.getValue()[1];
Map<String, Object> r = new LinkedHashMap<>();
r.put("schId", en.getKey());
r.put("recordCount", en.getValue()[0]);
r.put("discussCount", en.getValue()[1]);
schools.add(r);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("zrs", zrs);
result.put("totalDiscusses", totalDiscusses);
result.put("schools", schools);
return result;
}
@Override
public Map<String, Object> schoolTeacherStat(Integer schId, Integer type, String start, String end) {
LocalDateTime[] range = resolveRange(type, start, end, schId);
Map<String, Map<String, Object>> rows = new LinkedHashMap<>();
for (Object[] row : recordRepository.countGroupBySchoolTeacher(schId, range[0], range[1])) {
String key = row[0] + "-" + row[1];
Map<String, Object> r = rows.computeIfAbsent(key, k -> {
Map<String, Object> 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<String, Object> r = rows.get(row[0] + "-" + row[1]);
if (r != null) {
r.put("discussCount", ((Number) row[3]).longValue());
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("rows", new ArrayList<>(rows.values()));
return result;
}
@Override
public Map<String, Object> schoolModuleStat(Integer schId, Integer type, String start, String end) {
LocalDateTime[] range = resolveRange(type, start, end, schId);
List<Map<String, Object>> rows = new ArrayList<>();
for (Object[] row : recordRepository.countGroupBySchoolModule(schId, range[0], range[1])) {
Map<String, Object> r = new LinkedHashMap<>();
r.put("schId", row[0]);
r.put("moduleId", row[1]);
r.put("count", ((Number) row[2]).longValue());
rows.add(r);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("rows", rows);
return result;
}
@Override
public Map<String, Object> schoolSubjectStat(Integer schId, Integer type, String start, String end) {
LocalDateTime[] range = resolveRange(type, start, end, schId);
List<Map<String, Object>> rows = new ArrayList<>();
for (Object[] row : recordRepository.countGroupBySchoolSubject(schId, range[0], range[1])) {
Map<String, Object> 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<String, Object> result = new LinkedHashMap<>();
result.put("rows", rows);
return result;
}
@Override
public Map<String, Object> schoolPointsStat(Integer schId, Integer type, String start, String end) {
LocalDateTime[] range = resolveRange(type, start, end, schId);
Map<String, Map<String, Object>> rows = new LinkedHashMap<>();
for (Object[] row : recordRepository.sumPointsGroupBySchoolTeacher(schId, range[0], range[1])) {
String key = row[0] + "-" + row[1];
Map<String, Object> r = rows.computeIfAbsent(key, k -> {
Map<String, Object> 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<String, Object> r = rows.get(row[0] + "-" + row[1]);
if (r != null) {
r.put("commPoints", row[3] == null ? 0L : ((Number) row[3]).longValue());
}
}
List<Map<String, Object>> list = new ArrayList<>(rows.values());
for (Map<String, Object> 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<String, Object> 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<TermSetting> 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<ProfessionRecord> recordSpec(Integer schId, Integer moduleId, Integer teacherId,
String subject, String keyword, String grade,
LocalDateTime start, LocalDateTime end) {
return (root, cq, cb) -> {
List<Predicate> 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<Map<String, Object>> filterTeachers(Map<Integer, Map<String, Object>> byTeacher, String searchName) {
List<Map<String, Object>> rows = new ArrayList<>();
for (Map<String, Object> 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<String, Object> r : rows) {
r.put("rank", rank++);
}
return rows;
}
private Map<String, Object> newTeacherPointsRow(Integer teacherId, String teacherName) {
Map<String, Object> n = new LinkedHashMap<>();
n.put("teacherId", teacherId);
n.put("teacherName", teacherName);
n.put("modulePoints", new LinkedHashMap<Integer, Long>());
n.put("sendPoints", 0L);
n.put("commPoints", 0L);
return n;
}
private Map<Integer, String> moduleNameMap(Integer schId) {
Map<Integer, String> map = new HashMap<>();
for (ProfessionModule m : moduleRepository.findVisible(schId)) {
map.put(m.getId(), m.getName());
}
return map;
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> castRows(Map<String, Object> data) {
return (List<Map<String, Object>>) data.get("rows");
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> castColumns(Map<String, Object> data) {
Object columns = data.get("columns");
return columns == null ? new ArrayList<>() : (List<Map<String, Object>>) 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<String> row(String... cells) {
return new ArrayList<>(java.util.Arrays.asList(cells));
}
private static List<Object> row(Object... cells) {
return new ArrayList<>(java.util.Arrays.asList(cells));
}
}
@@ -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<SysOperationLog> page(BasePageQuery query, String operationType, Integer statusCode) {
Specification<SysOperationLog> spec = (root, cq, cb) -> {
List<Predicate> ps = new ArrayList<>();
if (StringUtils.hasText(operationType)) {
ps.add(cb.equal(root.get("operationType"), operationType));
}
if (statusCode != null) {
ps.add(cb.equal(root.get("statusCode"), statusCode));
}
if (StringUtils.hasText(query.getKeyword())) {
String kw = "%" + query.getKeyword().trim() + "%";
Predicate uriLike = cb.like(root.get("requestUri"), kw);
Predicate nameLike = cb.like(root.get("userName"), kw);
Predicate methodLike = cb.like(root.get("classMethod"), kw);
ps.add(cb.or(uriLike, nameLike, methodLike));
}
return cb.and(ps.toArray(new Predicate[0]));
};
Pageable pageable = PageUtils.toPageable(query, Sort.by(Sort.Direction.DESC, "createTime"));
Page<SysOperationLog> page = repository.findAll(spec, pageable);
page.getContent().forEach(e -> e.setResponseData(null));
return PageUtils.toResult(page);
}
}
@@ -0,0 +1,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;
/**
* 用户中心门面服务实现。
* <p>原 BaseApiController.getSchoolAll 的组树组装、教师挂组、递归建树、排序逻辑整体下沉至此;
* GetGroupUser 空桩改为显式报错(未接入用户中心对应开放接口时提示,而非静默返回空列表)。
* 跨仓调用统一收敛到 {@link YschBaseOpenApi} 门面,Controller 不再直接静态调用。</p>
*/
@Service
public class UserCenterService implements IUserCenterService {
private static final Logger log = LoggerFactory.getLogger(UserCenterService.class);
/** 学生组(UserBase GroupTypeEnum.STUDENT_GROUP),组树中剔除 */
private static final int GROUP_TYPE_STUDENT = 8;
/** 班级管理服务基址(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<SchTeaList> 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<List<SchTeaList>> resp = restTemplate.exchange(
url, HttpMethod.GET, null,
new ParameterizedTypeReference<List<SchTeaList>>() {
});
List<SchTeaList> list = resp.getBody();
log.info("[getSchTeaList] schId={}, 获取教师 {} 人", schId, list == null ? 0 : list.size());
return list != null ? list : Collections.emptyList();
} catch (RestClientException e) {
log.error("[getSchTeaList] 请求班级管理接口失败, schId={}, url={}", schId, url, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取教师数据失败");
}
}
// ============================ 学校组织树 ============================
@Override
public List<Map<String, Object>> getSchoolAllTree(Integer schId) {
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败");
}
// 1. userbase 组树(整棵学校子树),剔除学生组
List<Map<String, Object>> flatNodes = new ArrayList<>();
BoaGroup root = YschBaseOpenApi.getGroupById(schId, true);
if (root == null) {
log.warn("[getSchoolAll] 用户中心未找到学校组, schId={}", schId);
return Collections.emptyList();
}
flattenGroup(root, 0, flatNodes);
// 2. bschclass 全部教师,按 GroupIds 挂到对应组下;无有效组的兜底挂学校根节点
Set<Integer> treeGroupIds = new HashSet<>();
for (Map<String, Object> node : flatNodes) {
treeGroupIds.add((Integer) node.get("id"));
}
Map<Integer, SchTeaList> teacherMap = new LinkedHashMap<>();
Map<Integer, Set<Integer>> teacherPids = new LinkedHashMap<>();
List<SchTeaList> teachers = getSchTeaList(schId);
for (SchTeaList tea : teachers) {
if (tea.getUserTeaSerID() == null) {
continue;
}
// 同一教师多行(多组/接口重复数据)合并,按 (教师, 组) 去重
teacherMap.putIfAbsent(tea.getUserTeaSerID(), tea);
// 接口现行字段为单个 GroupID,兼容旧字段 GroupIds
List<Integer> teaGroupIds = tea.getGroupIds();
if ((teaGroupIds == null || teaGroupIds.isEmpty()) && tea.getGroupId() != null) {
teaGroupIds = Collections.singletonList(tea.getGroupId());
}
if (teaGroupIds != null) {
for (Integer gid : teaGroupIds) {
if (gid != null && treeGroupIds.contains(gid)) {
teacherPids.computeIfAbsent(tea.getUserTeaSerID(), k -> new HashSet<>()).add(gid);
}
}
}
}
for (Map.Entry<Integer, SchTeaList> entry : teacherMap.entrySet()) {
int teaId = entry.getKey();
SchTeaList tea = entry.getValue();
Set<Integer> pids = teacherPids.get(teaId);
if (pids == null || pids.isEmpty()) {
// 教师所属组均被剔除(如学生组)或无组信息,挂学校根节点
pids = new HashSet<>(Collections.singletonList(treeGroupIds.contains(schId) ? schId : 0));
}
for (Integer pid : pids) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", teaId);
node.put("name", tea.getTeaName() != null ? tea.getTeaName() : "");
node.put("pid", pid);
node.put("no", tea.getTeaNo() != null ? tea.getTeaNo() : 0);
node.put("type", 2);
node.put("isParent", false);
node.put("photo", "");
flatNodes.add(node);
}
}
// 3. 扁平节点递归组装为树
return formatUserTree(flatNodes, 0);
}
/**
* 将 BoaGroup 子树扁平化为组节点列表(type=1);学生组(type=8)整棵剔除。
* isParent=true 标识组节点,前端 ztree 据此渲染为文件夹。
*/
private void flattenGroup(BoaGroup group, int pid, List<Map<String, Object>> nodes) {
if (group == null || group.getId() == null) {
return;
}
if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) {
return;
}
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", group.getId());
node.put("name", group.getName() != null ? group.getName() : "");
node.put("pid", pid);
node.put("no", group.getSort() != null ? group.getSort() : 0);
node.put("type", 1);
node.put("isParent", true);
node.put("photo", "");
nodes.add(node);
if (group.getChildren() != null) {
for (BoaGroup child : group.getChildren()) {
flattenGroup(child, group.getId(), nodes);
}
}
}
/**
* 递归构建用户树(对应 C# HttpUserApi.FormatUserTree)。
* 根层取 pid=0 或父节点不在列表中的节点;type=1 且无子节点的空组剔除。
*/
private List<Map<String, Object>> formatUserTree(List<Map<String, Object>> treeList, int pid) {
List<Map<String, Object>> data;
if (pid == 0) {
Set<Integer> allIds = new HashSet<>();
for (Map<String, Object> n : treeList) {
allIds.add((Integer) n.get("id"));
}
data = new ArrayList<>();
for (Map<String, Object> n : treeList) {
int p = (Integer) n.get("pid");
if (p == 0 || !allIds.contains(p)) {
data.add(n);
}
}
} else {
data = new ArrayList<>();
for (Map<String, Object> n : treeList) {
if ((Integer) n.get("pid") == pid) {
data.add(n);
}
}
}
List<Map<String, Object>> result = new ArrayList<>();
for (Map<String, Object> item : data) {
int itemId = (Integer) item.get("id");
int itemType = (Integer) item.get("type");
List<Map<String, Object>> children = formatUserTree(treeList, itemId);
// type=1(组) 且无子节点 → 不加入(C# 逻辑)
if (itemType == 1 && children.isEmpty()) {
continue;
}
Map<String, Object> node = new LinkedHashMap<>(item);
// 仅组节点携带 children;叶子不带该字段,避免 ztree 误判 isParent
if (itemType == 1) {
node.put("children", children);
}
result.add(node);
}
// 按 no 排序(数字优先,无法解析的排最后)
result.sort(Comparator.comparingInt(n -> {
Object no = n.get("no");
if (no instanceof Integer) {
return (Integer) no;
}
if (no instanceof String) {
try {
return Integer.parseInt((String) no);
} catch (NumberFormatException e) {
return 9999;
}
}
return 9999;
}));
return result;
}
// ============================ 组内用户 ============================
@Override
public List<Map<String, Object>> getGroupUser(Integer gid, Integer role) {
if (gid == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "组ID不得为空");
}
log.info("[getGroupUser] gid={}, role={}, 用户中心组内用户开放接口尚未接入", gid, role);
// 显式报错而非静默返回空列表:原空桩会让前端误以为组内无用户
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "组内用户查询接口暂未接入用户中心,请联系管理员配置后重试");
}
// ============================ 用户模块树 ============================
@Override
public List<Map<String, Object>> getUserModuleTree(Integer userId, Integer schId) {
if (userId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空");
}
List<BoaModule> modules = YschBaseOpenApi.getUserModule(userId, schId);
if (modules == null || modules.isEmpty()) {
return Collections.emptyList();
}
// 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望
return buildModuleTree(modules);
}
/**
* 将扁平模块列表组装为树(对应原 NoticeController.buildModuleTree)。
* 顶层取 parentId=0 或父节点不在列表中的节点,子节点按 orderNumber 排序。
*/
private List<Map<String, Object>> buildModuleTree(List<BoaModule> modules) {
Map<Integer, BoaModule> moduleMap = modules.stream()
.collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a));
// 每个模块节点(含 children 占位)
Map<Integer, Map<String, Object>> nodeMap = new LinkedHashMap<>();
for (BoaModule m : modules) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", m.getId());
node.put("modId", m.getId());
node.put("name", m.getName());
node.put("parentId", m.getParentId() != null ? m.getParentId() : 0);
node.put("page", m.getPath() != null ? m.getPath() : "");
node.put("level", m.getLevel() != null ? m.getLevel() : 0);
node.put("icon", m.getIcon() != null ? m.getIcon() : "");
node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0);
node.put("usable", true);
node.put("children", new ArrayList<>());
nodeMap.put(m.getId(), node);
}
// 挂父子关系
List<Map<String, Object>> roots = new ArrayList<>();
for (BoaModule m : modules) {
Map<String, Object> node = nodeMap.get(m.getId());
int parentId = m.getParentId() != null ? m.getParentId() : 0;
if (parentId == 0 || !moduleMap.containsKey(parentId)) {
roots.add(node);
} else {
@SuppressWarnings("unchecked")
List<Map<String, Object>> children = (List<Map<String, Object>>) nodeMap.get(parentId).get("children");
children.add(node);
}
}
// 保持 SDK(用户中心)返回顺序,不再按 orderNumber 重排
return roots;
}
}
@@ -0,0 +1,27 @@
package com.yunhai.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];
}
}
}
@@ -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;
}
}
@@ -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 <T> BasePageResult<T> toResult(Page<T> page) {
return new BasePageResult<T>(
page.getContent(),
(int) page.getTotalElements(),
page.getNumber() + 1,
page.getSize(),
page.getTotalPages(),
page.hasNext()
);
}
}
@@ -0,0 +1,79 @@
package com.yunhai.profession.utils;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.profession.enums.UserType;
/**
* 当前请求上下文取值助手:统一从 BoaUser / UserContext 取当前学校、教师身份。
* <p>业务实体一律存教师 id(平台用户 id),不存账号。</p>
*/
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<Integer> csvToIntList(String csv) {
java.util.List<Integer> 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<Integer> 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();
}
}
@@ -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<Map<String, Object>> gradeList() {
List<Map<String, Object>> 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<Map<String, Object>> subjectList() {
String[][] subjects = {
{"01", "语文"}, {"02", "数学"}, {"03", "英语"}, {"04", "物理"},
{"05", "化学"}, {"06", "道德与法治"}, {"07", "历史"}, {"08", "地理"},
{"09", "生物"}, {"10", "音乐"}, {"11", "体育与健康"}, {"12", "美术"},
{"13", "信息技术"}, {"14", "劳动"}, {"99", "其他"}};
List<Map<String, Object>> list = new ArrayList<>();
for (String[] s : subjects) {
Map<String, Object> 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<String, Object> 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<String, Object> it : subjectList()) {
if (String.valueOf(it.get("subId")).equals("SUB" + code)) {
return String.valueOf(it.get("subName"));
}
}
return value;
}
private static Map<String, Object> item(String value, String label) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("value", value);
m.put("label", label);
return m;
}
}
@@ -0,0 +1,140 @@
package com.yunhai.profession.utils;
import lombok.Value;
/**
* 当前登录用户上下文(ThreadLocal 静态持有器)。
* <p>移植自 UserclassService(com.yunhai.bschclassmanage.utils.UserContext),
* 由 {@link com.yunhai.profession.aspect.ControllerUserContextAspect} 在每个请求进入 controller 前填充,
* 请求结束后 clear,防止线程复用串号。</p>
*/
public class UserContext {
private static final ThreadLocal<Integer> USER_ID = new ThreadLocal<>();
private static final ThreadLocal<String> USER_NAME = new ThreadLocal<>();
private static final ThreadLocal<Integer> ROLE_ID = new ThreadLocal<>();
private static final ThreadLocal<Integer> SCH_ID = new ThreadLocal<>();
private static final ThreadLocal<String> IP = new ThreadLocal<>();
private static final ThreadLocal<Integer> TYPE = new ThreadLocal<>();
/** 当前角色切换上下文(来自 currentrole header),家长场景 childId 即学生ID */
private static final ThreadLocal<Integer> CHILD_ID = new ThreadLocal<>();
public static void setUserId(Integer userId) {
USER_ID.set(userId);
}
public static Integer getUserId() {
return USER_ID.get();
}
public static void setUserName(String userName) {
USER_NAME.set(userName);
}
public static String getUserName() {
return USER_NAME.get();
}
public static void setRoleId(Integer roleId) {
ROLE_ID.set(roleId);
}
public static Integer getRoleId() {
return ROLE_ID.get();
}
public static void setSchId(Integer schId) {
SCH_ID.set(schId);
}
public static Integer getSchId() {
return SCH_ID.get();
}
public static void setIp(String ip) {
IP.set(ip);
}
public static String getIp() {
return IP.get();
}
public static void setType(Integer type) {
TYPE.set(type);
}
public static Integer getType() {
return TYPE.get();
}
public static void setChildId(Integer childId) {
CHILD_ID.set(childId);
}
/** 家长端当前孩子(学生)ID;主账号/教师为 0 或 null */
public static Integer getChildId() {
return CHILD_ID.get();
}
/** 用户信息快照(用于异步任务传递) */
@Value
public static class UserInfo {
Integer userId;
String userName;
Integer roleId;
Integer schId;
String ip;
Integer type;
Integer childId;
}
public static UserInfo getCurrentUser() {
return new UserInfo(
USER_ID.get(),
USER_NAME.get(),
ROLE_ID.get(),
SCH_ID.get(),
IP.get(),
TYPE.get(),
CHILD_ID.get()
);
}
public static void setUserInfo(UserInfo info) {
if (info == null) {
return;
}
USER_ID.set(info.getUserId());
USER_NAME.set(info.getUserName());
ROLE_ID.set(info.getRoleId());
SCH_ID.set(info.getSchId());
IP.set(info.getIp());
TYPE.set(info.getType());
CHILD_ID.set(info.getChildId());
}
/**
* 把当前用户快照带入子线程执行异步任务,执行完 clear。
*/
public static Runnable wrap(Runnable task) {
UserInfo info = getCurrentUser();
return () -> {
try {
setUserInfo(info);
task.run();
} finally {
clear();
}
};
}
public static void clear() {
USER_ID.remove();
USER_NAME.remove();
ROLE_ID.remove();
SCH_ID.remove();
IP.remove();
TYPE.remove();
CHILD_ID.remove();
}
}
+82
View File
@@ -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
+33
View File
@@ -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
+35
View File
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 引入 Spring Boot 默认彩色日志变量 -->
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
<!-- 控制台:所有环境都有,且带颜色2 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
<charset>${CONSOLE_LOG_CHARSET}</charset>
</encoder>
</appender>
<!-- 文件日志:只有非 local 环境才启用 -->
<springProfile name="!local">
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/app/logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/app/logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
</encoder>
</appender>
</springProfile>
<root level="INFO">
<appender-ref ref="CONSOLE" />
<springProfile name="!local">
<appender-ref ref="FILE" />
</springProfile>
</root>
</configuration>