From 6375562c8dd441e7d3340efa852a078838a8c9ff Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 1 Sep 2026 14:09:24 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=9A=E7=9F=A5=E4=BF=AE=E8=AE=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/yunhai/notice/NoticeApplication.java | 4 - .../notice/common/GlobalExceptionHandler.java | 55 -- .../yunhai/notice/common/NoticeException.java | 15 - .../notice/common/context/NoticeContext.java | 32 - .../common/event/NoticePublishedEvent.java | 32 - .../interceptor/CurrentUserInterceptor.java | 52 -- .../common/listener/NoticeAuditListener.java | 15 +- .../common/validation/ValidationGroups.java | 10 + .../yunhai/notice/config/WebMvcConfig.java | 31 - .../controller/api/BaseApiController.java | 114 +-- .../controller/api/ContactsController.java | 63 +- .../notice/controller/api/DictController.java | 80 +- .../notice/controller/api/FileController.java | 93 +- .../controller/api/NoticeApiController.java | 450 ---------- .../controller/api/NoticeController.java | 291 ++++--- .../notice/controller/api/SmsController.java | 109 +-- .../controller/api/TemplateController.java | 69 +- .../java/com/yunhai/notice/entity/Notice.java | 13 +- .../yunhai/notice/entity/NoticeReceiver.java | 40 + .../yunhai/notice/enums/NoticeStatusEnum.java | 5 +- .../com/yunhai/notice/job/TimingSendJob.java | 58 -- .../com/yunhai/notice/model/SchTeaList.java | 106 +++ .../notice/model/notice/InitializeRedVo.java | 20 + .../notice/model/notice/NoticeDetailVo.java | 74 ++ .../notice/model/notice/NoticePageResult.java | 26 + .../notice/model/notice/NoticeQuery.java | 19 +- .../notice/model/notice/NoticeRequest.java | 18 +- .../model/notice/NoticeStatisticsVo.java | 7 +- .../notice/model/notice/UpFileResultVo.java | 30 + .../notice/model/notice/UserModuleVo.java | 122 +++ .../ContactsGroupUserRepository.java | 25 +- .../repository/NoticeReceiverRepository.java | 31 + .../notice/repository/NoticeRepository.java | 53 +- .../notice/repository/SysDictRepository.java | 4 + .../notice/repository/UpFileRepository.java | 6 +- .../yunhai/notice/service/FileService.java | 56 -- .../yunhai/notice/service/IClassService.java | 20 + ...actsService.java => IContactsService.java} | 18 +- .../{DictService.java => IDictService.java} | 11 +- .../yunhai/notice/service/IFileService.java | 94 ++ .../yunhai/notice/service/INoticeService.java | 98 ++- .../{SmsService.java => ISmsService.java} | 8 +- ...lateService.java => ITemplateService.java} | 2 +- .../notice/service/IUserCenterService.java | 51 ++ .../notice/service/impl/ClassService.java | 63 ++ ...sServiceImpl.java => ContactsService.java} | 217 ++--- ...{DictServiceImpl.java => DictService.java} | 59 +- .../notice/service/impl/FileService.java | 326 +++++++ .../notice/service/impl/FileServiceImpl.java | 178 ---- .../notice/service/impl/NoticeService.java | 818 ++++++++++++++---- .../{SmsServiceImpl.java => SmsService.java} | 47 +- ...eServiceImpl.java => TemplateService.java} | 31 +- .../service/impl/UserCenterService.java | 280 ++++++ .../notice/service/sms/DefaultSmsSender.java | 22 + .../yunhai/notice/service/sms/SmsSender.java | 22 + src/main/resources/application.yml | 9 +- .../application.yml.tmp.16804.0ac1218e4e90 | 72 ++ src/main/resources/sql/notice_add_sch_id.sql | 16 + .../resources/sql/notice_data_backfill.sql | 159 ++++ .../resources/sql/notice_receiver_create.sql | 24 + 60 files changed, 3048 insertions(+), 1825 deletions(-) delete mode 100644 src/main/java/com/yunhai/notice/common/GlobalExceptionHandler.java delete mode 100644 src/main/java/com/yunhai/notice/common/NoticeException.java delete mode 100644 src/main/java/com/yunhai/notice/common/context/NoticeContext.java delete mode 100644 src/main/java/com/yunhai/notice/common/event/NoticePublishedEvent.java delete mode 100644 src/main/java/com/yunhai/notice/common/interceptor/CurrentUserInterceptor.java create mode 100644 src/main/java/com/yunhai/notice/common/validation/ValidationGroups.java delete mode 100644 src/main/java/com/yunhai/notice/config/WebMvcConfig.java delete mode 100644 src/main/java/com/yunhai/notice/controller/api/NoticeApiController.java create mode 100644 src/main/java/com/yunhai/notice/entity/NoticeReceiver.java delete mode 100644 src/main/java/com/yunhai/notice/job/TimingSendJob.java create mode 100644 src/main/java/com/yunhai/notice/model/SchTeaList.java create mode 100644 src/main/java/com/yunhai/notice/model/notice/InitializeRedVo.java create mode 100644 src/main/java/com/yunhai/notice/model/notice/NoticeDetailVo.java create mode 100644 src/main/java/com/yunhai/notice/model/notice/NoticePageResult.java create mode 100644 src/main/java/com/yunhai/notice/model/notice/UpFileResultVo.java create mode 100644 src/main/java/com/yunhai/notice/model/notice/UserModuleVo.java create mode 100644 src/main/java/com/yunhai/notice/repository/NoticeReceiverRepository.java delete mode 100644 src/main/java/com/yunhai/notice/service/FileService.java create mode 100644 src/main/java/com/yunhai/notice/service/IClassService.java rename src/main/java/com/yunhai/notice/service/{ContactsService.java => IContactsService.java} (82%) rename src/main/java/com/yunhai/notice/service/{DictService.java => IDictService.java} (81%) create mode 100644 src/main/java/com/yunhai/notice/service/IFileService.java rename src/main/java/com/yunhai/notice/service/{SmsService.java => ISmsService.java} (89%) rename src/main/java/com/yunhai/notice/service/{TemplateService.java => ITemplateService.java} (97%) create mode 100644 src/main/java/com/yunhai/notice/service/IUserCenterService.java create mode 100644 src/main/java/com/yunhai/notice/service/impl/ClassService.java rename src/main/java/com/yunhai/notice/service/impl/{ContactsServiceImpl.java => ContactsService.java} (66%) rename src/main/java/com/yunhai/notice/service/impl/{DictServiceImpl.java => DictService.java} (79%) create mode 100644 src/main/java/com/yunhai/notice/service/impl/FileService.java delete mode 100644 src/main/java/com/yunhai/notice/service/impl/FileServiceImpl.java rename src/main/java/com/yunhai/notice/service/impl/{SmsServiceImpl.java => SmsService.java} (86%) rename src/main/java/com/yunhai/notice/service/impl/{TemplateServiceImpl.java => TemplateService.java} (87%) create mode 100644 src/main/java/com/yunhai/notice/service/impl/UserCenterService.java create mode 100644 src/main/java/com/yunhai/notice/service/sms/DefaultSmsSender.java create mode 100644 src/main/java/com/yunhai/notice/service/sms/SmsSender.java create mode 100644 src/main/resources/application.yml.tmp.16804.0ac1218e4e90 create mode 100644 src/main/resources/sql/notice_add_sch_id.sql create mode 100644 src/main/resources/sql/notice_data_backfill.sql create mode 100644 src/main/resources/sql/notice_receiver_create.sql diff --git a/src/main/java/com/yunhai/notice/NoticeApplication.java b/src/main/java/com/yunhai/notice/NoticeApplication.java index 00971c2..52e3e2c 100644 --- a/src/main/java/com/yunhai/notice/NoticeApplication.java +++ b/src/main/java/com/yunhai/notice/NoticeApplication.java @@ -3,14 +3,10 @@ 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) { diff --git a/src/main/java/com/yunhai/notice/common/GlobalExceptionHandler.java b/src/main/java/com/yunhai/notice/common/GlobalExceptionHandler.java deleted file mode 100644 index b247f5b..0000000 --- a/src/main/java/com/yunhai/notice/common/GlobalExceptionHandler.java +++ /dev/null @@ -1,55 +0,0 @@ -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 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 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 handleConstraintViolation(ConstraintViolationException ex) { - log.warn("[ConstraintViolation] {}", ex.getMessage()); - return ApiResponse.fail(ErrorCode.PARAM_ERROR, ex.getMessage()); - } - - @ExceptionHandler(Exception.class) - @ResponseStatus(HttpStatus.OK) - public ApiResponse 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); - } -} diff --git a/src/main/java/com/yunhai/notice/common/NoticeException.java b/src/main/java/com/yunhai/notice/common/NoticeException.java deleted file mode 100644 index 47f503f..0000000 --- a/src/main/java/com/yunhai/notice/common/NoticeException.java +++ /dev/null @@ -1,15 +0,0 @@ -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); - } -} diff --git a/src/main/java/com/yunhai/notice/common/context/NoticeContext.java b/src/main/java/com/yunhai/notice/common/context/NoticeContext.java deleted file mode 100644 index 47749da..0000000 --- a/src/main/java/com/yunhai/notice/common/context/NoticeContext.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.yunhai.notice.common.context; - -/** - * 当前登录用户上下文(ThreadLocal)。
- * 由 {@link com.yunhai.notice.common.interceptor.CurrentUserInterceptor} 在请求入口写入, - * 供 JPA 审计监听器与业务层取用发布人/读者身份。

- * 身份取自 CAS 写入的 {@code request.getRemoteUser()},本地未接入 CAS 时回退到 - * application.yml 的 dev.default-user 或请求头 X-Login-Account,便于本地联调。 - */ -public class NoticeContext { - - private static final ThreadLocal ACCOUNT = new ThreadLocal<>(); - private static final ThreadLocal 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(); - } -} diff --git a/src/main/java/com/yunhai/notice/common/event/NoticePublishedEvent.java b/src/main/java/com/yunhai/notice/common/event/NoticePublishedEvent.java deleted file mode 100644 index 268e41f..0000000 --- a/src/main/java/com/yunhai/notice/common/event/NoticePublishedEvent.java +++ /dev/null @@ -1,32 +0,0 @@ -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 receiverAccounts; - - public NoticePublishedEvent(Integer noticeId, String title, List receiverAccounts) { - this.noticeId = noticeId; - this.title = title; - this.receiverAccounts = receiverAccounts; - } - - public Integer getNoticeId() { - return noticeId; - } - - public String getTitle() { - return title; - } - - public List getReceiverAccounts() { - return receiverAccounts; - } -} diff --git a/src/main/java/com/yunhai/notice/common/interceptor/CurrentUserInterceptor.java b/src/main/java/com/yunhai/notice/common/interceptor/CurrentUserInterceptor.java deleted file mode 100644 index 8a95140..0000000 --- a/src/main/java/com/yunhai/notice/common/interceptor/CurrentUserInterceptor.java +++ /dev/null @@ -1,52 +0,0 @@ -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(); - } -} diff --git a/src/main/java/com/yunhai/notice/common/listener/NoticeAuditListener.java b/src/main/java/com/yunhai/notice/common/listener/NoticeAuditListener.java index 5bcd6e9..0db9527 100644 --- a/src/main/java/com/yunhai/notice/common/listener/NoticeAuditListener.java +++ b/src/main/java/com/yunhai/notice/common/listener/NoticeAuditListener.java @@ -1,7 +1,8 @@ package com.yunhai.notice.common.listener; -import com.yunhai.notice.common.context.NoticeContext; import com.yunhai.notice.entity.BaseEntity; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; import org.springframework.stereotype.Component; import javax.persistence.PrePersist; @@ -9,8 +10,9 @@ import javax.persistence.PreUpdate; import java.time.LocalDateTime; /** - * JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(登录账号)与时间戳。 + * JPA 审计监听器:在实体持久化/更新前自动填充创建人/更新人(平台当前登录人)与时间戳。 * 对应 {@link BaseEntity} 的 createBy / updateBy / createTime / updateTime 字段。 + * 当前登录人取自基础平台 {@link UserContextHolder}。 */ @Component public class NoticeAuditListener { @@ -20,7 +22,7 @@ public class NoticeAuditListener { LocalDateTime now = LocalDateTime.now(); entity.setCreateTime(now); entity.setUpdateTime(now); - String account = NoticeContext.getAccount(); + String account = currentAccount(); if (account != null) { if (entity.getCreateBy() == null) { entity.setCreateBy(account); @@ -32,9 +34,14 @@ public class NoticeAuditListener { @PreUpdate public void preUpdate(BaseEntity entity) { entity.setUpdateTime(LocalDateTime.now()); - String account = NoticeContext.getAccount(); + String account = currentAccount(); if (account != null) { entity.setUpdateBy(account); } } + + private String currentAccount() { + LoginUser user = UserContextHolder.get(); + return user == null ? null : user.getLoginName(); + } } diff --git a/src/main/java/com/yunhai/notice/common/validation/ValidationGroups.java b/src/main/java/com/yunhai/notice/common/validation/ValidationGroups.java new file mode 100644 index 0000000..b1b9cb3 --- /dev/null +++ b/src/main/java/com/yunhai/notice/common/validation/ValidationGroups.java @@ -0,0 +1,10 @@ +package com.yunhai.notice.common.validation; + +/** + * JSR-303 分组校验标记接口(对齐 UserBase 规范)。 + * Create:新建场景必填字段;Update:编辑场景必填字段(通常含 id)。 + */ +public interface ValidationGroups { + interface Create {} + interface Update {} +} diff --git a/src/main/java/com/yunhai/notice/config/WebMvcConfig.java b/src/main/java/com/yunhai/notice/config/WebMvcConfig.java deleted file mode 100644 index 182b29e..0000000 --- a/src/main/java/com/yunhai/notice/config/WebMvcConfig.java +++ /dev/null @@ -1,31 +0,0 @@ -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"); - } -} diff --git a/src/main/java/com/yunhai/notice/controller/api/BaseApiController.java b/src/main/java/com/yunhai/notice/controller/api/BaseApiController.java index 4173c55..fddbaee 100644 --- a/src/main/java/com/yunhai/notice/controller/api/BaseApiController.java +++ b/src/main/java/com/yunhai/notice/controller/api/BaseApiController.java @@ -1,98 +1,110 @@ package com.yunhai.notice.controller.api; -import com.yunhai.notice.common.context.NoticeContext; +import com.yunhai.notice.config.Config; +import com.yunhai.notice.model.notice.UpFileResultVo; +import com.yunhai.notice.service.IFileService; +import com.yunhai.notice.service.IUserCenterService; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import com.yunhai.platform.utils.model.BoaUser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; +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.*; +import java.util.List; +import java.util.Map; /** - * 基础平台代理接口(兼容前端 Notice3.0 URL)。 - * 路径前缀: /{platform}/base/ - * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。 - *

- * 这些接口在 C# 原版中依赖用户中心 OpenAPI(BaseOpenApi/HttpUserApi)获取数据, - * Java 版暂以桩实现返回,后续接入平台用户中心 API 后补全。 + * 平台代理域接口(base/)。 + *

路径前缀:{@code Config.API_PATH + "/base/"}(原硬编码已修正)。 + * 承载平台代理接口:GetUserInfo / GetUserMod / GetSchoolAll / GetGroupUser / Logout, + * 业务逻辑已下沉 {@link IUserCenterService},本控制器仅做"取参 → 调 Service → 返回"。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

*/ @RestController -@RequestMapping(value = "/{platform}/base/") +@RequestMapping(value = Config.API_PATH + "/base/") public class BaseApiController { private static final Logger log = LoggerFactory.getLogger(BaseApiController.class); - @Value("${notice.app-name:通知公告}") - private String appName; + @Autowired + private IUserCenterService userCenterService; - // ============================ GetLayout ============================ + @Autowired + private IFileService fileService; + + // ============================ FileUpload ============================ /** - * 获取布局数据(模块+头部)。 - * 前端: GET base/GetLayout - * C# 通过 OpenAPI 获取用户模块权限列表。 - * Java 版返回基础布局信息,pathList 待接入用户中心后补全。 + * 附件上传(单文件)。 + * 前端: POST base/FileUpload (form-data: file) + * 返回: { fileid, host, fileurl, name, type, size } */ - @GetMapping("GetLayout") - public Map getLayout() { - String account = NoticeContext.getAccount(); - String name = NoticeContext.getName(); + @PostMapping("FileUpload") + public UpFileResultVo fileUpload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, "notice"); + } - Map 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; + // ============================ GetUserInfo ============================ + + /** + * 获取当前登录用户信息(CAS 会话注入)。 + */ + @GetMapping("GetUserInfo") + public BoaUser getUserInfo(BoaUser authUserWithDept) { + return authUserWithDept; + } + + // ============================ GetUserMod ============================ + + /** + * 获取当前用户已授权的模块列表(左侧菜单树)。 + * 模块树组装已下沉 IUserCenterService。 + */ + @GetMapping("GetUserMod") + public List> getUserMod(BoaUser authUserWithDept) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId); } // ============================ GetSchoolAll ============================ /** * 获取当前用户学校组及用户(树形结构)。 - * 前端: GET base/GetSchoolAll?type= - * C# 通过 ISchGroupBLL + ITeainfoBLL 获取学校组+教师树。 - * Java 版待接入用户中心API,暂返回空树。 + * 组树组装/教师挂组/递归建树逻辑已下沉 IUserCenterService。 */ @GetMapping("GetSchoolAll") - public List> getSchoolAll(@RequestParam(value = "type", defaultValue = "") String type) { - log.info("[getSchoolAll] 暂未接入用户中心API,返回空列表"); - return Collections.emptyList(); + public List> getSchoolAll(BoaUser authUserWithDept) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + return userCenterService.getSchoolAllTree(schId); } // ============================ GetGroupUser ============================ /** - * 获取当前组用户。 - * 前端: GET base/GetGroupUser?gid=&role= - * C# 通过 ITeainfoBLL.GetGroupTeainfo + IAdminfoBLL.GetGroupAdminfo 获取组内教师+管理员。 - * Java 版待接入用户中心API,暂返回空列表。 + * 获取指定组内用户(教师 + 管理员)。 + * 空桩已改为显式报错(避免前端误以为组内无用户),由 IUserCenterService 统一处理。 */ @GetMapping("GetGroupUser") public List> getGroupUser( @RequestParam("gid") Integer gid, @RequestParam(value = "role", defaultValue = "2") Integer role) { - log.info("[getGroupUser] gid={}, 暂未接入用户中心API,返回空列表", gid); - return Collections.emptyList(); + return userCenterService.getGroupUser(gid, role); } // ============================ Logout ============================ /** * 退出登录。 - * 前端: POST base/Logout - * C# 清除 Session + FormsAuth。 - * Java 版由 Spring Security/平台网关处理认证,此处仅返回成功。 + * 认证由平台网关处理,此处仅返回成功。 */ @PostMapping("Logout") public void logout() { - // 认证由平台网关处理,此处无需额外操作 - log.info("[logout] 用户退出登录: {}", NoticeContext.getAccount()); + LoginUser user = UserContextHolder.get(); + log.info("[logout] 用户退出登录: {}", user == null ? null : user.getLoginName()); } -} +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/notice/controller/api/ContactsController.java b/src/main/java/com/yunhai/notice/controller/api/ContactsController.java index 5e9468e..568e54f 100644 --- a/src/main/java/com/yunhai/notice/controller/api/ContactsController.java +++ b/src/main/java/com/yunhai/notice/controller/api/ContactsController.java @@ -1,29 +1,27 @@ package com.yunhai.notice.controller.api; -import com.yunhai.notice.service.ContactsService; +import com.yunhai.notice.config.Config; +import com.yunhai.notice.service.IContactsService; 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}。 + * 联系组接口(contacts/)。 + *

路径前缀:{@code Config.API_PATH + "/contacts/"}(原硬编码已修正为 Config 拼接)。 + * 方法名已为 PascalCase 且写操作已是 POST,保持不动。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

*/ @RestController -@RequestMapping(value = "/{platform}/contacts/") +@RequestMapping(value = Config.API_PATH + "/contacts/") public class ContactsController { @Autowired - private ContactsService contactsService; - - // ============================ AddGroup ============================ + private IContactsService contactsService; /** * 添加联系组。 - * 前端: POST contacts/AddGroup (form-urlencoded: name, type) */ @PostMapping("AddGroup") public void addGroup( @@ -32,11 +30,8 @@ public class ContactsController { contactsService.addGroup(name, Integer.parseInt(type)); } - // ============================ GetGroup ============================ - /** * 获取联系组列表。 - * 前端: GET contacts/GetGroup?page=&count=&type= */ @GetMapping("GetGroup") public Map getGroup( @@ -46,11 +41,8 @@ public class ContactsController { return contactsService.getGroup(page, count, type); } - // ============================ EditGroup ============================ - /** * 编辑联系组。 - * 前端: POST contacts/EditGroup (form-urlencoded: name, id, type) */ @PostMapping("EditGroup") public void editGroup( @@ -60,22 +52,16 @@ public class ContactsController { 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( @@ -84,11 +70,8 @@ public class ContactsController { contactsService.setContactsTea(Integer.parseInt(gid), ids); } - // ============================ GetContactsTea ============================ - /** * 获取联系组教师列表。 - * 前端: GET contacts/GetContactsTea?gid=&page=&count= */ @GetMapping("GetContactsTea") public Map getContactsTea( @@ -98,47 +81,19 @@ public class ContactsController { return contactsService.getContactsTea(gid, page, count); } - // ============================ GetGroupTree ============================ - /** * 获取联系组树。 - * 前端: GET contacts/GetGroupTree?type= */ @GetMapping("GetGroupTree") public List> getGroupTree(@RequestParam("type") Integer type) { return contactsService.getGroupTree(type); } - // ============================ GetDeptRoleGroupTree ============================ - /** * 获取部门角色组树。 - * 前端: GET contacts/GetDeptRoleGroupTree */ @GetMapping("GetDeptRoleGroupTree") public List> getDeptRoleGroupTree() { return contactsService.getDeptRoleGroupTree(); } - - // ============================ Import ============================ - - /** - * 导入联系组。 - * 前端: POST contacts/Import (Excel文件上传) - */ - @PostMapping("Import") - public Map importContacts() { - return contactsService.importContacts(); - } - - // ============================ Export ============================ - - /** - * 导出联系组。 - * 前端: GET contacts/Export - */ - @GetMapping("Export") - public Map exportContacts() { - return contactsService.exportContacts(); - } -} +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/notice/controller/api/DictController.java b/src/main/java/com/yunhai/notice/controller/api/DictController.java index 262c57a..7c5c685 100644 --- a/src/main/java/com/yunhai/notice/controller/api/DictController.java +++ b/src/main/java/com/yunhai/notice/controller/api/DictController.java @@ -1,111 +1,81 @@ package com.yunhai.notice.controller.api; -import com.yunhai.notice.service.DictService; +import com.yunhai.notice.config.Config; +import com.yunhai.notice.service.IDictService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; -import java.util.*; +import java.util.List; +import java.util.Map; /** - * 系统字典接口(兼容前端 Notice3.0 URL)。 - * 路径前缀: /{platform}/base/ - * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。 + * 系统字典接口(发文部门域)。 + *

路径前缀:{@code Config.API_PATH + "/dict/"}(原 base/ 前缀已修正)。 + * 写操作一律 POST(原 DelDept/OrdbyDicIds 为 GET 写操作,已 POST 化)。 */ @RestController -@RequestMapping(value = "/{platform}/base/") +@RequestMapping(value = Config.API_PATH + "/dict/") public class DictController { @Autowired - private DictService dictService; - - // ============================ GetSysDic ============================ + private IDictService dictService; /** * 获取系统字典(下拉框用)。 - * 前端: GET base/GetSysDic?Type=1 */ - @GetMapping("GetSysDic") - public List> getSysDic(@RequestParam(value = "type", defaultValue = "0") Integer type) { + @GetMapping("List") + public List> list(@RequestParam(value = "type", defaultValue = "0") Integer type) { return dictService.getSysDic(type); } - // ============================ GetDictMangeList ============================ - /** * 获取系统字典管理列表。 - * 前端: GET base/GetDictMangeList?type=dept */ - @GetMapping("GetDictMangeList") - public List> getDictMangeList(@RequestParam(value = "type", defaultValue = "dept") String type) { + @GetMapping("ManageList") + public List> manageList(@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) { + @PostMapping("Add") + public void add(@RequestParam("name") String name) { dictService.addDeptDic(name); } - // ============================ UpdateDeptDic ============================ - /** * 更新发文单位。 - * 前端: POST base/UpdateDeptDic (form-urlencoded: SerID, Name, Enable) */ - @PostMapping("UpdateDeptDic") - public void updateDeptDic( + @PostMapping("Update") + public void update( @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 + * 删除发文单位(原 GET DelDept,已 POST 化)。 */ - @GetMapping("DelDept") - public void delDept(@RequestParam("serid") Integer serID) { + @PostMapping("Delete") + public void delete(@RequestParam("serid") Integer serID) { dictService.delDept(serID); } - // ============================ OrdbyDicIds ============================ - /** - * 发文单位排序。 - * 前端: GET base/OrdbyDicIds?ids=1,2,3 + * 发文单位排序(原 GET OrdbyDicIds,已 POST 化)。 */ - @GetMapping("OrdbyDicIds") - public void ordbyDicIds(@RequestParam("ids") String ids) { + @PostMapping("Sort") + public void sort(@RequestParam("ids") String ids) { dictService.ordbyDicIds(ids); } - // ============================ GetDeptSync ============================ - /** * 发文单位同步。 - * 前端: GET base/GetDeptSync */ - @GetMapping("GetDeptSync") - public void getDeptSync() { + @GetMapping("Sync") + public void sync() { dictService.getDeptSync(); } - - // ============================ GetSchModuleIsSms ============================ - - /** - * 是否发送短信。 - * 前端: GET base/GetSchModuleIsSms - */ - @GetMapping("GetSchModuleIsSms") - public Boolean getSchModuleIsSms() { - return dictService.getSchModuleIsSms(); - } } diff --git a/src/main/java/com/yunhai/notice/controller/api/FileController.java b/src/main/java/com/yunhai/notice/controller/api/FileController.java index 8eb2b6d..39fb26a 100644 --- a/src/main/java/com/yunhai/notice/controller/api/FileController.java +++ b/src/main/java/com/yunhai/notice/controller/api/FileController.java @@ -1,113 +1,88 @@ package com.yunhai.notice.controller.api; -import com.yunhai.notice.entity.UpFile; +import com.yunhai.notice.config.Config; +import com.yunhai.notice.model.notice.UpFileResultVo; import com.yunhai.notice.model.notice.UpFileVo; -import com.yunhai.notice.repository.UpFileRepository; -import com.yunhai.notice.service.FileService; +import com.yunhai.notice.service.IFileService; 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.*; +import java.util.List; /** - * 文件接口(兼容前端 Notice3.0 URL)。 - * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。 + * 附件域接口(file/)。 + *

路径前缀:{@code Config.API_PATH + "/file/"}(原 base/ 前缀已修正为独立文件域)。 + * 方法名统一 PascalCase;写操作一律 POST。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。

*/ @RestController -@RequestMapping(value = "/{platform}/base/") +@RequestMapping(value = Config.API_PATH + "/file/") public class FileController { @Autowired - private FileService fileService; - - @Autowired - private UpFileRepository upFileRepository; + private IFileService fileService; /** * 附件上传(单文件)。 - * 前端: POST base/FileUpload (form-data, 含 AppType) + * 前端: POST file/Upload (form-data) * 返回: { fileid, host, fileurl, name, type, size } */ - @PostMapping("FileUpload") - public Map 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); + @PostMapping("Upload") + public UpFileResultVo upload(@RequestParam("file") MultipartFile file) { + return fileService.uploadToResult(file, "notice"); } /** * 图片上传(多文件,返回数组)。 - * 前端: POST base/FileUploadImage (form-data, accept=image/*) - * 返回: [{ fileid, host, fileurl, name, type, size }] + * 前端: POST file/UploadImage (form-data, accept=image/*) */ - @PostMapping("FileUploadImage") - public List> fileUploadImage( - @RequestParam("file") MultipartFile[] files, - @RequestParam(value = "AppType", required = false) String appType) { - List> results = new ArrayList<>(); - for (MultipartFile file : files) { - UpFileVo vo = fileService.upload(file, appType != null ? appType : "notice", null); - results.add(upFileToResult(vo)); - } - return results; + @PostMapping("UploadImage") + public List uploadImage(@RequestParam("file") MultipartFile[] files) { + return fileService.uploadImages(files, "notice"); } - /** * 文件下载(浏览器直接 location.href 跳转)。 - * 前端: GET base/FileDown?id=xxx */ - @GetMapping("FileDown") - public void fileDown(@RequestParam("id") Integer id, HttpServletResponse response) { + @GetMapping("Down") + public void down(@RequestParam("id") Integer id, HttpServletResponse response) { fileService.download(id, response); } /** - * 文件预览。 - * 前端: GET base/FilePreview?id=xxx&type=2 + * 文件在线预览(inline 输出,前端取 Blob 交给 open-file-viewer 渲染)。 */ - @GetMapping("FilePreview") - public void filePreview(@RequestParam("id") Integer id, - @RequestParam(value = "type", required = false) Integer type, - HttpServletResponse response) { - fileService.download(id, response); + @GetMapping("Preview") + public void preview(@RequestParam("id") Integer id, + @RequestParam(value = "type", required = false) Integer type, + HttpServletResponse response) { + fileService.preview(id, response); } /** * 删除文件。 */ - @PostMapping("fileDelete/{fileId}") - public void delete(@PathVariable Integer fileId) { + @PostMapping("Delete") + public void delete(@RequestParam("fileId") Integer fileId) { fileService.delete(fileId); } /** * 查询某通知下的附件列表。 */ - @GetMapping("fileList/{topicId}") - public List fileList(@PathVariable Integer topicId) { + @GetMapping("List") + public List list(@RequestParam("topicId") Integer topicId) { return fileService.listByTopic("notice", topicId); } /** * 批量绑定附件到通知。 */ - @PostMapping("fileBind") - public void bindFiles(@RequestParam("fileIds") List fileIds, - @RequestParam("topicId") Integer topicId) { + @PostMapping("Bind") + public void bind(@RequestParam("fileIds") List fileIds, + @RequestParam("topicId") Integer topicId) { fileService.bindToTopic(fileIds, topicId); } - private Map upFileToResult(UpFileVo vo) { - Map 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; - } -} +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/notice/controller/api/NoticeApiController.java b/src/main/java/com/yunhai/notice/controller/api/NoticeApiController.java deleted file mode 100644 index 3833bb8..0000000 --- a/src/main/java/com/yunhai/notice/controller/api/NoticeApiController.java +++ /dev/null @@ -1,450 +0,0 @@ -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; - -/** - * 通知公告前端对接接口。 - *

匹配前端 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 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> list = result.getContent().stream().map(vo -> { - Map 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 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 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 result = noticeService.adminList(query, PageUtils.toPageable(query, sort)); - - List> list = result.getContent().stream().map(vo -> { - Map 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 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 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 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 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 fileIds = parseIds(chaoSend); - Page 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 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> orgizeModify(@RequestParam("serid") Integer serid) { - List readDetails = noticeService.getReadDetail(serid); - List> teaslist = readDetails.stream().map(d -> { - Map item = new LinkedHashMap<>(); - item.put("teaname", d.getName()); - item.put("states", "read"); - return item; - }).collect(Collectors.toList()); - - Map 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 readIds = noticeReadRepository.findNoticeIdsByReaderAccount(account); - Set readSet = new HashSet<>(readIds); - List 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 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 voToDetailMap(NoticeVo vo, Integer serid) { - Map 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 files = upFileRepository.findByTopicTypeAndTopicId("notice", serid); - List> chaoSend = files.stream().map(f -> { - Map 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 parseIds(String idsStr) { - List 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")); - } -} diff --git a/src/main/java/com/yunhai/notice/controller/api/NoticeController.java b/src/main/java/com/yunhai/notice/controller/api/NoticeController.java index bd86a3b..4c0d128 100644 --- a/src/main/java/com/yunhai/notice/controller/api/NoticeController.java +++ b/src/main/java/com/yunhai/notice/controller/api/NoticeController.java @@ -1,150 +1,217 @@ package com.yunhai.notice.controller.api; import com.yunhai.notice.config.Config; +import com.yunhai.notice.common.validation.ValidationGroups; 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.model.notice.*; 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.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import javax.servlet.http.HttpServletResponse; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; /** - * 通知公告接口。 - *

路径前缀:/{platform}/base/(platform = api / manage / mobiapi), - * 与 UserBase BaseController 路径习惯一致,前端无需区分子应用 。

+ * 通知公告接口(合并 NoticeApiController + 旧 NoticeController)。 + *

路径前缀:{@code Config.API_PATH + "/notice/"}(platform = api / manage / mobiapi)。 + * 方法动词式 PascalCase(对齐 UserBase);写操作一律 POST。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。 */ @RestController -@RequestMapping(value = Config.API_PATH + "/base/") +@RequestMapping(value = Config.API_PATH + "/notice/") 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 getUserMod(BoaUser authUserWithDept) { - Integer schoolId = (authUserWithDept.getSchool() != null) ? authUserWithDept.getSchool().getId() : null; - List 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 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) { + /** + * 已收通知列表(用户端)。 + */ + @GetMapping("List") + public NoticePageResult list(NoticeQuery query, + @SortDefault(sort = {"topmost", "createTime"}, + direction = Sort.Direction.DESC) Sort sort) { return noticeService.userList(query, PageUtils.toPageable(query, sort)); } - /** 管理端列表:管理员查看全部通知 */ - @GetMapping("adminList") - public BasePageResult adminList(NoticeQuery query, - @SortDefault(sort = "createTime", - direction = Sort.Direction.DESC) Sort sort) { + // ============================ 已发/全部通知列表 ============================ + + /** + * 已发/全部通知列表(管理端)。type=user 只看自己。 + */ + @GetMapping("SendList") + public BasePageResult sendList(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); + // ============================ 查看详情(标读) ============================ + + /** + * 查看通知详情(type=1 普通查看标记已读, type=0 编辑模式不标记已读)。 + */ + @GetMapping("Detail") + public NoticeDetailVo detail(@RequestParam("serid") Integer serid, + @RequestParam(value = "type", defaultValue = "1") Integer type) { + return noticeService.detailMap(serid, type); } - /** 标记已读(前端主动调用,幂等) */ - @PostMapping("read/{id}") - public void read(@PathVariable Integer id) { - noticeService.markRead(id); + // ============================ 编辑回显(不标读) ============================ + + /** + * 获取通知详情(编辑/转发用,不标记已读)。 + */ + @GetMapping("GetDetail") + public NoticeDetailVo getDetail(@RequestParam("serid") Integer serid) { + return noticeService.getDetailMap(serid); } - /** 通知统计:总数/已发布/草稿、累计阅读人次、当前用户未读、按发布人、按月 */ - @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) { + // ============================ 新建 ============================ + + /** + * 新建通知(JSON body)。fileIds 为附件ID逗号串,smsFlag 为短信标记。 + */ + @PostMapping("Create") + public Integer create(@RequestBody @Validated(ValidationGroups.Create.class) NoticeRequest request) { + return noticeService.publish(request); + } + + // ============================ 编辑 ============================ + + /** + * 编辑通知(JSON body,含 id)。 + */ + @PostMapping("Edit") + public void edit(@RequestBody @Validated(ValidationGroups.Update.class) NoticeRequest request) { + noticeService.edit(request.getSourceId() != null ? request.getSourceId() : 0, request); + } + + // ============================ 批量删除 ============================ + + /** + * 批量删除通知(ids 数组)。 + */ + @PostMapping("Delete") + public void delete(@RequestBody List ids) { + noticeService.deleteBatch(ids); + } + + // ============================ 撤回 ============================ + + /** + * 撤回通知。 + */ + @PostMapping("UserPull") + public void userPull(@RequestParam("serid") Integer serid) { + noticeService.pull(serid); + } + + // ============================ 置顶 ============================ + + /** + * 置顶/取消置顶。 + */ + @PostMapping("SetTopmost") + public void setTopmost(@RequestParam("serid") Integer serid, + @RequestParam(value = "topmost", defaultValue = "1") Integer topmost) { + noticeService.setTopmost(serid, topmost); + } + + // ============================ 追加接收人 ============================ + + /** + * 追加接收人。 + */ + @PostMapping("ReceiveUsers") + public void receiveUsers(@RequestParam("serid") Integer serid, + @RequestParam("users") String users) { + noticeService.receiveUsers(serid, users); + } + + // ============================ 催收 ============================ + + /** + * 催收未读。 + */ + @PostMapping("Expedite") + public void expedite(@RequestParam("serid") Integer serid) { + noticeService.expedite(serid); + } + + // ============================ 一键已读 ============================ + + /** + * 一键全部已读(POST 化,原为 GET 写操作)。 + */ + @PostMapping("InitializeRed") + public InitializeRedVo initializeRed() { + return noticeService.initializeRedResult(); + } + + // ============================ 阅读情况 ============================ + + /** + * 阅读情况(按已读/未读分组)。 + */ + @GetMapping("ReadDetail") + public List> readDetail(@RequestParam("serid") Integer serid) { + return noticeService.getReadDetailGrouped(serid); + } + + // ============================ 上一篇/下一篇 ============================ + + /** + * 上一篇/下一篇通知ID。 + */ + @GetMapping("GetNextPrev") + public Map getNextPrev(@RequestParam("serid") Integer serid, + @RequestParam("type") String type) { + return noticeService.getNextPrev(serid, type); + } + + // ============================ 附件打包下载 ============================ + + /** + * 批量下载通知全部附件(打包 zip)。 + */ + @GetMapping("AllFileDown") + public void allFileDown(@RequestParam("dataid") Integer dataid, + HttpServletResponse response) { + noticeService.downloadAllFiles(dataid, response); + } + + // ============================ 统计 ============================ + + /** + * 通知统计。 + */ + @GetMapping("Statis") + public NoticeStatisticsVo statis( + @RequestParam(value = "starttime", required = false) + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime start, + @RequestParam(value = "endtime", required = false) + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime end) { return noticeService.statistics(new NoticeQuery(), start, end); } + + // ============================ 转发(合并旧 REST 壳 forward) ============================ + + /** + * 转发通知(基于源通知创建新通知)。sourceId 在 request body 中传递。 + */ + @PostMapping("Forward") + public Integer forward(@RequestBody @Validated(ValidationGroups.Create.class) NoticeRequest request) { + return noticeService.forward(request.getSourceId(), request); + } } diff --git a/src/main/java/com/yunhai/notice/controller/api/SmsController.java b/src/main/java/com/yunhai/notice/controller/api/SmsController.java index e5e17d6..9487762 100644 --- a/src/main/java/com/yunhai/notice/controller/api/SmsController.java +++ b/src/main/java/com/yunhai/notice/controller/api/SmsController.java @@ -1,145 +1,88 @@ package com.yunhai.notice.controller.api; -import com.yunhai.notice.service.SmsService; +import com.yunhai.notice.config.Config; +import com.yunhai.notice.service.ISmsService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; -import java.util.*; +import java.util.List; +import java.util.Map; /** - * 短信设置接口(兼容前端 Notice3.0 URL)。 - * 路径前缀: /{platform}/schsms/ - * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。 + * 短信设置接口(短信域)。 + *

路径前缀:{@code Config.API_PATH + "/sms/"}(原 schsms/ 已修正)。 + * 方法名 PascalCase(原全小写已修正);写操作一律 POST(原 setschallsms/setschmodulesms 为 GET 写操作,已 POST 化)。 */ @RestController -@RequestMapping(value = "/{platform}/schsms/") +@RequestMapping(value = Config.API_PATH + "/sms/") public class SmsController { @Autowired - private SmsService smsService; - - // ============================ getmodule ============================ + private ISmsService smsService; /** * 获取短信模块列表。 - * 前端: GET schsms/getmodule */ - @GetMapping("getmodule") - public List> getModuleList() { + @GetMapping("GetModule") + public List> getModule() { return smsService.getModuleList(); } - // ============================ getgroup ============================ - /** * 获取学校父类组列表。 - * 前端: GET schsms/getgroup */ - @GetMapping("getgroup") - public List> getSchParentGroup() { + @GetMapping("GetGroup") + public List> getGroup() { return smsService.getSchParentGroup(); } - // ============================ getschsms ============================ - /** * 分页获取学校短信开关列表。 - * 前端: GET schsms/getschsms?PageSize=&PageIndex=&SchName= */ - @GetMapping("getschsms") - public Map getSchIsSmsList( + @GetMapping("GetSchoolSms") + public Map getSchoolSms( @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> getSchList() { + @GetMapping("GetSchool") + public List> getSchool() { 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); + @PostMapping("SetSchoolSms") + public void setSchoolSms(@RequestParam("itemsModu") String itemsModuJson) { + smsService.setSchSmsFromJson(itemsModuJson); } - // ============================ setschallsms ============================ - /** - * 设置某校全部模块短信开关。 - * 前端: GET schsms/setschallsms?SchSerID=&IsSms= + * 设置某校全部模块短信开关(原 GET setschallsms,已 POST 化)。 */ - @GetMapping("setschallsms") - public void setSchSmsAllTrue( + @PostMapping("SetAllSchoolSms") + public void setAllSchoolSms( @RequestParam("SchSerID") Integer schSerID, @RequestParam("IsSms") Boolean isSms) { smsService.setSchSmsAll(schSerID, isSms); } - // ============================ setschmodulesms ============================ - /** - * 批量设置多校多模块短信开关。 - * 前端: GET schsms/setschmodulesms?SchSerID=1,2&Module=3,4&IsSms=true + * 批量设置多校多模块短信开关(原 GET setschmodulesms,已 POST 化)。 */ - @GetMapping("setschmodulesms") - public void setSchModuleSmsTrue( + @PostMapping("SetSchoolModuleSms") + public void setSchoolModuleSms( @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; - } } diff --git a/src/main/java/com/yunhai/notice/controller/api/TemplateController.java b/src/main/java/com/yunhai/notice/controller/api/TemplateController.java index 033bd23..95df0bb 100644 --- a/src/main/java/com/yunhai/notice/controller/api/TemplateController.java +++ b/src/main/java/com/yunhai/notice/controller/api/TemplateController.java @@ -1,80 +1,67 @@ package com.yunhai.notice.controller.api; -import com.yunhai.notice.service.TemplateService; +import com.yunhai.notice.config.Config; +import com.yunhai.notice.service.ITemplateService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; -import java.util.*; +import java.util.List; +import java.util.Map; /** - * 通知模板接口(兼容前端 Notice3.0 URL)。 - * 路径前缀: /{platform}/notice/Template/ - * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。 + * 通知模板接口(模板域)。 + *

路径前缀:{@code Config.API_PATH + "/template/"}(原 notice/Template/ 三级路径已修正)。 + * 写操作一律 POST(原 EditType/DelTemplate 为 GET 写操作,已 POST 化)。 */ @RestController -@RequestMapping(value = "/{platform}/notice/Template/") +@RequestMapping(value = Config.API_PATH + "/template/") public class TemplateController { @Autowired - private TemplateService templateService; - - // ============================ GetTypelist ============================ + private ITemplateService templateService; /** * 获取模板类型列表。 - * 前端: GET notice/Template/GetTypelist */ - @GetMapping("GetTypelist") + @GetMapping("Typelist") public List> getTypelist() { return templateService.getTypelist(); } - // ============================ EditType ============================ - /** - * 新增/编辑模板类型。 - * 前端: GET notice/Template/EditType?typeid=&TypeName= + * 新增/编辑模板类型(原 GET EditType,已 POST 化)。 */ - @GetMapping("EditType") - public void editType( + @PostMapping("TypeEdit") + public void typeEdit( @RequestParam("typeid") Integer typeid, @RequestParam("TypeName") String typeName) { templateService.editType(typeid, typeName); } - // ============================ DelType ============================ - /** - * 删除模板类型。 - * 前端: GET notice/Template/DelType?typeid= + * 删除模板类型(原 GET DelType,已 POST 化)。 */ - @GetMapping("DelType") - public void delType(@RequestParam("typeid") Integer typeid) { + @PostMapping("TypeDelete") + public void typeDelete(@RequestParam("typeid") Integer typeid) { templateService.delType(typeid); } - // ============================ GetTemplatelist ============================ - /** * 分页获取模板列表。 - * 前端: GET notice/Template/GetTemplatelist?PageSize=&PageIndex=&typeid= */ - @GetMapping("GetTemplatelist") - public Map getTemplatelist( + @GetMapping("List") + public Map list( @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( + @PostMapping("TemplateEdit") + public void templateEdit( @RequestParam(value = "SerID", required = false) Integer serID, @RequestParam("Title") String title, @RequestParam("Contents") String contents, @@ -83,25 +70,19 @@ public class TemplateController { templateService.editTemplate(serID, title, contents, type, cover); } - // ============================ DelTemplate ============================ - /** - * 删除模板。 - * 前端: GET notice/Template/DelTemplate?serid= + * 删除模板(原 GET DelTemplate,已 POST 化)。 */ - @GetMapping("DelTemplate") - public void delTemplate(@RequestParam("SerID") Integer serid) { + @PostMapping("TemplateDelete") + public void templateDelete(@RequestParam("SerID") Integer serid) { templateService.delTemplate(serid); } - // ============================ GetTemplateFist ============================ - /** * 获取单个模板详情。 - * 前端: GET notice/Template/GetTemplateFist?serid= */ - @GetMapping("GetTemplateFist") - public Map getTemplateFist(@RequestParam("serid") Integer serid) { + @GetMapping("TemplateFirst") + public Map templateFirst(@RequestParam("serid") Integer serid) { return templateService.getTemplateFist(serid); } } diff --git a/src/main/java/com/yunhai/notice/entity/Notice.java b/src/main/java/com/yunhai/notice/entity/Notice.java index 2e1b5f8..bc1bebc 100644 --- a/src/main/java/com/yunhai/notice/entity/Notice.java +++ b/src/main/java/com/yunhai/notice/entity/Notice.java @@ -7,8 +7,6 @@ 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; /** @@ -27,10 +25,15 @@ public class Notice extends BaseEntity { /** * 通知标题 */ - @NotBlank(message = "通知标题不得为空") @Column(name = "title", nullable = false, length = 200) private String title; + /** + * 学校ID(多租户隔离:发布时从当前用户角色填充,查询时按校过滤)。 + */ + @Column(name = "sch_id") + private Integer schId; + /** * 通知内容 */ @@ -40,7 +43,6 @@ public class Notice extends BaseEntity { /** * 通知类型:1 普通 / 2 重要(见 {@link com.yunhai.notice.enums.NoticeTypeEnum}) */ - @NotNull @Column(name = "type", nullable = false) private Integer type = 1; @@ -69,9 +71,8 @@ public class Notice extends BaseEntity { private String deptName; /** - * 状态:0 草稿 / 1 已发布(见 {@link com.yunhai.notice.enums.NoticeStatusEnum}) + * 状态:1 已发布(无草稿态,见 {@link com.yunhai.notice.enums.NoticeStatusEnum}) */ - @NotNull @Column(name = "status", nullable = false) private Integer status = 1; diff --git a/src/main/java/com/yunhai/notice/entity/NoticeReceiver.java b/src/main/java/com/yunhai/notice/entity/NoticeReceiver.java new file mode 100644 index 0000000..48775a0 --- /dev/null +++ b/src/main/java/com/yunhai/notice/entity/NoticeReceiver.java @@ -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.Index; +import javax.persistence.Table; + +/** + * 通知接收人关联表(替代旧 receive_list JSON 字符串存储)。 + *

+ * 每条记录代表一个通知的一个接收人。全员通知(receive_list="ALL")不写明细行, + * 读取时按 receive_list="ALL" 标识走旧逻辑兜底。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "notice_receiver", + indexes = { + @Index(name = "idx_notice_id", columnList = "notice_id"), + @Index(name = "idx_account", columnList = "account") + }) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class NoticeReceiver extends BaseEntity { + + /** 通知ID(关联 notice.id) */ + @Column(name = "notice_id", nullable = false) + private Integer noticeId; + + /** 接收人登录账号 */ + @Column(name = "account", nullable = false, length = 64) + private String account; + + /** 接收类型:1 接收 / 2 抄送 */ + @Column(name = "receive_type", nullable = false) + private Integer receiveType = 1; +} diff --git a/src/main/java/com/yunhai/notice/enums/NoticeStatusEnum.java b/src/main/java/com/yunhai/notice/enums/NoticeStatusEnum.java index abceffd..2d9cd77 100644 --- a/src/main/java/com/yunhai/notice/enums/NoticeStatusEnum.java +++ b/src/main/java/com/yunhai/notice/enums/NoticeStatusEnum.java @@ -3,12 +3,13 @@ package com.yunhai.notice.enums; import lombok.Getter; /** - * 通知状态。对应原 .NET Notify_Message.status(原系统已发布=2,此处归一化)。 + * 通知状态。无草稿态:发通知一律置为「已发布」, + * 定时发送通过 timingSendTime + 查询侧「已到点」过滤控制可见性。 + * 对应原 .NET Notify_Message.status(原系统已发布=2,此处归一化为 1)。 */ @Getter public enum NoticeStatusEnum { - DRAFT(0, "草稿"), PUBLISHED(1, "已发布"); private final int code; diff --git a/src/main/java/com/yunhai/notice/job/TimingSendJob.java b/src/main/java/com/yunhai/notice/job/TimingSendJob.java deleted file mode 100644 index 936537e..0000000 --- a/src/main/java/com/yunhai/notice/job/TimingSendJob.java +++ /dev/null @@ -1,58 +0,0 @@ -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 的通知,自动发布。 - *

- * 多实例部署时需加分布式锁(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 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); - } - } - } -} diff --git a/src/main/java/com/yunhai/notice/model/SchTeaList.java b/src/main/java/com/yunhai/notice/model/SchTeaList.java new file mode 100644 index 0000000..43b086d --- /dev/null +++ b/src/main/java/com/yunhai/notice/model/SchTeaList.java @@ -0,0 +1,106 @@ +package com.yunhai.notice.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * 班级管理接口-教师(bschclass {@code /oapi/DbClass/GetSchteaList} 响应项)。 + * 对应 C# BestSchool.Entity.Model.SchTeaList。 + */ +public class SchTeaList { + + /** 学校 GUID(SchSerID) */ + @JsonProperty("SchSerID") + private String schSerID; + + @JsonProperty("TeaTel") + private String teaTel; + + /** 教师姓名 */ + @JsonProperty("TeaName") + private String teaName; + + /** 教师用户ID */ + @JsonProperty("UserTeaSerID") + private Integer userTeaSerID; + + @JsonProperty("TeaIDCard") + private String teaIDCard; + + /** 教师工号 */ + @JsonProperty("TeaNo") + private String teaNo; + + /** 教师所属组ID列表(兼容旧字段 GroupIds) */ + @JsonProperty("GroupIds") + private List groupIds; + + /** 教师所属组ID(接口现行字段 GroupID,单个) */ + @JsonProperty("GroupID") + private Integer groupId; + + public Integer getGroupId() { + return groupId; + } + + public void setGroupId(Integer groupId) { + this.groupId = groupId; + } + + public String getSchSerID() { + return schSerID; + } + + public void setSchSerID(String schSerID) { + this.schSerID = schSerID; + } + + public String getTeaTel() { + return teaTel; + } + + public void setTeaTel(String teaTel) { + this.teaTel = teaTel; + } + + public String getTeaName() { + return teaName; + } + + public void setTeaName(String teaName) { + this.teaName = teaName; + } + + public Integer getUserTeaSerID() { + return userTeaSerID; + } + + public void setUserTeaSerID(Integer userTeaSerID) { + this.userTeaSerID = userTeaSerID; + } + + public String getTeaIDCard() { + return teaIDCard; + } + + public void setTeaIDCard(String teaIDCard) { + this.teaIDCard = teaIDCard; + } + + public String getTeaNo() { + return teaNo; + } + + public void setTeaNo(String teaNo) { + this.teaNo = teaNo; + } + + public List getGroupIds() { + return groupIds; + } + + public void setGroupIds(List groupIds) { + this.groupIds = groupIds; + } +} diff --git a/src/main/java/com/yunhai/notice/model/notice/InitializeRedVo.java b/src/main/java/com/yunhai/notice/model/notice/InitializeRedVo.java new file mode 100644 index 0000000..acbd41f --- /dev/null +++ b/src/main/java/com/yunhai/notice/model/notice/InitializeRedVo.java @@ -0,0 +1,20 @@ +package com.yunhai.notice.model.notice; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 一键已读结果视图对象(替代原 Map<String,Object> 返回)。 + */ +@Data +public class InitializeRedVo { + + @JsonProperty("count") + private Integer count; + + public InitializeRedVo() {} + + public InitializeRedVo(Integer count) { + this.count = count; + } +} diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticeDetailVo.java b/src/main/java/com/yunhai/notice/model/notice/NoticeDetailVo.java new file mode 100644 index 0000000..3a9587f --- /dev/null +++ b/src/main/java/com/yunhai/notice/model/notice/NoticeDetailVo.java @@ -0,0 +1,74 @@ +package com.yunhai.notice.model.notice; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * 通知详情视图对象(替代原 Map<String,Object> 返回)。 + * 使用 @JsonProperty 保持旧字段名兼容前端渲染。 + */ +@Data +public class NoticeDetailVo { + + @JsonProperty("serid") + private Integer serid; + + @JsonProperty("title") + private String title; + + @JsonProperty("Contents") + private String contents; + + @JsonProperty("DeptID") + private String deptId; + + @JsonProperty("DeptName") + private String deptName; + + @JsonProperty("TeaName") + private String publisherName; + + @JsonProperty("EditDate") + private String editDate; + + @JsonProperty("numcount") + private Integer numCount; + + @JsonProperty("readnumcount") + private Long readNumCount; + + @JsonProperty("ReceiveList") + private String receiveList; + + @JsonProperty("DsfsTime") + private String dsfsTime; + + @JsonProperty("isDsfs") + private Boolean isDsfs; + + @JsonProperty("ChaoSend") + private List chaoSend; + + /** 接收人列表:"all" 或账号列表 */ + @JsonProperty("tlist") + private Object tlist; + + /** + * 附件项(旧 ChaoSend 数组元素)。 + */ + @Data + public static class FileItem { + @JsonProperty("id") + private Integer id; + @JsonProperty("name") + private String name; + @JsonProperty("size") + private Integer size; + @JsonProperty("fileid") + private Integer fileid; + @JsonProperty("filename") + private String filename; + } +} diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticePageResult.java b/src/main/java/com/yunhai/notice/model/notice/NoticePageResult.java new file mode 100644 index 0000000..48d66ad --- /dev/null +++ b/src/main/java/com/yunhai/notice/model/notice/NoticePageResult.java @@ -0,0 +1,26 @@ +package com.yunhai.notice.model.notice; + +import com.yunhai.notice.model.BasePageResult; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 通知分页结果(扩展 BasePageResult,额外携带 unreadCount)。 + *

+ * 对齐方案 M7:unreadCount 并入分页外壳扩展子类,不改基类契约。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class NoticePageResult extends BasePageResult { + + /** 当前用户未读通知总数 */ + private long unreadCount; + + public NoticePageResult(List content, int total, int pageIndex, int pageSize, + int totalPages, boolean hasNext, long unreadCount) { + super(content, total, pageIndex, pageSize, totalPages, hasNext); + this.unreadCount = unreadCount; + } +} diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticeQuery.java b/src/main/java/com/yunhai/notice/model/notice/NoticeQuery.java index 26a0f80..91b4122 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticeQuery.java +++ b/src/main/java/com/yunhai/notice/model/notice/NoticeQuery.java @@ -5,7 +5,7 @@ import lombok.Data; import lombok.EqualsAndHashCode; import org.springframework.format.annotation.DateTimeFormat; -import java.time.LocalDateTime; +import java.time.LocalDate; /** * 通知列表查询条件。 @@ -17,25 +17,28 @@ import java.time.LocalDateTime; @EqualsAndHashCode(callSuper = true) public class NoticeQuery extends BasePageQuery { - /** 状态过滤:null 不过滤;0 草稿 / 1 已发布 */ + /** 状态过滤:null 不过滤;1 已发布 */ private Integer status; /** 类型过滤:1 普通 / 2 重要 */ private Integer type; + /** 列表范围(管理端):"user" 只看自己发布的,null 看全部 */ + private String scope; + /** 发布部门ID */ private String deptId; /** 发布人账号(管理端按发布人查) */ private String publisherAccount; - /** 开始时间(创建时间 >=) */ - @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime startTime; + /** 开始时间(创建时间 >=,前端传 yyyy-MM-dd 短日期) */ + @DateTimeFormat(pattern = "yyyy-MM-dd") + private LocalDate startTime; - /** 结束时间(创建时间 <=) */ - @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") - private LocalDateTime endTime; + /** 结束时间(创建时间 <= 当天 23:59:59,前端传 yyyy-MM-dd 短日期) */ + @DateTimeFormat(pattern = "yyyy-MM-dd") + private LocalDate endTime; /** 阅读状态过滤(仅用户端):0 全部 / 1 未读 / 2 已读 */ private Integer readType = 0; diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticeRequest.java b/src/main/java/com/yunhai/notice/model/notice/NoticeRequest.java index 068f781..2c58f89 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticeRequest.java +++ b/src/main/java/com/yunhai/notice/model/notice/NoticeRequest.java @@ -3,6 +3,7 @@ package com.yunhai.notice.model.notice; import lombok.Data; import org.springframework.format.annotation.DateTimeFormat; +import com.yunhai.notice.common.validation.ValidationGroups; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import java.time.LocalDateTime; @@ -14,18 +15,18 @@ import java.time.LocalDateTime; public class NoticeRequest { /** 通知标题 */ - @NotBlank(message = "通知标题不得为空") + @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "通知标题不得为空") private String title; /** 通知内容 */ private String content; /** 通知类型:1 普通 / 2 重要 */ - @NotNull(message = "通知类型不得为空") + @NotNull(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "通知类型不得为空") private Integer type = 1; /** 发布部门ID(原 DeptID,必填——对应原系统「发布部门不得为空」校验) */ - @NotBlank(message = "发布部门不得为空") + @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "发布部门不得为空") private String deptId; /** 发布部门名称 */ @@ -34,16 +35,19 @@ public class NoticeRequest { /** 是否置顶:0 否 / 1 是 */ private Integer topmost = 0; - /** 接收人列表(JSON 数组字符串,如 ["account1","account2"]) */ + /** 接收人列表(JSON 数组字符串,如 ["account1","account2"] 或 "ALL" 表示全员) */ private String receiveList; - /** 抄送人列表 */ - private String chaoSend; + /** 附件文件ID列表(逗号分隔串,如 "1,2,3");旧名 chaoSend,已更名 fileIds(M6) */ + private String fileIds; /** 定时发送时间(为空表示立即发布) */ @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime timingSendTime; - /** 短信发送标记:0 不发短信 / 1 发短信 */ + /** 短信发送标记:0 不发短信 / 1 发短信(旧名 topmost 实指发短信,已正名 smsFlag,M6) */ private Integer smsFlag = 0; + + /** 转发源通知ID(非空时表示转发创建,对应旧 REST 壳 forward/{sourceId}) */ + private Integer sourceId; } diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticeStatisticsVo.java b/src/main/java/com/yunhai/notice/model/notice/NoticeStatisticsVo.java index 3280fbf..067a320 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticeStatisticsVo.java +++ b/src/main/java/com/yunhai/notice/model/notice/NoticeStatisticsVo.java @@ -10,19 +10,16 @@ import java.util.List; @Data public class NoticeStatisticsVo { - /** 通知总数(含草稿+已发布) */ + /** 通知总数(全部已发布) */ private long total; /** 已发布数 */ private long publishedCount; - /** 草稿数 */ - private long draftCount; - /** 阅读记录总数(累计已读人次) */ private long totalReadRecords; - /** 当前用户未读数(已发布 - 当前用户已读) */ + /** 当前用户未读数(已到点的已发布 - 当前用户已读) */ private long unreadForCurrentUser; /** 按发布人统计 */ diff --git a/src/main/java/com/yunhai/notice/model/notice/UpFileResultVo.java b/src/main/java/com/yunhai/notice/model/notice/UpFileResultVo.java new file mode 100644 index 0000000..878c7f2 --- /dev/null +++ b/src/main/java/com/yunhai/notice/model/notice/UpFileResultVo.java @@ -0,0 +1,30 @@ +package com.yunhai.notice.model.notice; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 文件上传结果视图对象(替代原 Map<String,Object> 返回)。 + * 使用 @JsonProperty 保持旧字段名兼容前端渲染。 + */ +@Data +public class UpFileResultVo { + + @JsonProperty("fileid") + private Integer fileid; + + @JsonProperty("name") + private String name; + + @JsonProperty("fileurl") + private String fileurl; + + @JsonProperty("host") + private String host; + + @JsonProperty("type") + private String type; + + @JsonProperty("size") + private Integer size; +} diff --git a/src/main/java/com/yunhai/notice/model/notice/UserModuleVo.java b/src/main/java/com/yunhai/notice/model/notice/UserModuleVo.java new file mode 100644 index 0000000..bc12c21 --- /dev/null +++ b/src/main/java/com/yunhai/notice/model/notice/UserModuleVo.java @@ -0,0 +1,122 @@ +package com.yunhai.notice.model.notice; + +import java.util.ArrayList; +import java.util.List; + +/** + * 用户可访问模块(左菜单)视图对象。 + *

将基础平台返回的扁平 {@code List} 转换为前端 layout(参考 UserBase)期望的树结构: + * {@code modId}(路由高亮匹配)/{@code page}(跳转路径)/{@code children}(子菜单)。

+ */ +public class UserModuleVo { + + /** 模块主键 id(基础平台 module id) */ + private Integer id; + + /** 菜单高亮匹配用:等价于后端模块 id */ + private Integer modId; + + /** 模块名称 */ + private String name; + + /** 父级模块 id(0 表示根节点) */ + private Integer parentId; + + /** 前端路由路径(由 BoaModule.path 映射而来) */ + private String page; + + /** 层级(0=根分组) */ + private Integer level; + + /** 菜单图标 */ + private String icon; + + /** 排序号 */ + private Integer orderNumber; + + /** 是否可用 */ + private Boolean usable; + + /** 子菜单 */ + private List children = new ArrayList<>(); + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public Integer getModId() { + return modId; + } + + public void setModId(Integer modId) { + this.modId = modId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Integer getParentId() { + return parentId; + } + + public void setParentId(Integer parentId) { + this.parentId = parentId; + } + + public String getPage() { + return page; + } + + public void setPage(String page) { + this.page = page; + } + + public Integer getLevel() { + return level; + } + + public void setLevel(Integer level) { + this.level = level; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public Integer getOrderNumber() { + return orderNumber; + } + + public void setOrderNumber(Integer orderNumber) { + this.orderNumber = orderNumber; + } + + public Boolean getUsable() { + return usable; + } + + public void setUsable(Boolean usable) { + this.usable = usable; + } + + public List getChildren() { + return children; + } + + public void setChildren(List children) { + this.children = children; + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/notice/repository/ContactsGroupUserRepository.java b/src/main/java/com/yunhai/notice/repository/ContactsGroupUserRepository.java index 9f75871..1151a25 100644 --- a/src/main/java/com/yunhai/notice/repository/ContactsGroupUserRepository.java +++ b/src/main/java/com/yunhai/notice/repository/ContactsGroupUserRepository.java @@ -4,24 +4,37 @@ 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 org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; public interface ContactsGroupUserRepository extends JpaRepository { Page findByGroupId(Integer groupId, Pageable pageable); List findByGroupId(Integer groupId); - void deleteByGroupId(Integer groupId); + + /** + * 按组ID批量删除成员(JPQL bulk delete,SQL 立即执行)。 + * 注意:不能用 derived delete——其 DELETE 延迟到 flush 才执行, + * 同事务内先 insert 会触发 uk_group_user 唯一约束冲突。 + */ + @Modifying(clearAutomatically = true) + @Query("delete from ContactsGroupUser g where g.groupId = :groupId") + void deleteByGroupIdBulk(@Param("groupId") Integer groupId); + + /** + * 按多个组ID批量删除成员。 + */ + @Modifying(clearAutomatically = true) + @Query("delete from ContactsGroupUser g where g.groupId in :groupIds") + void deleteByGroupIdInBulk(@Param("groupIds") List groupIds); /** * 按多个组ID查询所有成员(用于统计每组人数、组树构建)。 */ List findByGroupIdIn(List groupIds); - /** - * 按多个组ID批量删除成员。 - */ - void deleteByGroupIdIn(List groupIds); - /** * 按组ID和用户ID查询(判断是否已存在)。 */ diff --git a/src/main/java/com/yunhai/notice/repository/NoticeReceiverRepository.java b/src/main/java/com/yunhai/notice/repository/NoticeReceiverRepository.java new file mode 100644 index 0000000..ed1133b --- /dev/null +++ b/src/main/java/com/yunhai/notice/repository/NoticeReceiverRepository.java @@ -0,0 +1,31 @@ +package com.yunhai.notice.repository; + +import com.yunhai.notice.entity.NoticeReceiver; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * 通知接收人关联表仓储。 + */ +public interface NoticeReceiverRepository extends JpaRepository { + + /** 按通知ID查询全部接收人 */ + List findByNoticeId(Integer noticeId); + + /** 按通知ID删除全部接收人 */ + void deleteByNoticeId(Integer noticeId); + + /** 按通知ID和接收类型查询接收人 */ + List findByNoticeIdAndReceiveType(Integer noticeId, Integer receiveType); + + /** + * 批量查 N 个通知的接收人,用于列表展示(避免 N+1)。 + * @param noticeIds 通知ID列表 + * @return 接收人关联记录列表 + */ + @Query("select r from NoticeReceiver r where r.noticeId in :noticeIds") + List findByNoticeIds(@Param("noticeIds") List noticeIds); +} diff --git a/src/main/java/com/yunhai/notice/repository/NoticeRepository.java b/src/main/java/com/yunhai/notice/repository/NoticeRepository.java index 62c1789..78a64ae 100644 --- a/src/main/java/com/yunhai/notice/repository/NoticeRepository.java +++ b/src/main/java/com/yunhai/notice/repository/NoticeRepository.java @@ -16,10 +16,18 @@ public interface NoticeRepository extends JpaRepository, JpaSpecificationExecutor { /** - * 按状态计数(用于统计:已发布/草稿总数)。 + * 按状态计数(用于统计已发布总数)。 */ long countByStatus(Integer status); + /** + * 已发布且已到点(定时发送未到点不计入)的通知数,用于用户端未读数统计。 + */ + @Query("select count(n) from Notice n where n.status = :status " + + "and (n.timingSendTime is null or n.timingSendTime <= :now)") + long countPublishedDue(@Param("status") Integer status, + @Param("now") LocalDateTime now); + /** * 查询指定状态、指定时间范围内的通知创建时间(用于按月统计,避免加载大字段)。 */ @@ -39,10 +47,43 @@ public interface NoticeRepository extends JpaRepository, @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 findPendingTimingSend(@Param("status") Integer status, - @Param("now") LocalDateTime now); + long countBySchIdAndStatus(Integer schId, Integer status); + + /** + * 按学校计数(多租户隔离统计:全部状态)。 + */ + long countBySchId(Integer schId); + + /** + * 已发布且已到点(定时发送未到点不计入)且属于指定学校的通知数(用户端未读数统计,多租户隔离)。 + */ + @Query("select count(n) from Notice n where n.schId = :schId and n.status = :status " + + "and (n.timingSendTime is null or n.timingSendTime <= :now)") + long countPublishedDueBySchId(@Param("schId") Integer schId, + @Param("status") Integer status, + @Param("now") LocalDateTime now); + + /** + * 查询指定学校、指定状态、指定时间范围内的通知创建时间(按月统计,多租户隔离)。 + */ + @Query("select n.createTime from Notice n " + + "where n.schId = :schId and n.status = :status " + + "and n.createTime >= :start and n.createTime <= :end") + List findCreateTimesBySchIdStatusAndRange(@Param("schId") Integer schId, + @Param("status") Integer status, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + /** + * 查询指定学校、指定状态、指定时间范围内的发布人姓名(按发布人统计,多租户隔离)。 + */ + @Query("select n.publisherName from Notice n " + + "where n.schId = :schId and n.status = :status " + + "and n.createTime >= :start and n.createTime <= :end") + List findPublisherNamesBySchStatusAndRange(@Param("schId") Integer schId, + @Param("status") Integer status, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); } diff --git a/src/main/java/com/yunhai/notice/repository/SysDictRepository.java b/src/main/java/com/yunhai/notice/repository/SysDictRepository.java index a122007..be42e8b 100644 --- a/src/main/java/com/yunhai/notice/repository/SysDictRepository.java +++ b/src/main/java/com/yunhai/notice/repository/SysDictRepository.java @@ -4,8 +4,12 @@ import com.yunhai.notice.entity.SysDict; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; public interface SysDictRepository extends JpaRepository { List findByDictTypeOrderBySortOrderAsc(String dictType); List findByDictTypeAndEnableFlagOrderBySortOrderAsc(String dictType, Integer enableFlag); + + /** 按类型+key 取单条字典(发文部门名称解析用) */ + Optional findByDictTypeAndDictKey(String dictType, String dictKey); } diff --git a/src/main/java/com/yunhai/notice/repository/UpFileRepository.java b/src/main/java/com/yunhai/notice/repository/UpFileRepository.java index ad93ad4..b00d663 100644 --- a/src/main/java/com/yunhai/notice/repository/UpFileRepository.java +++ b/src/main/java/com/yunhai/notice/repository/UpFileRepository.java @@ -4,7 +4,6 @@ import com.yunhai.notice.entity.UpFile; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; - /** * 附件文件仓储。 */ @@ -15,6 +14,11 @@ public interface UpFileRepository extends JpaRepository { */ List findByTopicTypeAndTopicId(String topicType, Integer topicId); + /** + * 按主题类型和多个主题ID批量查询附件(消除 N+1)。 + */ + List findByTopicTypeAndTopicIdIn(String topicType, List topicIds); + /** * 按主题类型和主题ID删除附件(通知删除时级联清理)。 */ diff --git a/src/main/java/com/yunhai/notice/service/FileService.java b/src/main/java/com/yunhai/notice/service/FileService.java deleted file mode 100644 index e80ce30..0000000 --- a/src/main/java/com/yunhai/notice/service/FileService.java +++ /dev/null @@ -1,56 +0,0 @@ -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 fileIds, Integer topicId); - - /** - * 查询某主题下的附件列表。 - * - * @param appType 应用类型 - * @param topicId 主题ID - * @return 附件列表 - */ - List listByTopic(String appType, Integer topicId); - - /** - * 下载文件。 - * - * @param fileId 文件记录ID - * @param response HTTP 响应 - */ - void download(Integer fileId, HttpServletResponse response); - - /** - * 删除文件(同时删除物理文件和数据库记录)。 - * - * @param fileId 文件记录ID - */ - void delete(Integer fileId); -} diff --git a/src/main/java/com/yunhai/notice/service/IClassService.java b/src/main/java/com/yunhai/notice/service/IClassService.java new file mode 100644 index 0000000..0a043be --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/IClassService.java @@ -0,0 +1,20 @@ +package com.yunhai.notice.service; + +import com.yunhai.notice.model.SchTeaList; + +import java.util.List; + +/** + * 班级管理(bschclass)开放接口客户端。 + * 基址取配置 {@code yh.open.bschclass},当前仅提供教师查询,后续班级/年级等接口按需追加。 + */ +public interface IClassService { + + /** + * 获取指定学校全部教师(含所属组 GroupIds)。 + * 对应 bschclass 开放接口:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}} + * + * @param schId 学校ID(即用户当前身份的 schId) + */ + List getSchTeaList(int schId); +} diff --git a/src/main/java/com/yunhai/notice/service/ContactsService.java b/src/main/java/com/yunhai/notice/service/IContactsService.java similarity index 82% rename from src/main/java/com/yunhai/notice/service/ContactsService.java rename to src/main/java/com/yunhai/notice/service/IContactsService.java index 39eb76a..6ec0b35 100644 --- a/src/main/java/com/yunhai/notice/service/ContactsService.java +++ b/src/main/java/com/yunhai/notice/service/IContactsService.java @@ -7,7 +7,7 @@ import java.util.Map; * 联系组业务接口。 * 对应前端 Contacts 组接口:AddGroup/GetGroup/EditGroup/DelGroup/SetContactsTea/GetContactsTea/GetGroupTree/GetDeptRoleGroupTree/Import/Export。 */ -public interface ContactsService { +public interface IContactsService { /** * 添加联系组。 @@ -76,20 +76,4 @@ public interface ContactsService { * @return 树形结构 List<Map> */ List> getDeptRoleGroupTree(); - - /** - * 导入联系组(Excel)。 - * C# 读取Excel文件,逐行匹配组名+教师。Java版预留。 - * - * @return 导入结果 Map - */ - Map importContacts(); - - /** - * 导出联系组(Excel)。 - * C# 生成Excel文件流返回。Java版预留。 - * - * @return 导出数据 Map - */ - Map exportContacts(); } diff --git a/src/main/java/com/yunhai/notice/service/DictService.java b/src/main/java/com/yunhai/notice/service/IDictService.java similarity index 81% rename from src/main/java/com/yunhai/notice/service/DictService.java rename to src/main/java/com/yunhai/notice/service/IDictService.java index d48ac5e..6d49338 100644 --- a/src/main/java/com/yunhai/notice/service/DictService.java +++ b/src/main/java/com/yunhai/notice/service/IDictService.java @@ -7,7 +7,7 @@ import java.util.Map; * 系统字典业务接口。 * 对应前端 Base 组接口:GetSysDic/GetDictMangeList/AddDeptDic/UpdateDeptDic/DelDept/OrdbyDicIds/GetDeptSync/GetSchModuleIsSms。 */ -public interface DictService { +public interface IDictService { /** * 获取系统字典(下拉框用)。 @@ -67,4 +67,13 @@ public interface DictService { * @return Boolean */ Boolean getSchModuleIsSms(); + + /** + * 按发文部门字典 key(CodeID)解析部门名称;未命中返回 null。 + * 用于通知列表/详情展示 DeptName(对应 C# GetDeptName)。 + * + * @param deptId 通知 deptId(存的是 sys_dict.dict_key) + * @return 部门名称;无匹配时返回 null + */ + String resolveDeptName(String deptId); } diff --git a/src/main/java/com/yunhai/notice/service/IFileService.java b/src/main/java/com/yunhai/notice/service/IFileService.java new file mode 100644 index 0000000..5e80a4c --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/IFileService.java @@ -0,0 +1,94 @@ +package com.yunhai.notice.service; + +import com.yunhai.notice.entity.UpFile; +import com.yunhai.notice.model.notice.UpFileVo; +import com.yunhai.notice.model.notice.UpFileResultVo; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; + +/** + * 文件上传/下载/删除业务接口。 + */ +public interface IFileService { + /** + * 上传单个文件。 + * + * @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 fileIds, Integer topicId); + + /** + * 查询某主题下的附件列表。 + * + * @param appType 应用类型 + * @param topicId 主题ID + * @return 附件列表 + */ + List listByTopic(String appType, Integer topicId); + + /** + * 下载文件。 + * + * @param fileId 文件记录ID + * @param response HTTP 响应 + */ + void download(Integer fileId, HttpServletResponse response); + + /** + * 在线预览文件(Content-Disposition: inline,按扩展名输出真实 Content-Type, + * 供前端 open-file-viewer 取 Blob / video 播放)。 + * + * @param fileId 文件记录ID + * @param response HTTP 响应 + */ + void preview(Integer fileId, HttpServletResponse response); + + /** + * 删除文件(同时删除物理文件和数据库记录)。 + * + * @param fileId 文件记录ID + */ + void delete(Integer fileId); + + /** + * 打包下载多个附件为 ZIP。 + * + * @param files 附件记录列表(调用方按 fileIds 查好) + * @param zipName 压缩包显示名(自动追加 .zip) + * @param response HTTP 响应 + */ + void downloadZip(List files, String zipName, HttpServletResponse response); + + /** + * 按主题类型和主题ID打包下载附件为 ZIP(Controller 便捷方法,内部自行查附件列表)。 + * + * @param topicType 主题类型(如 "notice") + * @param topicId 主题ID + * @param zipName 压缩包显示名 + * @param response HTTP 响应 + */ + void downloadZipByTopic(String topicType, Integer topicId, String zipName, HttpServletResponse response); + + /** + * 批量上传图片(循环上传,返回每个文件的视图对象列表)。 + */ + List uploadImages(MultipartFile[] files, String appType); + + /** + * 上传单个文件并返回前端兼容的结果 VO(含 fileid/name/fileurl/host/type/size)。 + */ + UpFileResultVo uploadToResult(MultipartFile file, String appType); +} diff --git a/src/main/java/com/yunhai/notice/service/INoticeService.java b/src/main/java/com/yunhai/notice/service/INoticeService.java index 235df4a..b9d28f2 100644 --- a/src/main/java/com/yunhai/notice/service/INoticeService.java +++ b/src/main/java/com/yunhai/notice/service/INoticeService.java @@ -2,15 +2,13 @@ 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 com.yunhai.notice.model.notice.*; import org.springframework.data.domain.Pageable; +import javax.servlet.http.HttpServletResponse; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; /** * 通知公告业务接口。 @@ -19,14 +17,12 @@ public interface INoticeService { /** * 发通知:以当前登录用户为发布人,状态置为「已发布」入库。 - * 若 request.timingSendTime 不为空且晚于当前时间,则置为草稿等待定时发送。 + * 若 request.timingSendTime 不为空且晚于当前时间,则作为定时发送, + * 未到点前用户端收件箱不可见(到点后由查询过滤自然可见)。 + * + * @return 新建通知 ID(用于后续回填附件 TopicID 等) */ - void publish(NoticeRequest request); - - /** - * 保存草稿:状态置为「草稿」,不发布。 - */ - Integer saveDraft(NoticeRequest request); + Integer publish(NoticeRequest request); /** * 编辑通知:按 id 更新标题/内容/类型/部门/置顶,发布人与状态保持不变。 @@ -34,9 +30,9 @@ public interface INoticeService { void edit(Integer id, NoticeRequest request); /** - * 删除通知:删除主记录并级联清理其阅读记录。 + * 批量删除通知:删除主记录并级联清理阅读记录、接收人、附件关联。 */ - void delete(Integer id); + void deleteBatch(List ids); /** * 撤回通知:仅已发布且未撤回的通知可撤回,置 isPulled=1。 @@ -49,19 +45,15 @@ public interface INoticeService { void setTopmost(Integer id, Integer topmost); /** - * 转发通知:基于源通知创建新草稿,parentId 关联源通知。 + * 转发通知:基于源通知创建新通知(已发布),parentId 关联源通知。 */ Integer forward(Integer sourceId, NoticeRequest request); /** - * 发送通知(供定时任务和手动发送调用):更新状态为已发布,异步推送消息。 + * 用户端列表:仅展示已发布且已到点的通知,并标记当前用户是否已读,附未读总数。 + * 返回 NoticePageResult(扩展 unreadCount)。 */ - void sendNotify(Integer noticeId); - - /** - * 用户端列表:仅展示已发布通知,并标记当前用户是否已读,附未读总数。 - */ - NoticeUserListResult userList(NoticeQuery query, Pageable pageable); + NoticePageResult userList(NoticeQuery query, Pageable pageable); /** * 管理端列表:按条件查询全部通知(管理员查看全部)。 @@ -73,18 +65,78 @@ public interface INoticeService { */ NoticeVo detail(Integer id); + /** + * 编辑回显详情(不标记已读)。 + */ + NoticeVo getDetail(Integer id); + /** * 标记已读:为当前用户写入(幂等)该通知的阅读记录。 */ void markRead(Integer id); + /** + * 一键全部已读(批量化):当前用户所有未读的已发布通知批量插入阅读记录。 + * @return 已读条数 + */ + int initializeRed(); + + /** + * 追加接收人(事务化):并入接收人列表并重算 allowcount。 + */ + void receiveUsers(Integer noticeId, String users); + + /** + * 上一篇/下一篇通知ID(SQL 相邻查询)。 + * @param serid 当前通知ID + * @param type 0 已发(当前用户发布的序列)/ 1 已收(阅读记录序列) + * @return {prevId, nextId} + */ + Map getNextPrev(Integer serid, String type); + /** * 获取某通知的阅读明细列表。 */ List getReadDetail(Integer noticeId); /** - * 通知统计:总数/已发布/草稿、累计阅读人次、当前用户未读、按发布人、按月。 + * 阅读情况分组(已读/未读)。 + */ + List> getReadDetailGrouped(Integer serid); + + /** + * 催收未读:枚举未读接收人,后续接入消息推送。 + */ + void expedite(Integer serid); + + /** + * 通知统计:总数/已发布、累计阅读人次、当前用户未读、按发布人、按月。 */ NoticeStatisticsVo statistics(NoticeQuery query, LocalDateTime start, LocalDateTime end); + + /** + * 通知详情视图(含附件、接收人信息),供 Controller 返回。 + */ + NoticeDetailVo getDetailMap(Integer serid); + + /** + * 已发列表附件批量查询(消除 N+1)。 + */ + Map> batchFileIds(List noticeIds); + + /** + * 查看通知详情视图(含附件、接收人)。 + * type=1 时先标记已读再返回,type=0 时直接返回不标记。 + */ + NoticeDetailVo detailMap(Integer serid, Integer type); + + /** + * 一键全部已读并返回结果(count = 已读条数)。 + */ + InitializeRedVo initializeRedResult(); + + /** + * 批量下载通知全部附件(打包 zip):查通知标题后调文件域打包下载。 + */ + void downloadAllFiles(Integer dataid, HttpServletResponse response); } diff --git a/src/main/java/com/yunhai/notice/service/SmsService.java b/src/main/java/com/yunhai/notice/service/ISmsService.java similarity index 89% rename from src/main/java/com/yunhai/notice/service/SmsService.java rename to src/main/java/com/yunhai/notice/service/ISmsService.java index 237f20b..a801a81 100644 --- a/src/main/java/com/yunhai/notice/service/SmsService.java +++ b/src/main/java/com/yunhai/notice/service/ISmsService.java @@ -7,7 +7,7 @@ import java.util.Map; * 短信设置业务接口。 * 对应前端 setSms 组接口:getmodule/getgroup/getschsms/getsch/setschsms/setschallsms/setschmodulesms。 */ -public interface SmsService { +public interface ISmsService { /** * 获取短信模块列表。 @@ -68,4 +68,10 @@ public interface SmsService { * @param isSms 是否开通 */ void setSchModuleSms(String schSerIDs, String modules, Boolean isSms); + + /** + * 从 JSON 字符串解析并设置单校单模块短信开关。 + * 解析 SchSerID/ModuID/IsSms 三个字段。 + */ + void setSchSmsFromJson(String itemsModuJson); } diff --git a/src/main/java/com/yunhai/notice/service/TemplateService.java b/src/main/java/com/yunhai/notice/service/ITemplateService.java similarity index 97% rename from src/main/java/com/yunhai/notice/service/TemplateService.java rename to src/main/java/com/yunhai/notice/service/ITemplateService.java index 38578a6..d5d7eb7 100644 --- a/src/main/java/com/yunhai/notice/service/TemplateService.java +++ b/src/main/java/com/yunhai/notice/service/ITemplateService.java @@ -7,7 +7,7 @@ import java.util.Map; * 通知模板业务接口。 * 对应前端 Temp 组接口:GetTypelist/EditType/DelType/GetTemplatelist/EditTemplate/DelTemplate/GetTemplateFist。 */ -public interface TemplateService { +public interface ITemplateService { /** * 获取模板类型列表(首项固定为"全部")。 diff --git a/src/main/java/com/yunhai/notice/service/IUserCenterService.java b/src/main/java/com/yunhai/notice/service/IUserCenterService.java new file mode 100644 index 0000000..400ee2c --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/IUserCenterService.java @@ -0,0 +1,51 @@ +package com.yunhai.notice.service; + +import java.util.List; +import java.util.Map; + +/** + * 用户中心门面服务(对齐 UserBase {@code UserCenterService} 模式)。 + *

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

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

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

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

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

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

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

+ * + * @param userId 用户ID + * @param schId 学校ID(可为 null) + * @return 模块树(顶层 List,含 children 嵌套) + */ + List> getUserModuleTree(Integer userId, Integer schId); +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/notice/service/impl/ClassService.java b/src/main/java/com/yunhai/notice/service/impl/ClassService.java new file mode 100644 index 0000000..3b9b50e --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/impl/ClassService.java @@ -0,0 +1,63 @@ +package com.yunhai.notice.service.impl; + +import com.yunhai.notice.model.SchTeaList; +import com.yunhai.notice.service.IClassService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import java.util.Collections; +import java.util.List; + +/** + * 班级管理(bschclass)开放接口实现。 + * bschclass 为开放接口(无需应用签名),使用独立 RestTemplate 直连。 + */ +@Service +public class ClassService implements IClassService { + + private static final Logger log = LoggerFactory.getLogger(ClassService.class); + + /** 班级管理服务基址,如 http://base.yunhaixingtong.com/bschclass */ + @Value("${yh.open.bschclass:}") + private String bschClassHost; + + private final RestTemplate restTemplate; + + public ClassService() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(10000); + this.restTemplate = new RestTemplate(factory); + } + + @Override + public List getSchTeaList(int schId) { + if (!StringUtils.hasText(bschClassHost)) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)"); + } + String url = bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId; + try { + ResponseEntity> resp = restTemplate.exchange( + url, HttpMethod.GET, null, + new ParameterizedTypeReference>() { + }); + List list = resp.getBody(); + log.info("[getSchTeaList] schId={}, 获取教师 {} 人", schId, list == null ? 0 : list.size()); + return list != null ? list : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[getSchTeaList] 请求班级管理接口失败, schId={}, url={}", schId, url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取教师数据失败"); + } + } +} diff --git a/src/main/java/com/yunhai/notice/service/impl/ContactsServiceImpl.java b/src/main/java/com/yunhai/notice/service/impl/ContactsService.java similarity index 66% rename from src/main/java/com/yunhai/notice/service/impl/ContactsServiceImpl.java rename to src/main/java/com/yunhai/notice/service/impl/ContactsService.java index e754424..06d342f 100644 --- a/src/main/java/com/yunhai/notice/service/impl/ContactsServiceImpl.java +++ b/src/main/java/com/yunhai/notice/service/impl/ContactsService.java @@ -1,12 +1,16 @@ 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 com.yunhai.notice.service.IContactsService; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -24,9 +28,9 @@ import java.util.stream.Collectors; * 对应 C# ContactsController + ManageContacts.cs。 */ @Service -public class ContactsServiceImpl implements ContactsService { +public class ContactsService implements IContactsService { - private static final Logger log = LoggerFactory.getLogger(ContactsServiceImpl.class); + private static final Logger log = LoggerFactory.getLogger(ContactsService.class); @Autowired private ContactsGroupRepository groupRepository; @@ -40,19 +44,19 @@ public class ContactsServiceImpl implements ContactsService { @Transactional public void addGroup(String name, Integer type) { if (!StringUtils.hasText(name)) { - throw new NoticeException("请输入自定义组的名字"); + throw new BizException(ErrorCode.PARAM_ERROR, "请输入自定义组的名字"); } if (type == null) { type = 1; } - String account = NoticeContext.getAccount(); + String account = currentAccount(); // 查重:同创建人下同名同类型组不重复 List existing = groupRepository.findByCreatorAccount(account); boolean duplicate = existing.stream().anyMatch(g -> name.equals(g.getGroupName())); if (duplicate) { - throw new NoticeException("已存在该联系组!"); + throw new BizException(ErrorCode.FORBIDDEN, "已存在该联系组!"); } ContactsGroup group = new ContactsGroup(); @@ -68,7 +72,7 @@ public class ContactsServiceImpl implements ContactsService { if (type == null) { type = 1; } - String account = NoticeContext.getAccount(); + String account = currentAccount(); // 查询当前用户创建的全部联系组 List allGroups = groupRepository.findByCreatorAccount(account); @@ -113,23 +117,23 @@ public class ContactsServiceImpl implements ContactsService { @Transactional public void editGroup(Integer id, String name, Integer type) { if (!StringUtils.hasText(name)) { - throw new NoticeException("联系组名称不能为空"); + throw new BizException(ErrorCode.PARAM_ERROR, "联系组名称不能为空"); } if (id == null) { - throw new NoticeException("参数错误!"); + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!"); } - String account = NoticeContext.getAccount(); + String account = currentAccount(); // 查重(排除自身) List existing = groupRepository.findByCreatorAccount(account); boolean duplicate = existing.stream().anyMatch(g -> !g.getId().equals(id) && name.equals(g.getGroupName())); if (duplicate) { - throw new NoticeException("已存在该联系组"); + throw new BizException(ErrorCode.FORBIDDEN, "已存在该联系组"); } ContactsGroup group = groupRepository.findById(id) - .orElseThrow(() -> new NoticeException("联系组不存在")); + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "联系组不存在")); group.setGroupName(name); groupRepository.save(group); } @@ -140,7 +144,7 @@ public class ContactsServiceImpl implements ContactsService { @Transactional public void delGroup(String ids) { if (!StringUtils.hasText(ids)) { - throw new NoticeException("参数错误,删除失败"); + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误,删除失败"); } List idList = Arrays.stream(ids.split(",")) .map(String::trim) @@ -149,7 +153,7 @@ public class ContactsServiceImpl implements ContactsService { .collect(Collectors.toList()); // 先删成员再删组 - groupUserRepository.deleteByGroupIdIn(idList); + groupUserRepository.deleteByGroupIdInBulk(idList); groupRepository.findAllById(idList).forEach(groupRepository::delete); } @@ -159,21 +163,34 @@ public class ContactsServiceImpl implements ContactsService { @Transactional public void setContactsTea(Integer gid, String ids) { if (gid == null || gid == 0) { - throw new NoticeException("参数错误"); + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); } - // 全量替换:先删除该组全部成员,再新增 - groupUserRepository.deleteByGroupId(gid); + // 全量替换:bulk delete 立即执行 SQL,避免 derived delete 延迟 flush + // 导致同事务 insert 先行触发 uk_group_user(参考 PartyLearn GroupService.memberEdit) + groupUserRepository.deleteByGroupIdBulk(gid); if (StringUtils.hasText(ids)) { List userIds = Arrays.stream(ids.split(",")) .map(String::trim) .filter(s -> !s.isEmpty()) .map(Integer::parseInt) + .distinct() .collect(Collectors.toList()); for (Integer userId : userIds) { ContactsGroupUser member = new ContactsGroupUser(); member.setGroupId(gid); member.setUserId(userId); + // 姓名/账号快照落库(对齐 PartyLearn memberEdit 保存 userName 的做法), + // 查询时 fillUserInfo 仅对缺失值兜底,避免组树/分页反复远程调用 + try { + BoaUser user = YschBaseOpenApi.getUserInfo(userId); + if (user != null) { + member.setUserAccount(user.getAccount()); + member.setUserName(user.getName()); + } + } catch (Exception e) { + log.warn("[setContactsTea] 查询用户信息失败, userId={}", userId, e); + } groupUserRepository.save(member); } } @@ -199,32 +216,73 @@ public class ContactsServiceImpl implements ContactsService { List allMembers = groupUserRepository.findByGroupId(groupId); int total = allMembers.size(); - List> listData = allMembers.stream().map(m -> { - Map 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()); - - // 分页 + // 先分页再补全用户信息(避免为未分页数据调用用户中心) + List pageMembers = allMembers; 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(); - } + pageMembers = fromIndex < total ? allMembers.subList(fromIndex, toIndex) : Collections.emptyList(); } + List> listData = pageMembers.stream().map(m -> { + Map item = new LinkedHashMap<>(); + item.put("sid", m.getId()); + item.put("id", m.getUserId()); + fillUserInfo(item, m); + return item; + }).collect(Collectors.toList()); + Map result = new LinkedHashMap<>(); result.put("list", listData); result.put("total", total); return result; } + /** + * 补全成员的账号/姓名:落库值为空时从用户中心按 userId 反查。 + * 对应 C# 保存成员时同步写入 UserName/UserAccount 的行为。 + * 查到的值会回写落库自愈,避免历史空数据每次读取都重复远程调用。 + */ + private void fillUserInfo(Map item, ContactsGroupUser m) { + String name = m.getUserName(); + String account = m.getUserAccount(); + if (m.getUserId() != null && (!StringUtils.hasText(name) || !StringUtils.hasText(account))) { + BoaUser user = null; + try { + user = YschBaseOpenApi.getUserInfo(m.getUserId()); + } catch (Exception e) { + log.warn("[fillUserInfo] 查询用户中心用户失败, userId={}", m.getUserId(), e); + } + if (user == null) { + log.warn("[fillUserInfo] 用户中心未查到用户(历史脏数据?), userId={}", m.getUserId()); + } else { + if (!StringUtils.hasText(account)) { + account = user.getAccount(); + } + if (!StringUtils.hasText(name)) { + name = user.getName(); + } + // 回写落库:下次读取直接命中快照 + if ((!StringUtils.hasText(m.getUserAccount()) && StringUtils.hasText(account)) + || (!StringUtils.hasText(m.getUserName()) && StringUtils.hasText(name))) { + if (StringUtils.hasText(account)) { + m.setUserAccount(account); + } + if (StringUtils.hasText(name)) { + m.setUserName(name); + } + try { + groupUserRepository.save(m); + } catch (Exception e) { + log.warn("[fillUserInfo] 回写用户信息失败, userId={}", m.getUserId(), e); + } + } + } + } + item.put("name", name != null ? name : ""); + item.put("account", account != null ? account : ""); + } + // ============================ GetGroupTree ============================ @Override @@ -232,7 +290,7 @@ public class ContactsServiceImpl implements ContactsService { if (type == null) { type = 1; } - String account = NoticeContext.getAccount(); + String account = currentAccount(); // 查询当前用户的联系组 List groups = groupRepository.findByCreatorAccount(account); @@ -253,35 +311,28 @@ public class ContactsServiceImpl implements ContactsService { node.put("pid", 0); node.put("no", 0); node.put("type", 1); + node.put("isParent", true); node.put("photo", ""); node.put("children", new ArrayList>()); treeNodes.add(node); } - // 用户节点 (type=2, pid=groupId) + // 用户节点 (type=2, pid=groupId),姓名缺失时从用户中心补全 for (ContactsGroupUser m : allMembers) { Map node = new LinkedHashMap<>(); node.put("id", m.getUserId()); - node.put("name", m.getUserName() != null ? m.getUserName() : ""); + node.put("name", ""); node.put("pid", m.getGroupId()); node.put("no", 0); node.put("type", 2); + node.put("isParent", false); node.put("photo", ""); + fillUserInfo(node, m); treeNodes.add(node); } - // 递归构建树 - List> tree = formatUserTree(treeNodes, 0); - - // 包裹根节点 - Map 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); + // 递归构建树:各自定义组为顶级节点(空组自动剔除),组合成森林 + return formatUserTree(treeNodes, 0); } // ============================ GetDeptRoleGroupTree ============================ @@ -294,61 +345,6 @@ public class ContactsServiceImpl implements ContactsService { return Collections.emptyList(); } - // ============================ Import ============================ - - @Override - @Transactional - public Map importContacts() { - // C# 读取上传的Excel文件逐行匹配组名+教师。 - // Java版暂未实现Excel解析,预留接口。 - log.info("[importContacts] Excel导入功能暂未实现"); - Map result = new LinkedHashMap<>(); - result.put("msg", "导入功能暂未实现"); - return result; - } - - // ============================ Export ============================ - - @Override - public Map exportContacts() { - // C# 生成Excel文件流返回。 - // Java版暂未实现Excel导出,预留接口。 - log.info("[exportContacts] Excel导出功能暂未实现"); - - // 返回联系组数据供前端参考 - String account = NoticeContext.getAccount(); - List groups = groupRepository.findByCreatorAccount(account); - List groupIds = groups.stream().map(ContactsGroup::getId).collect(Collectors.toList()); - List allMembers = groupIds.isEmpty() ? Collections.emptyList() - : groupUserRepository.findByGroupIdIn(groupIds); - - List> rows = new ArrayList<>(); - for (ContactsGroup g : groups) { - List members = allMembers.stream() - .filter(m -> m.getGroupId().equals(g.getId())) - .collect(Collectors.toList()); - if (members.isEmpty()) { - Map row = new LinkedHashMap<>(); - row.put("组名", g.getGroupName()); - row.put("教师", ""); - row.put("用户名", ""); - rows.add(row); - } else { - for (ContactsGroupUser m : members) { - Map 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 result = new LinkedHashMap<>(); - result.put("list", rows); - return result; - } - // ============================ 辅助方法 ============================ /** @@ -387,7 +383,10 @@ public class ContactsServiceImpl implements ContactsService { } Map node = new LinkedHashMap<>(item); - node.put("children", children); + // 仅组节点携带 children;叶子不带该字段,避免 ztree 误判 isParent + if (itemType == 1) { + node.put("children", children); + } result.add(node); } @@ -403,4 +402,12 @@ public class ContactsServiceImpl implements ContactsService { return result; } + + /** + * 当前登录人账号(来自平台 UserContextHolder,无登录态时为 null)。 + */ + private String currentAccount() { + LoginUser user = UserContextHolder.get(); + return user == null ? null : user.getLoginName(); + } } diff --git a/src/main/java/com/yunhai/notice/service/impl/DictServiceImpl.java b/src/main/java/com/yunhai/notice/service/impl/DictService.java similarity index 79% rename from src/main/java/com/yunhai/notice/service/impl/DictServiceImpl.java rename to src/main/java/com/yunhai/notice/service/impl/DictService.java index b398642..e87ec44 100644 --- a/src/main/java/com/yunhai/notice/service/impl/DictServiceImpl.java +++ b/src/main/java/com/yunhai/notice/service/impl/DictService.java @@ -1,12 +1,14 @@ 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 com.yunhai.notice.service.IDictService; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -22,9 +24,9 @@ import java.util.stream.Collectors; * 对应 C# CommonController + GetSysDict.cs 中的字典管理逻辑。 */ @Service -public class DictServiceImpl implements DictService { +public class DictService implements IDictService { - private static final Logger log = LoggerFactory.getLogger(DictServiceImpl.class); + private static final Logger log = LoggerFactory.getLogger(DictService.class); /** 字典类型常量:发文部门 */ private static final String DICT_TYPE_DEPT = "dept"; @@ -42,11 +44,11 @@ public class DictServiceImpl implements DictService { // C# 中 Type=1 为部门类别(DEPT),其余类型(文印、请假等)本系统暂不涉及 String dictType = mapDictType(type); if (dictType == null) { - throw new NoticeException("参数错误"); + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); } List list = sysDictRepository.findByDictTypeAndEnableFlagOrderBySortOrderAsc(dictType, 1); if (list.isEmpty()) { - throw new NoticeException("尚未添加发文部门,请联系管理员添加!"); + throw new BizException(ErrorCode.FORBIDDEN, "尚未添加发文部门,请联系管理员添加!"); } // 按 sortOrder 升序返回 {k, v} return list.stream() @@ -68,7 +70,7 @@ public class DictServiceImpl implements DictService { String dictType = "dept".equalsIgnoreCase(type) ? DICT_TYPE_DEPT : null; if (dictType == null) { // 非 DEPT 类型需超管权限,本系统暂只支持 DEPT - throw new NoticeException("非超管不得操作!"); + throw new BizException(ErrorCode.FORBIDDEN, "非超管不得操作!"); } List list = sysDictRepository.findByDictTypeOrderBySortOrderAsc(dictType); return list.stream() @@ -91,14 +93,14 @@ public class DictServiceImpl implements DictService { @Transactional public void addDeptDic(String name) { if (!StringUtils.hasText(name)) { - throw new NoticeException("输入字符串格式不正确!"); + throw new BizException(ErrorCode.PARAM_ERROR, "输入字符串格式不正确!"); } // 校验重名 List 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("已存在该分类,请重新填写!"); + throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!"); } // 计算 sortOrder = max + 1 @@ -122,18 +124,18 @@ public class DictServiceImpl implements DictService { @Transactional public void updateDeptDic(Integer serID, String name, Integer enable) { if (serID == null) { - throw new NoticeException("参数错误!"); + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误!"); } // 校验重名(排除自身) List 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("已存在该分类,请重新填写!"); + throw new BizException(ErrorCode.FORBIDDEN, "已存在该分类,请重新填写!"); } SysDict dict = sysDictRepository.findById(serID) - .orElseThrow(() -> new NoticeException("字典记录不存在")); + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在")); if (StringUtils.hasText(name)) { dict.setDictValue(name); } @@ -149,14 +151,14 @@ public class DictServiceImpl implements DictService { @Transactional public void delDept(Integer serID) { SysDict dict = sysDictRepository.findById(serID) - .orElseThrow(() -> new NoticeException("字典记录不存在")); + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "字典记录不存在")); // 校验是否被通知引用(按 deptId = dictKey 匹配) // C# 中检查 Notify_Message.DeptID == dict.CodeID // Java 中 Notice.deptId 存的是 dictKey List referenced = noticeRepository.findAll((root, query, cb) -> cb.equal(root.get("deptId"), dict.getDictKey())); if (!referenced.isEmpty()) { - throw new NoticeException("已引用该部门,无法删除!"); + throw new BizException(ErrorCode.FORBIDDEN, "已引用该部门,无法删除!"); } sysDictRepository.delete(dict); } @@ -167,11 +169,11 @@ public class DictServiceImpl implements DictService { @Transactional public void ordbyDicIds(String ids) { if (!StringUtils.hasText(ids)) { - throw new NoticeException("参数错误"); + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); } List list = sysDictRepository.findByDictTypeOrderBySortOrderAsc(DICT_TYPE_DEPT); if (list.isEmpty()) { - throw new NoticeException("字典类型错误,未找到该字典类型"); + throw new BizException(ErrorCode.FORBIDDEN, "字典类型错误,未找到该字典类型"); } // C# 逻辑:取当前最大 Sort 为起始序号,逆序遍历 ids 递减赋值 Des @@ -216,7 +218,7 @@ public class DictServiceImpl implements DictService { public Boolean getSchModuleIsSms() { // C# 通过外部 ISchIsSmsBLL 查询学校短信开关。 // Java 版暂不接入外部短信平台,默认返回 false。 - String account = NoticeContext.getAccount(); + String account = currentAccount(); if (account == null) { return false; } @@ -226,6 +228,19 @@ public class DictServiceImpl implements DictService { // ============================ 辅助方法 ============================ + /** + * 按发文部门字典 key 解析部门名称(对应 C# GetDeptName)。 + */ + @Override + public String resolveDeptName(String deptId) { + if (!StringUtils.hasText(deptId)) { + return null; + } + return sysDictRepository.findByDictTypeAndDictKey(DICT_TYPE_DEPT, deptId.trim()) + .map(SysDict::getDictValue) + .orElse(null); + } + /** * 将前端 Type 参数映射到字典类型字符串。 * C# 中使用枚举,Java 版仅支持 DEPT(1)。 @@ -238,4 +253,12 @@ public class DictServiceImpl implements DictService { default: return null; } } + + /** + * 当前登录人账号(来自平台 UserContextHolder,无登录态时为 null)。 + */ + private String currentAccount() { + LoginUser user = UserContextHolder.get(); + return user == null ? null : user.getLoginName(); + } } diff --git a/src/main/java/com/yunhai/notice/service/impl/FileService.java b/src/main/java/com/yunhai/notice/service/impl/FileService.java new file mode 100644 index 0000000..5480832 --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/impl/FileService.java @@ -0,0 +1,326 @@ +package com.yunhai.notice.service.impl; + +import com.yunhai.notice.entity.UpFile; +import com.yunhai.notice.model.notice.UpFileVo; +import com.yunhai.notice.model.notice.UpFileResultVo; +import com.yunhai.notice.repository.UpFileRepository; +import com.yunhai.notice.service.IFileService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.FileInputStream; +import java.io.OutputStream; +import java.net.URLConnection; +import java.net.URLEncoder; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.ArrayList; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 文件上传/下载/删除业务实现。 + *

+ * 存储路径规则:{uploadPath}/{appType}/{yyyyMMdd}/{uuid}.{ext} + * 与 C# UpLoadFile.FileSave 逻辑等价,但去除分片上传和 Office 转 PDF(后续按需补充)。 + */ +@Service +public class FileService implements IFileService { + + private static final Logger log = LoggerFactory.getLogger(FileService.class); + + @Autowired + private UpFileRepository upFileRepository; + + @Value("${file.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 BizException(ErrorCode.PARAM_ERROR, "上传文件不得为空"); + } + String originalName = file.getOriginalFilename(); + if (!StringUtils.hasText(originalName)) { + throw new BizException(ErrorCode.PARAM_ERROR, "文件名为空"); + } + // 提取后缀 + String ext = ""; + int dotIdx = originalName.lastIndexOf('.'); + if (dotIdx >= 0) { + ext = originalName.substring(dotIdx + 1).toLowerCase(); + } + + // 构造存储子路径:{appType}/{yyyyMMdd}/{uuid}.{ext} + String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String savedName = UUID.randomUUID().toString().replace("-", "") + (ext.isEmpty() ? "" : "." + ext); + String relativePath = (appType == null ? DEFAULT_APP_TYPE : appType) + "/" + dateDir + "/" + savedName; + + // 物理写入 + Path fullPath = Paths.get(uploadPath, relativePath); + try { + Files.createDirectories(fullPath.getParent()); + file.transferTo(fullPath.toFile()); + } catch (Exception e) { + log.error("[upload] 文件保存失败: {}", fullPath, e); + throw new BizException(ErrorCode.ERROR, "文件保存失败", e); + } + + // 入库 + UpFile entity = new UpFile(); + entity.setTopicType(appType == null ? DEFAULT_APP_TYPE : appType); + entity.setTopicId(topicId); + entity.setFileTrueName(originalName); + entity.setFileName(relativePath); + entity.setFileSize((int) file.getSize()); + entity.setFileType(ext); + upFileRepository.save(entity); + + return toVo(entity); + } + + // ============================ 绑定主题 ============================ + + @Override + @Transactional + public void bindToTopic(List fileIds, Integer topicId) { + if (fileIds == null || fileIds.isEmpty() || topicId == null) { + return; + } + for (Integer fileId : fileIds) { + upFileRepository.findById(fileId).ifPresent(f -> { + f.setTopicId(topicId); + upFileRepository.save(f); + }); + } + } + + // ============================ 查询附件列表 ============================ + + @Override + public List listByTopic(String appType, Integer topicId) { + String type = appType == null ? DEFAULT_APP_TYPE : appType; + return upFileRepository.findByTopicTypeAndTopicId(type, topicId).stream() + .map(this::toVo) + .collect(Collectors.toList()); + } + + // ============================ 下载 ============================ + + @Override + public void download(Integer fileId, HttpServletResponse response) { + writeFile(fileId, response, false); + } + + @Override + public void preview(Integer fileId, HttpServletResponse response) { + writeFile(fileId, response, true); + } + + /** + * 文件输出。inline=true 时 Content-Disposition 用 inline 并按扩展名输出真实 Content-Type + * (浏览器/前端组件可直接渲染),否则 attachment 强制下载。 + */ + private void writeFile(Integer fileId, HttpServletResponse response, boolean inline) { + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId)); + Path fullPath = Paths.get(uploadPath, file.getFileName()); + if (!Files.exists(fullPath)) { + throw new BizException(ErrorCode.FORBIDDEN, "物理文件不存在: " + file.getFileName()); + } + try (FileInputStream fis = new FileInputStream(fullPath.toFile()); + OutputStream os = response.getOutputStream()) { + String encodedName = URLEncoder.encode(file.getFileTrueName(), "UTF-8") + .replaceAll("\\+", "%20"); + if (inline) { + response.setContentType(resolveContentType(file.getFileName())); + response.setHeader("Content-Disposition", "inline; filename=\"" + encodedName + "\""); + } else { + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedName + "\""); + } + response.setContentLengthLong(file.getFileSize()); + byte[] buffer = new byte[4096]; + int len; + while ((len = fis.read(buffer)) != -1) { + os.write(buffer, 0, len); + } + os.flush(); + } catch (Exception e) { + log.error("[writeFile] 文件输出失败: {}", file.getFileName(), e); + throw new BizException(ErrorCode.ERROR, "文件下载失败", e); + } + } + + /** + * 按扩展名推断 Content-Type(JDK 自带表 + 常见办公/图片格式兜底),未知类型回退流格式。 + */ + private String resolveContentType(String fileName) { + String name = String.valueOf(fileName).toLowerCase(); + Map table = new HashMap<>(); + table.put("pdf", "application/pdf"); + table.put("png", "image/png"); + table.put("jpg", "image/jpeg"); + table.put("jpeg", "image/jpeg"); + table.put("gif", "image/gif"); + table.put("bmp", "image/bmp"); + table.put("webp", "image/webp"); + table.put("svg", "image/svg+xml"); + table.put("txt", "text/plain;charset=UTF-8"); + table.put("mp4", "video/mp4"); + table.put("webm", "video/webm"); + table.put("ogg", "video/ogg"); + table.put("doc", "application/msword"); + table.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + table.put("xls", "application/vnd.ms-excel"); + table.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + table.put("ppt", "application/vnd.ms-powerpoint"); + table.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"); + String type = table.get(name.substring(name.lastIndexOf('.') + 1)); + if (type != null) { + return type; + } + String guessed = URLConnection.guessContentTypeFromName(name); + return guessed != null ? guessed : "application/octet-stream"; + } + + // ============================ 删除 ============================ + + @Override + @Transactional + public void delete(Integer fileId) { + UpFile file = upFileRepository.findById(fileId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "文件不存在: " + fileId)); + // 删物理文件 + Path fullPath = Paths.get(uploadPath, file.getFileName()); + try { + Files.deleteIfExists(fullPath); + } catch (Exception e) { + log.warn("[delete] 物理文件删除失败: {}(数据库记录仍会删除)", fullPath, e); + } + // 删数据库记录 + upFileRepository.delete(file); + } + + // ============================ 打包下载 ZIP ============================ + + @Override + public void downloadZip(List files, String zipName, HttpServletResponse response) { + if (files == null || files.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "无附件可下载"); + } + try { + response.setContentType("application/zip"); + String name = URLEncoder.encode(zipName == null ? "附件" : zipName, "UTF-8") + .replaceAll("\\+", "%20"); + response.setHeader("Content-Disposition", "attachment; filename=\"" + name + ".zip\""); + try (ZipOutputStream zos = new ZipOutputStream(response.getOutputStream())) { + Set used = new HashSet<>(); + for (UpFile f : files) { + Path p = Paths.get(uploadPath, f.getFileName()); + if (!Files.exists(p)) { + log.warn("[downloadZip] 物理文件缺失,跳过: {}", f.getFileName()); + continue; + } + String entryName = sanitizeEntryName(f.getFileTrueName()); + if (used.contains(entryName)) { + entryName = f.getId() + "_" + entryName; + } + used.add(entryName); + zos.putNextEntry(new ZipEntry(entryName)); + Files.copy(p, zos); + zos.closeEntry(); + } + } + } catch (Exception e) { + log.error("[downloadZip] 打包下载失败", e); + throw new BizException(ErrorCode.ERROR, "打包下载失败", e); + } + } + + /** + * 净化 ZIP 条目名:去除路径分隔符等非法字符,防止 Zip Slip。 + */ + private String sanitizeEntryName(String name) { + if (name == null || name.trim().isEmpty()) { + return "file"; + } + return name.replaceAll("[\\\\/:*?\"<>|]", "_"); + } + + // ============================ 按主题打包下载 ============================ + + @Override + public void downloadZipByTopic(String topicType, Integer topicId, String zipName, + HttpServletResponse response) { + String type = topicType == null ? DEFAULT_APP_TYPE : topicType; + List files = upFileRepository.findByTopicTypeAndTopicId(type, topicId); + downloadZip(files, zipName, response); + } + + // ============================ 批量上传图片 ============================ + + @Override + @Transactional + public List uploadImages(MultipartFile[] files, String appType) { + List results = new ArrayList<>(); + if (files == null || files.length == 0) { + return results; + } + String type = appType != null ? appType : DEFAULT_APP_TYPE; + for (MultipartFile file : files) { + results.add(upload(file, type, null)); + } + return results; + } + + // ============================ 上传(返回前端兼容 VO) ============================ + + @Override + @Transactional + public UpFileResultVo uploadToResult(MultipartFile file, String appType) { + UpFileVo vo = upload(file, appType, null); + UpFileResultVo result = new UpFileResultVo(); + result.setFileid(vo.getId()); + result.setName(vo.getFileTrueName()); + result.setFileurl(vo.getFileName()); + result.setHost(""); + result.setType(vo.getFileType()); + result.setSize(vo.getFileSize()); + return result; + } + + // ============================ 转换 ============================ + + private UpFileVo toVo(UpFile entity) { + UpFileVo vo = new UpFileVo(); + BeanUtils.copyProperties(entity, vo); + return vo; + } +} diff --git a/src/main/java/com/yunhai/notice/service/impl/FileServiceImpl.java b/src/main/java/com/yunhai/notice/service/impl/FileServiceImpl.java deleted file mode 100644 index 98c0459..0000000 --- a/src/main/java/com/yunhai/notice/service/impl/FileServiceImpl.java +++ /dev/null @@ -1,178 +0,0 @@ -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; - -/** - * 文件上传/下载/删除业务实现。 - *

- * 存储路径规则:{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 fileIds, Integer topicId) { - if (fileIds == null || fileIds.isEmpty() || topicId == null) { - return; - } - for (Integer fileId : fileIds) { - upFileRepository.findById(fileId).ifPresent(f -> { - f.setTopicId(topicId); - upFileRepository.save(f); - }); - } - } - - // ============================ 查询附件列表 ============================ - - @Override - public List listByTopic(String appType, Integer topicId) { - String type = appType == null ? DEFAULT_APP_TYPE : appType; - return upFileRepository.findByTopicTypeAndTopicId(type, topicId).stream() - .map(this::toVo) - .collect(Collectors.toList()); - } - - // ============================ 下载 ============================ - - @Override - public void download(Integer fileId, HttpServletResponse response) { - 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; - } -} diff --git a/src/main/java/com/yunhai/notice/service/impl/NoticeService.java b/src/main/java/com/yunhai/notice/service/impl/NoticeService.java index c272fcd..c53e119 100644 --- a/src/main/java/com/yunhai/notice/service/impl/NoticeService.java +++ b/src/main/java/com/yunhai/notice/service/impl/NoticeService.java @@ -1,26 +1,38 @@ 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.entity.NoticeReceiver; +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.NoticeReceiverRepository; import com.yunhai.notice.repository.NoticeRepository; +import com.yunhai.notice.repository.UpFileRepository; +import com.yunhai.notice.service.IFileService; +import com.yunhai.notice.service.IDictService; import com.yunhai.notice.service.INoticeService; +import com.yunhai.notice.service.sms.SmsSender; import com.yunhai.notice.utils.PageUtils; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import com.yunhai.platform.auth.spring.CurrentBoaUser; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaUser; 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.domain.Sort; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,7 +42,9 @@ import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaQuery; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; +import javax.servlet.http.HttpServletResponse; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.*; import java.util.stream.Collectors; @@ -49,51 +63,77 @@ public class NoticeService implements INoticeService { private NoticeReadRepository noticeReadRepository; @Autowired - private ApplicationEventPublisher eventPublisher; + private NoticeReceiverRepository noticeReceiverRepository; + + @Autowired + private UpFileRepository upFileRepository; + + @Autowired + private SmsSender smsSender; + + @Autowired + private IFileService fileService; + + @Autowired + private IDictService dictService; + + @Autowired + private CurrentBoaUser currentBoaUser; private static final int PUBLISHED = NoticeStatusEnum.PUBLISHED.getCode(); - private static final int DRAFT = NoticeStatusEnum.DRAFT.getCode(); + + /** + * 当前登录人账号(来自平台 UserContextHolder,无登录态时为 null)。 + */ + private static String currentAccount() { + LoginUser user = UserContextHolder.get(); + return user == null ? null : user.getLoginName(); + } + + /** + * 当前登录人姓名(平台默认仅提供登录名,展示姓名后续可接用户中心补齐)。 + */ + private static String currentName() { + LoginUser user = UserContextHolder.get(); + return user == null ? null : user.getDisplayName(); + } + + /** + * 当前登录人学校ID(多租户隔离:发布时填充,查询时过滤)。 + * 取 BoaUser.currentRole.schId(当前激活身份学校),无身份时为 null。 + */ + private Integer currentSchId() { + BoaUser boaUser = currentBoaUser.get(); + if (boaUser == null || boaUser.getCurrentRole() == null) { + return null; + } + return boaUser.getCurrentRole().getSchId(); + } // ============================ 发通知 ============================ @Override @Transactional - public void publish(NoticeRequest request) { + public Integer 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.setDeptName(resolveDeptName(request)); + normalizeReceiveList(entity, request.getReceiveList()); + // 无草稿态:一律置为「已发布」;定时发送保留 timingSendTime,由查询侧按「已到点」过滤可见性 + entity.setStatus(PUBLISHED); + entity.setPublisherAccount(currentAccount()); + entity.setPublisherName(currentName()); + entity.setSchId(currentSchId()); entity.setIsPulled(0); entity.setReadCount(0); noticeRepository.save(entity); + // 写入接收人关联表(非全员时) + saveReceivers(entity); + // 绑定附件(前端上传后回传的 fileIds 逗号串) + fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); + // 立即发布且开启短信时预留短信发送(真实通道后续接入) + trySendSms(entity); return entity.getId(); } @@ -104,29 +144,71 @@ public class NoticeService implements INoticeService { public void edit(Integer id, NoticeRequest request) { Objects.requireNonNull(id, "通知ID不得为空"); Notice entity = noticeRepository.findById(id) - .orElseThrow(() -> new NoticeException("通知不存在: " + id)); - // 发布人/状态保持不变,仅更新可编辑字段 + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); entity.setTitle(request.getTitle()); entity.setContent(request.getContent()); entity.setType(request.getType()); entity.setDeptId(request.getDeptId()); - entity.setDeptName(request.getDeptName()); + entity.setDeptName(resolveDeptName(request)); entity.setTopmost(request.getTopmost()); + entity.setSmsFlag(request.getSmsFlag()); + if (request.getReceiveList() != null) { + normalizeReceiveList(entity, request.getReceiveList()); + // 更新接收人关联表 + noticeReceiverRepository.deleteByNoticeId(entity.getId()); + saveReceivers(entity); + } + if (request.getTimingSendTime() != null) { + entity.setTimingSendTime(request.getTimingSendTime()); + } + // 附件全量替换:先解绑旧附件,再绑定本次提交的列表(fileIds 为 null 时不改动附件) + if (request.getFileIds() != null) { + for (UpFile old : upFileRepository.findByTopicTypeAndTopicId("notice", entity.getId())) { + old.setTopicId(null); + upFileRepository.save(old); + } + fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); + } noticeRepository.save(entity); } - // ============================ 删除 ============================ + /** 解析 "1,2,3" 形式的附件ID串,忽略非法片段 */ + private List parseFileIds(String fileIds) { + if (!StringUtils.hasText(fileIds)) { + return Collections.emptyList(); + } + return Arrays.stream(fileIds.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(s -> { + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + return null; + } + }) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + // ============================ 删除(批量) ============================ @Override @Transactional - public void delete(Integer id) { - Objects.requireNonNull(id, "通知ID不得为空"); - if (!noticeRepository.existsById(id)) { - throw new NoticeException("通知不存在: " + id); + public void deleteBatch(List ids) { + Objects.requireNonNull(ids, "通知ID列表不得为空"); + if (ids.isEmpty()) { + return; + } + for (Integer id : ids) { + if (!noticeRepository.existsById(id)) { + continue; + } + noticeReadRepository.deleteByNoticeId(id); + noticeReceiverRepository.deleteByNoticeId(id); + upFileRepository.deleteByTopicTypeAndTopicId("notice", id); + noticeRepository.deleteById(id); } - // 级联清理阅读记录(逻辑外键,无物理 FK) - noticeReadRepository.deleteByNoticeId(id); - noticeRepository.deleteById(id); } // ============================ 撤回 ============================ @@ -136,12 +218,12 @@ public class NoticeService implements INoticeService { public void pull(Integer id) { Objects.requireNonNull(id, "通知ID不得为空"); Notice entity = noticeRepository.findById(id) - .orElseThrow(() -> new NoticeException("通知不存在: " + id)); + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); if (entity.getStatus() != PUBLISHED) { - throw new NoticeException("仅已发布的通知可撤回"); + throw new BizException(ErrorCode.FORBIDDEN, "仅已发布的通知可撤回"); } if (entity.getIsPulled() != null && entity.getIsPulled() == 1) { - throw new NoticeException("通知已被撤回,请勿重复操作"); + throw new BizException(ErrorCode.FORBIDDEN, "通知已被撤回,请勿重复操作"); } entity.setIsPulled(1); noticeRepository.save(entity); @@ -154,7 +236,7 @@ public class NoticeService implements INoticeService { public void setTopmost(Integer id, Integer topmost) { Objects.requireNonNull(id, "通知ID不得为空"); Notice entity = noticeRepository.findById(id) - .orElseThrow(() -> new NoticeException("通知不存在: " + id)); + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); entity.setTopmost(topmost == null ? 0 : topmost); noticeRepository.save(entity); } @@ -166,42 +248,31 @@ public class NoticeService implements INoticeService { public Integer forward(Integer sourceId, NoticeRequest request) { Objects.requireNonNull(sourceId, "源通知ID不得为空"); Notice source = noticeRepository.findById(sourceId) - .orElseThrow(() -> new NoticeException("源通知不存在: " + sourceId)); + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "源通知不存在: " + 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.setDeptName(resolveDeptName(request)); + normalizeReceiveList(entity, request.getReceiveList()); entity.setStatus(PUBLISHED); + entity.setPublisherAccount(currentAccount()); + entity.setPublisherName(currentName()); + entity.setParentId(sourceId); +entity.setIsPulled(0); + entity.setReadCount(0); + entity.setParentId(sourceId); + entity.setSchId(currentSchId()); noticeRepository.save(entity); - publishEvent(entity); + saveReceivers(entity); + trySendSms(entity); + return entity.getId(); } // ============================ 用户端列表(含未读) ============================ @Override - public NoticeUserListResult userList(NoticeQuery query, Pageable pageable) { - String account = NoticeContext.getAccount(); + public NoticePageResult userList(NoticeQuery query, Pageable pageable) { + String account = currentAccount(); Set readIds = new HashSet<>(noticeReadRepository.findNoticeIdsByReaderAccount(account)); Page page = noticeRepository.findAll(userSpec(query, readIds), pageable); @@ -210,31 +281,42 @@ public class NoticeService implements INoticeService { .map(n -> toVo(n, readIds.contains(n.getId()))) .collect(Collectors.toList()); - long unreadCount = noticeRepository.countByStatus(PUBLISHED) - - noticeReadRepository.countByReaderAccount(account); + long unreadCount = 0; + Integer schId = currentSchId(); + if (schId != null) { + unreadCount = noticeRepository.countPublishedDueBySchId(schId, PUBLISHED, LocalDateTime.now()) + - noticeReadRepository.countByReaderAccount(account); + } else { + unreadCount = noticeRepository.countPublishedDue(PUBLISHED, LocalDateTime.now()) + - noticeReadRepository.countByReaderAccount(account); + } if (unreadCount < 0) { unreadCount = 0; } - return new NoticeUserListResult(content, (int) page.getTotalElements(), + return new NoticePageResult<>(content, (int) page.getTotalElements(), page.getNumber() + 1, page.getSize(), page.getTotalPages(), page.hasNext(), unreadCount); } /** - * 用户端动态查询:仅已发布;支持标题关键词、类型、部门、时间范围、未读/已读过滤。 + * 用户端动态查询:仅已发布且已到点(定时发送未到点不可见);支持标题关键词、类型、部门、时间范围、未读/已读过滤。 + * 多租户:按当前用户学校过滤(schId 为空时不加过滤,兼容无学校归属用户)。 */ private Specification userSpec(NoticeQuery query, Set readIds) { return (Root root, CriteriaQuery cq, CriteriaBuilder cb) -> { List predicates = new ArrayList<>(); predicates.add(cb.equal(root.get("status"), PUBLISHED)); + predicates.add(cb.or(cb.isNull(root.get("timingSendTime")), + cb.lessThanOrEqualTo(root.get("timingSendTime"), LocalDateTime.now()))); appendCommonPredicates(query, root, cb, predicates); + appendSchIdPredicate(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)); // 无已读,结果集为空 + predicates.add(cb.equal(root.get("id"), -1)); } else { predicates.add(root.get("id").in(readIds)); } @@ -256,6 +338,8 @@ public class NoticeService implements INoticeService { /** * 管理端动态查询:按状态/标题/类型/部门/发布人/时间范围过滤。 + * 多租户:按当前用户学校过滤(schId 为空时不加过滤)。 + * scope="user" 时仅看当前用户发布的通知。 */ private Specification adminSpec(NoticeQuery query) { return (Root root, CriteriaQuery cq, CriteriaBuilder cb) -> { @@ -264,13 +348,30 @@ public class NoticeService implements INoticeService { predicates.add(cb.equal(root.get("status"), query.getStatus())); } appendCommonPredicates(query, root, cb, predicates); - if (StringUtils.hasText(query.getPublisherAccount())) { + // scope="user" → 只看自己发布的通知 + if ("user".equalsIgnoreCase(query.getScope())) { + String account = currentAccount(); + if (account != null) { + predicates.add(cb.equal(root.get("publisherAccount"), account)); + } + } else if (StringUtils.hasText(query.getPublisherAccount())) { predicates.add(cb.equal(root.get("publisherAccount"), query.getPublisherAccount())); } + appendSchIdPredicate(root, cb, predicates); return cq.where(predicates.toArray(new Predicate[0])).getRestriction(); }; } + /** + * 多租户学校过滤:当前用户有学校身份时按 schId 过滤,无学校归属(区域组/根直属)不过滤。 + */ + private void appendSchIdPredicate(Root root, CriteriaBuilder cb, List predicates) { + Integer schId = currentSchId(); + if (schId != null) { + predicates.add(cb.equal(root.get("schId"), schId)); + } + } + private void appendCommonPredicates(NoticeQuery query, Root root, CriteriaBuilder cb, List predicates) { if (StringUtils.hasText(query.getKeyword())) { @@ -282,11 +383,12 @@ public class NoticeService implements INoticeService { if (StringUtils.hasText(query.getDeptId())) { predicates.add(cb.equal(root.get("deptId"), query.getDeptId())); } + // 前端传 yyyy-MM-dd 短日期:startTime 当天 00:00:00 起,endTime 补全到当天 23:59:59 if (query.getStartTime() != null) { - predicates.add(cb.greaterThanOrEqualTo(root.get("createTime"), query.getStartTime())); + predicates.add(cb.greaterThanOrEqualTo(root.get("createTime"), query.getStartTime().atStartOfDay())); } if (query.getEndTime() != null) { - predicates.add(cb.lessThanOrEqualTo(root.get("createTime"), query.getEndTime())); + predicates.add(cb.lessThanOrEqualTo(root.get("createTime"), query.getEndTime().atTime(23, 59, 59))); } } @@ -295,8 +397,8 @@ public class NoticeService implements INoticeService { @Override public NoticeVo detail(Integer id) { Notice entity = noticeRepository.findById(id) - .orElseThrow(() -> new NoticeException("通知不存在: " + id)); - String account = NoticeContext.getAccount(); + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); + String account = currentAccount(); boolean read = noticeReadRepository.existsByNoticeIdAndReaderAccount(id, account); NoticeVo vo = toVo(entity, read); if (!read) { @@ -306,77 +408,118 @@ public class NoticeService implements INoticeService { return vo; } + // ============================ 编辑回显(不标记已读) ============================ + + @Override + public NoticeVo getDetail(Integer id) { + Notice entity = noticeRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); + NoticeVo vo = toVo(entity, false); + vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId())); + return vo; + } + // ============================ 标记已读 ============================ @Override @Transactional public void markRead(Integer id) { Objects.requireNonNull(id, "通知ID不得为空"); - String account = NoticeContext.getAccount(); + String account = currentAccount(); if (noticeReadRepository.existsByNoticeIdAndReaderAccount(id, account)) { return; } NoticeRead read = new NoticeRead(); read.setNoticeId(id); read.setReaderAccount(account); - read.setReaderName(NoticeContext.getName()); + read.setReaderName(currentName()); 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; + @Transactional + public int initializeRed() { + String account = currentAccount(); + List readIds = noticeReadRepository.findNoticeIdsByReaderAccount(account); + Set readSet = new HashSet<>(readIds); + List published = noticeRepository.findAll((root, query, cb) -> + cb.and( + cb.equal(root.get("status"), PUBLISHED), + cb.or(cb.isNull(root.get("timingSendTime")), + cb.lessThanOrEqualTo(root.get("timingSendTime"), LocalDateTime.now())) + )); + int count = 0; + for (Notice n : published) { + if (!readSet.contains(n.getId())) { + markRead(n.getId()); + count++; + } } + return count; + } - // 按月统计(已发布) - List createTimes = noticeRepository - .findCreateTimesByStatusAndRange(PUBLISHED, s, e); - Map monthMap = new LinkedHashMap<>(); - for (LocalDateTime t : createTimes) { - String key = t.getYear() + "-" + String.format("%02d", t.getMonthValue()); - monthMap.merge(key, 1L, Long::sum); + // ============================ 追加接收人(事务化) ============================ + + @Override + @Transactional + public void receiveUsers(Integer noticeId, String users) { + if (!StringUtils.hasText(users)) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择追送用户!"); } - List 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 publishers = noticeRepository - .findPublisherNamesByStatusAndRange(PUBLISHED, s, e); - Map pubMap = new LinkedHashMap<>(); - for (String p : publishers) { - pubMap.merge(p == null ? "未知" : p, 1L, Long::sum); + Notice entity = noticeRepository.findById(noticeId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId)); + if (entity.getReceiveList() != null && "ALL".equalsIgnoreCase(entity.getReceiveList().trim())) { + return; } - List byPublisher = pubMap.entrySet().stream() - .map(en -> new NoticeStatisticsVo.PublisherStat(en.getKey(), en.getValue())) - .collect(Collectors.toList()); + // 合并旧接收人 + 新接收人 + Set merged = new LinkedHashSet<>(parseReceiveAccounts(entity.getReceiveList())); + for (String u : users.split(",")) { + String account = u.trim(); + if (StringUtils.hasText(account)) { + merged.add(account); + } + } + entity.setReceiveList(toJsonAccounts(new ArrayList<>(merged))); + entity.setAllowCount(merged.size()); + noticeRepository.save(entity); + // 更新接收人关联表 + noticeReceiverRepository.deleteByNoticeId(noticeId); + saveReceivers(entity); + log.info("[receiveUsers] 通知 {} 追送用户, 现接收人 {} 人", noticeId, merged.size()); + } - 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; + // ============================ 上一篇/下一篇(SQL 相邻查询) ============================ + + @Override + public Map getNextPrev(Integer serid, String type) { + Map data = new LinkedHashMap<>(); + Notice current = noticeRepository.findById(serid).orElse(null); + if (current == null) { + data.put("prevId", 0); + data.put("nextId", 0); + return data; + } + if ("0".equals(type)) { + // 已发序列:按当前用户发布的通知,按 createTime 排序 + List published = noticeRepository.findAll( + (root, query, cb) -> cb.equal(root.get("publisherAccount"), currentAccount()), + Sort.by(Sort.Direction.DESC, "createTime")); + List ids = published.stream().map(Notice::getId).collect(Collectors.toList()); + int index = ids.indexOf(serid); + data.put("prevId", index > 0 ? ids.get(index - 1) : 0); + data.put("nextId", index >= 0 && index < ids.size() - 1 ? ids.get(index + 1) : 0); + } else { + // 已收序列:按阅读记录 + List ids = noticeReadRepository.findNoticeIdsByReaderAccount(currentAccount()); + Collections.reverse(ids); + int index = ids.indexOf(serid); + data.put("prevId", index > 0 ? ids.get(index - 1) : 0); + data.put("nextId", index >= 0 && index < ids.size() - 1 ? ids.get(index + 1) : 0); + } + return data; } // ============================ 阅读明细 ============================ @@ -395,6 +538,269 @@ public class NoticeService implements INoticeService { }).collect(Collectors.toList()); } + // ============================ 阅读情况分组 ============================ + + /** + * 阅读情况按「学校组」分组(对齐旧 .NET OrgizeModify:组名 + 组内用户,绿已读/红未读)。 + *

与旧版差异:旧版接收人逐行落库(含状态),本实现已读=notice_read 明细、 + * 接收人=notice_receiver 明细;用户姓名/所属组经用户中心 byLoginName 实时查询, + * 查不到或跨校的接收人归「其他」组。全员通知(receive_list=ALL)不落接收明细, + * 仅能展示已读人员。

+ */ + @Override + public List> getReadDetailGrouped(Integer serid) { + Notice notice = noticeRepository.findById(serid) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + serid)); + List readDetails = getReadDetail(serid); + Set readAccounts = readDetails.stream() + .map(NoticeVo.ReadDetail::getAccount) + .collect(Collectors.toSet()); + // 已读快照姓名:用户中心查不到时兜底展示 + Map readNameMap = new HashMap<>(); + for (NoticeVo.ReadDetail d : readDetails) { + if (StringUtils.hasText(d.getName())) { + readNameMap.putIfAbsent(d.getAccount(), d.getName()); + } + } + + // 接收人:优先关联表明细,缺失回退 receive_list JSON(历史数据) + List receivers = noticeReceiverRepository.findByNoticeId(serid).stream() + .map(NoticeReceiver::getAccount) + .distinct() + .collect(Collectors.toList()); + if (receivers.isEmpty()) { + receivers = parseReceiveAccounts(notice.getReceiveList()); + } + // 已读者不在接收明细中(如追送/数据修补),补充到末尾,保证已读情况完整 + for (String account : readAccounts) { + if (!receivers.contains(account)) { + receivers.add(account); + } + } + + // 逐账号查用户中心(姓名+组织链)。量级为校内接收人(几十),串行可接受 + Map userMap = new LinkedHashMap<>(); + for (String account : receivers) { + try { + BoaUser user = YschBaseOpenApi.getUserInfo(account); + if (user != null) { + userMap.put(account, user); + } + } catch (Exception e) { + log.warn("[readDetailGrouped] 查询用户中心失败, account={}", account, e); + } + } + + // 组名=用户组织链最近端(主组),跨校/查不到归「其他」(对齐旧版逻辑) + Map>> groupUsers = new LinkedHashMap<>(); + for (String account : receivers) { + BoaUser user = userMap.get(account); + String groupName = resolveReadGroupName(user, notice.getSchId()); + String teaName = user != null && StringUtils.hasText(user.getName()) + ? user.getName() + : readNameMap.getOrDefault(account, account); + Map item = new LinkedHashMap<>(); + item.put("account", account); + item.put("teaname", teaName); + item.put("states", readAccounts.contains(account) ? "read" : "unread"); + groupUsers.computeIfAbsent(groupName, k -> new ArrayList<>()).add(item); + } + + List> groups = new ArrayList<>(); + for (Map.Entry>> entry : groupUsers.entrySet()) { + Map group = new LinkedHashMap<>(); + group.put("GroupName", entry.getKey()); + group.put("teaslist", entry.getValue()); + groups.add(group); + } + return groups; + } + + /** + * 阅读情况分组名:用户中心组织链最近端(主组)即所属组(如「英语组」); + * 直接挂学校根的展示学校名;跨校或用户中心查不到的归「其他」。 + */ + private String resolveReadGroupName(BoaUser user, Integer noticeSchId) { + if (user == null) { + return "其他"; + } + if (noticeSchId != null && user.getSchool() != null && user.getSchool().getId() != null + && !noticeSchId.equals(user.getSchool().getId())) { + return "其他"; + } + List groups = user.getGroups(); + if (groups != null && !groups.isEmpty() && StringUtils.hasText(groups.get(0).getName())) { + return groups.get(0).getName(); + } + if (user.getSchool() != null && StringUtils.hasText(user.getSchool().getName())) { + return user.getSchool().getName(); + } + return "其他"; + } + + // ============================ 催收未读 ============================ + + @Override + public void expedite(Integer serid) { + Notice notice = noticeRepository.findById(serid) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + serid)); + Set readAccounts = noticeReadRepository.findByNoticeIdOrderByReadTimeDesc(serid).stream() + .map(NoticeRead::getReaderAccount) + .collect(Collectors.toSet()); + List receivers = parseReceiveAccounts(notice.getReceiveList()); + List unreadAccounts = receivers.stream() + .filter(a -> !readAccounts.contains(a)) + .collect(Collectors.toList()); + log.info("[expedite] 通知 {} 催收未读 {} 人: {}", serid, unreadAccounts.size(), unreadAccounts); + // TODO: 接入 yh-platform 消息推送后,对 unreadAccounts 发送催办消息 + } + + // ============================ 统计 ============================ + + @Override + public NoticeStatisticsVo statistics(NoticeQuery query, LocalDateTime start, LocalDateTime end) { + String account = currentAccount(); + Integer schId = currentSchId(); + LocalDateTime s = (start == null) ? LocalDateTime.of(1970, 1, 1, 0, 0) : start; + LocalDateTime e = (end == null) ? LocalDateTime.now().plusDays(1) : end; + + // 多租户:统计总数按当前学校过滤(schId 为空时统计全校) + long total = schId != null ? noticeRepository.countBySchId(schId) + : noticeRepository.count(); + long publishedCount = schId != null ? noticeRepository.countBySchIdAndStatus(schId, PUBLISHED) + : noticeRepository.countByStatus(PUBLISHED); + long totalReadRecords = noticeReadRepository.count(); + long unreadForCurrentUser = (schId != null ? noticeRepository.countPublishedDueBySchId(schId, PUBLISHED, LocalDateTime.now()) + : noticeRepository.countPublishedDue(PUBLISHED, LocalDateTime.now())) + - noticeReadRepository.countByReaderAccount(account); + if (unreadForCurrentUser < 0) { + unreadForCurrentUser = 0; + } + + List createTimes = schId != null + ? noticeRepository.findCreateTimesBySchIdStatusAndRange(schId, PUBLISHED, s, e) + : noticeRepository.findCreateTimesByStatusAndRange(PUBLISHED, s, e); + Map monthMap = new LinkedHashMap<>(); + for (LocalDateTime t : createTimes) { + String key = t.getYear() + "-" + String.format("%02d", t.getMonthValue()); + monthMap.merge(key, 1L, Long::sum); + } + List 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 publishers = schId != null + ? noticeRepository.findPublisherNamesBySchStatusAndRange(schId, PUBLISHED, s, e) + : noticeRepository.findPublisherNamesByStatusAndRange(PUBLISHED, s, e); + Map pubMap = new LinkedHashMap<>(); + for (String p : publishers) { + pubMap.merge(p == null ? "未知" : p, 1L, Long::sum); + } + List 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.setTotalReadRecords(totalReadRecords); + vo.setUnreadForCurrentUser(unreadForCurrentUser); + vo.setByPublisher(byPublisher); + vo.setByMonth(byMonth); + return vo; + } + + // ============================ 详情视图 VO(供 Controller 返回) ============================ + + @Override + public NoticeDetailVo getDetailMap(Integer serid) { + NoticeVo vo = getDetail(serid); + NoticeDetailVo data = new NoticeDetailVo(); + data.setSerid(serid); + data.setTitle(vo.getTitle()); + data.setContents(vo.getContent()); + data.setDeptId(vo.getDeptId()); + data.setDeptName(vo.getDeptName()); + data.setPublisherName(vo.getPublisherName()); + data.setEditDate(formatTime(vo.getCreateTime())); + data.setNumCount(vo.getAllowCount() != null ? vo.getAllowCount() : 0); + data.setReadNumCount(vo.getReadCount()); + data.setReceiveList(vo.getReceiveList()); + data.setDsfsTime(vo.getTimingSendTime() != null ? formatTime(vo.getTimingSendTime()) : null); + data.setIsDsfs(vo.getTimingSendTime() != null); + List files = upFileRepository.findByTopicTypeAndTopicId("notice", serid); + List fileList = files.stream().map(f -> { + NoticeDetailVo.FileItem item = new NoticeDetailVo.FileItem(); + item.setId(f.getId()); + item.setName(f.getFileTrueName()); + item.setSize(f.getFileSize()); + item.setFileid(f.getId()); + item.setFilename(f.getFileName()); + return item; + }).collect(Collectors.toList()); + data.setChaoSend(fileList); + String rl = vo.getReceiveList(); + if (rl == null || rl.trim().isEmpty() || "ALL".equalsIgnoreCase(rl.trim())) { + data.setTlist("all"); + } else { + data.setTlist(parseReceiveAccounts(rl)); + } + return data; + } + + // ============================ 已发列表附件批量查询 ============================ + + @Override + public Map> batchFileIds(List noticeIds) { + if (noticeIds == null || noticeIds.isEmpty()) { + return Collections.emptyMap(); + } + Map> result = new HashMap<>(); + for (Integer id : noticeIds) { + result.put(id, new ArrayList<>()); + } + List files = upFileRepository.findByTopicTypeAndTopicIdIn("notice", noticeIds); + for (UpFile f : files) { + result.computeIfAbsent(f.getTopicId(), k -> new ArrayList<>()).add(f.getId()); + } + return result; + } + + // ============================ 详情视图 VO(type 控制标读) ============================ + + @Override + public NoticeDetailVo detailMap(Integer serid, Integer type) { + if (type != null && type == 0) { + // 编辑模式:不标记已读 + return getDetailMap(serid); + } + // 查看模式:先标记已读再返回 + detail(serid); + return getDetailMap(serid); + } + + // ============================ 一键已读结果 ============================ + + @Override + @Transactional + public InitializeRedVo initializeRedResult() { + int count = initializeRed(); + return new InitializeRedVo(count); + } + + // ============================ 批量下载附件 ============================ + + @Override + public void downloadAllFiles(Integer dataid, HttpServletResponse response) { + NoticeDetailVo detail = getDetailMap(dataid); + String title = detail.getTitle(); + fileService.downloadZipByTopic("notice", dataid, title != null ? title : "附件", response); + } + // ============================ 转换 ============================ private NoticeVo toVo(Notice entity, boolean read) { @@ -402,22 +808,111 @@ public class NoticeService implements INoticeService { BeanUtils.copyProperties(entity, vo); vo.setTypeDesc(NoticeTypeEnum.of(entity.getType()).getDesc()); vo.setStatusDesc(NoticeStatusEnum.of(entity.getStatus()).getDesc()); + // 存量数据 dept_name 为空时按 deptId 从字典兜底解析(对应 C# GetDeptName) + if (!StringUtils.hasText(vo.getDeptName())) { + vo.setDeptName(dictService.resolveDeptName(vo.getDeptId())); + } vo.setRead(read); vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId())); return vo; } - // ============================ 事件发布(异步推送消息) ============================ + /** + * 写入时解析部门名称:优先按 deptId 查字典,字典缺失时回退前端传值。 + */ + private String resolveDeptName(NoticeRequest request) { + String resolved = dictService.resolveDeptName(request.getDeptId()); + return StringUtils.hasText(resolved) ? resolved : request.getDeptName(); + } + + // ============================ 短信(预留能力) ============================ + + private void trySendSms(Notice entity) { + if (entity.getSmsFlag() == null || entity.getSmsFlag() != 1) { + return; + } + if (!isImmediatePublish(entity)) { + return; + } + smsSender.sendNoticeSms(entity.getId(), entity.getTitle(), + parseReceiveList(entity.getReceiveList())); + } + + private boolean isImmediatePublish(Notice entity) { + return entity.getTimingSendTime() == null + || !entity.getTimingSendTime().isAfter(LocalDateTime.now()); + } + + // ============================ 接收人工具方法 ============================ /** - * 发布通知发布事件,由消息服务监听后在事务提交后异步推送(App/短信/企微/角标)。 - * 接收人列表从 receiveList(JSON数组)中解析;为空时表示全员通知。 + * 接收人列表归一化:前端/存量可能传 "all"/"UserAll"/"ALL"(全员),统一存 "ALL"。 + * 其余视为 JSON 数组(账号)或逗号分隔串,原样保留。 */ - private void publishEvent(Notice notice) { - List receivers = parseReceiveList(notice.getReceiveList()); - NoticePublishedEvent event = new NoticePublishedEvent( - notice.getId(), notice.getTitle(), receivers); - eventPublisher.publishEvent(event); + private void normalizeReceiveList(Notice entity, String receiveList) { + if (receiveList == null) { + entity.setReceiveList(null); + return; + } + String rl = receiveList.trim(); + if ("all".equalsIgnoreCase(rl) || "UserAll".equalsIgnoreCase(rl) || "ALL".equalsIgnoreCase(rl)) { + entity.setReceiveList("ALL"); + return; + } + entity.setReceiveList(receiveList); + // 设置接收人数 + entity.setAllowCount(parseReceiveAccounts(receiveList).size()); + } + + /** + * 写入接收人关联表(非全员时)。 + */ + private void saveReceivers(Notice entity) { + String rl = entity.getReceiveList(); + if (rl == null || "ALL".equalsIgnoreCase(rl.trim())) { + return; + } + List accounts = parseReceiveAccounts(rl); + for (String account : accounts) { + NoticeReceiver receiver = new NoticeReceiver(); + receiver.setNoticeId(entity.getId()); + receiver.setAccount(account); + receiver.setReceiveType(1); + noticeReceiverRepository.save(receiver); + } + } + + /** + * 解析接收人列表为账号集合:支持 "ALL"→空、JSON 数组 ["a","b"]、逗号分隔串。 + */ + private List parseReceiveAccounts(String receiveList) { + if (receiveList == null || receiveList.trim().isEmpty()) { + return Collections.emptyList(); + } + String rl = receiveList.trim(); + if ("ALL".equalsIgnoreCase(rl) || "UserAll".equalsIgnoreCase(rl) || "all".equalsIgnoreCase(rl)) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + if (rl.startsWith("[")) { + String inner = rl.substring(1, rl.length() - 1).trim(); + if (!inner.isEmpty()) { + for (String part : inner.split(",")) { + String account = part.trim().replace("\"", "").replace("'", ""); + if (StringUtils.hasText(account) && !result.contains(account)) { + result.add(account); + } + } + } + } else { + for (String part : rl.split(",")) { + String account = part.trim(); + if (StringUtils.hasText(account) && !result.contains(account)) { + result.add(account); + } + } + } + return result; } /** @@ -425,30 +920,17 @@ public class NoticeService implements INoticeService { * 为空或解析失败时返回空列表(表示全员通知)。 */ private List 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 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(); + return parseReceiveAccounts(receiveListJson); + } + + private String toJsonAccounts(List accounts) { + return accounts.stream() + .map(a -> "\"" + a.replace("\"", "\\\"") + "\"") + .collect(Collectors.joining(",", "[", "]")); + } + + private String formatTime(LocalDateTime time) { + if (time == null) return null; + return time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } } diff --git a/src/main/java/com/yunhai/notice/service/impl/SmsServiceImpl.java b/src/main/java/com/yunhai/notice/service/impl/SmsService.java similarity index 86% rename from src/main/java/com/yunhai/notice/service/impl/SmsServiceImpl.java rename to src/main/java/com/yunhai/notice/service/impl/SmsService.java index d8b88ff..438f466 100644 --- a/src/main/java/com/yunhai/notice/service/impl/SmsServiceImpl.java +++ b/src/main/java/com/yunhai/notice/service/impl/SmsService.java @@ -4,7 +4,7 @@ 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 com.yunhai.notice.service.ISmsService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -25,9 +25,9 @@ import java.util.stream.Collectors; * Java版改为从本地 sms_module + sms_school_config 表管理。 */ @Service -public class SmsServiceImpl implements SmsService { +public class SmsService implements ISmsService { - private static final Logger log = LoggerFactory.getLogger(SmsServiceImpl.class); + private static final Logger log = LoggerFactory.getLogger(SmsService.class); @Autowired private SmsModuleRepository smsModuleRepository; @@ -240,4 +240,45 @@ public class SmsServiceImpl implements SmsService { } } } + + // ============================ setschsms (JSON 解析) ============================ + + @Override + @Transactional + public void setSchSmsFromJson(String itemsModuJson) { + Integer schSerID = extractJsonField(itemsModuJson, "SchSerID"); + Integer moduID = extractJsonField(itemsModuJson, "ModuID"); + Boolean isSms = extractJsonBool(itemsModuJson, "IsSms"); + setSchSms(schSerID, moduID, isSms); + } + + // ============================ 辅助方法 ============================ + + /** + * 从 JSON 字符串中提取整数字段。 + */ + private Integer extractJsonField(String json, String field) { + if (json == null) return null; + 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; + } } diff --git a/src/main/java/com/yunhai/notice/service/impl/TemplateServiceImpl.java b/src/main/java/com/yunhai/notice/service/impl/TemplateService.java similarity index 87% rename from src/main/java/com/yunhai/notice/service/impl/TemplateServiceImpl.java rename to src/main/java/com/yunhai/notice/service/impl/TemplateService.java index 6858d80..d51825c 100644 --- a/src/main/java/com/yunhai/notice/service/impl/TemplateServiceImpl.java +++ b/src/main/java/com/yunhai/notice/service/impl/TemplateService.java @@ -1,13 +1,14 @@ 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 com.yunhai.notice.service.ITemplateService; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -24,7 +25,7 @@ import java.util.stream.Collectors; * 对应 C# TemplateController + Template.cs。 */ @Service -public class TemplateServiceImpl implements TemplateService { +public class TemplateService implements ITemplateService { @Autowired private TemplateTypeRepository templateTypeRepository; @@ -63,7 +64,7 @@ public class TemplateServiceImpl implements TemplateService { @Transactional public void editType(Integer typeid, String typeName) { if (!StringUtils.hasText(typeName)) { - throw new NoticeException("类型名称不能为空!"); + throw new BizException(ErrorCode.PARAM_ERROR, "类型名称不能为空!"); } // 重名校验 boolean exists; @@ -73,7 +74,7 @@ public class TemplateServiceImpl implements TemplateService { exists = templateTypeRepository.existsByTypeNameAndIdNot(typeName, typeid); } if (exists) { - throw new NoticeException("类型名称已存在!"); + throw new BizException(ErrorCode.FORBIDDEN, "类型名称已存在!"); } if (typeid == null || typeid == 0) { @@ -90,7 +91,7 @@ public class TemplateServiceImpl implements TemplateService { } else { // 编辑 TemplateType type = templateTypeRepository.findById(typeid) - .orElseThrow(() -> new NoticeException("类型不存在!")); + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "类型不存在!")); type.setTypeName(typeName); templateTypeRepository.save(type); } @@ -102,14 +103,14 @@ public class TemplateServiceImpl implements TemplateService { @Transactional public void delType(Integer typeid) { if (typeid == null) { - throw new NoticeException("类型不存在!"); + throw new BizException(ErrorCode.PARAM_ERROR, "类型不存在!"); } TemplateType type = templateTypeRepository.findById(typeid) - .orElseThrow(() -> new NoticeException("类型不存在!")); + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "类型不存在!")); // 校验是否有模板引用此类型 long refCount = noticeTemplateRepository.countByTypeId(typeid); if (refCount > 0) { - throw new NoticeException("类型存在模板!"); + throw new BizException(ErrorCode.FORBIDDEN, "类型存在模板!"); } templateTypeRepository.delete(type); } @@ -174,10 +175,10 @@ public class TemplateServiceImpl implements TemplateService { @Transactional public void editTemplate(Integer serID, String title, String content, Integer typeId, Integer cover) { if (!StringUtils.hasText(title)) { - throw new NoticeException("模板标题不能为空!"); + throw new BizException(ErrorCode.PARAM_ERROR, "模板标题不能为空!"); } if (!StringUtils.hasText(content)) { - throw new NoticeException("模板内容不能为空!"); + throw new BizException(ErrorCode.PARAM_ERROR, "模板内容不能为空!"); } if (serID == null || serID == 0) { @@ -192,7 +193,7 @@ public class TemplateServiceImpl implements TemplateService { } else { // 编辑 NoticeTemplate template = noticeTemplateRepository.findById(serID) - .orElseThrow(() -> new NoticeException("模板不存在!")); + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "模板不存在!")); template.setTitle(title); template.setContent(content); template.setTypeId(typeId); @@ -207,7 +208,7 @@ public class TemplateServiceImpl implements TemplateService { @Transactional public void delTemplate(Integer serID) { if (serID == null) { - throw new NoticeException("参数错误"); + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); } noticeTemplateRepository.findById(serID).ifPresent(noticeTemplateRepository::delete); } @@ -217,10 +218,10 @@ public class TemplateServiceImpl implements TemplateService { @Override public Map getTemplateFist(Integer serID) { if (serID == null) { - throw new NoticeException("参数错误"); + throw new BizException(ErrorCode.PARAM_ERROR, "参数错误"); } NoticeTemplate t = noticeTemplateRepository.findById(serID) - .orElseThrow(() -> new NoticeException("模板不存在!")); + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "模板不存在!")); // 加载类型名 String typeName = ""; diff --git a/src/main/java/com/yunhai/notice/service/impl/UserCenterService.java b/src/main/java/com/yunhai/notice/service/impl/UserCenterService.java new file mode 100644 index 0000000..35fb212 --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/impl/UserCenterService.java @@ -0,0 +1,280 @@ +package com.yunhai.notice.service.impl; + +import com.yunhai.notice.model.SchTeaList; +import com.yunhai.notice.service.IClassService; +import com.yunhai.notice.service.IUserCenterService; +import com.yunhai.platform.openapi.client.YschBaseOpenApi; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * 用户中心门面服务实现。 + *

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

+ */ +@Service +public class UserCenterService implements IUserCenterService { + + private static final Logger log = LoggerFactory.getLogger(UserCenterService.class); + + /** 学生组(UserBase GroupTypeEnum.STUDENT_GROUP),组树中剔除 */ + private static final int GROUP_TYPE_STUDENT = 8; + + @Autowired + private IClassService classService; + + // ============================ 学校组织树 ============================ + + @Override + public List> getSchoolAllTree(Integer schId) { + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + + // 1. userbase 组树(整棵学校子树),剔除学生组 + List> flatNodes = new ArrayList<>(); + BoaGroup root = YschBaseOpenApi.getGroupById(schId, true); + if (root == null) { + log.warn("[getSchoolAll] 用户中心未找到学校组, schId={}", schId); + return Collections.emptyList(); + } + flattenGroup(root, 0, flatNodes); + + // 2. bschclass 全部教师,按 GroupIds 挂到对应组下;无有效组的兜底挂学校根节点 + Set treeGroupIds = new HashSet<>(); + for (Map node : flatNodes) { + treeGroupIds.add((Integer) node.get("id")); + } + Map teacherMap = new LinkedHashMap<>(); + Map> teacherPids = new LinkedHashMap<>(); + List teachers = classService.getSchTeaList(schId); + for (SchTeaList tea : teachers) { + if (tea.getUserTeaSerID() == null) { + continue; + } + // 同一教师多行(多组/接口重复数据)合并,按 (教师, 组) 去重 + teacherMap.putIfAbsent(tea.getUserTeaSerID(), tea); + // 接口现行字段为单个 GroupID,兼容旧字段 GroupIds + List teaGroupIds = tea.getGroupIds(); + if ((teaGroupIds == null || teaGroupIds.isEmpty()) && tea.getGroupId() != null) { + teaGroupIds = Collections.singletonList(tea.getGroupId()); + } + if (teaGroupIds != null) { + for (Integer gid : teaGroupIds) { + if (gid != null && treeGroupIds.contains(gid)) { + teacherPids.computeIfAbsent(tea.getUserTeaSerID(), k -> new HashSet<>()).add(gid); + } + } + } + } + for (Map.Entry entry : teacherMap.entrySet()) { + int teaId = entry.getKey(); + SchTeaList tea = entry.getValue(); + Set pids = teacherPids.get(teaId); + if (pids == null || pids.isEmpty()) { + // 教师所属组均被剔除(如学生组)或无组信息,挂学校根节点 + pids = new HashSet<>(Collections.singletonList(treeGroupIds.contains(schId) ? schId : 0)); + } + for (Integer pid : pids) { + Map node = new LinkedHashMap<>(); + node.put("id", teaId); + node.put("name", tea.getTeaName() != null ? tea.getTeaName() : ""); + node.put("pid", pid); + node.put("no", tea.getTeaNo() != null ? tea.getTeaNo() : 0); + node.put("type", 2); + node.put("isParent", false); + node.put("photo", ""); + flatNodes.add(node); + } + } + + // 3. 扁平节点递归组装为树 + return formatUserTree(flatNodes, 0); + } + + /** + * 将 BoaGroup 子树扁平化为组节点列表(type=1);学生组(type=8)整棵剔除。 + * isParent=true 标识组节点,前端 ztree 据此渲染为文件夹。 + */ + private void flattenGroup(BoaGroup group, int pid, List> nodes) { + if (group == null || group.getId() == null) { + return; + } + if (group.getType() != null && group.getType() == GROUP_TYPE_STUDENT) { + return; + } + Map node = new LinkedHashMap<>(); + node.put("id", group.getId()); + node.put("name", group.getName() != null ? group.getName() : ""); + node.put("pid", pid); + node.put("no", group.getSort() != null ? group.getSort() : 0); + node.put("type", 1); + node.put("isParent", true); + node.put("photo", ""); + nodes.add(node); + + if (group.getChildren() != null) { + for (BoaGroup child : group.getChildren()) { + flattenGroup(child, group.getId(), nodes); + } + } + } + + /** + * 递归构建用户树(对应 C# HttpUserApi.FormatUserTree)。 + * 根层取 pid=0 或父节点不在列表中的节点;type=1 且无子节点的空组剔除。 + */ + private List> formatUserTree(List> treeList, int pid) { + List> data; + if (pid == 0) { + Set allIds = new HashSet<>(); + for (Map n : treeList) { + allIds.add((Integer) n.get("id")); + } + data = new ArrayList<>(); + for (Map n : treeList) { + int p = (Integer) n.get("pid"); + if (p == 0 || !allIds.contains(p)) { + data.add(n); + } + } + } else { + data = new ArrayList<>(); + for (Map n : treeList) { + if ((Integer) n.get("pid") == pid) { + data.add(n); + } + } + } + + List> result = new ArrayList<>(); + for (Map item : data) { + int itemId = (Integer) item.get("id"); + int itemType = (Integer) item.get("type"); + + List> children = formatUserTree(treeList, itemId); + + // type=1(组) 且无子节点 → 不加入(C# 逻辑) + if (itemType == 1 && children.isEmpty()) { + continue; + } + + Map node = new LinkedHashMap<>(item); + // 仅组节点携带 children;叶子不带该字段,避免 ztree 误判 isParent + if (itemType == 1) { + node.put("children", children); + } + result.add(node); + } + + // 按 no 排序(数字优先,无法解析的排最后) + result.sort(Comparator.comparingInt(n -> { + Object no = n.get("no"); + if (no instanceof Integer) { + return (Integer) no; + } + if (no instanceof String) { + try { + return Integer.parseInt((String) no); + } catch (NumberFormatException e) { + return 9999; + } + } + return 9999; + })); + + return result; + } + + // ============================ 组内用户 ============================ + + @Override + public List> getGroupUser(Integer gid, Integer role) { + if (gid == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "组ID不得为空"); + } + log.info("[getGroupUser] gid={}, role={}, 用户中心组内用户开放接口尚未接入", gid, role); + // 显式报错而非静默返回空列表:原空桩会让前端误以为组内无用户 + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "组内用户查询接口暂未接入用户中心,请联系管理员配置后重试"); + } + + // ============================ 用户模块树 ============================ + + @Override + public List> getUserModuleTree(Integer userId, Integer schId) { + if (userId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空"); + } + List modules = YschBaseOpenApi.getUserModule(userId, schId); + if (modules == null || modules.isEmpty()) { + return Collections.emptyList(); + } + // 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望 + return buildModuleTree(modules); + } + + /** + * 将扁平模块列表组装为树(对应原 NoticeController.buildModuleTree)。 + * 顶层取 parentId=0 或父节点不在列表中的节点,子节点按 orderNumber 排序。 + */ + private List> buildModuleTree(List modules) { + Map moduleMap = modules.stream() + .collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a)); + + // 每个模块节点(含 children 占位) + Map> nodeMap = new LinkedHashMap<>(); + for (BoaModule m : modules) { + Map node = new LinkedHashMap<>(); + node.put("id", m.getId()); + node.put("modId", m.getId()); + node.put("name", m.getName()); + node.put("parentId", m.getParentId() != null ? m.getParentId() : 0); + node.put("page", m.getPath() != null ? m.getPath() : ""); + node.put("level", m.getLevel() != null ? m.getLevel() : 0); + node.put("icon", m.getIcon() != null ? m.getIcon() : ""); + node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0); + node.put("usable", true); + node.put("children", new ArrayList<>()); + nodeMap.put(m.getId(), node); + } + + // 挂父子关系 + List> roots = new ArrayList<>(); + for (BoaModule m : modules) { + Map node = nodeMap.get(m.getId()); + int parentId = m.getParentId() != null ? m.getParentId() : 0; + if (parentId == 0 || !moduleMap.containsKey(parentId)) { + roots.add(node); + } else { + @SuppressWarnings("unchecked") + List> children = (List>) nodeMap.get(parentId).get("children"); + children.add(node); + } + } + + // 全树按 orderNumber 排序 + sortTree(roots); + return roots; + } + + @SuppressWarnings("unchecked") + private void sortTree(List> nodes) { + nodes.sort(Comparator.comparingInt(n -> (Integer) n.get("orderNumber"))); + for (Map node : nodes) { + List> children = (List>) node.get("children"); + if (!children.isEmpty()) { + sortTree(children); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/yunhai/notice/service/sms/DefaultSmsSender.java b/src/main/java/com/yunhai/notice/service/sms/DefaultSmsSender.java new file mode 100644 index 0000000..c3d85c6 --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/sms/DefaultSmsSender.java @@ -0,0 +1,22 @@ +package com.yunhai.notice.service.sms; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 短信发送默认空实现:仅记录日志,真实短信通道接入前的占位。 + */ +@Component +public class DefaultSmsSender implements SmsSender { + + private static final Logger log = LoggerFactory.getLogger(DefaultSmsSender.class); + + @Override + public void sendNoticeSms(Integer noticeId, String title, List receiverAccounts) { + log.info("[SmsSender] 预留短信能力:通知 {} 标题「{}」,接收人 {} 人(暂未接入真实短信通道)", + noticeId, title, receiverAccounts == null ? 0 : receiverAccounts.size()); + } +} diff --git a/src/main/java/com/yunhai/notice/service/sms/SmsSender.java b/src/main/java/com/yunhai/notice/service/sms/SmsSender.java new file mode 100644 index 0000000..f1bcdd4 --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/sms/SmsSender.java @@ -0,0 +1,22 @@ +package com.yunhai.notice.service.sms; + +import java.util.List; + +/** + * 通知短信发送抽象接口。 + *

+ * 预留未来短信能力:发通知且开启短信(smsFlag=1)时调用; + * 当前仅提供默认空实现(记录日志),真实短信通道后续接入。 + *

+ */ +public interface SmsSender { + + /** + * 发送通知短信。 + * + * @param noticeId 通知ID + * @param title 通知标题 + * @param receiverAccounts 接收人账号列表(空表示全员通知) + */ + void sendNoticeSms(Integer noticeId, String title, List receiverAccounts); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0a6265d..525251a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,7 +1,5 @@ server: port: 8070 - servlet: - context-path: /notice spring: mvc: @@ -47,11 +45,6 @@ spring: file: upload-path: D:/data/upload -# 定时发送任务 -timing-send: - enabled: true - cron: "0 * * * * ?" # 每分钟检查一次 - # 短信模块配置 sms: enabled: true @@ -75,3 +68,5 @@ yh: enabled: true base-host: http://base.yunhaixingtong.com user-center-url: http://base.yunhaixingtong.com/userbase + # 班级管理(bschclass)服务基址 + bschclass: http://base.yunhaixingtong.com/bschclass diff --git a/src/main/resources/application.yml.tmp.16804.0ac1218e4e90 b/src/main/resources/application.yml.tmp.16804.0ac1218e4e90 new file mode 100644 index 0000000..525251a --- /dev/null +++ b/src/main/resources/application.yml.tmp.16804.0ac1218e4e90 @@ -0,0 +1,72 @@ +server: + port: 8070 + +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 + +# 短信模块配置 +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 + # 班级管理(bschclass)服务基址 + bschclass: http://base.yunhaixingtong.com/bschclass diff --git a/src/main/resources/sql/notice_add_sch_id.sql b/src/main/resources/sql/notice_add_sch_id.sql new file mode 100644 index 0000000..a6d160d --- /dev/null +++ b/src/main/resources/sql/notice_add_sch_id.sql @@ -0,0 +1,16 @@ +-- ============================================================ +-- notice 表补 sch_id 列(多租户隔离) +-- 决策 D3:发布时从 BoaUser.currentRole.schId 填充,查询时按校过滤 +-- +-- 对应实体:Notice.schId / NoticeRepository 多租户统计方法 +-- +-- 执行方式:由 DBA 手动在对应环境执行,AI 不代跑 +-- MySQL 5.7+ 兼容 +-- ============================================================ + +-- 1) 加列 +ALTER TABLE `notice` + ADD COLUMN `sch_id` INT NULL COMMENT '学校ID(多租户隔离:发布时从当前用户角色填充)'; + +-- 2) 加索引(用于按学校过滤查询) +CREATE INDEX `idx_notice_sch_id` ON `notice` (`sch_id`); diff --git a/src/main/resources/sql/notice_data_backfill.sql b/src/main/resources/sql/notice_data_backfill.sql new file mode 100644 index 0000000..e0dcbdd --- /dev/null +++ b/src/main/resources/sql/notice_data_backfill.sql @@ -0,0 +1,159 @@ +-- ============================================================ +-- 存量数据回填脚本 +-- 1. notice.sch_id:按发布人所属学校回填(从 sys_user_role_rel 取 main=1 的 sch_id) +-- 2. notice_receiver:从 notice.receive_list JSON 字符串展开为关联表行 +-- +-- 幂等设计:可重复执行,已存在的数据不会重复插入 +-- 执行方式:由 DBA 手动在对应环境执行,AI 不代跑 +-- MySQL 5.7+ 兼容 +-- ============================================================ + +-- ============================================================ +-- Part 1:回填 notice.sch_id +-- ============================================================ + +-- 方式一:如果 base_user / sys_user 表有 sch_id 字段,直接关联回填 +-- 注意:需根据实际平台库表结构调整关联条件 +UPDATE `notice` n + INNER JOIN ( + SELECT DISTINCT u.login_name, u.sch_id + FROM base_user u + WHERE u.sch_id IS NOT NULL + ) t ON n.publisher_account = t.login_name +SET n.sch_id = t.sch_id +WHERE n.sch_id IS NULL + AND n.publisher_account IS NOT NULL + AND n.publisher_account != ''; + +-- 方式二(备用):如果用户学校关系在 sys_user_role_rel 表 +-- 取每个用户的主身份学校(is_main=1),回填 sch_id +-- 取消注释后执行(视实际库表结构选用) +-- +-- UPDATE `notice` n +-- INNER JOIN ( +-- SELECT r.account, r.sch_id +-- FROM sys_user_role_rel r +-- WHERE r.is_main = 1 +-- AND r.sch_id IS NOT NULL +-- ) t ON n.publisher_account = t.account +-- SET n.sch_id = t.sch_id +-- WHERE n.sch_id IS NULL +-- AND n.publisher_account IS NOT NULL +-- AND n.publisher_account != ''; + + +-- ============================================================ +-- Part 2:从 notice.receive_list 展开到 notice_receiver 关联表 +-- ============================================================ + +-- 说明: +-- - notice.receive_list 存储格式有两种: +-- 1) "ALL"(全员通知)—— 不写明细行,跳过 +-- 2) JSON 数组 ["account1","account2",...] 或逗号分隔串 +-- - 本脚本使用 MySQL JSON 函数解析(MySQL 5.7+ 支持 JSON_EXTRACT) +-- - 对于非标准 JSON 格式(逗号分隔串),使用循环逐条插入 +-- - 幂等:每次插入前先删除该通知的旧接收人记录 + +-- 注意:MySQL 5.7 的 JSON_TABLE 不可用,这里用存储过程逐条解析 +-- 如果 receive_list 是标准 JSON 数组格式,可以直接用 JSON_EXTRACT + +DELIMITER $$ + +-- 存储过程:展开单个通知的接收人列表 +DROP PROCEDURE IF EXISTS `expand_notice_receivers`$$ +CREATE PROCEDURE `expand_notice_receivers`() +BEGIN + DECLARE v_done INT DEFAULT 0; + DECLARE v_notice_id INT; + DECLARE v_receive_list TEXT; + DECLARE cur CURSOR FOR + SELECT id, receive_list + FROM notice + WHERE receive_list IS NOT NULL + AND TRIM(receive_list) != '' + AND UPPER(TRIM(receive_list)) != 'ALL' + AND NOT EXISTS ( + SELECT 1 FROM notice_receiver WHERE notice_id = notice.id LIMIT 1 + ); + DECLARE CONTINUE HANDLER FOR NOT FOUND SET v_done = 1; + + OPEN cur; + read_loop: LOOP + FETCH cur INTO v_notice_id, v_receive_list; + IF v_done = 1 THEN + LEAVE read_loop; + END IF; + + -- 清理旧数据(幂等) + DELETE FROM notice_receiver WHERE notice_id = v_notice_id; + + -- 尝试 JSON 数组解析 + -- receive_list 格式: ["account1","account2"] 或 account1,account2 + SET v_receive_list = TRIM(v_receive_list); + + -- 去除首尾方括号(如有) + IF LEFT(v_receive_list, 1) = '[' AND RIGHT(v_receive_list, 1) = ']' THEN + SET v_receive_list = SUBSTRING(v_receive_list, 2, CHAR_LENGTH(v_receive_list) - 2); + END IF; + + -- 替换引号为空,按逗号分隔 + SET v_receive_list = REPLACE(REPLACE(v_receive_list, '"', ''), '''', ''); + + -- 逐条插入 + -- 简单实现:用 FIND_IN_SET 不适用于变量,改用临时表 + BEGIN + DECLARE v_pos INT DEFAULT 1; + DECLARE v_account VARCHAR(64); + DECLARE v_remaining TEXT; + SET v_remaining = v_receive_list; + + WHILE v_remaining IS NOT NULL AND TRIM(v_remaining) != '' DO + IF LOCATE(',', v_remaining) > 0 THEN + SET v_account = TRIM(SUBSTRING_INDEX(v_remaining, ',', 1)); + SET v_remaining = SUBSTRING(v_remaining, LOCATE(',', v_remaining) + 1); + ELSE + SET v_account = TRIM(v_remaining); + SET v_remaining = NULL; + END IF; + + IF v_account != '' AND v_account IS NOT NULL THEN + INSERT INTO notice_receiver (notice_id, account, receive_type, create_time) + VALUES (v_notice_id, v_account, 1, NOW()); + END IF; + END WHILE; + END; + END LOOP; + + CLOSE cur; +END$$ + +DELIMITER ; + +-- 执行存储过程 +CALL expand_notice_receivers(); + +-- 清理临时存储过程 +DROP PROCEDURE IF EXISTS `expand_notice_receivers`; + +-- ============================================================ +-- 回填结果验证查询(执行后手动检查) +-- ============================================================ + +-- 检查1:sch_id 回填覆盖率 +-- SELECT +-- COUNT(*) AS total, +-- SUM(CASE WHEN sch_id IS NOT NULL THEN 1 ELSE 0 END) AS sch_id_filled, +-- SUM(CASE WHEN sch_id IS NULL THEN 1 ELSE 0 END) AS sch_id_null +-- FROM notice; + +-- 检查2:notice_receiver 展开行数 +-- SELECT COUNT(*) FROM notice_receiver; + +-- 检查3:未展开的通知(receive_list 非空非 ALL 但 receiver 表无记录) +-- SELECT n.id, n.receive_list +-- FROM notice n +-- LEFT JOIN notice_receiver r ON r.notice_id = n.id +-- WHERE n.receive_list IS NOT NULL +-- AND TRIM(n.receive_list) != '' +-- AND UPPER(TRIM(n.receive_list)) != 'ALL' +-- AND r.id IS NULL; diff --git a/src/main/resources/sql/notice_receiver_create.sql b/src/main/resources/sql/notice_receiver_create.sql new file mode 100644 index 0000000..780bfff --- /dev/null +++ b/src/main/resources/sql/notice_receiver_create.sql @@ -0,0 +1,24 @@ +-- ============================================================ +-- notice_receiver 建表(替代 notice.receive_list JSON 字符串存储) +-- 决策 D2-B:拆关联表,支撑阅读情况/催收/统计 SQL 化 +-- +-- 对应实体:com.yunhai.notice.entity.NoticeReceiver +-- 索引:idx_notice_id(notice_id) + idx_account(account) +-- +-- 执行方式:由 DBA 手动在对应环境执行,AI 不代跑 +-- MySQL 5.7+ 兼容 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `notice_receiver` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '自增主键', + `notice_id` INT NOT NULL COMMENT '通知ID(关联 notice.id)', + `account` VARCHAR(64) NOT NULL COMMENT '接收人登录账号', + `receive_type` INT NOT NULL DEFAULT 1 COMMENT '接收类型:1 接收 / 2 抄送', + `create_by` VARCHAR(64) NULL COMMENT '创建人登录账号(审计填充)', + `create_time` DATETIME NULL COMMENT '创建时间(审计填充)', + `update_by` VARCHAR(64) NULL COMMENT '更新人登录账号(审计填充)', + `update_time` DATETIME NULL COMMENT '更新时间(审计填充)', + PRIMARY KEY (`id`), + KEY `idx_notice_id` (`notice_id`), + KEY `idx_account` (`account`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知接收人关联表';