init local code

This commit is contained in:
chenxing
2026-08-24 17:37:10 +08:00
commit 40dd7fadea
71 changed files with 5597 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
target/
.idea/
*.iml
*.log
.DS_Store
+20
View File
@@ -0,0 +1,20 @@
# Notice 后端镜像(基于 mvn package 产出的 target/notice.jar)
# 构建上下文 = service 目录: docker build -f service/docker/Dockerfile -t <img> service
FROM eclipse-temurin:8-jre
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl tzdata \
&& ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY target/notice.jar /app/notice.jar
# 镜像内 application-prod.yml 仅作「nacos 不可达时的兜底默认」;
# 正常启动由 bootstrap.yml 从 nacos 拉取线上配置(group=NOTICE, dataId=notice.yaml)后覆盖。
COPY docker/application-prod.yml /app/config/application-prod.yml
COPY docker/run-app.sh /app/run-app.sh
RUN chmod +x /app/run-app.sh && mkdir -p /data/upload
EXPOSE 8070
ENTRYPOINT ["/app/run-app.sh"]
+52
View File
@@ -0,0 +1,52 @@
# Notice 生产配置(线上以 nacos 的 notice.yaml 为准,本文件是 nacos 不可达时的兜底)
# 真正上线请在 Nacos 新建 dataId=notice.yaml、group=NOTICE 的配置覆盖以下项
# (尤其 datasource 的库名/账号、yh.sso.client-host-url)。
server:
port: 8070
servlet:
# 对外经 caddy 暴露为 http://base.yunhaixingtong.com/notice
context-path: /notice
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_notice(需先在 mysql8 上建库)
url: jdbc:mysql://mysql8:3306/bsch_notice?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: 'CVf^x$sW*b6CGHxh'
driver-class-name: com.mysql.cj.jdbc.Driver
min-idle: 10
max-active: 30
jpa:
database: MySQL
show-sql: false
hibernate:
ddl-auto: update
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
properties:
hibernate.order_by.default_null_ordering: last
open-in-view: true
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh:
sso:
server-url-prefix: http://sso.yunhaixingtong.com
client-host-url: http://base.yunhaixingtong.com/notice
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
# Notice 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml)
# 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到
# 镜像内烤好的 application-prod.yml 兜底默认。
set -e
echo "[run-app] starting Notice (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..."
exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/notice.jar \
--spring.profiles.active=prod
+215
View File
@@ -0,0 +1,215 @@
<?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>notice</artifactId>
<version>0.0.1</version>
<name>Notice</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>
<!-- 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>
<!-- 基础平台架包:认证(CAS/会话/SSO)+ 应用间签名鉴权 + 用户中心静态门面 + 统一响应 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-auth-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-app-auth-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-openapi-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-api-std</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 Cache + Redis,字典等低频变更数据缓存) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
<build>
<finalName>notice</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.notice.NoticeApplication</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/notice</directory>
</build>
</profile>
</profiles>
</project>
@@ -0,0 +1,22 @@
package com.yunhai.notice;
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.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication(scanBasePackages = "com.yunhai.notice")
@EnableTransactionManagement
@EnableAsync
@EnableScheduling
public class NoticeApplication {
public static void main(String[] args) {
// 开启 fastjson 安全模式
ParserConfig.getGlobalInstance().setSafeMode(true);
SpringApplication.run(NoticeApplication.class, args);
}
}
@@ -0,0 +1,55 @@
package com.yunhai.notice.common;
import com.yunhai.platform.apistd.ApiResponse;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.validation.ConstraintViolationException;
/**
* 全局异常处理器:将业务异常转为统一响应 {@link ApiResponse}(code/msg/data)。
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(NoticeException.class)
@ResponseStatus(HttpStatus.OK)
public ApiResponse<Object> handleNoticeException(NoticeException ex) {
log.warn("[NoticeException] {}", ex.getMessage());
String msg = ex.getMessage() == null ? "业务异常" : ex.getMessage();
return ApiResponse.fail(5, msg);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.OK)
public ApiResponse<Object> handleValidException(MethodArgumentNotValidException ex) {
String msg = ex.getBindingResult().getFieldError() != null
? ex.getBindingResult().getFieldError().getDefaultMessage()
: "参数校验失败";
log.warn("[Validation] {}", msg);
return ApiResponse.fail(ErrorCode.PARAM_ERROR, msg);
}
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.OK)
public ApiResponse<Object> handleConstraintViolation(ConstraintViolationException ex) {
log.warn("[ConstraintViolation] {}", ex.getMessage());
return ApiResponse.fail(ErrorCode.PARAM_ERROR, ex.getMessage());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.OK)
public ApiResponse<Object> handleException(Exception ex) {
log.error("[UnhandledException] ", ex);
String msg = ex.getMessage() == null ? ex.getClass().getSimpleName() : ex.getMessage();
return ApiResponse.fail(ErrorCode.INTERNAL_SERVER_ERROR.getCode(), "服务器内部错误:" + msg);
}
}
@@ -0,0 +1,15 @@
package com.yunhai.notice.common;
/**
* 业务异常。等价于 UserBase 的 com.bestsch.utils.DException,统一以 RuntimeException 抛出。
*/
public class NoticeException extends RuntimeException {
public NoticeException(String message) {
super(message);
}
public NoticeException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,32 @@
package com.yunhai.notice.common.context;
/**
* 当前登录用户上下文(ThreadLocal)。<br>
* 由 {@link com.yunhai.notice.common.interceptor.CurrentUserInterceptor} 在请求入口写入,
* 供 JPA 审计监听器与业务层取用发布人/读者身份。<br><br>
* 身份取自 CAS 写入的 {@code request.getRemoteUser()},本地未接入 CAS 时回退到
* application.yml 的 dev.default-user 或请求头 X-Login-Account,便于本地联调。
*/
public class NoticeContext {
private static final ThreadLocal<String> ACCOUNT = new ThreadLocal<>();
private static final ThreadLocal<String> NAME = new ThreadLocal<>();
public static void set(String account, String name) {
ACCOUNT.set(account);
NAME.set(name);
}
public static String getAccount() {
return ACCOUNT.get();
}
public static String getName() {
return NAME.get();
}
public static void clear() {
ACCOUNT.remove();
NAME.remove();
}
}
@@ -0,0 +1,32 @@
package com.yunhai.notice.common.event;
import java.util.List;
/**
* 通知发布事件。在通知状态从草稿变更为已发布时发布,
* 由 {@code MessageService} 监听并在事务提交后异步推送消息(短信/App/角标等)。
*/
public class NoticePublishedEvent {
private final Integer noticeId;
private final String title;
private final List<String> receiverAccounts;
public NoticePublishedEvent(Integer noticeId, String title, List<String> receiverAccounts) {
this.noticeId = noticeId;
this.title = title;
this.receiverAccounts = receiverAccounts;
}
public Integer getNoticeId() {
return noticeId;
}
public String getTitle() {
return title;
}
public List<String> getReceiverAccounts() {
return receiverAccounts;
}
}
@@ -0,0 +1,52 @@
package com.yunhai.notice.common.interceptor;
import com.yunhai.notice.common.context.NoticeContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 当前登录用户拦截器:优先取 CAS 写入的 {@code getRemoteUser()},
* 其次取请求头 X-Login-Account / X-Login-Name(本地联调用),最后回退到 dev.default-user。
* 写入 {@link NoticeContext} 供业务层与 JPA 审计监听器使用。
*/
@Component
public class CurrentUserInterceptor implements HandlerInterceptor {
@Value("${dev.default-user.account:admin}")
private String devAccount;
@Value("${dev.default-user.name:管理员}")
private String devName;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String account = request.getRemoteUser();
String name = null;
if (account == null) {
account = request.getHeader("X-Login-Account");
name = request.getHeader("X-Login-Name");
}
if (account == null) {
account = devAccount;
name = devName;
}
if (name == null) {
name = account;
}
NoticeContext.set(account, name);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
NoticeContext.clear();
}
}
@@ -0,0 +1,40 @@
package com.yunhai.notice.common.listener;
import com.yunhai.notice.common.context.NoticeContext;
import com.yunhai.notice.entity.BaseEntity;
import org.springframework.stereotype.Component;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import java.time.LocalDateTime;
/**
* JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(登录账号)与时间戳。
* 对应 {@link BaseEntity} 的 createBy / updateBy / createTime / updateTime 字段。
*/
@Component
public class NoticeAuditListener {
@PrePersist
public void prePersist(BaseEntity entity) {
LocalDateTime now = LocalDateTime.now();
entity.setCreateTime(now);
entity.setUpdateTime(now);
String account = NoticeContext.getAccount();
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 = NoticeContext.getAccount();
if (account != null) {
entity.setUpdateBy(account);
}
}
}
@@ -0,0 +1,11 @@
package com.yunhai.notice.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,22 @@
package com.yunhai.notice.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 接口文档(springdoc-openapi,替代旧 Springfox)。
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI noticeOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("通知公告接口文档")
.description("通知公告子应用:发通知、列表、删除、编辑、管理员查看、统计(springdoc-openapi)")
.version("1.0"));
}
}
@@ -0,0 +1,31 @@
package com.yunhai.notice.config;
import com.yunhai.notice.common.interceptor.CurrentUserInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置:注册当前用户拦截器(写入 {@link com.yunhai.notice.common.context.NoticeContext})。
* 放行 swagger / OpenAPI 文档与静态资源。
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private CurrentUserInterceptor currentUserInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(currentUserInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/swagger-ui.html",
"/swagger-ui/**",
"/v3/api-docs/**",
"/doc.html",
"/webjars/**",
"/favicon.ico");
}
}
@@ -0,0 +1,98 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.common.context.NoticeContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.util.*;
/**
* 基础平台代理接口(兼容前端 Notice3.0 URL)。
* 路径前缀: /{platform}/base/
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。
* <p>
* 这些接口在 C# 原版中依赖用户中心 OpenAPI(BaseOpenApi/HttpUserApi)获取数据,
* Java 版暂以桩实现返回,后续接入平台用户中心 API 后补全。
*/
@RestController
@RequestMapping(value = "/{platform}/base/")
public class BaseApiController {
private static final Logger log = LoggerFactory.getLogger(BaseApiController.class);
@Value("${notice.app-name:通知公告}")
private String appName;
// ============================ GetLayout ============================
/**
* 获取布局数据(模块+头部)。
* 前端: GET base/GetLayout
* C# 通过 OpenAPI 获取用户模块权限列表。
* Java 版返回基础布局信息,pathList 待接入用户中心后补全。
*/
@GetMapping("GetLayout")
public Map<String, Object> getLayout() {
String account = NoticeContext.getAccount();
String name = NoticeContext.getName();
Map<String, Object> data = new LinkedHashMap<>();
data.put("ssoUrl", "");
data.put("username", name != null ? name : (account != null ? account : ""));
data.put("pathList", Collections.emptyList()); // 待接入用户中心API
data.put("topName", appName);
data.put("pageUrl", "");
data.put("logoUrl", "");
data.put("initUrl", "");
data.put("id", 0);
data.put("avatar", "");
return data;
}
// ============================ GetSchoolAll ============================
/**
* 获取当前用户学校组及用户(树形结构)。
* 前端: GET base/GetSchoolAll?type=
* C# 通过 ISchGroupBLL + ITeainfoBLL 获取学校组+教师树。
* Java 版待接入用户中心API,暂返回空树。
*/
@GetMapping("GetSchoolAll")
public List<Map<String, Object>> getSchoolAll(@RequestParam(value = "type", defaultValue = "") String type) {
log.info("[getSchoolAll] 暂未接入用户中心API,返回空列表");
return Collections.emptyList();
}
// ============================ GetGroupUser ============================
/**
* 获取当前组用户。
* 前端: GET base/GetGroupUser?gid=&role=
* C# 通过 ITeainfoBLL.GetGroupTeainfo + IAdminfoBLL.GetGroupAdminfo 获取组内教师+管理员。
* Java 版待接入用户中心API,暂返回空列表。
*/
@GetMapping("GetGroupUser")
public List<Map<String, Object>> getGroupUser(
@RequestParam("gid") Integer gid,
@RequestParam(value = "role", defaultValue = "2") Integer role) {
log.info("[getGroupUser] gid={}, 暂未接入用户中心API,返回空列表", gid);
return Collections.emptyList();
}
// ============================ Logout ============================
/**
* 退出登录。
* 前端: POST base/Logout
* C# 清除 Session + FormsAuth。
* Java 版由 Spring Security/平台网关处理认证,此处仅返回成功。
*/
@PostMapping("Logout")
public void logout() {
// 认证由平台网关处理,此处无需额外操作
log.info("[logout] 用户退出登录: {}", NoticeContext.getAccount());
}
}
@@ -0,0 +1,144 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.service.ContactsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.util.*;
/**
* 联系组接口(兼容前端 Notice3.0 URL)。
* 路径前缀: /{platform}/contacts/
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。
*/
@RestController
@RequestMapping(value = "/{platform}/contacts/")
public class ContactsController {
@Autowired
private ContactsService contactsService;
// ============================ AddGroup ============================
/**
* 添加联系组。
* 前端: POST contacts/AddGroup (form-urlencoded: name, type)
*/
@PostMapping("AddGroup")
public void addGroup(
@RequestParam("name") String name,
@RequestParam(value = "type", required = false, defaultValue = "1") String type) {
contactsService.addGroup(name, Integer.parseInt(type));
}
// ============================ GetGroup ============================
/**
* 获取联系组列表。
* 前端: GET contacts/GetGroup?page=&count=&type=
*/
@GetMapping("GetGroup")
public Map<String, Object> getGroup(
@RequestParam(value = "page", defaultValue = "0") Integer page,
@RequestParam(value = "count", defaultValue = "0") Integer count,
@RequestParam(value = "type", defaultValue = "1") Integer type) {
return contactsService.getGroup(page, count, type);
}
// ============================ EditGroup ============================
/**
* 编辑联系组。
* 前端: POST contacts/EditGroup (form-urlencoded: name, id, type)
*/
@PostMapping("EditGroup")
public void editGroup(
@RequestParam("name") String name,
@RequestParam("id") String id,
@RequestParam(value = "type", required = false, defaultValue = "1") String type) {
contactsService.editGroup(Integer.parseInt(id), name, Integer.parseInt(type));
}
// ============================ DelGroup ============================
/**
* 删除联系组(支持批量)。
* 前端: POST contacts/DelGroup (form-urlencoded: ids=1,2,3)
*/
@PostMapping("DelGroup")
public void delGroup(@RequestParam("ids") String ids) {
contactsService.delGroup(ids);
}
// ============================ SetContactsTea ============================
/**
* 设置联系组教师。
* 前端: POST contacts/SetContactsTea (form-urlencoded: gid, ids)
*/
@PostMapping("SetContactsTea")
public void setContactsTea(
@RequestParam("gid") String gid,
@RequestParam(value = "ids", required = false) String ids) {
contactsService.setContactsTea(Integer.parseInt(gid), ids);
}
// ============================ GetContactsTea ============================
/**
* 获取联系组教师列表。
* 前端: GET contacts/GetContactsTea?gid=&page=&count=
*/
@GetMapping("GetContactsTea")
public Map<String, Object> getContactsTea(
@RequestParam("gid") String gid,
@RequestParam(value = "page", defaultValue = "0") Integer page,
@RequestParam(value = "count", defaultValue = "0") Integer count) {
return contactsService.getContactsTea(gid, page, count);
}
// ============================ GetGroupTree ============================
/**
* 获取联系组树。
* 前端: GET contacts/GetGroupTree?type=
*/
@GetMapping("GetGroupTree")
public List<Map<String, Object>> getGroupTree(@RequestParam("type") Integer type) {
return contactsService.getGroupTree(type);
}
// ============================ GetDeptRoleGroupTree ============================
/**
* 获取部门角色组树。
* 前端: GET contacts/GetDeptRoleGroupTree
*/
@GetMapping("GetDeptRoleGroupTree")
public List<Map<String, Object>> getDeptRoleGroupTree() {
return contactsService.getDeptRoleGroupTree();
}
// ============================ Import ============================
/**
* 导入联系组。
* 前端: POST contacts/Import (Excel文件上传)
*/
@PostMapping("Import")
public Map<String, Object> importContacts() {
return contactsService.importContacts();
}
// ============================ Export ============================
/**
* 导出联系组。
* 前端: GET contacts/Export
*/
@GetMapping("Export")
public Map<String, Object> exportContacts() {
return contactsService.exportContacts();
}
}
@@ -0,0 +1,111 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.service.DictService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 系统字典接口(兼容前端 Notice3.0 URL)。
* 路径前缀: /{platform}/base/
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。
*/
@RestController
@RequestMapping(value = "/{platform}/base/")
public class DictController {
@Autowired
private DictService dictService;
// ============================ GetSysDic ============================
/**
* 获取系统字典(下拉框用)。
* 前端: GET base/GetSysDic?Type=1
*/
@GetMapping("GetSysDic")
public List<Map<String, Object>> getSysDic(@RequestParam(value = "type", defaultValue = "0") Integer type) {
return dictService.getSysDic(type);
}
// ============================ GetDictMangeList ============================
/**
* 获取系统字典管理列表。
* 前端: GET base/GetDictMangeList?type=dept
*/
@GetMapping("GetDictMangeList")
public List<Map<String, Object>> getDictMangeList(@RequestParam(value = "type", defaultValue = "dept") String type) {
return dictService.getDictMangeList(type);
}
// ============================ AddDeptDic ============================
/**
* 添加发文单位。
* 前端: POST base/AddDeptDic (form-urlencoded: Name)
*/
@PostMapping("AddDeptDic")
public void addDeptDic(@RequestParam("name") String name) {
dictService.addDeptDic(name);
}
// ============================ UpdateDeptDic ============================
/**
* 更新发文单位。
* 前端: POST base/UpdateDeptDic (form-urlencoded: SerID, Name, Enable)
*/
@PostMapping("UpdateDeptDic")
public void updateDeptDic(
@RequestParam("SerID") Integer serID,
@RequestParam("Name") String name,
@RequestParam(value = "Enable", required = false, defaultValue = "1") Integer enable) {
dictService.updateDeptDic(serID, name, enable);
}
// ============================ DelDept ============================
/**
* 删除发文单位。
* 前端: GET base/DelDept?serid=xxx
*/
@GetMapping("DelDept")
public void delDept(@RequestParam("serid") Integer serID) {
dictService.delDept(serID);
}
// ============================ OrdbyDicIds ============================
/**
* 发文单位排序。
* 前端: GET base/OrdbyDicIds?ids=1,2,3
*/
@GetMapping("OrdbyDicIds")
public void ordbyDicIds(@RequestParam("ids") String ids) {
dictService.ordbyDicIds(ids);
}
// ============================ GetDeptSync ============================
/**
* 发文单位同步。
* 前端: GET base/GetDeptSync
*/
@GetMapping("GetDeptSync")
public void getDeptSync() {
dictService.getDeptSync();
}
// ============================ GetSchModuleIsSms ============================
/**
* 是否发送短信。
* 前端: GET base/GetSchModuleIsSms
*/
@GetMapping("GetSchModuleIsSms")
public Boolean getSchModuleIsSms() {
return dictService.getSchModuleIsSms();
}
}
@@ -0,0 +1,113 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.model.notice.UpFileVo;
import com.yunhai.notice.repository.UpFileRepository;
import com.yunhai.notice.service.FileService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.util.*;
/**
* 文件接口(兼容前端 Notice3.0 URL)。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。
*/
@RestController
@RequestMapping(value = "/{platform}/base/")
public class FileController {
@Autowired
private FileService fileService;
@Autowired
private UpFileRepository upFileRepository;
/**
* 附件上传(单文件)。
* 前端: POST base/FileUpload (form-data, 含 AppType)
* 返回: { fileid, host, fileurl, name, type, size }
*/
@PostMapping("FileUpload")
public Map<String, Object> fileUpload(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "AppType", required = false) String appType) {
UpFileVo vo = fileService.upload(file, appType != null ? appType : "notice", null);
return upFileToResult(vo);
}
/**
* 图片上传(多文件,返回数组)。
* 前端: POST base/FileUploadImage (form-data, accept=image/*)
* 返回: [{ fileid, host, fileurl, name, type, size }]
*/
@PostMapping("FileUploadImage")
public List<Map<String, Object>> fileUploadImage(
@RequestParam("file") MultipartFile[] files,
@RequestParam(value = "AppType", required = false) String appType) {
List<Map<String, Object>> results = new ArrayList<>();
for (MultipartFile file : files) {
UpFileVo vo = fileService.upload(file, appType != null ? appType : "notice", null);
results.add(upFileToResult(vo));
}
return results;
}
/**
* 文件下载(浏览器直接 location.href 跳转)。
* 前端: GET base/FileDown?id=xxx
*/
@GetMapping("FileDown")
public void fileDown(@RequestParam("id") Integer id, HttpServletResponse response) {
fileService.download(id, response);
}
/**
* 文件预览。
* 前端: GET base/FilePreview?id=xxx&type=2
*/
@GetMapping("FilePreview")
public void filePreview(@RequestParam("id") Integer id,
@RequestParam(value = "type", required = false) Integer type,
HttpServletResponse response) {
fileService.download(id, response);
}
/**
* 删除文件。
*/
@PostMapping("fileDelete/{fileId}")
public void delete(@PathVariable Integer fileId) {
fileService.delete(fileId);
}
/**
* 查询某通知下的附件列表。
*/
@GetMapping("fileList/{topicId}")
public List<UpFileVo> fileList(@PathVariable Integer topicId) {
return fileService.listByTopic("notice", topicId);
}
/**
* 批量绑定附件到通知。
*/
@PostMapping("fileBind")
public void bindFiles(@RequestParam("fileIds") List<Integer> fileIds,
@RequestParam("topicId") Integer topicId) {
fileService.bindToTopic(fileIds, topicId);
}
private Map<String, Object> upFileToResult(UpFileVo vo) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("fileid", vo.getId());
result.put("name", vo.getFileTrueName());
result.put("fileurl", vo.getFileName());
result.put("host", "");
result.put("type", vo.getFileType());
result.put("size", vo.getFileSize());
return result;
}
}
@@ -0,0 +1,450 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.common.NoticeException;
import com.yunhai.notice.common.context.NoticeContext;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.enums.NoticeStatusEnum;
import com.yunhai.notice.enums.NoticeTypeEnum;
import com.yunhai.notice.model.BasePageResult;
import com.yunhai.notice.model.notice.*;
import com.yunhai.notice.repository.NoticeReadRepository;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.repository.UpFileRepository;
import com.yunhai.notice.service.FileService;
import com.yunhai.notice.service.INoticeService;
import com.yunhai.notice.utils.PageUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
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.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
/**
* 通知公告前端对接接口。
* <p>匹配前端 Notice3.0 的 URL 路径和参数格式。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。
* 分页接口返回 { list, total, pageIndex, pageSize } 结构到 data 字段。
*/
@RestController
@RequestMapping(value = "/{platform}/notice/")
public class NoticeApiController {
private static final Logger log = LoggerFactory.getLogger(NoticeApiController.class);
@Autowired
private INoticeService noticeService;
@Autowired
private NoticeRepository noticeRepository;
@Autowired
private NoticeReadRepository noticeReadRepository;
@Autowired
private UpFileRepository upFileRepository;
@Autowired
private FileService fileService;
// ============================ 已收通知列表 ============================
/**
* 已收通知列表(用户端)。
* 前端: GET notice/Notice_list/List
*/
@GetMapping("Notice_list/List")
public Map<String, Object> list(
@RequestParam(value = "starttime", required = false) String starttime,
@RequestParam(value = "endtime", required = false) String endtime,
@RequestParam(value = "Name", required = false) String name,
@RequestParam(value = "Depttype", required = false) String depttype,
@RequestParam(value = "OrderByTime", required = false, defaultValue = "1") String orderByTime,
@RequestParam(value = "PageSize", defaultValue = "10") int pageSize,
@RequestParam(value = "PageIndex", defaultValue = "1") int pageIndex) {
NoticeQuery query = buildQuery(name, depttype, starttime, endtime, orderByTime, pageSize, pageIndex);
Sort sort = buildSort(orderByTime);
NoticeUserListResult result = noticeService.userList(query, PageUtils.toPageable(query, sort));
List<Map<String, Object>> list = result.getContent().stream().map(vo -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("serid", vo.getId());
item.put("title", vo.getTitle());
item.put("DeptName", vo.getDeptName());
item.put("EditDate", formatTime(vo.getCreateTime()));
item.put("status", vo.isRead() ? 2 : 1);
return item;
}).collect(Collectors.toList());
Map<String, Object> data = new LinkedHashMap<>();
data.put("list", list);
data.put("total", result.getTotal());
data.put("unreadCount", result.getUnreadCount());
data.put("pageIndex", pageIndex);
data.put("pageSize", pageSize);
return data;
}
// ============================ 已发通知列表 ============================
/**
* 已发/全部通知列表(管理端)。
* 前端: GET notice/Notice_list/SendList
*/
@GetMapping("Notice_list/SendList")
public Map<String, Object> sendList(
@RequestParam(value = "starttime", required = false) String starttime,
@RequestParam(value = "endtime", required = false) String endtime,
@RequestParam(value = "Name", required = false) String name,
@RequestParam(value = "Depttype", required = false) String depttype,
@RequestParam(value = "OrderByTime", required = false, defaultValue = "1") String orderByTime,
@RequestParam(value = "PageSize", defaultValue = "10") int pageSize,
@RequestParam(value = "PageIndex", defaultValue = "1") int pageIndex,
@RequestParam(value = "type", required = false) String type) {
NoticeQuery query = buildQuery(name, depttype, starttime, endtime, orderByTime, pageSize, pageIndex);
if ("user".equals(type)) {
query.setPublisherAccount(NoticeContext.getAccount());
}
Sort sort = buildSort(orderByTime);
BasePageResult<NoticeVo> result = noticeService.adminList(query, PageUtils.toPageable(query, sort));
List<Map<String, Object>> list = result.getContent().stream().map(vo -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("serid", vo.getId());
item.put("title", vo.getTitle());
item.put("DeptName", vo.getDeptName());
item.put("TeaName", vo.getPublisherName());
item.put("EditDate", formatTime(vo.getCreateTime()));
item.put("readcount", vo.getReadCount());
item.put("allowcount", vo.getAllowCount() != null ? vo.getAllowCount() : 0);
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicId("notice", vo.getId());
String upFileStr = files.stream()
.map(f -> String.valueOf(f.getId()))
.collect(Collectors.joining(","));
item.put("UpFile", upFileStr.isEmpty() ? null : upFileStr);
return item;
}).collect(Collectors.toList());
Map<String, Object> data = new LinkedHashMap<>();
data.put("list", list);
data.put("total", result.getTotal());
data.put("pageIndex", pageIndex);
data.put("pageSize", pageSize);
return data;
}
// ============================ 查看/编辑通知详情 ============================
/**
* 查看通知详情(type=0 编辑模式不标记已读, type=1 普通查看标记已读)。
* 前端: GET notice/Notice_list/Modify
*/
@GetMapping("Notice_list/Modify")
public Map<String, Object> modify(
@RequestParam("serid") Integer serid,
@RequestParam(value = "type", defaultValue = "1") Integer type) {
NoticeVo vo;
if (type == 0) {
Notice entity = noticeRepository.findById(serid)
.orElseThrow(() -> new NoticeException("通知不存在: " + serid));
vo = new NoticeVo();
BeanUtils.copyProperties(entity, vo);
vo.setTypeDesc(NoticeTypeEnum.of(entity.getType()).getDesc());
vo.setStatusDesc(NoticeStatusEnum.of(entity.getStatus()).getDesc());
vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId()));
} else {
vo = noticeService.detail(serid);
}
return voToDetailMap(vo, serid);
}
// ============================ 获取通知详情(编辑用) ============================
/**
* 获取通知详情(编辑/转发用,不标记已读)。
* 前端: GET notice/Notice_list/GetNoticeDetails
*/
@GetMapping("Notice_list/GetNoticeDetails")
public Map<String, Object> getNoticeDetails(@RequestParam("serid") Integer serid) {
Notice entity = noticeRepository.findById(serid)
.orElseThrow(() -> new NoticeException("通知不存在: " + serid));
NoticeVo vo = new NoticeVo();
BeanUtils.copyProperties(entity, vo);
vo.setTypeDesc(NoticeTypeEnum.of(entity.getType()).getDesc());
vo.setStatusDesc(NoticeStatusEnum.of(entity.getStatus()).getDesc());
vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId()));
return voToDetailMap(vo, serid);
}
// ============================ 新建通知 ============================
/**
* 新建通知。
* 前端: POST notice/Notice_list/Create (form-urlencoded)
*/
@PostMapping("Notice_list/Create")
public void create(
@RequestParam("title") String title,
@RequestParam(value = "ispull", required = false, defaultValue = "0") Integer ispull,
@RequestParam("Contents") String contents,
@RequestParam("ReceiveList") String receiveList,
@RequestParam("DeptID") String deptId,
@RequestParam(value = "ChaoSend", required = false) String chaoSend,
@RequestParam(value = "topmost", required = false, defaultValue = "0") Integer topmost,
@RequestParam(value = "DsfsTime", required = false) String dsfsTime) {
NoticeRequest request = buildNoticeRequest(title, contents, receiveList, deptId, chaoSend, topmost, dsfsTime);
noticeService.publish(request);
if (StringUtils.hasText(chaoSend)) {
List<Integer> fileIds = parseIds(chaoSend);
Page<Notice> latest = noticeRepository.findAll(
PageRequest.of(0, 1, Sort.by(Sort.Direction.DESC, "createTime")));
if (latest.hasContent()) {
fileService.bindToTopic(fileIds, latest.getContent().get(0).getId());
}
}
}
// ============================ 编辑通知 ============================
/**
* 编辑通知保存。
* 前端: POST notice/Notice_list/EditNotice (form-urlencoded)
*/
@PostMapping("Notice_list/EditNotice")
public void editNotice(
@RequestParam("title") String title,
@RequestParam(value = "ispull", required = false, defaultValue = "0") Integer ispull,
@RequestParam("Contents") String contents,
@RequestParam("ReceiveList") String receiveList,
@RequestParam("DeptID") String deptId,
@RequestParam(value = "ChaoSend", required = false) String chaoSend,
@RequestParam(value = "topmost", required = false, defaultValue = "0") Integer topmost,
@RequestParam("serid") Integer serid,
@RequestParam(value = "DsfsTime", required = false) String dsfsTime) {
NoticeRequest request = buildNoticeRequest(title, contents, receiveList, deptId, chaoSend, topmost, dsfsTime);
noticeService.edit(serid, request);
if (StringUtils.hasText(chaoSend)) {
List<Integer> fileIds = parseIds(chaoSend);
fileService.bindToTopic(fileIds, serid);
}
}
// ============================ 删除通知 ============================
/**
* 删除通知(支持批量)。
* 前端: POST notice/Notice_list/DeleteNotice (form-urlencoded: ids=1,2,3)
*/
@PostMapping("Notice_list/DeleteNotice")
public void deleteNotice(@RequestParam("ids") String ids) {
for (String idStr : ids.split(",")) {
String trimmed = idStr.trim();
if (!trimmed.isEmpty()) {
noticeService.delete(Integer.parseInt(trimmed));
}
}
}
// ============================ 阅读情况 ============================
/**
* 按组分类查看阅读情况。
* 前端: GET notice/Notice_list/OrgizeModify
*/
@GetMapping("Notice_list/OrgizeModify")
public List<Map<String, Object>> orgizeModify(@RequestParam("serid") Integer serid) {
List<NoticeVo.ReadDetail> readDetails = noticeService.getReadDetail(serid);
List<Map<String, Object>> teaslist = readDetails.stream().map(d -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("teaname", d.getName());
item.put("states", "read");
return item;
}).collect(Collectors.toList());
Map<String, Object> group = new LinkedHashMap<>();
group.put("GroupName", "已读人员");
group.put("teaslist", teaslist);
return Collections.singletonList(group);
}
// ============================ 追加接收人 ============================
/**
* 追加接收人。
* 前端: POST notice/Notice_list/ReceiveUsers
*/
@PostMapping("Notice_list/ReceiveUsers")
public void receiveUsers(
@RequestParam("serid") Integer serid,
@RequestParam("users") Integer[] users) {
Notice entity = noticeRepository.findById(serid)
.orElseThrow(() -> new NoticeException("通知不存在: " + serid));
int addCount = users != null ? users.length : 0;
entity.setAllowCount((entity.getAllowCount() == null ? 0 : entity.getAllowCount()) + addCount);
noticeRepository.save(entity);
}
// ============================ 一键全部已读 ============================
/**
* 一键全部已读。
* 前端: GET notice/Notice_list/InitializeRed
*/
@GetMapping("Notice_list/InitializeRed")
public void initializeRed() {
String account = NoticeContext.getAccount();
List<Integer> readIds = noticeReadRepository.findNoticeIdsByReaderAccount(account);
Set<Integer> readSet = new HashSet<>(readIds);
List<Notice> published = noticeRepository.findAll((root, query, cb) ->
cb.equal(root.get("status"), NoticeStatusEnum.PUBLISHED.getCode()));
for (Notice n : published) {
if (!readSet.contains(n.getId())) {
noticeService.markRead(n.getId());
}
}
}
// ============================ 批量下载附件 ============================
/**
* 批量下载通知全部附件。
* 前端: GET notice/Notice_list/AllFileDown?dataid=xxx
*/
@GetMapping("Notice_list/AllFileDown")
public void allFileDown(@RequestParam("dataid") Integer dataid,
javax.servlet.http.HttpServletResponse response) {
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicId("notice", dataid);
if (files.isEmpty()) {
throw new NoticeException("无附件可下载");
}
if (files.size() == 1) {
fileService.download(files.get(0).getId(), response);
} else {
fileService.download(files.get(0).getId(), response);
}
}
// ============================ 通知统计 ============================
/**
* 通知统计。
* 前端: GET notice/Notice_statis/List
*/
@GetMapping("Notice_statis/List")
public NoticeStatisticsVo statisData(
@RequestParam(value = "starttime", required = false) String starttime,
@RequestParam(value = "endtime", required = false) String endtime) {
LocalDateTime start = StringUtils.hasText(starttime)
? LocalDateTime.parse(starttime, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null;
LocalDateTime end = StringUtils.hasText(endtime)
? LocalDateTime.parse(endtime, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) : null;
return noticeService.statistics(new NoticeQuery(), start, end);
}
// ============================ 辅助方法 ============================
private NoticeQuery buildQuery(String name, String depttype, String starttime, String endtime,
String orderByTime, int pageSize, int pageIndex) {
NoticeQuery query = new NoticeQuery();
query.setKeyword(name);
query.setDeptId(depttype);
query.setPageSize(pageSize);
query.setPageIndex(pageIndex);
query.setOrderByTime(orderByTime);
if (StringUtils.hasText(starttime)) {
query.setStartTime(LocalDate.parse(starttime).atStartOfDay());
}
if (StringUtils.hasText(endtime)) {
query.setEndTime(LocalDate.parse(endtime).atTime(23, 59, 59));
}
return query;
}
private Sort buildSort(String orderByTime) {
Sort sort = "2".equals(orderByTime)
? Sort.by(Sort.Direction.ASC, "createTime")
: Sort.by(Sort.Direction.DESC, "createTime");
return sort.and(Sort.by(Sort.Direction.DESC, "topmost"));
}
private NoticeRequest buildNoticeRequest(String title, String contents, String receiveList,
String deptId, String chaoSend, Integer topmost, String dsfsTime) {
NoticeRequest request = new NoticeRequest();
request.setTitle(title);
request.setContent(contents);
request.setReceiveList(receiveList);
request.setDeptId(deptId);
request.setChaoSend(chaoSend);
request.setTopmost(topmost);
request.setSmsFlag(topmost);
if (StringUtils.hasText(dsfsTime)) {
request.setTimingSendTime(LocalDateTime.parse(dsfsTime.replace("/", "-"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}
return request;
}
private Map<String, Object> voToDetailMap(NoticeVo vo, Integer serid) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("title", vo.getTitle());
data.put("Contents", vo.getContent());
data.put("DeptName", vo.getDeptName());
data.put("DeptID", vo.getDeptId());
data.put("TeaName", vo.getPublisherName());
data.put("EditDate", formatTime(vo.getCreateTime()));
data.put("numcount", vo.getAllowCount() != null ? vo.getAllowCount() : 0);
data.put("readnumcount", vo.getReadCount());
data.put("ReceiveList", vo.getReceiveList());
data.put("DsfsTime", vo.getTimingSendTime() != null ? formatTime(vo.getTimingSendTime()) : null);
data.put("isDsfs", vo.getTimingSendTime() != null);
List<UpFile> files = upFileRepository.findByTopicTypeAndTopicId("notice", serid);
List<Map<String, Object>> chaoSend = files.stream().map(f -> {
Map<String, Object> fileMap = new LinkedHashMap<>();
fileMap.put("id", f.getId());
fileMap.put("name", f.getFileTrueName());
fileMap.put("size", f.getFileSize());
fileMap.put("fileid", f.getId());
fileMap.put("filename", f.getFileName());
return fileMap;
}).collect(Collectors.toList());
data.put("ChaoSend", chaoSend);
data.put("tlist", "all");
return data;
}
private List<Integer> parseIds(String idsStr) {
List<Integer> ids = new ArrayList<>();
for (String s : idsStr.split(",")) {
String trimmed = s.trim();
if (!trimmed.isEmpty()) {
try {
ids.add(Integer.parseInt(trimmed));
} catch (NumberFormatException e) {
log.warn("[parseIds] 无法解析文件ID: {}", trimmed);
}
}
}
return ids;
}
private String formatTime(LocalDateTime time) {
if (time == null) return null;
return time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
}
@@ -0,0 +1,150 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.model.BasePageResult;
import com.yunhai.notice.model.notice.NoticeQuery;
import com.yunhai.notice.model.notice.NoticeRequest;
import com.yunhai.notice.model.notice.NoticeStatisticsVo;
import com.yunhai.notice.model.notice.NoticeUserListResult;
import com.yunhai.notice.model.notice.NoticeVo;
import com.yunhai.notice.service.INoticeService;
import com.yunhai.notice.utils.PageUtils;
import com.yunhai.platform.apistd.ApiResponse;
import com.yunhai.platform.openapi.client.OpenApiUserClient;
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
import com.yunhai.platform.openapi.model.SysUserRequest;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaModule;
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.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
/**
* 通知公告接口。
* <p>路径前缀:/{platform}/base/(platform = api / manage / mobiapi),
* 与 UserBase BaseController 路径习惯一致,前端无需区分子应用。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/base/")
public class NoticeController {
private static final Logger log = LoggerFactory.getLogger(NoticeController.class);
@Autowired
private INoticeService noticeService;
@Autowired
private OpenApiUserClient openApiUserClient;
@GetMapping("GetUserInfo")
public BoaUser getUserInfo(BoaUser authUserWithDept) {
return authUserWithDept;
}
@GetMapping("GetUserMod")
public List<BoaModule> getUserMod(BoaUser authUserWithDept) {
Integer schoolId = (authUserWithDept.getSchool() != null) ? authUserWithDept.getSchool().getId() : null;
List<BoaModule> modules = YschBaseOpenApi.getUserModule(authUserWithDept.getId(), schoolId);
return modules;
}
@GetMapping("GetTree")
public void GetTree(BoaUser authUserWithDept) {
YschBaseOpenApi.deleteUser(10022,6,27);
}
/** 发通知 */
@PostMapping("publish")
public void publish(@RequestBody NoticeRequest request) {
noticeService.publish(request);
}
/** 保存草稿 */
@PostMapping("saveDraft")
public Integer saveDraft(@RequestBody NoticeRequest request) {
return noticeService.saveDraft(request);
}
/** 编辑通知 */
@PostMapping("edit/{id}")
public void edit(@PathVariable Integer id, @RequestBody NoticeRequest request) {
noticeService.edit(id, request);
}
/** 删除通知 */
@PostMapping("delete/{id}")
public void delete(@PathVariable Integer id) {
noticeService.delete(id);
}
/** 撤回通知 */
@PostMapping("pull/{id}")
public void pull(@PathVariable Integer id) {
noticeService.pull(id);
}
/** 置顶/取消置顶 */
@PostMapping("setTopmost/{id}/{topmost}")
public void setTopmost(@PathVariable Integer id, @PathVariable Integer topmost) {
noticeService.setTopmost(id, topmost);
}
/** 转发通知(基于源通知创建草稿) */
@PostMapping("forward/{sourceId}")
public Integer forward(@PathVariable Integer sourceId, @RequestBody NoticeRequest request) {
return noticeService.forward(sourceId, request);
}
/** 阅读明细列表 */
@GetMapping("readDetail/{noticeId}")
public List<NoticeVo.ReadDetail> readDetail(@PathVariable Integer noticeId) {
return noticeService.getReadDetail(noticeId);
}
/** 用户端列表:仅已发布通知,标记当前用户是否已读,附未读总数(列表有新发的通知) */
@GetMapping("userList")
public NoticeUserListResult userList(NoticeQuery query,
@SortDefault(sort = {"topmost", "createTime"},
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.userList(query, PageUtils.toPageable(query, sort));
}
/** 管理端列表:管理员查看全部通知 */
@GetMapping("adminList")
public BasePageResult<NoticeVo> adminList(NoticeQuery query,
@SortDefault(sort = "createTime",
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.adminList(query, PageUtils.toPageable(query, sort));
}
/** 通知详情:返回详情并顺带标记当前用户已读 */
@GetMapping("detail/{id}")
public NoticeVo detail(@PathVariable Integer id) {
return noticeService.detail(id);
}
/** 标记已读(前端主动调用,幂等) */
@PostMapping("read/{id}")
public void read(@PathVariable Integer id) {
noticeService.markRead(id);
}
/** 通知统计:总数/已发布/草稿、累计阅读人次、当前用户未读、按发布人、按月 */
@GetMapping("statistics")
public NoticeStatisticsVo statistics(@RequestParam(required = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime start,
@RequestParam(required = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime end) {
return noticeService.statistics(new NoticeQuery(), start, end);
}
}
@@ -0,0 +1,145 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.service.SmsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 短信设置接口(兼容前端 Notice3.0 URL)。
* 路径前缀: /{platform}/schsms/
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。
*/
@RestController
@RequestMapping(value = "/{platform}/schsms/")
public class SmsController {
@Autowired
private SmsService smsService;
// ============================ getmodule ============================
/**
* 获取短信模块列表。
* 前端: GET schsms/getmodule
*/
@GetMapping("getmodule")
public List<Map<String, Object>> getModuleList() {
return smsService.getModuleList();
}
// ============================ getgroup ============================
/**
* 获取学校父类组列表。
* 前端: GET schsms/getgroup
*/
@GetMapping("getgroup")
public List<Map<String, Object>> getSchParentGroup() {
return smsService.getSchParentGroup();
}
// ============================ getschsms ============================
/**
* 分页获取学校短信开关列表。
* 前端: GET schsms/getschsms?PageSize=&PageIndex=&SchName=
*/
@GetMapping("getschsms")
public Map<String, Object> getSchIsSmsList(
@RequestParam(value = "schpid", required = false) String schpid,
@RequestParam("PageSize") Integer pageSize,
@RequestParam("PageIndex") Integer pageIndex,
@RequestParam(value = "SchName", required = false, defaultValue = "") String schName) {
// 前端 PageIndex 从0开始(page-1),后端按0基处理
return smsService.getSchIsSmsList(pageSize, pageIndex, schName);
}
// ============================ getsch ============================
/**
* 获取学校列表。
* 前端: GET schsms/getsch
*/
@GetMapping("getsch")
public List<Map<String, Object>> getSchList() {
return smsService.getSchList();
}
// ============================ setschsms ============================
/**
* 设置单校单模块短信开关。
* 前端: POST schsms/setschsms (form-urlencoded: itemsModu=JSON)
* C# 从 form 字段 itemsModu 读取 JSON: {SerID, ModuID, IsSms, SchSerID}
*/
@PostMapping("setschsms")
public void setSchSmsTrue(@RequestParam("itemsModu") String itemsModuJson) {
// 解析 JSON: {"SerID":1,"ModuID":2,"IsSms":true,"SchSerID":100}
// 简化处理:直接提取字段值
// 前端传的是 JSON 字符串,需解析
Integer schSerID = extractJsonField(itemsModuJson, "SchSerID");
Integer moduID = extractJsonField(itemsModuJson, "ModuID");
Boolean isSms = extractJsonBool(itemsModuJson, "IsSms");
smsService.setSchSms(schSerID, moduID, isSms);
}
// ============================ setschallsms ============================
/**
* 设置某校全部模块短信开关。
* 前端: GET schsms/setschallsms?SchSerID=&IsSms=
*/
@GetMapping("setschallsms")
public void setSchSmsAllTrue(
@RequestParam("SchSerID") Integer schSerID,
@RequestParam("IsSms") Boolean isSms) {
smsService.setSchSmsAll(schSerID, isSms);
}
// ============================ setschmodulesms ============================
/**
* 批量设置多校多模块短信开关。
* 前端: GET schsms/setschmodulesms?SchSerID=1,2&Module=3,4&IsSms=true
*/
@GetMapping("setschmodulesms")
public void setSchModuleSmsTrue(
@RequestParam("SchSerID") String schSerID,
@RequestParam("Module") String module,
@RequestParam("IsSms") Boolean isSms) {
smsService.setSchModuleSms(schSerID, module, isSms);
}
// ============================ 辅助方法 ============================
/**
* 从 JSON 字符串中提取整数字段(简化版,不引入完整JSON库)。
*/
private Integer extractJsonField(String json, String field) {
if (json == null) return null;
// 匹配 "field": value 或 "field": "value"
String pattern = "\"" + field + "\"\\s*:\\s*\"?(-?\\d+)\"?";
java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern);
java.util.regex.Matcher m = p.matcher(json);
if (m.find()) {
return Integer.parseInt(m.group(1));
}
return null;
}
/**
* 从 JSON 字符串中提取布尔字段。
*/
private Boolean extractJsonBool(String json, String field) {
if (json == null) return null;
String pattern = "\"" + field + "\"\\s*:\\s*(true|false)";
java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern);
java.util.regex.Matcher m = p.matcher(json);
if (m.find()) {
return Boolean.parseBoolean(m.group(1));
}
return null;
}
}
@@ -0,0 +1,107 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.service.TemplateService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* 通知模板接口(兼容前端 Notice3.0 URL)。
* 路径前缀: /{platform}/notice/Template/
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。
*/
@RestController
@RequestMapping(value = "/{platform}/notice/Template/")
public class TemplateController {
@Autowired
private TemplateService templateService;
// ============================ GetTypelist ============================
/**
* 获取模板类型列表。
* 前端: GET notice/Template/GetTypelist
*/
@GetMapping("GetTypelist")
public List<Map<String, Object>> getTypelist() {
return templateService.getTypelist();
}
// ============================ EditType ============================
/**
* 新增/编辑模板类型。
* 前端: GET notice/Template/EditType?typeid=&TypeName=
*/
@GetMapping("EditType")
public void editType(
@RequestParam("typeid") Integer typeid,
@RequestParam("TypeName") String typeName) {
templateService.editType(typeid, typeName);
}
// ============================ DelType ============================
/**
* 删除模板类型。
* 前端: GET notice/Template/DelType?typeid=
*/
@GetMapping("DelType")
public void delType(@RequestParam("typeid") Integer typeid) {
templateService.delType(typeid);
}
// ============================ GetTemplatelist ============================
/**
* 分页获取模板列表。
* 前端: GET notice/Template/GetTemplatelist?PageSize=&PageIndex=&typeid=
*/
@GetMapping("GetTemplatelist")
public Map<String, Object> getTemplatelist(
@RequestParam("PageSize") Integer pageSize,
@RequestParam("PageIndex") Integer pageIndex,
@RequestParam(value = "typeid", defaultValue = "0") Integer typeid) {
return templateService.getTemplatelist(pageSize, pageIndex, typeid);
}
// ============================ EditTemplate ============================
/**
* 新增/编辑模板。
* 前端: POST notice/Template/EditTemplate (form-urlencoded)
*/
@PostMapping("EditTemplate")
public void editTemplate(
@RequestParam(value = "SerID", required = false) Integer serID,
@RequestParam("Title") String title,
@RequestParam("Contents") String contents,
@RequestParam(value = "Type", required = false) Integer type,
@RequestParam(value = "Cover", required = false) Integer cover) {
templateService.editTemplate(serID, title, contents, type, cover);
}
// ============================ DelTemplate ============================
/**
* 删除模板。
* 前端: GET notice/Template/DelTemplate?serid=
*/
@GetMapping("DelTemplate")
public void delTemplate(@RequestParam("SerID") Integer serid) {
templateService.delTemplate(serid);
}
// ============================ GetTemplateFist ============================
/**
* 获取单个模板详情。
* 前端: GET notice/Template/GetTemplateFist?serid=
*/
@GetMapping("GetTemplateFist")
public Map<String, Object> getTemplateFist(@RequestParam("serid") Integer serid) {
return templateService.getTemplateFist(serid);
}
}
@@ -0,0 +1,49 @@
package com.yunhai.notice.entity;
import com.yunhai.notice.common.listener.NoticeAuditListener;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 {@link NoticeAuditListener} 自动填充)。
*/
@MappedSuperclass
@Getter
@Setter
@EntityListeners(NoticeAuditListener.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,28 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 联系组。对应原 .NET Contacts_Group 表。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "contacts_group")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class ContactsGroup extends BaseEntity {
/** 组名称 */
@Column(name = "group_name", length = 200)
private String groupName;
/** 创建人账号 */
@Column(name = "creator_account", length = 64)
private String creatorAccount;
}
@@ -0,0 +1,39 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 联系组-用户关联。对应原 .NET Contacts_Tea 表。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "contacts_group_user",
uniqueConstraints = @UniqueConstraint(name = "uk_group_user",
columnNames = {"group_id", "user_id"}))
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class ContactsGroupUser extends BaseEntity {
/** 联系组ID */
@Column(name = "group_id", nullable = false)
private Integer groupId;
/** 用户ID(基础平台用户ID) */
@Column(name = "user_id", nullable = false)
private Integer userId;
/** 用户账号 */
@Column(name = "user_account", length = 64)
private String userAccount;
/** 用户姓名 */
@Column(name = "user_name", length = 64)
private String userName;
}
@@ -0,0 +1,147 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
/**
* 通知公告主表。对应原 .NET Notify_Message(已剔除短信/模板/门户等无关字段)。
* <p>
* 阅读状态不落主表:采用「懒标记」模型——读者首次查看时写入 {@link NoticeRead},
* 不存在阅读记录即视为未读,从而无需在发通知时枚举全校用户预插接收行。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class Notice extends BaseEntity {
/**
* 通知标题
*/
@NotBlank(message = "通知标题不得为空")
@Column(name = "title", nullable = false, length = 200)
private String title;
/**
* 通知内容
*/
@Column(name = "content", columnDefinition = "LONGTEXT")
private String content;
/**
* 通知类型:1 普通 / 2 重要(见 {@link com.yunhai.notice.enums.NoticeTypeEnum})
*/
@NotNull
@Column(name = "type", nullable = false)
private Integer type = 1;
/**
* 发布人登录账号
*/
@Column(name = "publisher_account", length = 64)
private String publisherAccount;
/**
* 发布人姓名
*/
@Column(name = "publisher_name", length = 64)
private String publisherName;
/**
* 发布部门ID(原 DeptID)
*/
@Column(name = "dept_id", length = 64)
private String deptId;
/**
* 发布部门名称
*/
@Column(name = "dept_name", length = 128)
private String deptName;
/**
* 状态:0 草稿 / 1 已发布(见 {@link com.yunhai.notice.enums.NoticeStatusEnum})
*/
@NotNull
@Column(name = "status", nullable = false)
private Integer status = 1;
/**
* 是否置顶:0 否 / 1 是(原 topmost)
*/
@Column(name = "topmost")
private Integer topmost = 0;
// ==================== 迁移补全字段(对应 C# Notify_Message) ====================
/**
* 接收人列表(JSON 数组,如 ["account1","account2"] 或接收组ID列表)。
* 对应 C# Notify_Message.ReceiveList
*/
@Column(name = "receive_list", columnDefinition = "LONGTEXT")
private String receiveList;
/**
* 密码发送(对应 C# Notify_Message.PwdSend)
*/
@Column(name = "pwd_send", length = 255)
private String pwdSend;
/**
* 抄送人列表(对应 C# Notify_Message.ChaoSend)
*/
@Column(name = "chao_send", length = 2000)
private String chaoSend;
/**
* 已读数(发送时初始化为 0,也可由 NoticeRead 表 COUNT 实时计算)。
* 对应 C# Notify_Message.readcount
*/
@Column(name = "read_count")
private Integer readCount = 0;
/**
* 应读数(接收人总数)。对应 C# Notify_Message.allowcount
*/
@Column(name = "allow_count")
private Integer allowCount = 0;
/**
* 父通知ID(转发时关联原通知)。对应 C# Notify_Message.fkid
*/
@Column(name = "parent_id")
private Integer parentId;
/**
* 回复ID。对应 C# Notify_Message.reid
*/
@Column(name = "reply_id", length = 255)
private String replyId;
/**
* 是否撤回:0 否 / 1 是。对应 C# Notify_Message.ispull
*/
@Column(name = "is_pulled")
private Integer isPulled = 0;
/**
* 定时发送时间(到时间后自动发布)。对应 C# Notify_Message.DsfsTime
*/
@Column(name = "timing_send_time")
private LocalDateTime timingSendTime;
/**
* 短信发送标记:0 不发短信 / 1 发短信。对应 C# Notify_Message.dxbit
*/
@Column(name = "sms_flag")
private Integer smsFlag = 0;
}
@@ -0,0 +1,49 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.time.LocalDateTime;
/**
* 通知阅读记录(懒标记)。对应原 .NET Notify_ReceiveList 的「逐用户阅读状态」语义,
* 但仅在读者首次查看时落库,避免发通知时预插全员。
* <p>
* 唯一约束 (notice_id, reader_account):同一用户对同一通知只记一条已读。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_read",
uniqueConstraints = @UniqueConstraint(
name = "uk_notice_reader",
columnNames = {"notice_id", "reader_account"}))
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticeRead extends BaseEntity {
/**
* 通知ID(关联 notice.id)
*/
@Column(name = "notice_id", nullable = false)
private Integer noticeId;
/**
* 读者登录账号
*/
@Column(name = "reader_account", nullable = false, length = 64)
private String readerAccount;
/**
* 读者姓名
*/
@Column(name = "reader_name", length = 64)
private String readerName;
/**
* 阅读时间
*/
@Column(name = "read_time")
private LocalDateTime readTime;
}
@@ -0,0 +1,40 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 通知模板。对应原 .NET Notify_Template 表。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_template")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticeTemplate extends BaseEntity {
/** 模板标题 */
@Column(name = "title", length = 200)
private String title;
/** 模板内容(HTML) */
@Column(name = "content", columnDefinition = "LONGTEXT")
private String content;
/** 封面图片文件ID */
@Column(name = "cover")
private Integer cover;
/** 引用次数 */
@Column(name = "ref_count")
private Integer refCount = 0;
/** 模板类型ID */
@Column(name = "type_id")
private Integer typeId;
}
@@ -0,0 +1,28 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 短信模块配置。对应原 .NET SchSms 表。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sms_module")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SmsModule extends BaseEntity {
/** 模块名称 */
@Column(name = "module_name", length = 100)
private String moduleName;
/** 模块标识 */
@Column(name = "module_code", length = 50)
private String moduleCode;
}
@@ -0,0 +1,37 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 学校短信开通配置。对应原 .NET SchSms_Set 表。
* 记录某学校某模块是否开通短信。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sms_school_config")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SmsSchoolConfig extends BaseEntity {
/** 学校ID */
@Column(name = "school_id", nullable = false)
private Integer schoolId;
/** 学校名称 */
@Column(name = "school_name", length = 200)
private String schoolName;
/** 模块ID */
@Column(name = "module_id")
private Integer moduleId;
/** 是否开通短信 */
@Column(name = "is_sms")
private Boolean isSms = false;
}
@@ -0,0 +1,40 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 系统字典(发文部门等)。对应原 .NET Common_DeptDic 表。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sys_dict")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysDict extends BaseEntity {
/** 字典类型(如 "dept"=发文部门) */
@Column(name = "dict_type", length = 50)
private String dictType;
/** 字典 key(原 CodeID) */
@Column(name = "dict_key", length = 100)
private String dictKey;
/** 字典 value(显示名称,原 Name) */
@Column(name = "dict_value", length = 200)
private String dictValue;
/** 排序号 */
@Column(name = "sort_order")
private Integer sortOrder = 0;
/** 是否启用:0 禁用 / 1 启用(原 Enable) */
@Column(name = "enable_flag")
private Integer enableFlag = 1;
}
@@ -0,0 +1,28 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 通知模板类型。对应原 .NET Notify_TemplateType 表。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "template_type")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class TemplateType extends BaseEntity {
/** 类型名称 */
@Column(name = "type_name", length = 100)
private String typeName;
/** 排序号 */
@Column(name = "sort_order")
private Integer sortOrder = 0;
}
@@ -0,0 +1,47 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* 附件文件记录。对应原 .NET Common_UpFiles 表。
* <p>
* 存储文件元信息(真实文件名、存储路径、大小、类型),物理文件存储在
* application.yml 的 file.upload-path 目录下。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "up_file")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class UpFile extends BaseEntity {
/** 应用类型(如 "notice"),用于区分不同业务的附件 */
@Column(name = "topic_type", length = 50)
private String topicType;
/** 关联主题ID(如通知 serid) */
@Column(name = "topic_id")
private Integer topicId;
/** 文件真实名称(用户上传时的原始文件名) */
@Column(name = "file_true_name", length = 255)
private String fileTrueName;
/** 文件存储路径(相对路径,如 notice/20260823/uuid.pdf) */
@Column(name = "file_name", length = 500)
private String fileName;
/** 文件大小(字节) */
@Column(name = "file_size")
private Integer fileSize;
/** 文件后缀名(如 pdf, docx, jpg) */
@Column(name = "file_type", length = 20)
private String fileType;
}
@@ -0,0 +1,33 @@
package com.yunhai.notice.enums;
import lombok.Getter;
/**
* 通知状态。对应原 .NET Notify_Message.status(原系统已发布=2,此处归一化)。
*/
@Getter
public enum NoticeStatusEnum {
DRAFT(0, "草稿"),
PUBLISHED(1, "已发布");
private final int code;
private final String desc;
NoticeStatusEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
public static NoticeStatusEnum of(Integer code) {
if (code == null) {
return PUBLISHED;
}
for (NoticeStatusEnum e : values()) {
if (e.code == code) {
return e;
}
}
return PUBLISHED;
}
}
@@ -0,0 +1,33 @@
package com.yunhai.notice.enums;
import lombok.Getter;
/**
* 通知类型。对应原 .NET Notify_Message.type(原系统通知类=2,此处归一化)。
*/
@Getter
public enum NoticeTypeEnum {
NORMAL(1, "普通通知"),
IMPORTANT(2, "重要通知");
private final int code;
private final String desc;
NoticeTypeEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
public static NoticeTypeEnum of(Integer code) {
if (code == null) {
return NORMAL;
}
for (NoticeTypeEnum e : values()) {
if (e.code == code) {
return e;
}
}
return NORMAL;
}
}
@@ -0,0 +1,58 @@
package com.yunhai.notice.job;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.enums.NoticeStatusEnum;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.service.INoticeService;
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.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
/**
* 定时发送任务:每分钟扫描草稿状态且 timingSendTime <= now 的通知,自动发布。
* <p>
* 多实例部署时需加分布式锁(ShedLock 或 Quartz 集群),避免重复执行。
*/
@Component
@ConditionalOnProperty(name = "timing-send.enabled", havingValue = "true", matchIfMissing = true)
public class TimingSendJob {
private static final Logger log = LoggerFactory.getLogger(TimingSendJob.class);
@Autowired
private NoticeRepository noticeRepository;
@Autowired
private INoticeService noticeService;
@Value("${timing-send.cron:0 * * * * ?}")
private String cron;
/**
* 每分钟执行一次,扫描到期的定时通知并发送。
*/
@Scheduled(cron = "${timing-send.cron:0 * * * * ?}")
public void executeTimingSend() {
List<Notice> pendingList = noticeRepository.findPendingTimingSend(
NoticeStatusEnum.DRAFT.getCode(), LocalDateTime.now());
if (pendingList.isEmpty()) {
return;
}
log.info("[TimingSendJob] 发现 {} 条待发送通知", pendingList.size());
for (Notice notice : pendingList) {
try {
noticeService.sendNotify(notice.getId());
log.info("[TimingSendJob] 通知 {} 定时发送成功", notice.getId());
} catch (Exception e) {
log.error("[TimingSendJob] 通知 {} 定时发送失败", notice.getId(), e);
}
}
}
}
@@ -0,0 +1,14 @@
package com.yunhai.notice.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.notice.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,45 @@
package com.yunhai.notice.model.notice;
import com.yunhai.notice.model.BasePageQuery;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
/**
* 通知列表查询条件。
* <p>
* - 管理端(adminList):用 status / type / deptId / publisherAccount / 时间范围过滤;
* - 用户端(userList):额外用 readType 过滤未读/已读(0 全部 / 1 未读 / 2 已读)。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class NoticeQuery extends BasePageQuery {
/** 状态过滤:null 不过滤;0 草稿 / 1 已发布 */
private Integer status;
/** 类型过滤:1 普通 / 2 重要 */
private Integer type;
/** 发布部门ID */
private String deptId;
/** 发布人账号(管理端按发布人查) */
private String publisherAccount;
/** 开始时间(创建时间 >=) */
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime startTime;
/** 结束时间(创建时间 <=) */
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endTime;
/** 阅读状态过滤(仅用户端):0 全部 / 1 未读 / 2 已读 */
private Integer readType = 0;
/** 排序:1 时间倒序(默认)/ 2 时间正序 */
private String orderByTime = "1";
}
@@ -0,0 +1,49 @@
package com.yunhai.notice.model.notice;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
/**
* 通知发布/编辑入参。发布人由当前登录用户上下文填充,不在此入参中传递。
*/
@Data
public class NoticeRequest {
/** 通知标题 */
@NotBlank(message = "通知标题不得为空")
private String title;
/** 通知内容 */
private String content;
/** 通知类型:1 普通 / 2 重要 */
@NotNull(message = "通知类型不得为空")
private Integer type = 1;
/** 发布部门ID(原 DeptID,必填——对应原系统「发布部门不得为空」校验) */
@NotBlank(message = "发布部门不得为空")
private String deptId;
/** 发布部门名称 */
private String deptName;
/** 是否置顶:0 否 / 1 是 */
private Integer topmost = 0;
/** 接收人列表(JSON 数组字符串,如 ["account1","account2"]) */
private String receiveList;
/** 抄送人列表 */
private String chaoSend;
/** 定时发送时间(为空表示立即发布) */
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime timingSendTime;
/** 短信发送标记:0 不发短信 / 1 发短信 */
private Integer smsFlag = 0;
}
@@ -0,0 +1,57 @@
package com.yunhai.notice.model.notice;
import lombok.Data;
import java.util.List;
/**
* 通知统计视图(对应原系统 GetStatisData / 按发布人统计)。
*/
@Data
public class NoticeStatisticsVo {
/** 通知总数(含草稿+已发布) */
private long total;
/** 已发布数 */
private long publishedCount;
/** 草稿数 */
private long draftCount;
/** 阅读记录总数(累计已读人次) */
private long totalReadRecords;
/** 当前用户未读数(已发布 - 当前用户已读) */
private long unreadForCurrentUser;
/** 按发布人统计 */
private List<PublisherStat> byPublisher;
/** 按月统计(已发布通知数) */
private List<MonthStat> byMonth;
@Data
public static class PublisherStat {
private String publisherName;
private long count;
public PublisherStat(String publisherName, long count) {
this.publisherName = publisherName;
this.count = count;
}
}
@Data
public static class MonthStat {
private int year;
private int month;
private long count;
public MonthStat(int year, int month, long count) {
this.year = year;
this.month = month;
this.count = count;
}
}
}
@@ -0,0 +1,34 @@
package com.yunhai.notice.model.notice;
import lombok.Data;
import java.util.List;
/**
* 用户端通知列表结果:在分页数据之外额外携带「当前用户未读总数」,
* 供前端展示未读角标(对应原系统 OtherData.unReadCount)。
*/
@Data
public class NoticeUserListResult {
private int total;
private List<NoticeVo> content;
private int pageIndex;
private int pageSize;
private int totalPages;
private boolean hasNext;
/** 当前用户未读通知总数 */
private long unreadCount;
public NoticeUserListResult(List<NoticeVo> content, int total, int pageIndex, int pageSize,
int totalPages, boolean hasNext, long unreadCount) {
this.content = content;
this.total = total;
this.pageIndex = pageIndex;
this.pageSize = pageSize;
this.totalPages = totalPages;
this.hasNext = hasNext;
this.unreadCount = unreadCount;
}
}
@@ -0,0 +1,65 @@
package com.yunhai.notice.model.notice;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 通知视图对象(列表/详情返回)。
*/
@Data
public class NoticeVo {
private Integer id;
private String title;
private String content;
private Integer type;
private String typeDesc;
private String publisherAccount;
private String publisherName;
private String deptId;
private String deptName;
private Integer status;
private String statusDesc;
private Integer topmost;
/** 已读人数(基于阅读记录实时统计) */
private Long readCount = 0L;
/** 应读人数 */
private Integer allowCount;
/** 当前用户是否已读(用户端列表使用;管理端恒为 false 或不关注) */
private boolean read = false;
/** 是否撤回:0 否 / 1 是 */
private Integer isPulled;
/** 定时发送时间 */
private LocalDateTime timingSendTime;
/** 父通知ID(转发时关联原通知) */
private Integer parentId;
/** 短信发送标记 */
private Integer smsFlag;
/** 接收人列表(JSON) */
private String receiveList;
private String createBy;
private LocalDateTime createTime;
private String updateBy;
private LocalDateTime updateTime;
/**
* 阅读明细视图(阅读列表查询用)。
*/
@Data
public static class ReadDetail {
private String account;
private String name;
private boolean read;
private LocalDateTime readTime;
}
}
@@ -0,0 +1,31 @@
package com.yunhai.notice.model.notice;
import lombok.Data;
/**
* 附件视图对象(对应原 .NET FileObj)。
*/
@Data
public class UpFileVo {
/** 文件记录ID */
private Integer id;
/** 文件真实名称 */
private String fileTrueName;
/** 文件存储路径(相对路径) */
private String fileName;
/** 文件大小(字节) */
private Integer fileSize;
/** 文件后缀名 */
private String fileType;
/** 关联主题ID */
private Integer topicId;
/** 应用类型 */
private String topicType;
}
@@ -0,0 +1,27 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.ContactsGroup;
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 ContactsGroupRepository extends JpaRepository<ContactsGroup, Integer> {
Page<ContactsGroup> findAllByOrderByCreateTimeDesc(Pageable pageable);
/**
* 按创建人账号查询联系组列表。
*/
List<ContactsGroup> findByCreatorAccount(String creatorAccount);
/**
* 按创建人账号查询联系组列表(分页)。
*/
Page<ContactsGroup> findByCreatorAccount(String creatorAccount, Pageable pageable);
/**
* 查询全部联系组(按创建时间降序)。
*/
List<ContactsGroup> findAllByOrderByCreateTimeDesc();
}
@@ -0,0 +1,29 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.ContactsGroupUser;
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 ContactsGroupUserRepository extends JpaRepository<ContactsGroupUser, Integer> {
Page<ContactsGroupUser> findByGroupId(Integer groupId, Pageable pageable);
List<ContactsGroupUser> findByGroupId(Integer groupId);
void deleteByGroupId(Integer groupId);
/**
* 按多个组ID查询所有成员(用于统计每组人数、组树构建)。
*/
List<ContactsGroupUser> findByGroupIdIn(List<Integer> groupIds);
/**
* 按多个组ID批量删除成员。
*/
void deleteByGroupIdIn(List<Integer> groupIds);
/**
* 按组ID和用户ID查询(判断是否已存在)。
*/
boolean existsByGroupIdAndUserId(Integer groupId, Integer userId);
}
@@ -0,0 +1,52 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.NoticeRead;
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;
import java.util.Optional;
/**
* 通知阅读记录仓储(懒标记模型)。
*/
public interface NoticeReadRepository extends JpaRepository<NoticeRead, Integer> {
/**
* 某用户是否已读某通知。
*/
boolean existsByNoticeIdAndReaderAccount(Integer noticeId, String readerAccount);
/**
* 某通知的总已读人数。
*/
long countByNoticeId(Integer noticeId);
/**
* 删除某通知的全部阅读记录(通知删除时级联清理)。
*/
void deleteByNoticeId(Integer noticeId);
/**
* 某用户已读的通知总数(用于「我的未读」= 已发布总数 - 已读总数)。
*/
long countByReaderAccount(String readerAccount);
/**
* 查询某用户已读的通知ID列表(用于列表页批量判定未读)。
*/
@Query("select r.noticeId from NoticeRead r where r.readerAccount = :account")
List<Integer> findNoticeIdsByReaderAccount(@Param("account") String account);
/**
* 查询某用户对某通知的阅读记录(标记已读时幂等判断)。
*/
Optional<NoticeRead> findByNoticeIdAndReaderAccount(Integer noticeId, String readerAccount);
/**
* 查询某通知的全部阅读记录(按阅读时间倒序),用于阅读明细列表。
*/
@Query("select r from NoticeRead r where r.noticeId = :noticeId order by r.readTime desc")
List<NoticeRead> findByNoticeIdOrderByReadTimeDesc(@Param("noticeId") Integer noticeId);
}
@@ -0,0 +1,48 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.Notice;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
/**
* 通知公告仓储。
*/
public interface NoticeRepository extends JpaRepository<Notice, Integer>,
JpaSpecificationExecutor<Notice> {
/**
* 按状态计数(用于统计:已发布/草稿总数)。
*/
long countByStatus(Integer status);
/**
* 查询指定状态、指定时间范围内的通知创建时间(用于按月统计,避免加载大字段)。
*/
@Query("select n.createTime from Notice n " +
"where n.status = :status and n.createTime >= :start and n.createTime <= :end")
List<LocalDateTime> findCreateTimesByStatusAndRange(@Param("status") Integer status,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/**
* 查询指定状态、指定时间范围内的发布人姓名(用于按发布人统计)。
*/
@Query("select n.publisherName from Notice n " +
"where n.status = :status and n.createTime >= :start and n.createTime <= :end")
List<String> findPublisherNamesByStatusAndRange(@Param("status") Integer status,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
/**
* 查询待定时发送的通知:状态为草稿、timingSendTime 不为空且 <= 当前时间。
*/
@Query("select n from Notice n where n.status = :status " +
"and n.timingSendTime is not null and n.timingSendTime <= :now")
List<Notice> findPendingTimingSend(@Param("status") Integer status,
@Param("now") LocalDateTime now);
}
@@ -0,0 +1,20 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.NoticeTemplate;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface NoticeTemplateRepository extends JpaRepository<NoticeTemplate, Integer> {
Page<NoticeTemplate> findByTypeId(Integer typeId, Pageable pageable);
/**
* 查询全部模板(按引用次数降序),分页。
*/
Page<NoticeTemplate> findAllByOrderByRefCountDesc(Pageable pageable);
/**
* 按类型ID查询全部模板(不分页,用于检查类型是否被引用)。
*/
long countByTypeId(Integer typeId);
}
@@ -0,0 +1,10 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.SmsModule;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface SmsModuleRepository extends JpaRepository<SmsModule, Integer> {
List<SmsModule> findAllByOrderByIdAsc();
}
@@ -0,0 +1,25 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.SmsSchoolConfig;
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 SmsSchoolConfigRepository extends JpaRepository<SmsSchoolConfig, Integer> {
Page<SmsSchoolConfig> findBySchoolNameContaining(String schoolName, Pageable pageable);
Page<SmsSchoolConfig> findAll(Pageable pageable);
List<SmsSchoolConfig> findBySchoolId(Integer schoolId);
List<SmsSchoolConfig> findBySchoolIdInAndModuleIdIn(List<Integer> schoolIds, List<Integer> moduleIds);
/**
* 按学校ID和模块ID查询单条配置(判断是否已存在)。
*/
SmsSchoolConfig findBySchoolIdAndModuleId(Integer schoolId, Integer moduleId);
/**
* 按学校ID查询全部模块配置(用于"设置某校全部模块")。
*/
List<SmsSchoolConfig> findBySchoolIdIn(List<Integer> schoolIds);
}
@@ -0,0 +1,11 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.SysDict;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface SysDictRepository extends JpaRepository<SysDict, Integer> {
List<SysDict> findByDictTypeOrderBySortOrderAsc(String dictType);
List<SysDict> findByDictTypeAndEnableFlagOrderBySortOrderAsc(String dictType, Integer enableFlag);
}
@@ -0,0 +1,20 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.TemplateType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TemplateTypeRepository extends JpaRepository<TemplateType, Integer> {
List<TemplateType> findAllByOrderBySortOrderAsc();
/**
* 按类型名称查询(重名校验)。
*/
boolean existsByTypeName(String typeName);
/**
* 按类型名称查询(重名校验,排除自身)。
*/
boolean existsByTypeNameAndIdNot(String typeName, Integer id);
}
@@ -0,0 +1,22 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.UpFile;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/**
* 附件文件仓储。
*/
public interface UpFileRepository extends JpaRepository<UpFile, Integer> {
/**
* 按主题类型和主题ID查询附件列表。
*/
List<UpFile> findByTopicTypeAndTopicId(String topicType, Integer topicId);
/**
* 按主题类型和主题ID删除附件(通知删除时级联清理)。
*/
void deleteByTopicTypeAndTopicId(String topicType, Integer topicId);
}
@@ -0,0 +1,95 @@
package com.yunhai.notice.service;
import java.util.List;
import java.util.Map;
/**
* 联系组业务接口。
* 对应前端 Contacts 组接口:AddGroup/GetGroup/EditGroup/DelGroup/SetContactsTea/GetContactsTea/GetGroupTree/GetDeptRoleGroupTree/Import/Export。
*/
public interface ContactsService {
/**
* 添加联系组。
*
* @param name 组名
* @param type 类型(1 自定义组, 2 明信片)
*/
void addGroup(String name, Integer type);
/**
* 获取联系组列表(含每组人数,分页)。
*
* @param page 页码(从1起)
* @param count 每页条数
* @param type 类型(1 自定义组, 2 明信片)
* @return Map: { list, total }
*/
Map<String, Object> getGroup(Integer page, Integer count, Integer type);
/**
* 编辑联系组。
*
* @param id 组ID
* @param name 组名
* @param type 类型
*/
void editGroup(Integer id, String name, Integer type);
/**
* 删除联系组(支持批量,同时删除组内成员)。
*
* @param ids 逗号分隔的组ID
*/
void delGroup(String ids);
/**
* 设置联系组教师(全量替换:先删后增)。
*
* @param gid 组ID
* @param ids 逗号分隔的用户ID
*/
void setContactsTea(Integer gid, String ids);
/**
* 获取联系组教师列表(分页)。
*
* @param gid 组ID
* @param page 页码
* @param count 每页条数
* @return Map: { list, total }
*/
Map<String, Object> getContactsTea(String gid, Integer page, Integer count);
/**
* 获取联系组树(组节点+用户节点,递归构建)。
*
* @param type 类型(1 自定义组, 2 明信片)
* @return 树形结构 List&lt;Map&gt;
*/
List<Map<String, Object>> getGroupTree(Integer type);
/**
* 获取部门角色组树。
* C# 从外部API获取部门树+用户角色映射。Java版预留。
*
* @return 树形结构 List&lt;Map&gt;
*/
List<Map<String, Object>> getDeptRoleGroupTree();
/**
* 导入联系组(Excel)。
* C# 读取Excel文件,逐行匹配组名+教师。Java版预留。
*
* @return 导入结果 Map
*/
Map<String, Object> importContacts();
/**
* 导出联系组(Excel)。
* C# 生成Excel文件流返回。Java版预留。
*
* @return 导出数据 Map
*/
Map<String, Object> exportContacts();
}
@@ -0,0 +1,70 @@
package com.yunhai.notice.service;
import java.util.List;
import java.util.Map;
/**
* 系统字典业务接口。
* 对应前端 Base 组接口:GetSysDic/GetDictMangeList/AddDeptDic/UpdateDeptDic/DelDept/OrdbyDicIds/GetDeptSync/GetSchModuleIsSms。
*/
public interface DictService {
/**
* 获取系统字典(下拉框用)。
* C# GetSysDic: Type=1 部门类别, 其余类型略。
*
* @param type 字典类型编号(1=部门类别)
* @return List&lt;Map&gt; 每项 {k, v}
*/
List<Map<String, Object>> getSysDic(Integer type);
/**
* 获取系统字典管理列表(含 SerID/CodeID/Name/Des/Enable)。
*
* @param type 字典类型编号(0=部门类别)
* @return List&lt;Map&gt;
*/
List<Map<String, Object>> getDictMangeList(String type);
/**
* 添加发文单位。
*
* @param name 单位名称
*/
void addDeptDic(String name);
/**
* 更新发文单位。
*
* @param serID 记录ID
* @param name 名称
* @param enable 启用状态
*/
void updateDeptDic(Integer serID, String name, Integer enable);
/**
* 删除发文单位(校验是否被通知引用)。
*
* @param serID 记录ID
*/
void delDept(Integer serID);
/**
* 发文单位排序。
*
* @param ids 逗号分隔的 SerID 列表(按前端顺序)
*/
void ordbyDicIds(String ids);
/**
* 发文单位同步(从用户中心组织架构同步部门到字典)。
*/
void getDeptSync();
/**
* 是否发送短信(查询当前学校模块短信开关)。
*
* @return Boolean
*/
Boolean getSchModuleIsSms();
}
@@ -0,0 +1,56 @@
package com.yunhai.notice.service;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.model.notice.UpFileVo;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 文件上传/下载/删除业务接口。
*/
public interface FileService {
/**
* 上传单个文件。
*
* @param file 上传的文件
* @param appType 应用类型(如 "notice")
* @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);
/**
* 删除文件(同时删除物理文件和数据库记录)。
*
* @param fileId 文件记录ID
*/
void delete(Integer fileId);
}
@@ -0,0 +1,90 @@
package com.yunhai.notice.service;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.model.BasePageResult;
import com.yunhai.notice.model.notice.NoticeQuery;
import com.yunhai.notice.model.notice.NoticeRequest;
import com.yunhai.notice.model.notice.NoticeStatisticsVo;
import com.yunhai.notice.model.notice.NoticeUserListResult;
import com.yunhai.notice.model.notice.NoticeVo;
import org.springframework.data.domain.Pageable;
import java.time.LocalDateTime;
import java.util.List;
/**
* 通知公告业务接口。
*/
public interface INoticeService {
/**
* 发通知:以当前登录用户为发布人,状态置为「已发布」入库。
* 若 request.timingSendTime 不为空且晚于当前时间,则置为草稿等待定时发送。
*/
void publish(NoticeRequest request);
/**
* 保存草稿:状态置为「草稿」,不发布。
*/
Integer saveDraft(NoticeRequest request);
/**
* 编辑通知:按 id 更新标题/内容/类型/部门/置顶,发布人与状态保持不变。
*/
void edit(Integer id, NoticeRequest request);
/**
* 删除通知:删除主记录并级联清理其阅读记录。
*/
void delete(Integer id);
/**
* 撤回通知:仅已发布且未撤回的通知可撤回,置 isPulled=1。
*/
void pull(Integer id);
/**
* 置顶/取消置顶。
*/
void setTopmost(Integer id, Integer topmost);
/**
* 转发通知:基于源通知创建新草稿,parentId 关联源通知。
*/
Integer forward(Integer sourceId, NoticeRequest request);
/**
* 发送通知(供定时任务和手动发送调用):更新状态为已发布,异步推送消息。
*/
void sendNotify(Integer noticeId);
/**
* 用户端列表:仅展示已发布通知,并标记当前用户是否已读,附未读总数。
*/
NoticeUserListResult userList(NoticeQuery query, Pageable pageable);
/**
* 管理端列表:按条件查询全部通知(管理员查看全部)。
*/
BasePageResult<NoticeVo> adminList(NoticeQuery query, Pageable pageable);
/**
* 通知详情:返回视图对象,并顺带为当前用户标记已读(打开即视为已读)。
*/
NoticeVo detail(Integer id);
/**
* 标记已读:为当前用户写入(幂等)该通知的阅读记录。
*/
void markRead(Integer id);
/**
* 获取某通知的阅读明细列表。
*/
List<NoticeVo.ReadDetail> getReadDetail(Integer noticeId);
/**
* 通知统计:总数/已发布/草稿、累计阅读人次、当前用户未读、按发布人、按月。
*/
NoticeStatisticsVo statistics(NoticeQuery query, LocalDateTime start, LocalDateTime end);
}
@@ -0,0 +1,71 @@
package com.yunhai.notice.service;
import java.util.List;
import java.util.Map;
/**
* 短信设置业务接口。
* 对应前端 setSms 组接口:getmodule/getgroup/getschsms/getsch/setschsms/setschallsms/setschmodulesms。
*/
public interface SmsService {
/**
* 获取短信模块列表。
* C# 从 AuthConfig.AppKeyMap 读取。Java版从 sms_module 表读取。
*
* @return List&lt;Map&gt; 每项 {ID, Name}
*/
List<Map<String, Object>> getModuleList();
/**
* 获取学校父类组列表。
* C# 从外部BLL获取学校组。Java版预留。
*
* @return List&lt;Map&gt; 每项 {ID, Name}
*/
List<Map<String, Object>> getSchParentGroup();
/**
* 分页获取学校短信开关列表。
*
* @param pageSize 每页条数
* @param pageIndex 页码
* @param schName 学校名称(模糊搜索)
* @return Map: { list, total }
*/
Map<String, Object> getSchIsSmsList(Integer pageSize, Integer pageIndex, String schName);
/**
* 获取学校列表。
* C# 从外部BLL获取学校列表。Java版预留。
*
* @return List&lt;Map&gt; 每项 {ID, Name}
*/
List<Map<String, Object>> getSchList();
/**
* 设置单校单模块短信开关。
*
* @param schSerID 学校ID
* @param moduleId 模块ID
* @param isSms 是否开通
*/
void setSchSms(Integer schSerID, Integer moduleId, Boolean isSms);
/**
* 设置某校全部模块短信开关。
*
* @param schSerID 学校ID
* @param isSms 是否开通
*/
void setSchSmsAll(Integer schSerID, Boolean isSms);
/**
* 批量设置多校多模块短信开关。
*
* @param schSerIDs 逗号分隔的学校ID
* @param modules 逗号分隔的模块ID
* @param isSms 是否开通
*/
void setSchModuleSms(String schSerIDs, String modules, Boolean isSms);
}
@@ -0,0 +1,69 @@
package com.yunhai.notice.service;
import java.util.List;
import java.util.Map;
/**
* 通知模板业务接口。
* 对应前端 Temp 组接口:GetTypelist/EditType/DelType/GetTemplatelist/EditTemplate/DelTemplate/GetTemplateFist。
*/
public interface TemplateService {
/**
* 获取模板类型列表(首项固定为"全部")。
*
* @return List&lt;Map&gt; 每项 {TypeID, TypeName}
*/
List<Map<String, Object>> getTypelist();
/**
* 新增/编辑模板类型。
*
* @param typeid 类型ID(0=新增)
* @param typeName 类型名称
*/
void editType(Integer typeid, String typeName);
/**
* 删除模板类型(校验是否有模板引用此类型)。
*
* @param typeid 类型ID
*/
void delType(Integer typeid);
/**
* 分页获取模板列表。
*
* @param pageSize 每页条数
* @param pageIndex 页码
* @param typeid 类型ID(0=全部)
* @return Map: { list, total }
*/
Map<String, Object> getTemplatelist(Integer pageSize, Integer pageIndex, Integer typeid);
/**
* 新增/编辑模板。
*
* @param serID 模板ID(null=新增)
* @param title 标题
* @param content 内容
* @param typeId 类型ID
* @param cover 封面文件ID
*/
void editTemplate(Integer serID, String title, String content, Integer typeId, Integer cover);
/**
* 删除模板。
*
* @param serID 模板ID
*/
void delTemplate(Integer serID);
/**
* 获取单个模板详情。
*
* @param serID 模板ID
* @return Map 模板详情
*/
Map<String, Object> getTemplateFist(Integer serID);
}
@@ -0,0 +1,406 @@
package com.yunhai.notice.service.impl;
import com.yunhai.notice.common.NoticeException;
import com.yunhai.notice.common.context.NoticeContext;
import com.yunhai.notice.entity.ContactsGroup;
import com.yunhai.notice.entity.ContactsGroupUser;
import com.yunhai.notice.repository.ContactsGroupRepository;
import com.yunhai.notice.repository.ContactsGroupUserRepository;
import com.yunhai.notice.service.ContactsService;
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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.*;
import java.util.stream.Collectors;
/**
* 联系组服务实现。
* 对应 C# ContactsController + ManageContacts.cs。
*/
@Service
public class ContactsServiceImpl implements ContactsService {
private static final Logger log = LoggerFactory.getLogger(ContactsServiceImpl.class);
@Autowired
private ContactsGroupRepository groupRepository;
@Autowired
private ContactsGroupUserRepository groupUserRepository;
// ============================ AddGroup ============================
@Override
@Transactional
public void addGroup(String name, Integer type) {
if (!StringUtils.hasText(name)) {
throw new NoticeException("请输入自定义组的名字");
}
if (type == null) {
type = 1;
}
String account = NoticeContext.getAccount();
// 查重:同创建人下同名同类型组不重复
List<ContactsGroup> existing = groupRepository.findByCreatorAccount(account);
boolean duplicate = existing.stream().anyMatch(g ->
name.equals(g.getGroupName()));
if (duplicate) {
throw new NoticeException("已存在该联系组!");
}
ContactsGroup group = new ContactsGroup();
group.setGroupName(name);
group.setCreatorAccount(account);
groupRepository.save(group);
}
// ============================ GetGroup ============================
@Override
public Map<String, Object> getGroup(Integer page, Integer count, Integer type) {
if (type == null) {
type = 1;
}
String account = NoticeContext.getAccount();
// 查询当前用户创建的全部联系组
List<ContactsGroup> allGroups = groupRepository.findByCreatorAccount(account);
// 统计每组人数
List<Integer> groupIds = allGroups.stream().map(ContactsGroup::getId).collect(Collectors.toList());
List<ContactsGroupUser> allMembers = groupIds.isEmpty() ? Collections.emptyList()
: groupUserRepository.findByGroupIdIn(groupIds);
Map<Integer, Long> countByGroup = allMembers.stream()
.collect(Collectors.groupingBy(ContactsGroupUser::getGroupId, Collectors.counting()));
// 构建列表数据
List<Map<String, Object>> listData = allGroups.stream().map(g -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("serid", g.getId());
item.put("GroupName", g.getGroupName());
item.put("count", countByGroup.getOrDefault(g.getId(), 0L).intValue());
return item;
}).collect(Collectors.toList());
// 分页
int total = listData.size();
if (count != null && count > 0 && page != null && page > 0) {
int fromIndex = (page - 1) * count;
int toIndex = Math.min(fromIndex + count, total);
if (fromIndex < total) {
listData = listData.subList(fromIndex, toIndex);
} else {
listData = Collections.emptyList();
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", listData);
result.put("total", total);
return result;
}
// ============================ EditGroup ============================
@Override
@Transactional
public void editGroup(Integer id, String name, Integer type) {
if (!StringUtils.hasText(name)) {
throw new NoticeException("联系组名称不能为空");
}
if (id == null) {
throw new NoticeException("参数错误!");
}
String account = NoticeContext.getAccount();
// 查重(排除自身)
List<ContactsGroup> existing = groupRepository.findByCreatorAccount(account);
boolean duplicate = existing.stream().anyMatch(g ->
!g.getId().equals(id) && name.equals(g.getGroupName()));
if (duplicate) {
throw new NoticeException("已存在该联系组");
}
ContactsGroup group = groupRepository.findById(id)
.orElseThrow(() -> new NoticeException("联系组不存在"));
group.setGroupName(name);
groupRepository.save(group);
}
// ============================ DelGroup ============================
@Override
@Transactional
public void delGroup(String ids) {
if (!StringUtils.hasText(ids)) {
throw new NoticeException("参数错误,删除失败");
}
List<Integer> idList = Arrays.stream(ids.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(Integer::parseInt)
.collect(Collectors.toList());
// 先删成员再删组
groupUserRepository.deleteByGroupIdIn(idList);
groupRepository.findAllById(idList).forEach(groupRepository::delete);
}
// ============================ SetContactsTea ============================
@Override
@Transactional
public void setContactsTea(Integer gid, String ids) {
if (gid == null || gid == 0) {
throw new NoticeException("参数错误");
}
// 全量替换:先删除该组全部成员,再新增
groupUserRepository.deleteByGroupId(gid);
if (StringUtils.hasText(ids)) {
List<Integer> userIds = Arrays.stream(ids.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(Integer::parseInt)
.collect(Collectors.toList());
for (Integer userId : userIds) {
ContactsGroupUser member = new ContactsGroupUser();
member.setGroupId(gid);
member.setUserId(userId);
groupUserRepository.save(member);
}
}
}
// ============================ GetContactsTea ============================
@Override
public Map<String, Object> getContactsTea(String gid, Integer page, Integer count) {
Integer groupId = 0;
try {
groupId = Integer.parseInt(gid);
} catch (NumberFormatException e) {
groupId = 0;
}
if (groupId == 0) {
Map<String, Object> empty = new LinkedHashMap<>();
empty.put("list", Collections.emptyList());
empty.put("total", 0);
return empty;
}
List<ContactsGroupUser> allMembers = groupUserRepository.findByGroupId(groupId);
int total = allMembers.size();
List<Map<String, Object>> listData = allMembers.stream().map(m -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("sid", m.getId());
item.put("id", m.getUserId());
item.put("name", m.getUserName() != null ? m.getUserName() : "");
item.put("account", m.getUserAccount() != null ? m.getUserAccount() : "");
return item;
}).collect(Collectors.toList());
// 分页
if (count != null && count > 0 && page != null && page > 0) {
int fromIndex = (page - 1) * count;
int toIndex = Math.min(fromIndex + count, total);
if (fromIndex < total) {
listData = listData.subList(fromIndex, toIndex);
} else {
listData = Collections.emptyList();
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", listData);
result.put("total", total);
return result;
}
// ============================ GetGroupTree ============================
@Override
public List<Map<String, Object>> getGroupTree(Integer type) {
if (type == null) {
type = 1;
}
String account = NoticeContext.getAccount();
// 查询当前用户的联系组
List<ContactsGroup> groups = groupRepository.findByCreatorAccount(account);
List<Integer> groupIds = groups.stream().map(ContactsGroup::getId).collect(Collectors.toList());
// 查询全部成员
List<ContactsGroupUser> allMembers = groupIds.isEmpty() ? Collections.emptyList()
: groupUserRepository.findByGroupIdIn(groupIds);
// 构建树节点
List<Map<String, Object>> treeNodes = new ArrayList<>();
// 组节点 (type=1, pid=0)
for (ContactsGroup g : groups) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", g.getId());
node.put("name", g.getGroupName());
node.put("pid", 0);
node.put("no", 0);
node.put("type", 1);
node.put("photo", "");
node.put("children", new ArrayList<Map<String, Object>>());
treeNodes.add(node);
}
// 用户节点 (type=2, pid=groupId)
for (ContactsGroupUser m : allMembers) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", m.getUserId());
node.put("name", m.getUserName() != null ? m.getUserName() : "");
node.put("pid", m.getGroupId());
node.put("no", 0);
node.put("type", 2);
node.put("photo", "");
treeNodes.add(node);
}
// 递归构建树
List<Map<String, Object>> tree = formatUserTree(treeNodes, 0);
// 包裹根节点
Map<String, Object> root = new LinkedHashMap<>();
root.put("id", 0);
root.put("name", "通知公告系统");
root.put("children", tree);
root.put("no", "0");
root.put("type", 1);
return Collections.singletonList(root);
}
// ============================ GetDeptRoleGroupTree ============================
@Override
public List<Map<String, Object>> getDeptRoleGroupTree() {
// C# 从外部API获取部门树+用户角色映射。
// Java版暂未接入用户中心OpenAPI,返回空列表。
log.info("[getDeptRoleGroupTree] 暂未接入用户中心API,返回空列表");
return Collections.emptyList();
}
// ============================ Import ============================
@Override
@Transactional
public Map<String, Object> importContacts() {
// C# 读取上传的Excel文件逐行匹配组名+教师。
// Java版暂未实现Excel解析,预留接口。
log.info("[importContacts] Excel导入功能暂未实现");
Map<String, Object> result = new LinkedHashMap<>();
result.put("msg", "导入功能暂未实现");
return result;
}
// ============================ Export ============================
@Override
public Map<String, Object> exportContacts() {
// C# 生成Excel文件流返回。
// Java版暂未实现Excel导出,预留接口。
log.info("[exportContacts] Excel导出功能暂未实现");
// 返回联系组数据供前端参考
String account = NoticeContext.getAccount();
List<ContactsGroup> groups = groupRepository.findByCreatorAccount(account);
List<Integer> groupIds = groups.stream().map(ContactsGroup::getId).collect(Collectors.toList());
List<ContactsGroupUser> allMembers = groupIds.isEmpty() ? Collections.emptyList()
: groupUserRepository.findByGroupIdIn(groupIds);
List<Map<String, Object>> rows = new ArrayList<>();
for (ContactsGroup g : groups) {
List<ContactsGroupUser> members = allMembers.stream()
.filter(m -> m.getGroupId().equals(g.getId()))
.collect(Collectors.toList());
if (members.isEmpty()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("组名", g.getGroupName());
row.put("教师", "");
row.put("用户名", "");
rows.add(row);
} else {
for (ContactsGroupUser m : members) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("组名", g.getGroupName());
row.put("教师", m.getUserName() != null ? m.getUserName() : "");
row.put("用户名", m.getUserAccount() != null ? m.getUserAccount() : "");
rows.add(row);
}
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", rows);
return result;
}
// ============================ 辅助方法 ============================
/**
* 递归构建用户树(对应 C# HttpUserApi.FormatUserTree)。
*/
@SuppressWarnings("unchecked")
private List<Map<String, Object>> formatUserTree(List<Map<String, Object>> treeList, int pid) {
List<Map<String, Object>> data;
if (pid == 0) {
// 根层:取没有父节点引用的节点(pid=0 或 pid不在列表中的)
Set<Integer> allIds = treeList.stream()
.map(n -> (Integer) n.get("id"))
.collect(Collectors.toSet());
data = treeList.stream()
.filter(n -> {
int p = (Integer) n.get("pid");
return p == 0 || !allIds.contains(p);
})
.collect(Collectors.toList());
} else {
data = treeList.stream()
.filter(n -> (Integer) n.get("pid") == pid)
.collect(Collectors.toList());
}
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);
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;
}
}
@@ -0,0 +1,241 @@
package com.yunhai.notice.service.impl;
import com.yunhai.notice.common.NoticeException;
import com.yunhai.notice.common.context.NoticeContext;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.SysDict;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.repository.SysDictRepository;
import com.yunhai.notice.service.DictService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.*;
import java.util.stream.Collectors;
/**
* 系统字典服务实现。
* 对应 C# CommonController + GetSysDict.cs 中的字典管理逻辑。
*/
@Service
public class DictServiceImpl implements DictService {
private static final Logger log = LoggerFactory.getLogger(DictServiceImpl.class);
/** 字典类型常量:发文部门 */
private static final String DICT_TYPE_DEPT = "dept";
@Autowired
private SysDictRepository sysDictRepository;
@Autowired
private NoticeRepository noticeRepository;
// ============================ GetSysDic ============================
@Override
public List<Map<String, Object>> getSysDic(Integer type) {
// C# 中 Type=1 为部门类别(DEPT),其余类型(文印、请假等)本系统暂不涉及
String dictType = mapDictType(type);
if (dictType == null) {
throw new NoticeException("参数错误");
}
List<SysDict> list = sysDictRepository.findByDictTypeAndEnableFlagOrderBySortOrderAsc(dictType, 1);
if (list.isEmpty()) {
throw new NoticeException("尚未添加发文部门,请联系管理员添加!");
}
// 按 sortOrder 升序返回 {k, v}
return list.stream()
.sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? Integer.MAX_VALUE : d.getSortOrder()))
.map(d -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("k", d.getDictKey());
item.put("v", d.getDictValue());
return item;
})
.collect(Collectors.toList());
}
// ============================ GetDictMangeList ============================
@Override
public List<Map<String, Object>> getDictMangeList(String type) {
// type="dept" 表示部门类别(DEPT)
String dictType = "dept".equalsIgnoreCase(type) ? DICT_TYPE_DEPT : null;
if (dictType == null) {
// 非 DEPT 类型需超管权限,本系统暂只支持 DEPT
throw new NoticeException("非超管不得操作!");
}
List<SysDict> list = sysDictRepository.findByDictTypeOrderBySortOrderAsc(dictType);
return list.stream()
.sorted(Comparator.comparingInt(d -> d.getSortOrder() == null ? 999 : d.getSortOrder()))
.map(d -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("SerID", d.getId());
item.put("CodeID", d.getDictKey());
item.put("Name", d.getDictValue());
item.put("Des", d.getSortOrder() != null ? d.getSortOrder().toString() : "");
item.put("Enable", d.getEnableFlag());
return item;
})
.collect(Collectors.toList());
}
// ============================ AddDeptDic ============================
@Override
@Transactional
public void addDeptDic(String name) {
if (!StringUtils.hasText(name)) {
throw new NoticeException("输入字符串格式不正确!");
}
// 校验重名
List<SysDict> existing = sysDictRepository.findByDictTypeOrderBySortOrderAsc(DICT_TYPE_DEPT);
boolean duplicate = existing.stream().anyMatch(d ->
DICT_TYPE_DEPT.equals(d.getDictType()) && name.equals(d.getDictValue()) && d.getEnableFlag() != 3);
if (duplicate) {
throw new NoticeException("已存在该分类,请重新填写!");
}
// 计算 sortOrder = max + 1
int maxSort = existing.stream()
.filter(d -> d.getEnableFlag() != 3)
.mapToInt(d -> d.getSortOrder() != null ? d.getSortOrder() : 0)
.max().orElse(0);
SysDict dict = new SysDict();
dict.setDictType(DICT_TYPE_DEPT);
dict.setDictValue(name);
dict.setSortOrder(maxSort + 1);
dict.setDictKey(DICT_TYPE_DEPT + (maxSort + 1));
dict.setEnableFlag(1);
sysDictRepository.save(dict);
}
// ============================ UpdateDeptDic ============================
@Override
@Transactional
public void updateDeptDic(Integer serID, String name, Integer enable) {
if (serID == null) {
throw new NoticeException("参数错误!");
}
// 校验重名(排除自身)
List<SysDict> existing = sysDictRepository.findByDictTypeOrderBySortOrderAsc(DICT_TYPE_DEPT);
boolean duplicate = existing.stream().anyMatch(d ->
!d.getId().equals(serID) && name.equals(d.getDictValue()) && d.getEnableFlag() != 3);
if (duplicate) {
throw new NoticeException("已存在该分类,请重新填写!");
}
SysDict dict = sysDictRepository.findById(serID)
.orElseThrow(() -> new NoticeException("字典记录不存在"));
if (StringUtils.hasText(name)) {
dict.setDictValue(name);
}
if (enable != null) {
dict.setEnableFlag(enable);
}
sysDictRepository.save(dict);
}
// ============================ DelDept ============================
@Override
@Transactional
public void delDept(Integer serID) {
SysDict dict = sysDictRepository.findById(serID)
.orElseThrow(() -> new NoticeException("字典记录不存在"));
// 校验是否被通知引用(按 deptId = dictKey 匹配)
// C# 中检查 Notify_Message.DeptID == dict.CodeID
// Java 中 Notice.deptId 存的是 dictKey
List<Notice> referenced = noticeRepository.findAll((root, query, cb) ->
cb.equal(root.get("deptId"), dict.getDictKey()));
if (!referenced.isEmpty()) {
throw new NoticeException("已引用该部门,无法删除!");
}
sysDictRepository.delete(dict);
}
// ============================ OrdbyDicIds ============================
@Override
@Transactional
public void ordbyDicIds(String ids) {
if (!StringUtils.hasText(ids)) {
throw new NoticeException("参数错误");
}
List<SysDict> list = sysDictRepository.findByDictTypeOrderBySortOrderAsc(DICT_TYPE_DEPT);
if (list.isEmpty()) {
throw new NoticeException("字典类型错误,未找到该字典类型");
}
// C# 逻辑:取当前最大 Sort 为起始序号,逆序遍历 ids 递减赋值 Des
int[] idArr = Arrays.stream(ids.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.mapToInt(Integer::parseInt)
.toArray();
int maxSort = list.stream()
.mapToInt(d -> d.getSortOrder() != null ? d.getSortOrder() : idArr.length)
.max().orElse(idArr.length);
// lambda 中不能修改局部变量,用数组包装
final int[] indexOrd = { maxSort };
// 逆序遍历,从最大序号递减
for (int i = idArr.length - 1; i >= 0; i--) {
int id = idArr[i];
sysDictRepository.findById(id).ifPresent(dict -> {
dict.setSortOrder(indexOrd[0]);
sysDictRepository.save(dict);
indexOrd[0]--;
});
}
}
// ============================ GetDeptSync ============================
@Override
@Transactional
public void getDeptSync() {
// C# 从用户中心 OpenAPI 获取学校部门树并同步到字典。
// Java 版暂不接入外部 OpenAPI,预留空实现。
// 后续接入平台用户中心 API 后补全同步逻辑。
log.info("[getDeptSync] 部门同步暂未接入用户中心API,跳过");
}
// ============================ GetSchModuleIsSms ============================
@Override
public Boolean getSchModuleIsSms() {
// C# 通过外部 ISchIsSmsBLL 查询学校短信开关。
// Java 版暂不接入外部短信平台,默认返回 false。
String account = NoticeContext.getAccount();
if (account == null) {
return false;
}
// 后续接入短信平台后补全查询逻辑
return false;
}
// ============================ 辅助方法 ============================
/**
* 将前端 Type 参数映射到字典类型字符串。
* C# 中使用枚举,Java 版仅支持 DEPT(1)。
*/
private String mapDictType(Integer type) {
if (type == null) return null;
switch (type) {
case 1: return DICT_TYPE_DEPT;
// 其余类型(文印/请假/物品等)本系统不涉及
default: return null;
}
}
}
@@ -0,0 +1,178 @@
package com.yunhai.notice.service.impl;
import com.yunhai.notice.common.NoticeException;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.model.notice.UpFileVo;
import com.yunhai.notice.repository.UpFileRepository;
import com.yunhai.notice.service.FileService;
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.File;
import java.io.FileInputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
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.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 文件上传/下载/删除业务实现。
* <p>
* 存储路径规则:{uploadPath}/{appType}/{yyyyMMdd}/{uuid}.{ext}
* 与 C# UpLoadFile.FileSave 逻辑等价,但去除分片上传和 Office 转 PDF(后续按需补充)。
*/
@Service
public class FileServiceImpl implements FileService {
private static final Logger log = LoggerFactory.getLogger(FileServiceImpl.class);
@Autowired
private UpFileRepository upFileRepository;
@Value("${file.upload-path}")
private String uploadPath;
private static final String DEFAULT_APP_TYPE = "notice";
// ============================ 上传 ============================
@Override
@Transactional
public UpFileVo upload(MultipartFile file, String appType, Integer topicId) {
if (file == null || file.isEmpty()) {
throw new NoticeException("上传文件不得为空");
}
String originalName = file.getOriginalFilename();
if (!StringUtils.hasText(originalName)) {
throw new NoticeException("文件名为空");
}
// 提取后缀
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 NoticeException("文件保存失败", 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) {
UpFile file = upFileRepository.findById(fileId)
.orElseThrow(() -> new NoticeException("文件不存在: " + fileId));
Path fullPath = Paths.get(uploadPath, file.getFileName());
if (!Files.exists(fullPath)) {
throw new NoticeException("物理文件不存在: " + file.getFileName());
}
try (FileInputStream fis = new FileInputStream(fullPath.toFile());
OutputStream os = response.getOutputStream()) {
String encodedName = URLEncoder.encode(file.getFileTrueName(), "UTF-8")
.replaceAll("\\+", "%20");
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("[download] 文件下载失败: {}", file.getFileName(), e);
throw new NoticeException("文件下载失败", e);
}
}
// ============================ 删除 ============================
@Override
@Transactional
public void delete(Integer fileId) {
UpFile file = upFileRepository.findById(fileId)
.orElseThrow(() -> new NoticeException("文件不存在: " + fileId));
// 删物理文件
Path fullPath = Paths.get(uploadPath, file.getFileName());
try {
Files.deleteIfExists(fullPath);
} catch (Exception e) {
log.warn("[delete] 物理文件删除失败: {}(数据库记录仍会删除)", fullPath, e);
}
// 删数据库记录
upFileRepository.delete(file);
}
// ============================ 转换 ============================
private UpFileVo toVo(UpFile entity) {
UpFileVo vo = new UpFileVo();
BeanUtils.copyProperties(entity, vo);
return vo;
}
}
@@ -0,0 +1,454 @@
package com.yunhai.notice.service.impl;
import com.yunhai.notice.common.NoticeException;
import com.yunhai.notice.common.context.NoticeContext;
import com.yunhai.notice.common.event.NoticePublishedEvent;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.NoticeRead;
import com.yunhai.notice.enums.NoticeStatusEnum;
import com.yunhai.notice.enums.NoticeTypeEnum;
import com.yunhai.notice.model.BasePageResult;
import com.yunhai.notice.model.notice.*;
import com.yunhai.notice.repository.NoticeReadRepository;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.service.INoticeService;
import com.yunhai.notice.utils.PageUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
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.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/**
* 通知公告业务实现。
*/
@Service
public class NoticeService implements INoticeService {
private static final Logger log = LoggerFactory.getLogger(NoticeService.class);
@Autowired
private NoticeRepository noticeRepository;
@Autowired
private NoticeReadRepository noticeReadRepository;
@Autowired
private ApplicationEventPublisher eventPublisher;
private static final int PUBLISHED = NoticeStatusEnum.PUBLISHED.getCode();
private static final int DRAFT = NoticeStatusEnum.DRAFT.getCode();
// ============================ 发通知 ============================
@Override
@Transactional
public void publish(NoticeRequest request) {
Notice entity = new Notice();
BeanUtils.copyProperties(request, entity);
entity.setId(null);
// 定时发送:如果设置了未来时间,先存为草稿,由定时任务自动发布
if (request.getTimingSendTime() != null && request.getTimingSendTime().isAfter(LocalDateTime.now())) {
entity.setStatus(DRAFT);
} else {
entity.setStatus(PUBLISHED);
}
entity.setPublisherAccount(NoticeContext.getAccount());
entity.setPublisherName(NoticeContext.getName());
entity.setIsPulled(0);
entity.setReadCount(0);
noticeRepository.save(entity);
// 立即发布时触发消息推送事件
if (entity.getStatus() == PUBLISHED) {
publishEvent(entity);
}
}
// ============================ 保存草稿 ============================
@Override
@Transactional
public Integer saveDraft(NoticeRequest request) {
Notice entity = new Notice();
BeanUtils.copyProperties(request, entity);
entity.setId(null);
entity.setStatus(DRAFT);
entity.setPublisherAccount(NoticeContext.getAccount());
entity.setPublisherName(NoticeContext.getName());
entity.setIsPulled(0);
entity.setReadCount(0);
noticeRepository.save(entity);
return entity.getId();
}
// ============================ 编辑 ============================
@Override
@Transactional
public void edit(Integer id, NoticeRequest request) {
Objects.requireNonNull(id, "通知ID不得为空");
Notice entity = noticeRepository.findById(id)
.orElseThrow(() -> new NoticeException("通知不存在: " + id));
// 发布人/状态保持不变,仅更新可编辑字段
entity.setTitle(request.getTitle());
entity.setContent(request.getContent());
entity.setType(request.getType());
entity.setDeptId(request.getDeptId());
entity.setDeptName(request.getDeptName());
entity.setTopmost(request.getTopmost());
noticeRepository.save(entity);
}
// ============================ 删除 ============================
@Override
@Transactional
public void delete(Integer id) {
Objects.requireNonNull(id, "通知ID不得为空");
if (!noticeRepository.existsById(id)) {
throw new NoticeException("通知不存在: " + id);
}
// 级联清理阅读记录(逻辑外键,无物理 FK)
noticeReadRepository.deleteByNoticeId(id);
noticeRepository.deleteById(id);
}
// ============================ 撤回 ============================
@Override
@Transactional
public void pull(Integer id) {
Objects.requireNonNull(id, "通知ID不得为空");
Notice entity = noticeRepository.findById(id)
.orElseThrow(() -> new NoticeException("通知不存在: " + id));
if (entity.getStatus() != PUBLISHED) {
throw new NoticeException("仅已发布的通知可撤回");
}
if (entity.getIsPulled() != null && entity.getIsPulled() == 1) {
throw new NoticeException("通知已被撤回,请勿重复操作");
}
entity.setIsPulled(1);
noticeRepository.save(entity);
}
// ============================ 置顶/取消置顶 ============================
@Override
@Transactional
public void setTopmost(Integer id, Integer topmost) {
Objects.requireNonNull(id, "通知ID不得为空");
Notice entity = noticeRepository.findById(id)
.orElseThrow(() -> new NoticeException("通知不存在: " + id));
entity.setTopmost(topmost == null ? 0 : topmost);
noticeRepository.save(entity);
}
// ============================ 转发 ============================
@Override
@Transactional
public Integer forward(Integer sourceId, NoticeRequest request) {
Objects.requireNonNull(sourceId, "源通知ID不得为空");
Notice source = noticeRepository.findById(sourceId)
.orElseThrow(() -> new NoticeException("源通知不存在: " + sourceId));
Notice entity = new Notice();
BeanUtils.copyProperties(request, entity);
entity.setId(null);
entity.setStatus(DRAFT);
entity.setPublisherAccount(NoticeContext.getAccount());
entity.setPublisherName(NoticeContext.getName());
entity.setParentId(sourceId);
entity.setIsPulled(0);
entity.setReadCount(0);
noticeRepository.save(entity);
return entity.getId();
}
// ============================ 发送通知(定时任务/手动发送) ============================
@Override
@Transactional
public void sendNotify(Integer noticeId) {
Objects.requireNonNull(noticeId, "通知ID不得为空");
Notice entity = noticeRepository.findById(noticeId)
.orElseThrow(() -> new NoticeException("通知不存在: " + noticeId));
if (entity.getStatus() == PUBLISHED) {
log.warn("[sendNotify] 通知 {} 已发布,跳过重复发送", noticeId);
return;
}
entity.setStatus(PUBLISHED);
noticeRepository.save(entity);
publishEvent(entity);
}
// ============================ 用户端列表(含未读) ============================
@Override
public NoticeUserListResult userList(NoticeQuery query, Pageable pageable) {
String account = NoticeContext.getAccount();
Set<Integer> readIds = new HashSet<>(noticeReadRepository.findNoticeIdsByReaderAccount(account));
Page<Notice> page = noticeRepository.findAll(userSpec(query, readIds), pageable);
List<NoticeVo> content = page.getContent().stream()
.map(n -> toVo(n, readIds.contains(n.getId())))
.collect(Collectors.toList());
long unreadCount = noticeRepository.countByStatus(PUBLISHED)
- noticeReadRepository.countByReaderAccount(account);
if (unreadCount < 0) {
unreadCount = 0;
}
return new NoticeUserListResult(content, (int) page.getTotalElements(),
page.getNumber() + 1, page.getSize(), page.getTotalPages(), page.hasNext(), unreadCount);
}
/**
* 用户端动态查询:仅已发布;支持标题关键词、类型、部门、时间范围、未读/已读过滤。
*/
private Specification<Notice> userSpec(NoticeQuery query, Set<Integer> readIds) {
return (Root<Notice> root, CriteriaQuery<?> cq, CriteriaBuilder cb) -> {
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("status"), PUBLISHED));
appendCommonPredicates(query, root, cb, predicates);
Integer readType = query.getReadType() == null ? 0 : query.getReadType();
if (readType == 1 && !readIds.isEmpty()) {
predicates.add(cb.not(root.get("id").in(readIds)));
} else if (readType == 2) {
if (readIds.isEmpty()) {
predicates.add(cb.equal(root.get("id"), -1)); // 无已读,结果集为空
} else {
predicates.add(root.get("id").in(readIds));
}
}
return cq.where(predicates.toArray(new Predicate[0])).getRestriction();
};
}
// ============================ 管理端列表(全部) ============================
@Override
public BasePageResult<NoticeVo> adminList(NoticeQuery query, Pageable pageable) {
Page<Notice> page = noticeRepository.findAll(adminSpec(query), pageable);
List<NoticeVo> content = page.getContent().stream()
.map(n -> toVo(n, false))
.collect(Collectors.toList());
return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements()));
}
/**
* 管理端动态查询:按状态/标题/类型/部门/发布人/时间范围过滤。
*/
private Specification<Notice> adminSpec(NoticeQuery query) {
return (Root<Notice> root, CriteriaQuery<?> cq, CriteriaBuilder cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (query.getStatus() != null) {
predicates.add(cb.equal(root.get("status"), query.getStatus()));
}
appendCommonPredicates(query, root, cb, predicates);
if (StringUtils.hasText(query.getPublisherAccount())) {
predicates.add(cb.equal(root.get("publisherAccount"), query.getPublisherAccount()));
}
return cq.where(predicates.toArray(new Predicate[0])).getRestriction();
};
}
private void appendCommonPredicates(NoticeQuery query, Root<Notice> root, CriteriaBuilder cb,
List<Predicate> predicates) {
if (StringUtils.hasText(query.getKeyword())) {
predicates.add(cb.like(root.get("title"), "%" + query.getKeyword() + "%"));
}
if (query.getType() != null) {
predicates.add(cb.equal(root.get("type"), query.getType()));
}
if (StringUtils.hasText(query.getDeptId())) {
predicates.add(cb.equal(root.get("deptId"), query.getDeptId()));
}
if (query.getStartTime() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("createTime"), query.getStartTime()));
}
if (query.getEndTime() != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("createTime"), query.getEndTime()));
}
}
// ============================ 详情(顺带标记已读) ============================
@Override
public NoticeVo detail(Integer id) {
Notice entity = noticeRepository.findById(id)
.orElseThrow(() -> new NoticeException("通知不存在: " + id));
String account = NoticeContext.getAccount();
boolean read = noticeReadRepository.existsByNoticeIdAndReaderAccount(id, account);
NoticeVo vo = toVo(entity, read);
if (!read) {
markRead(id);
vo.setRead(true);
}
return vo;
}
// ============================ 标记已读 ============================
@Override
@Transactional
public void markRead(Integer id) {
Objects.requireNonNull(id, "通知ID不得为空");
String account = NoticeContext.getAccount();
if (noticeReadRepository.existsByNoticeIdAndReaderAccount(id, account)) {
return;
}
NoticeRead read = new NoticeRead();
read.setNoticeId(id);
read.setReaderAccount(account);
read.setReaderName(NoticeContext.getName());
read.setReadTime(LocalDateTime.now());
noticeReadRepository.save(read);
}
// ============================ 统计 ============================
@Override
public NoticeStatisticsVo statistics(NoticeQuery query, LocalDateTime start, LocalDateTime end) {
String account = NoticeContext.getAccount();
LocalDateTime s = (start == null) ? LocalDateTime.of(1970, 1, 1, 0, 0) : start;
LocalDateTime e = (end == null) ? LocalDateTime.now().plusDays(1) : end;
long total = noticeRepository.count();
long publishedCount = noticeRepository.countByStatus(PUBLISHED);
long draftCount = noticeRepository.countByStatus(NoticeStatusEnum.DRAFT.getCode());
long totalReadRecords = noticeReadRepository.count();
long unreadForCurrentUser = publishedCount - noticeReadRepository.countByReaderAccount(account);
if (unreadForCurrentUser < 0) {
unreadForCurrentUser = 0;
}
// 按月统计(已发布)
List<LocalDateTime> createTimes = noticeRepository
.findCreateTimesByStatusAndRange(PUBLISHED, s, e);
Map<String, Long> monthMap = new LinkedHashMap<>();
for (LocalDateTime t : createTimes) {
String key = t.getYear() + "-" + String.format("%02d", t.getMonthValue());
monthMap.merge(key, 1L, Long::sum);
}
List<NoticeStatisticsVo.MonthStat> byMonth = monthMap.entrySet().stream()
.map(en -> {
String[] ym = en.getKey().split("-");
return new NoticeStatisticsVo.MonthStat(
Integer.parseInt(ym[0]), Integer.parseInt(ym[1]), en.getValue());
})
.collect(Collectors.toList());
// 按发布人统计(已发布)
List<String> publishers = noticeRepository
.findPublisherNamesByStatusAndRange(PUBLISHED, s, e);
Map<String, Long> pubMap = new LinkedHashMap<>();
for (String p : publishers) {
pubMap.merge(p == null ? "未知" : p, 1L, Long::sum);
}
List<NoticeStatisticsVo.PublisherStat> byPublisher = pubMap.entrySet().stream()
.map(en -> new NoticeStatisticsVo.PublisherStat(en.getKey(), en.getValue()))
.collect(Collectors.toList());
NoticeStatisticsVo vo = new NoticeStatisticsVo();
vo.setTotal(total);
vo.setPublishedCount(publishedCount);
vo.setDraftCount(draftCount);
vo.setTotalReadRecords(totalReadRecords);
vo.setUnreadForCurrentUser(unreadForCurrentUser);
vo.setByPublisher(byPublisher);
vo.setByMonth(byMonth);
return vo;
}
// ============================ 阅读明细 ============================
@Override
public List<NoticeVo.ReadDetail> getReadDetail(Integer noticeId) {
Objects.requireNonNull(noticeId, "通知ID不得为空");
List<NoticeRead> readList = noticeReadRepository.findByNoticeIdOrderByReadTimeDesc(noticeId);
return readList.stream().map(r -> {
NoticeVo.ReadDetail detail = new NoticeVo.ReadDetail();
detail.setAccount(r.getReaderAccount());
detail.setName(r.getReaderName());
detail.setRead(true);
detail.setReadTime(r.getReadTime());
return detail;
}).collect(Collectors.toList());
}
// ============================ 转换 ============================
private NoticeVo toVo(Notice entity, boolean read) {
NoticeVo vo = new NoticeVo();
BeanUtils.copyProperties(entity, vo);
vo.setTypeDesc(NoticeTypeEnum.of(entity.getType()).getDesc());
vo.setStatusDesc(NoticeStatusEnum.of(entity.getStatus()).getDesc());
vo.setRead(read);
vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId()));
return vo;
}
// ============================ 事件发布(异步推送消息) ============================
/**
* 发布通知发布事件,由消息服务监听后在事务提交后异步推送(App/短信/企微/角标)。
* 接收人列表从 receiveList(JSON数组)中解析;为空时表示全员通知。
*/
private void publishEvent(Notice notice) {
List<String> receivers = parseReceiveList(notice.getReceiveList());
NoticePublishedEvent event = new NoticePublishedEvent(
notice.getId(), notice.getTitle(), receivers);
eventPublisher.publishEvent(event);
}
/**
* 解析接收人列表(JSON 数组字符串 -> 账号列表)。
* 为空或解析失败时返回空列表(表示全员通知)。
*/
private List<String> parseReceiveList(String receiveListJson) {
if (!StringUtils.hasText(receiveListJson)) {
return Collections.emptyList();
}
try {
// 简单解析 JSON 数组 ["account1","account2"]
String trimmed = receiveListJson.trim();
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
String inner = trimmed.substring(1, trimmed.length() - 1).trim();
if (inner.isEmpty()) {
return Collections.emptyList();
}
String[] parts = inner.split(",");
List<String> result = new ArrayList<>();
for (String part : parts) {
String account = part.trim().replace("\"", "").replace("'", "");
if (StringUtils.hasText(account)) {
result.add(account);
}
}
return result;
}
} catch (Exception e) {
log.warn("[parseReceiveList] 解析接收人列表失败: {}", receiveListJson, e);
}
return Collections.emptyList();
}
}
@@ -0,0 +1,243 @@
package com.yunhai.notice.service.impl;
import com.yunhai.notice.entity.SmsModule;
import com.yunhai.notice.entity.SmsSchoolConfig;
import com.yunhai.notice.repository.SmsModuleRepository;
import com.yunhai.notice.repository.SmsSchoolConfigRepository;
import com.yunhai.notice.service.SmsService;
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.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.*;
import java.util.stream.Collectors;
/**
* 短信设置服务实现。
* 对应 C# SchModuleSmsController + SchModuleSms.cs。
* 注意:C# 原版依赖外部DLL(ISchIsSmsBLL/ISchoolBLL/ISchGroupBLL)获取学校和组数据,
* Java版改为从本地 sms_module + sms_school_config 表管理。
*/
@Service
public class SmsServiceImpl implements SmsService {
private static final Logger log = LoggerFactory.getLogger(SmsServiceImpl.class);
@Autowired
private SmsModuleRepository smsModuleRepository;
@Autowired
private SmsSchoolConfigRepository smsSchoolConfigRepository;
// ============================ getmodule ============================
@Override
public List<Map<String, Object>> getModuleList() {
List<SmsModule> modules = smsModuleRepository.findAllByOrderByIdAsc();
return modules.stream().map(m -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("ID", m.getId());
item.put("Name", m.getModuleName());
return item;
}).collect(Collectors.toList());
}
// ============================ getgroup ============================
@Override
public List<Map<String, Object>> getSchParentGroup() {
// C# 从外部BLL获取学校组数据。Java版暂不接入用户中心API。
// 返回空列表,前端可按需处理。
log.info("[getSchParentGroup] 暂未接入用户中心API,返回空列表");
return Collections.emptyList();
}
// ============================ getschsms ============================
@Override
public Map<String, Object> getSchIsSmsList(Integer pageSize, Integer pageIndex, String schName) {
if (pageSize == null || pageSize <= 0) pageSize = 10;
// 前端 PageIndex 从0开始(page-1),后端按0基处理
if (pageIndex == null || pageIndex < 0) pageIndex = 0;
// 查询学校配置(按学校名模糊搜索或全部)
Page<SmsSchoolConfig> page;
int offset = pageIndex; // 0基
Sort sort = Sort.by(Sort.Direction.DESC, "schoolName");
if (StringUtils.hasText(schName)) {
page = smsSchoolConfigRepository.findBySchoolNameContaining(schName,
PageRequest.of(offset, pageSize, sort));
} else {
page = smsSchoolConfigRepository.findAll(PageRequest.of(offset, pageSize, sort));
}
// 加载全部模块列表
List<SmsModule> modules = smsModuleRepository.findAllByOrderByIdAsc();
// 按 schoolId 分组配置
Map<Integer, List<SmsSchoolConfig>> configBySchool = page.getContent().stream()
.collect(Collectors.groupingBy(SmsSchoolConfig::getSchoolId));
// 构建返回数据:每个学校一行,包含各模块的开关状态
List<Map<String, Object>> listData = page.getContent().stream()
.collect(Collectors.groupingBy(SmsSchoolConfig::getSchoolId))
.entrySet().stream()
.map(entry -> {
Integer schoolId = entry.getKey();
List<SmsSchoolConfig> configs = entry.getValue();
String schoolName = configs.get(0).getSchoolName();
// 构建模块开关矩阵
List<Map<String, Object>> moduleList = new ArrayList<>();
boolean isAll = true;
for (SmsModule module : modules) {
Map<String, Object> modu = new LinkedHashMap<>();
SmsSchoolConfig config = configs.stream()
.filter(c -> module.getId().equals(c.getModuleId()))
.findFirst().orElse(null);
modu.put("SerID", config != null ? config.getId() : -1);
modu.put("ModuID", module.getId());
modu.put("IsSms", config != null && Boolean.TRUE.equals(config.getIsSms()));
modu.put("SchSerID", schoolId);
moduleList.add(modu);
if (config == null || !Boolean.TRUE.equals(config.getIsSms())) {
isAll = false;
}
}
Map<String, Object> schoolItem = new LinkedHashMap<>();
schoolItem.put("SchSerID", schoolId);
schoolItem.put("SchName", schoolName);
schoolItem.put("IsAll", isAll);
schoolItem.put("itemsModu", moduleList);
return schoolItem;
})
.collect(Collectors.toList());
// 如果没有配置数据,返回空列表(但保留total)
if (listData.isEmpty() && page.hasContent()) {
// 这种情况不应发生,但防御性处理
listData = Collections.emptyList();
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", listData);
result.put("total", page.getTotalElements());
return result;
}
// ============================ getsch ============================
@Override
public List<Map<String, Object>> getSchList() {
// C# 从外部BLL获取学校列表。Java版从 sms_school_config 表提取已配置学校。
// 获取所有不重复的学校
List<SmsSchoolConfig> allConfigs = smsSchoolConfigRepository.findAll();
Map<Integer, String> schoolMap = new LinkedHashMap<>();
for (SmsSchoolConfig config : allConfigs) {
schoolMap.putIfAbsent(config.getSchoolId(), config.getSchoolName());
}
return schoolMap.entrySet().stream().map(entry -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("ID", entry.getKey());
item.put("Name", entry.getValue());
return item;
}).collect(Collectors.toList());
}
// ============================ setschsms ============================
@Override
@Transactional
public void setSchSms(Integer schSerID, Integer moduleId, Boolean isSms) {
if (schSerID == null || moduleId == null) {
return;
}
SmsSchoolConfig config = smsSchoolConfigRepository.findBySchoolIdAndModuleId(schSerID, moduleId);
if (config == null) {
config = new SmsSchoolConfig();
config.setSchoolId(schSerID);
config.setModuleId(moduleId);
// 学校名称从已有配置中获取
List<SmsSchoolConfig> existing = smsSchoolConfigRepository.findBySchoolId(schSerID);
if (!existing.isEmpty()) {
config.setSchoolName(existing.get(0).getSchoolName());
}
}
config.setIsSms(isSms != null ? isSms : false);
smsSchoolConfigRepository.save(config);
}
// ============================ setschallsms ============================
@Override
@Transactional
public void setSchSmsAll(Integer schSerID, Boolean isSms) {
if (schSerID == null) {
return;
}
List<SmsModule> modules = smsModuleRepository.findAllByOrderByIdAsc();
boolean sms = Boolean.TRUE.equals(isSms);
for (SmsModule module : modules) {
SmsSchoolConfig config = smsSchoolConfigRepository.findBySchoolIdAndModuleId(schSerID, module.getId());
if (config == null) {
config = new SmsSchoolConfig();
config.setSchoolId(schSerID);
config.setModuleId(module.getId());
List<SmsSchoolConfig> existing = smsSchoolConfigRepository.findBySchoolId(schSerID);
if (!existing.isEmpty()) {
config.setSchoolName(existing.get(0).getSchoolName());
}
}
config.setIsSms(sms);
smsSchoolConfigRepository.save(config);
}
}
// ============================ setschmodulesms ============================
@Override
@Transactional
public void setSchModuleSms(String schSerIDs, String modules, Boolean isSms) {
if (!StringUtils.hasText(schSerIDs) || !StringUtils.hasText(modules)) {
return;
}
List<Integer> schoolIds = Arrays.stream(schSerIDs.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(Integer::parseInt)
.collect(Collectors.toList());
List<Integer> moduleIds = Arrays.stream(modules.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(Integer::parseInt)
.collect(Collectors.toList());
boolean sms = Boolean.TRUE.equals(isSms);
for (Integer schoolId : schoolIds) {
for (Integer moduleId : moduleIds) {
SmsSchoolConfig config = smsSchoolConfigRepository.findBySchoolIdAndModuleId(schoolId, moduleId);
if (config == null) {
config = new SmsSchoolConfig();
config.setSchoolId(schoolId);
config.setModuleId(moduleId);
List<SmsSchoolConfig> existing = smsSchoolConfigRepository.findBySchoolId(schoolId);
if (!existing.isEmpty()) {
config.setSchoolName(existing.get(0).getSchoolName());
}
}
config.setIsSms(sms);
smsSchoolConfigRepository.save(config);
}
}
}
}
@@ -0,0 +1,263 @@
package com.yunhai.notice.service.impl;
import com.yunhai.notice.common.NoticeException;
import com.yunhai.notice.entity.NoticeTemplate;
import com.yunhai.notice.entity.TemplateType;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.repository.NoticeTemplateRepository;
import com.yunhai.notice.repository.TemplateTypeRepository;
import com.yunhai.notice.repository.UpFileRepository;
import com.yunhai.notice.service.TemplateService;
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.util.*;
import java.util.stream.Collectors;
/**
* 通知模板服务实现。
* 对应 C# TemplateController + Template.cs。
*/
@Service
public class TemplateServiceImpl implements TemplateService {
@Autowired
private TemplateTypeRepository templateTypeRepository;
@Autowired
private NoticeTemplateRepository noticeTemplateRepository;
@Autowired
private UpFileRepository upFileRepository;
// ============================ GetTypelist ============================
@Override
public List<Map<String, Object>> getTypelist() {
List<TemplateType> types = templateTypeRepository.findAllByOrderBySortOrderAsc();
List<Map<String, Object>> result = new ArrayList<>();
// 首项固定为"全部"
Map<String, Object> allItem = new LinkedHashMap<>();
allItem.put("TypeID", 0);
allItem.put("TypeName", "全部");
result.add(allItem);
for (TemplateType t : types) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("TypeID", t.getId());
item.put("TypeName", t.getTypeName());
result.add(item);
}
return result;
}
// ============================ EditType ============================
@Override
@Transactional
public void editType(Integer typeid, String typeName) {
if (!StringUtils.hasText(typeName)) {
throw new NoticeException("类型名称不能为空!");
}
// 重名校验
boolean exists;
if (typeid == null || typeid == 0) {
exists = templateTypeRepository.existsByTypeName(typeName);
} else {
exists = templateTypeRepository.existsByTypeNameAndIdNot(typeName, typeid);
}
if (exists) {
throw new NoticeException("类型名称已存在!");
}
if (typeid == null || typeid == 0) {
// 新增
TemplateType type = new TemplateType();
type.setTypeName(typeName);
// sortOrder = max + 1
List<TemplateType> all = templateTypeRepository.findAllByOrderBySortOrderAsc();
int maxSort = all.stream()
.mapToInt(t -> t.getSortOrder() != null ? t.getSortOrder() : 0)
.max().orElse(0);
type.setSortOrder(maxSort + 1);
templateTypeRepository.save(type);
} else {
// 编辑
TemplateType type = templateTypeRepository.findById(typeid)
.orElseThrow(() -> new NoticeException("类型不存在!"));
type.setTypeName(typeName);
templateTypeRepository.save(type);
}
}
// ============================ DelType ============================
@Override
@Transactional
public void delType(Integer typeid) {
if (typeid == null) {
throw new NoticeException("类型不存在!");
}
TemplateType type = templateTypeRepository.findById(typeid)
.orElseThrow(() -> new NoticeException("类型不存在!"));
// 校验是否有模板引用此类型
long refCount = noticeTemplateRepository.countByTypeId(typeid);
if (refCount > 0) {
throw new NoticeException("类型存在模板!");
}
templateTypeRepository.delete(type);
}
// ============================ GetTemplatelist ============================
@Override
public Map<String, Object> getTemplatelist(Integer pageSize, Integer pageIndex, Integer typeid) {
if (pageSize == null || pageSize <= 0) pageSize = 10;
if (pageIndex == null || pageIndex <= 0) pageIndex = 1;
if (typeid == null) typeid = 0;
Page<NoticeTemplate> page;
int offset = pageIndex - 1;
if (typeid > 0) {
page = noticeTemplateRepository.findByTypeId(typeid,
PageRequest.of(offset, pageSize, Sort.by(Sort.Direction.DESC, "refCount")));
} else {
page = noticeTemplateRepository.findAllByOrderByRefCountDesc(PageRequest.of(offset, pageSize));
}
// 加载类型名映射
List<TemplateType> allTypes = templateTypeRepository.findAllByOrderBySortOrderAsc();
Map<Integer, String> typeNameMap = allTypes.stream()
.collect(Collectors.toMap(TemplateType::getId, TemplateType::getTypeName, (a, b) -> a));
// 加载封面图
List<Integer> coverIds = page.getContent().stream()
.map(NoticeTemplate::getCover)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<Integer, String> coverUrlMap = new HashMap<>();
if (!coverIds.isEmpty()) {
for (Integer fid : coverIds) {
upFileRepository.findById(fid).ifPresent(f -> coverUrlMap.put(fid, f.getFileName()));
}
}
List<Map<String, Object>> listData = page.getContent().stream().map(t -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("SerID", t.getId());
item.put("Title", t.getTitle());
item.put("TypeName", typeNameMap.getOrDefault(t.getTypeId(), ""));
item.put("Contents", t.getContent());
item.put("ContentsFilter", filterHtml(t.getContent()));
item.put("Num", t.getRefCount() != null ? t.getRefCount() : 0);
item.put("imgurl", t.getCover() != null ? coverUrlMap.get(t.getCover()) : null);
return item;
}).collect(Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", listData);
result.put("total", page.getTotalElements());
return result;
}
// ============================ EditTemplate ============================
@Override
@Transactional
public void editTemplate(Integer serID, String title, String content, Integer typeId, Integer cover) {
if (!StringUtils.hasText(title)) {
throw new NoticeException("模板标题不能为空!");
}
if (!StringUtils.hasText(content)) {
throw new NoticeException("模板内容不能为空!");
}
if (serID == null || serID == 0) {
// 新增
NoticeTemplate template = new NoticeTemplate();
template.setTitle(title);
template.setContent(content);
template.setTypeId(typeId);
template.setCover(cover);
template.setRefCount(0);
noticeTemplateRepository.save(template);
} else {
// 编辑
NoticeTemplate template = noticeTemplateRepository.findById(serID)
.orElseThrow(() -> new NoticeException("模板不存在!"));
template.setTitle(title);
template.setContent(content);
template.setTypeId(typeId);
template.setCover(cover);
noticeTemplateRepository.save(template);
}
}
// ============================ DelTemplate ============================
@Override
@Transactional
public void delTemplate(Integer serID) {
if (serID == null) {
throw new NoticeException("参数错误");
}
noticeTemplateRepository.findById(serID).ifPresent(noticeTemplateRepository::delete);
}
// ============================ GetTemplateFist ============================
@Override
public Map<String, Object> getTemplateFist(Integer serID) {
if (serID == null) {
throw new NoticeException("参数错误");
}
NoticeTemplate t = noticeTemplateRepository.findById(serID)
.orElseThrow(() -> new NoticeException("模板不存在!"));
// 加载类型名
String typeName = "";
if (t.getTypeId() != null) {
Optional<TemplateType> type = templateTypeRepository.findById(t.getTypeId());
if (type.isPresent()) {
typeName = type.get().getTypeName();
}
}
// 加载封面图
String imgurl = null;
if (t.getCover() != null) {
Optional<UpFile> file = upFileRepository.findById(t.getCover());
if (file.isPresent()) {
imgurl = file.get().getFileName();
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("SerID", t.getId());
result.put("Title", t.getTitle());
result.put("Type", t.getTypeId());
result.put("TypeName", typeName);
result.put("Contents", t.getContent());
result.put("Num", t.getRefCount() != null ? t.getRefCount() : 0);
result.put("imgurl", imgurl);
return result;
}
// ============================ 辅助方法 ============================
/**
* 过滤HTML标签,返回纯文本(对应 C# WebHelper.FilterHtml)。
*/
private String filterHtml(String html) {
if (html == null) return "";
return html.replaceAll("<[^>]+>", "").trim();
}
}
@@ -0,0 +1,37 @@
package com.yunhai.notice.utils;
import com.yunhai.notice.model.BasePageQuery;
import com.yunhai.notice.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()
);
}
}
+77
View File
@@ -0,0 +1,77 @@
server:
port: 8070
servlet:
context-path: /notice
spring:
mvc:
date-format: yyyy-MM-dd HH:mm:ss
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss
datasource:
url: jdbc:mysql://localhost:3306/bsch_notice?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
min-idle: 10
max-active: 30
#jpa
jpa:
database: MySQL
show-sql: true
hibernate:
# 开发期使用 update 自动建表;生产环境设置成 none
ddl-auto: update
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
properties:
hibernate.order_by.default_null_ordering: last
open-in-view: true
#文件上传
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
#本地关闭 rabbit
rabbitmq:
listener:
direct:
auto-startup: false
simple:
auto-startup: false
# 文件上传存储路径(Docker 部署时与 volume 挂载一致)
file:
upload-path: D:/data/upload
# 定时发送任务
timing-send:
enabled: true
cron: "0 * * * * ?" # 每分钟检查一次
# 短信模块配置
sms:
enabled: true
module-id: notice
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh:
sso:
server-url-prefix: http://sso.yunhaixingtong.com/cas/
client-host-url: http://localhost:8070
allow-headers: all
allow-origins: all
url-pattern: /api/*
mobi-api-pattern: /mobiapi/*
app-auth:
enabled: true
# 本应用自身身份(客户端签名用)
app-key: 100001
app-secret: ec4e860206374c80938719fff8e14a86
open:
enabled: true
base-host: http://base.yunhaixingtong.com
user-center-url: http://base.yunhaixingtong.com/userbase
+33
View File
@@ -0,0 +1,33 @@
spring:
application:
name: notice
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},即 notice.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>