diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index e62b2d9..aad2a62 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -73,8 +73,8 @@ jobs: set -e TAG=$(echo "$GITHUB_SHA" | cut -c1-8) echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u "${{ secrets.NEXUS_USER }}" --password-stdin - docker build -t "nexus:8083/docker-hosted/notice-service:${TAG}" -f docker/Dockerfile . - docker push "nexus:8083/docker-hosted/notice-service:${TAG}" + docker build -t "nexus:8083/docker-hosted/campusnotice:${TAG}" -f docker/Dockerfile . + docker push "nexus:8083/docker-hosted/campusnotice:${TAG}" echo "TAG=${TAG}" >> "$GITHUB_ENV" # 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual) @@ -83,4 +83,4 @@ jobs: curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \ -H "Content-Type: application/json" \ -H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}" \ - -d "{\"app\":\"notice\",\"tag\":\"${TAG}\",\"sha\":\"${GITHUB_SHA}\",\"message\":\"${COMMIT_MSG}\"}" + -d "{\"app\":\"campusnotice\",\"tag\":\"${TAG}\",\"sha\":\"${GITHUB_SHA}\",\"message\":\"${COMMIT_MSG}\"}" diff --git a/docker/application-prod.yml b/docker/application-prod.yml index b0142bf..c322ccb 100644 --- a/docker/application-prod.yml +++ b/docker/application-prod.yml @@ -1,11 +1,11 @@ -# Notice 生产配置(线上以 nacos 的 notice.yaml 为准,本文件是 nacos 不可达时的兜底) -# 真正上线请在 Nacos 新建 dataId=notice.yaml、group=NOTICE 的配置覆盖以下项 +# CampusNotice 生产配置(线上以 nacos 的 campusnotice.yaml 为准,本文件是 nacos 不可达时的兜底) +# 真正上线请在 Nacos 新建 dataId=campusnotice.yaml、group=OA_APP 的配置覆盖以下项 # (尤其 datasource 的库名/账号、yh.sso.client-host-url)。 server: port: 8070 servlet: - # 对外经 caddy 暴露为 http://base.yunhaixingtong.com/notice - context-path: /notice + # 对外经 caddy 暴露为 http://base.yunhaixingtong.com/campusnotice + context-path: /campusnotice spring: mvc: @@ -35,7 +35,7 @@ spring: yh: sso: server-url-prefix: http://sso.yunhaixingtong.com - client-host-url: http://base.yunhaixingtong.com/notice + client-host-url: http://base.yunhaixingtong.com/campusnotice allow-headers: all allow-origins: all url-pattern: /api/* diff --git a/docker/run-app.sh b/docker/run-app.sh index 1da625f..59e72fa 100644 --- a/docker/run-app.sh +++ b/docker/run-app.sh @@ -1,9 +1,9 @@ #!/bin/sh -# Notice 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml) +# CampusNotice 启动入口:配置统一由 Nacos 配置中心在 bootstrap 阶段下发(见 bootstrap.yml) # 不再需要手动 curl 拉取;若 nacos 不可达,bootstrap 中 fail-fast=false 会自动回退到 # 镜像内烤好的 application-prod.yml 兜底默认。 set -e -echo "[run-app] starting Notice (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..." -exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/notice.jar \ +echo "[run-app] starting CampusNotice (profile=prod, nacos=${NACOS_ADDR:-nacos.yunhaixingtong.com:8848}) ..." +exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/app.jar \ --spring.profiles.active=prod diff --git a/pom.xml b/pom.xml index 8b3a428..c822d1b 100644 --- a/pom.xml +++ b/pom.xml @@ -11,9 +11,9 @@ com.yunhai - notice + campusnotice 0.0.1 - Notice + CampusNotice 通知公告子应用 1.8 @@ -141,7 +141,7 @@ - notice + campusnotice org.apache.maven.plugins @@ -157,7 +157,7 @@ org.springframework.boot spring-boot-maven-plugin - com.yunhai.notice.NoticeApplication + com.yunhai.campusnotice.CampusNoticeApplication diff --git a/src/main/java/com/yunhai/notice/NoticeApplication.java b/src/main/java/com/yunhai/campusnotice/CampusNoticeApplication.java similarity index 59% rename from src/main/java/com/yunhai/notice/NoticeApplication.java rename to src/main/java/com/yunhai/campusnotice/CampusNoticeApplication.java index 52e3e2c..9e02b5e 100644 --- a/src/main/java/com/yunhai/notice/NoticeApplication.java +++ b/src/main/java/com/yunhai/campusnotice/CampusNoticeApplication.java @@ -1,18 +1,20 @@ -package com.yunhai.notice; +package com.yunhai.campusnotice; import com.alibaba.fastjson.parser.ParserConfig; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.transaction.annotation.EnableTransactionManagement; -@SpringBootApplication(scanBasePackages = "com.yunhai.notice") +@SpringBootApplication(scanBasePackages = "com.yunhai.campusnotice") @EnableTransactionManagement -public class NoticeApplication { +@EnableScheduling +public class CampusNoticeApplication { public static void main(String[] args) { // 开启 fastjson 安全模式 ParserConfig.getGlobalInstance().setSafeMode(true); - SpringApplication.run(NoticeApplication.class, args); + SpringApplication.run(CampusNoticeApplication.class, args); } } diff --git a/src/main/java/com/yunhai/notice/common/listener/NoticeAuditListener.java b/src/main/java/com/yunhai/campusnotice/common/listener/NoticeAuditListener.java similarity index 93% rename from src/main/java/com/yunhai/notice/common/listener/NoticeAuditListener.java rename to src/main/java/com/yunhai/campusnotice/common/listener/NoticeAuditListener.java index 0db9527..e45c46b 100644 --- a/src/main/java/com/yunhai/notice/common/listener/NoticeAuditListener.java +++ b/src/main/java/com/yunhai/campusnotice/common/listener/NoticeAuditListener.java @@ -1,6 +1,6 @@ -package com.yunhai.notice.common.listener; +package com.yunhai.campusnotice.common.listener; -import com.yunhai.notice.entity.BaseEntity; +import com.yunhai.campusnotice.entity.BaseEntity; import com.yunhai.platform.auth.LoginUser; import com.yunhai.platform.auth.UserContextHolder; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/yunhai/notice/common/validation/ValidationGroups.java b/src/main/java/com/yunhai/campusnotice/common/validation/ValidationGroups.java similarity index 83% rename from src/main/java/com/yunhai/notice/common/validation/ValidationGroups.java rename to src/main/java/com/yunhai/campusnotice/common/validation/ValidationGroups.java index b1b9cb3..acc3f65 100644 --- a/src/main/java/com/yunhai/notice/common/validation/ValidationGroups.java +++ b/src/main/java/com/yunhai/campusnotice/common/validation/ValidationGroups.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.common.validation; +package com.yunhai.campusnotice.common.validation; /** * JSR-303 分组校验标记接口(对齐 UserBase 规范)。 diff --git a/src/main/java/com/yunhai/notice/config/Config.java b/src/main/java/com/yunhai/campusnotice/config/Config.java similarity index 90% rename from src/main/java/com/yunhai/notice/config/Config.java rename to src/main/java/com/yunhai/campusnotice/config/Config.java index 6375822..b2d0b15 100644 --- a/src/main/java/com/yunhai/notice/config/Config.java +++ b/src/main/java/com/yunhai/campusnotice/config/Config.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.config; +package com.yunhai.campusnotice.config; /** * 全局路径常量。与 UserBase 一致:API 统一挂在 /{platform}/ 之下(platform = api / manage / mobiapi)。 diff --git a/src/main/java/com/yunhai/notice/config/JacksonConfig.java b/src/main/java/com/yunhai/campusnotice/config/JacksonConfig.java similarity index 97% rename from src/main/java/com/yunhai/notice/config/JacksonConfig.java rename to src/main/java/com/yunhai/campusnotice/config/JacksonConfig.java index e3c61bf..27a2dda 100644 --- a/src/main/java/com/yunhai/notice/config/JacksonConfig.java +++ b/src/main/java/com/yunhai/campusnotice/config/JacksonConfig.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.config; +package com.yunhai.campusnotice.config; import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; import org.springframework.context.annotation.Bean; diff --git a/src/main/java/com/yunhai/notice/config/SwaggerConfig.java b/src/main/java/com/yunhai/campusnotice/config/SwaggerConfig.java similarity index 94% rename from src/main/java/com/yunhai/notice/config/SwaggerConfig.java rename to src/main/java/com/yunhai/campusnotice/config/SwaggerConfig.java index d264e0c..c582016 100644 --- a/src/main/java/com/yunhai/notice/config/SwaggerConfig.java +++ b/src/main/java/com/yunhai/campusnotice/config/SwaggerConfig.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.config; +package com.yunhai.campusnotice.config; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; diff --git a/src/main/java/com/yunhai/notice/controller/api/BaseApiController.java b/src/main/java/com/yunhai/campusnotice/controller/api/BaseApiController.java similarity index 83% rename from src/main/java/com/yunhai/notice/controller/api/BaseApiController.java rename to src/main/java/com/yunhai/campusnotice/controller/api/BaseApiController.java index fddbaee..b177e9e 100644 --- a/src/main/java/com/yunhai/notice/controller/api/BaseApiController.java +++ b/src/main/java/com/yunhai/campusnotice/controller/api/BaseApiController.java @@ -1,9 +1,11 @@ -package com.yunhai.notice.controller.api; +package com.yunhai.campusnotice.controller.api; -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.campusnotice.config.Config; +import com.yunhai.campusnotice.model.bsch.SchTreeNodeDto; +import com.yunhai.campusnotice.model.notice.UpFileResultVo; +import com.yunhai.campusnotice.service.IClassService; +import com.yunhai.campusnotice.service.IFileService; +import com.yunhai.campusnotice.service.IUserCenterService; import com.yunhai.platform.auth.LoginUser; import com.yunhai.platform.auth.UserContextHolder; import com.yunhai.platform.utils.model.BoaUser; @@ -32,6 +34,9 @@ public class BaseApiController { @Autowired private IUserCenterService userCenterService; + @Autowired + private IClassService classService; + @Autowired private IFileService fileService; @@ -73,9 +78,16 @@ public class BaseApiController { // ============================ GetSchoolAll ============================ /** - * 获取当前用户学校组及用户(树形结构)。 + * 获取当前用户学校学生树及学生 * 组树组装/教师挂组/递归建树逻辑已下沉 IUserCenterService。 */ + @GetMapping("getStuTree") + public List getStuTree(BoaUser authUserWithDept) { + Integer schId = authUserWithDept.getCurrentRole() != null + ? authUserWithDept.getCurrentRole().getSchId() : null; + return classService.getStuTree(schId); + } + @GetMapping("GetSchoolAll") public List> getSchoolAll(BoaUser authUserWithDept) { Integer schId = authUserWithDept.getCurrentRole() != null diff --git a/src/main/java/com/yunhai/notice/controller/api/ContactsController.java b/src/main/java/com/yunhai/campusnotice/controller/api/ContactsController.java similarity index 95% rename from src/main/java/com/yunhai/notice/controller/api/ContactsController.java rename to src/main/java/com/yunhai/campusnotice/controller/api/ContactsController.java index 568e54f..507f71d 100644 --- a/src/main/java/com/yunhai/notice/controller/api/ContactsController.java +++ b/src/main/java/com/yunhai/campusnotice/controller/api/ContactsController.java @@ -1,7 +1,7 @@ -package com.yunhai.notice.controller.api; +package com.yunhai.campusnotice.controller.api; -import com.yunhai.notice.config.Config; -import com.yunhai.notice.service.IContactsService; +import com.yunhai.campusnotice.config.Config; +import com.yunhai.campusnotice.service.IContactsService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/yunhai/notice/controller/api/DictController.java b/src/main/java/com/yunhai/campusnotice/controller/api/DictController.java similarity index 93% rename from src/main/java/com/yunhai/notice/controller/api/DictController.java rename to src/main/java/com/yunhai/campusnotice/controller/api/DictController.java index a1414b6..27e9ed1 100644 --- a/src/main/java/com/yunhai/notice/controller/api/DictController.java +++ b/src/main/java/com/yunhai/campusnotice/controller/api/DictController.java @@ -1,7 +1,7 @@ -package com.yunhai.notice.controller.api; +package com.yunhai.campusnotice.controller.api; -import com.yunhai.notice.config.Config; -import com.yunhai.notice.service.IDictService; +import com.yunhai.campusnotice.config.Config; +import com.yunhai.campusnotice.service.IDictService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/yunhai/notice/controller/api/FileController.java b/src/main/java/com/yunhai/campusnotice/controller/api/FileController.java similarity index 90% rename from src/main/java/com/yunhai/notice/controller/api/FileController.java rename to src/main/java/com/yunhai/campusnotice/controller/api/FileController.java index 39fb26a..d259efd 100644 --- a/src/main/java/com/yunhai/notice/controller/api/FileController.java +++ b/src/main/java/com/yunhai/campusnotice/controller/api/FileController.java @@ -1,9 +1,9 @@ -package com.yunhai.notice.controller.api; +package com.yunhai.campusnotice.controller.api; -import com.yunhai.notice.config.Config; -import com.yunhai.notice.model.notice.UpFileResultVo; -import com.yunhai.notice.model.notice.UpFileVo; -import com.yunhai.notice.service.IFileService; +import com.yunhai.campusnotice.config.Config; +import com.yunhai.campusnotice.model.notice.UpFileResultVo; +import com.yunhai.campusnotice.model.notice.UpFileVo; +import com.yunhai.campusnotice.service.IFileService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; diff --git a/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java b/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java new file mode 100644 index 0000000..905df77 --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java @@ -0,0 +1,391 @@ +package com.yunhai.campusnotice.controller.api; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.campusnotice.config.Config; +import com.yunhai.campusnotice.common.validation.ValidationGroups; +import com.yunhai.campusnotice.model.BasePageResult; +import com.yunhai.campusnotice.model.notice.*; +import com.yunhai.campusnotice.service.INoticeService; +import com.yunhai.campusnotice.utils.PageUtils; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +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.io.IOException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 通知公告接口(合并 NoticeApiController + 旧 NoticeController)。 + *

路径前缀:{@code Config.API_PATH + "/notice/"}(platform = api / manage / mobiapi)。 + * 方法动词式 PascalCase(对齐 UserBase);写操作一律 POST。 + * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。 + */ +@RestController +@RequestMapping(value = Config.API_PATH + "/notice/") +public class NoticeController { + + @Autowired + private INoticeService noticeService; + + // ============================ 收件箱列表 ============================ + + /** + * 已收通知列表(用户端)。 + */ + @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)); + } + + // ============================ 已发/全部通知列表 ============================ + + /** + * 已发/全部通知列表(管理端)。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)); + } + + // ============================ 查看详情(标读) ============================ + + /** + * 查看通知详情(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); + } + + // ============================ 编辑回显(不标读) ============================ + + /** + * 获取通知详情(编辑/转发用,不标记已读)。 + */ + @GetMapping("GetDetail") + public NoticeDetailVo getDetail(@RequestParam("serid") Integer serid) { + return noticeService.getDetailMap(serid); + } + + // ============================ 新建 ============================ + + /** + * 新建通知(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); + } + + // ============================ 推送范围树(新版发布表单,改造方案 3.1) ============================ + + /** + * 当前学校学员推送范围树:全校根 + 学段→年级→班级(数据源 bschclass), + * 供表单「推送范围」胶囊按钮渲染。节点结构:{scopeType, scopeId, name, children}。 + */ + @GetMapping("ScopeTree") + public List> scopeTree() { + return noticeService.scopeTree(); + } + + // ============================ 推送范围预估 ============================ + + /** + * 按已选范围预估接收人数(学员 = 范围展开去重账号数 + 教师 = receiveList 定向名单数)。 + * scopes 为 JSON 数组:[{"scopeType":3,"scopeId":5,"scopeName":"初一"}] + */ + @GetMapping("ScopePreview") + public ScopePreviewVo scopePreview(@RequestParam(value = "scopes", required = false) String scopes, + @RequestParam(value = "receiveList", required = false) String receiveList) { + return noticeService.scopePreview(parseScopes(scopes), receiveList); + } + + // ============================ 存为草稿 ============================ + + /** + * 存为草稿(status=0):不圈人、不发短信、不落接收人,仅存内容 + 范围快照 + 附件。 + */ + @PostMapping("SaveDraft") + public Integer saveDraft(@RequestBody NoticeRequest request) { + return noticeService.saveDraft(request); + } + + // ============================ 草稿箱列表 ============================ + + /** + * 草稿箱列表(status=0 本人草稿):重要程度 type/typeDesc、定时标注与 timingSendTime。 + */ + @GetMapping("DraftList") + public BasePageResult draftList(NoticeQuery query, + @SortDefault(sort = "updateTime", + direction = Sort.Direction.DESC) Sort sort) { + return noticeService.draftList(query, PageUtils.toPageable(query, sort)); + } + + // ============================ 已发送阅读回执 ============================ + + /** + * 已发送通知阅读回执:按 年级→班级→学生 分组返回(身份快照), + * 学生带已读/未读状态与阅读时间(前端着色),教师接收行归「教师」组。 + */ + @GetMapping("ReadReceipt") + public ReadReceiptVo readReceipt(@RequestParam("serid") Integer serid) { + return noticeService.readReceipt(serid); + } + + // ============================ 审批人记忆回显 ============================ + + /** + * 审批人记忆回显:当前发布人最近一次所选审批人组合(无记忆返回空)。 + */ + @GetMapping("ApproverSuggest") + public ApproverSuggestVo approverSuggest() { + return noticeService.approverSuggest(); + } + + // ============================ 审批人解析(教师树ID↔账号) ============================ + + /** + * 审批人解析:教师树(base/GetSchoolAll 叶子 id=教师用户ID)与登录账号互转。 + * ids=1,2,3 解析账号(选人提交前);accounts=a,b 解析回ID(编辑回显勾树)。 + * 两参数至少传其一;解析失败的项保留原值、缺目标字段,由前端提示。 + */ + @GetMapping("ApproverResolve") + public List approverResolve( + @RequestParam(value = "ids", required = false) String ids, + @RequestParam(value = "accounts", required = false) String accounts) { + if ((ids == null || ids.trim().isEmpty()) && (accounts == null || accounts.trim().isEmpty())) { + throw new BizException(ErrorCode.PARAM_ERROR, "ids 与 accounts 至少传其一"); + } + return noticeService.approverResolve( + parseCsvInts(ids), parseCsvStrings(accounts)); + } + + /** 逗号串 → Integer 列表(忽略空段与非数字) */ + private List parseCsvInts(String csv) { + if (csv == null || csv.trim().isEmpty()) { + return null; + } + List result = new ArrayList<>(); + for (String s : csv.split(",")) { + try { + result.add(Integer.valueOf(s.trim())); + } catch (NumberFormatException ignore) { + } + } + return result; + } + + /** 逗号串 → 字符串列表(忽略空段,兼容中文逗号/分号) */ + private List parseCsvStrings(String csv) { + if (csv == null || csv.trim().isEmpty()) { + return null; + } + return new ArrayList<>(java.util.Arrays.asList(csv.split("[,,;;]"))); + } + + // ============================ 提交审批 ============================ + + /** + * 提交审批(status: 0/3→2):发布人选定审批人(可多选,或签), + * 写 notice_approval 提交行并 upsert 审批人记忆表。 + */ + @PostMapping("Submit") + public void submit(@RequestBody SubmitRequest request) { + noticeService.submit(request.getSerid(), request.getApproverAccounts(), request.getOpinion()); + } + + // ============================ 审批通过 ============================ + + /** + * 审批通过(或签):任一审批人通过即 status→1 并触发真实发布; + * 被他人先审时返回带实际处理人/时间的冲突提示。 + */ + @PostMapping("Approve") + public void approve(@RequestParam("serid") Integer serid, + @RequestParam(value = "opinion", required = false) String opinion) { + noticeService.approve(serid, opinion); + } + + // ============================ 审批驳回 ============================ + + /** + * 审批驳回(或签):任一审批人驳回即 status→3,意见必填;发布人可改后重新提交。 + */ + @PostMapping("Reject") + public void reject(@RequestParam("serid") Integer serid, + @RequestParam("opinion") String opinion) { + noticeService.reject(serid, opinion); + } + + // ============================ 审批列表(待审/已审合一) ============================ + + /** + * 审批列表:按审批人快照含当前账号过滤(schId 隔离)。 + * auditState=pending(默认) 待审 / reviewed 已审(含或签下他人已处理,带实际处理人)。 + */ + @GetMapping("ApprovalList") + public BasePageResult approvalList( + @RequestParam(value = "auditState", defaultValue = "pending") String auditState, + NoticeQuery query, + @SortDefault(sort = "updateTime", direction = Sort.Direction.DESC) Sort sort) { + return noticeService.approvalList(auditState, query, PageUtils.toPageable(query, sort)); + } + + /** 解析 scopes JSON 参数为范围列表(空/非法返回 null = 无范围) */ + private List parseScopes(String scopes) { + if (scopes == null || scopes.trim().isEmpty()) { + return null; + } + try { + return new ObjectMapper().readValue(scopes, new TypeReference>() { + }); + } catch (IOException e) { + throw new BizException(ErrorCode.PARAM_ERROR, "scopes 参数格式错误,应为 JSON 数组:[{\"scopeType\":3,\"scopeId\":5}]"); + } + } +} diff --git a/src/main/java/com/yunhai/notice/controller/api/SmsController.java b/src/main/java/com/yunhai/campusnotice/controller/api/SmsController.java similarity index 94% rename from src/main/java/com/yunhai/notice/controller/api/SmsController.java rename to src/main/java/com/yunhai/campusnotice/controller/api/SmsController.java index 9487762..f58015a 100644 --- a/src/main/java/com/yunhai/notice/controller/api/SmsController.java +++ b/src/main/java/com/yunhai/campusnotice/controller/api/SmsController.java @@ -1,7 +1,7 @@ -package com.yunhai.notice.controller.api; +package com.yunhai.campusnotice.controller.api; -import com.yunhai.notice.config.Config; -import com.yunhai.notice.service.ISmsService; +import com.yunhai.campusnotice.config.Config; +import com.yunhai.campusnotice.service.ISmsService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/yunhai/notice/controller/api/TemplateController.java b/src/main/java/com/yunhai/campusnotice/controller/api/TemplateController.java similarity index 94% rename from src/main/java/com/yunhai/notice/controller/api/TemplateController.java rename to src/main/java/com/yunhai/campusnotice/controller/api/TemplateController.java index 95df0bb..d512ae1 100644 --- a/src/main/java/com/yunhai/notice/controller/api/TemplateController.java +++ b/src/main/java/com/yunhai/campusnotice/controller/api/TemplateController.java @@ -1,7 +1,7 @@ -package com.yunhai.notice.controller.api; +package com.yunhai.campusnotice.controller.api; -import com.yunhai.notice.config.Config; -import com.yunhai.notice.service.ITemplateService; +import com.yunhai.campusnotice.config.Config; +import com.yunhai.campusnotice.service.ITemplateService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/yunhai/notice/entity/BaseEntity.java b/src/main/java/com/yunhai/campusnotice/entity/BaseEntity.java similarity index 90% rename from src/main/java/com/yunhai/notice/entity/BaseEntity.java rename to src/main/java/com/yunhai/campusnotice/entity/BaseEntity.java index 3583dcc..1daf5a9 100644 --- a/src/main/java/com/yunhai/notice/entity/BaseEntity.java +++ b/src/main/java/com/yunhai/campusnotice/entity/BaseEntity.java @@ -1,6 +1,6 @@ -package com.yunhai.notice.entity; +package com.yunhai.campusnotice.entity; -import com.yunhai.notice.common.listener.NoticeAuditListener; +import com.yunhai.campusnotice.common.listener.NoticeAuditListener; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/com/yunhai/notice/entity/ContactsGroup.java b/src/main/java/com/yunhai/campusnotice/entity/ContactsGroup.java similarity index 94% rename from src/main/java/com/yunhai/notice/entity/ContactsGroup.java rename to src/main/java/com/yunhai/campusnotice/entity/ContactsGroup.java index 87e983e..c71e63d 100644 --- a/src/main/java/com/yunhai/notice/entity/ContactsGroup.java +++ b/src/main/java/com/yunhai/campusnotice/entity/ContactsGroup.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.entity; +package com.yunhai.campusnotice.entity; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/src/main/java/com/yunhai/notice/entity/ContactsGroupUser.java b/src/main/java/com/yunhai/campusnotice/entity/ContactsGroupUser.java similarity index 96% rename from src/main/java/com/yunhai/notice/entity/ContactsGroupUser.java rename to src/main/java/com/yunhai/campusnotice/entity/ContactsGroupUser.java index 841c221..b87dee9 100644 --- a/src/main/java/com/yunhai/notice/entity/ContactsGroupUser.java +++ b/src/main/java/com/yunhai/campusnotice/entity/ContactsGroupUser.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.entity; +package com.yunhai.campusnotice.entity; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/src/main/java/com/yunhai/notice/entity/Notice.java b/src/main/java/com/yunhai/campusnotice/entity/Notice.java similarity index 69% rename from src/main/java/com/yunhai/notice/entity/Notice.java rename to src/main/java/com/yunhai/campusnotice/entity/Notice.java index bc1bebc..9026899 100644 --- a/src/main/java/com/yunhai/notice/entity/Notice.java +++ b/src/main/java/com/yunhai/campusnotice/entity/Notice.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.entity; +package com.yunhai.campusnotice.entity; import lombok.Data; import lombok.EqualsAndHashCode; @@ -10,7 +10,7 @@ import javax.persistence.Table; import java.time.LocalDateTime; /** - * 通知公告主表。对应原 .NET Notify_Message(已剔除短信/模板/门户等无关字段)。 + * 校园公告主表。对应原 .NET Notify_Message(已剔除短信/模板/门户等无关字段)。 *

* 阅读状态不落主表:采用「懒标记」模型——读者首次查看时写入 {@link NoticeRead}, * 不存在阅读记录即视为未读,从而无需在发通知时枚举全校用户预插接收行。 @@ -41,7 +41,7 @@ public class Notice extends BaseEntity { private String content; /** - * 通知类型:1 普通 / 2 重要(见 {@link com.yunhai.notice.enums.NoticeTypeEnum}) + * 通知类型:1 普通 / 2 重要(见 {@link com.yunhai.campusnotice.enums.NoticeTypeEnum}) */ @Column(name = "type", nullable = false) private Integer type = 1; @@ -59,22 +59,23 @@ public class Notice extends BaseEntity { private String publisherName; /** - * 发布部门ID(原 DeptID) + * 所属分类ID(原 DeptID) */ @Column(name = "dept_id", length = 64) private String deptId; /** - * 发布部门名称 + * 所属分类名称 */ @Column(name = "dept_name", length = 128) private String deptName; /** - * 状态:1 已发布(无草稿态,见 {@link com.yunhai.notice.enums.NoticeStatusEnum}) + * 状态机值见 {@link com.yunhai.campusnotice.enums.NoticeStatusEnum}: + * 0 草稿 / 1 已发布 / 2 待审批 / 3 已驳回。 */ @Column(name = "status", nullable = false) - private Integer status = 1; + private Integer status = 0; /** * 是否置顶:0 否 / 1 是(原 topmost) @@ -145,4 +146,29 @@ public class Notice extends BaseEntity { */ @Column(name = "sms_flag") private Integer smsFlag = 0; + + // ==================== 新版发布表单扩展字段(改造方案 2.1) ==================== + + /** + * 是否需要学员阅读回执:0 否 / 1 是。 + * =1 时管理端展示已读/未读统计与「催收未读」入口,学员端展示回执提示; + * =0 时阅读仍照常记录(notice_read),但不出催收与回执提示(改动面最小)。 + */ + @Column(name = "receipt_flag") + private Integer receiptFlag = 0; + + /** + * 审批人快照(JSON 账号数组,如 ["account1","account2"]):提交审批时由发布人选定(可多选)。 + * ApprovalList 据此过滤「待我审批」;或签任一处理后状态即离开待审批,快照留作历史追溯。 + * 驳回重提并更换审批人后,该快照被新一轮提交覆盖(已知边界,见改造方案 4.4)。 + */ + @Column(name = "approver_accounts", columnDefinition = "LONGTEXT") + private String approverAccounts; + + /** + * 定时发送已真正发布标记:到点调度后回写为 1,避免重复触发(改造方案 4.2)。 + * 立即发布的通知在真实发布时一并置 1。 + */ + @Column(name = "timing_published") + private Integer timingPublished = 0; } diff --git a/src/main/java/com/yunhai/notice/entity/NoticeRead.java b/src/main/java/com/yunhai/campusnotice/entity/NoticeRead.java similarity index 66% rename from src/main/java/com/yunhai/notice/entity/NoticeRead.java rename to src/main/java/com/yunhai/campusnotice/entity/NoticeRead.java index 754b892..81ad83e 100644 --- a/src/main/java/com/yunhai/notice/entity/NoticeRead.java +++ b/src/main/java/com/yunhai/campusnotice/entity/NoticeRead.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.entity; +package com.yunhai.campusnotice.entity; import lombok.Data; import lombok.EqualsAndHashCode; @@ -46,4 +46,26 @@ public class NoticeRead extends BaseEntity { */ @Column(name = "read_time") private LocalDateTime readTime; + + // ==================== 阅读身份快照(改造方案 2.3,学员阅读时带出) ==================== + + /** 学校ID(取通知 sch_id) */ + @Column(name = "sch_id") + private Integer schId; + + /** 年级ID快照 */ + @Column(name = "grade_id") + private Integer gradeId; + + /** 年级名称快照 */ + @Column(name = "grade_name", length = 128) + private String gradeName; + + /** 班级ID快照 */ + @Column(name = "class_id") + private Integer classId; + + /** 班级名称快照 */ + @Column(name = "class_name", length = 128) + private String className; } diff --git a/src/main/java/com/yunhai/campusnotice/entity/NoticeReceiver.java b/src/main/java/com/yunhai/campusnotice/entity/NoticeReceiver.java new file mode 100644 index 0000000..225c8de --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/entity/NoticeReceiver.java @@ -0,0 +1,67 @@ +package com.yunhai.campusnotice.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="ALL")不写明细行,读取时按 receive_list="ALL" 标识走旧逻辑兜底。 + * 学员圈人(新版发布表单):推送范围按 bschclass 展开为学员账号集合批量写入, + * 并携带年级/班级身份快照,供学员端可见性过滤、回执分组(年级→班级→学生)、催收取数。 + */ +@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 { + + /** 接收类型:1 教师(现状语义,存量数据全为 1) / 2 学员(推送范围解析快照) */ + public static final int TYPE_TEACHER = 1; + public static final int TYPE_STUDENT = 2; + + /** 通知ID(关联 notice.id) */ + @Column(name = "notice_id", nullable = false) + private Integer noticeId; + + /** 接收人登录账号(学员=SchStudent.account) */ + @Column(name = "account", nullable = false, length = 64) + private String account; + + /** 接收类型:1 教师 / 2 学员(原「2 抄送」从未写入过数据,语义重定义,存量无需迁移) */ + @Column(name = "receive_type", nullable = false) + private Integer receiveType = TYPE_TEACHER; + + // ==================== 学员身份快照(发布时从 bschclass 带出,仅 receive_type=2 使用) ==================== + + /** 学员姓名快照 */ + @Column(name = "stu_name", length = 64) + private String stuName; + + /** 年级ID快照 */ + @Column(name = "grade_id") + private Integer gradeId; + + /** 年级名称快照(如「初二年级」) */ + @Column(name = "grade_name", length = 128) + private String gradeName; + + /** 班级ID快照(sch_class.class_id) */ + @Column(name = "class_id") + private Integer classId; + + /** 班级名称快照 */ + @Column(name = "class_name", length = 128) + private String className; +} diff --git a/src/main/java/com/yunhai/notice/entity/NoticeTemplate.java b/src/main/java/com/yunhai/campusnotice/entity/NoticeTemplate.java similarity index 95% rename from src/main/java/com/yunhai/notice/entity/NoticeTemplate.java rename to src/main/java/com/yunhai/campusnotice/entity/NoticeTemplate.java index 1109854..5ed9fba 100644 --- a/src/main/java/com/yunhai/notice/entity/NoticeTemplate.java +++ b/src/main/java/com/yunhai/campusnotice/entity/NoticeTemplate.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.entity; +package com.yunhai.campusnotice.entity; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/src/main/java/com/yunhai/notice/entity/SmsModule.java b/src/main/java/com/yunhai/campusnotice/entity/SmsModule.java similarity index 94% rename from src/main/java/com/yunhai/notice/entity/SmsModule.java rename to src/main/java/com/yunhai/campusnotice/entity/SmsModule.java index f83f5e0..a365b0c 100644 --- a/src/main/java/com/yunhai/notice/entity/SmsModule.java +++ b/src/main/java/com/yunhai/campusnotice/entity/SmsModule.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.entity; +package com.yunhai.campusnotice.entity; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/src/main/java/com/yunhai/notice/entity/SmsSchoolConfig.java b/src/main/java/com/yunhai/campusnotice/entity/SmsSchoolConfig.java similarity index 95% rename from src/main/java/com/yunhai/notice/entity/SmsSchoolConfig.java rename to src/main/java/com/yunhai/campusnotice/entity/SmsSchoolConfig.java index 3379825..30dbd56 100644 --- a/src/main/java/com/yunhai/notice/entity/SmsSchoolConfig.java +++ b/src/main/java/com/yunhai/campusnotice/entity/SmsSchoolConfig.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.entity; +package com.yunhai.campusnotice.entity; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/src/main/java/com/yunhai/notice/entity/SysDict.java b/src/main/java/com/yunhai/campusnotice/entity/SysDict.java similarity index 91% rename from src/main/java/com/yunhai/notice/entity/SysDict.java rename to src/main/java/com/yunhai/campusnotice/entity/SysDict.java index 59cff97..681acaa 100644 --- a/src/main/java/com/yunhai/notice/entity/SysDict.java +++ b/src/main/java/com/yunhai/campusnotice/entity/SysDict.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.entity; +package com.yunhai.campusnotice.entity; import lombok.Data; import lombok.EqualsAndHashCode; @@ -18,7 +18,7 @@ import javax.persistence.Table; @EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) public class SysDict extends BaseEntity { - /** 字典类型(如 "dept"=发文部门) */ + /** 字典类型(如 "dept"=所属分类) */ @Column(name = "dict_type", length = 50) private String dictType; diff --git a/src/main/java/com/yunhai/notice/entity/TemplateType.java b/src/main/java/com/yunhai/campusnotice/entity/TemplateType.java similarity index 94% rename from src/main/java/com/yunhai/notice/entity/TemplateType.java rename to src/main/java/com/yunhai/campusnotice/entity/TemplateType.java index f5d386e..2384267 100644 --- a/src/main/java/com/yunhai/notice/entity/TemplateType.java +++ b/src/main/java/com/yunhai/campusnotice/entity/TemplateType.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.entity; +package com.yunhai.campusnotice.entity; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/src/main/java/com/yunhai/notice/entity/UpFile.java b/src/main/java/com/yunhai/campusnotice/entity/UpFile.java similarity index 97% rename from src/main/java/com/yunhai/notice/entity/UpFile.java rename to src/main/java/com/yunhai/campusnotice/entity/UpFile.java index 464af23..4db89c2 100644 --- a/src/main/java/com/yunhai/notice/entity/UpFile.java +++ b/src/main/java/com/yunhai/campusnotice/entity/UpFile.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.entity; +package com.yunhai.campusnotice.entity; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/src/main/java/com/yunhai/campusnotice/enums/NoticeStatusEnum.java b/src/main/java/com/yunhai/campusnotice/enums/NoticeStatusEnum.java new file mode 100644 index 0000000..1089c9b --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/enums/NoticeStatusEnum.java @@ -0,0 +1,44 @@ +package com.yunhai.campusnotice.enums; + +import lombok.Getter; + +/** + * 通知状态机(新版发布表单:直接发布 / 提交审批 / 存为草稿 / 定时发送)。 + *

+ * 0 DRAFT     草稿(存为草稿)
+ * 1 PUBLISHED 已发布(直接发布 / 审批通过 / 定时到点)
+ * 2 PENDING   待审批(提交校办审批)
+ * 3 REJECTED  已驳回(可改后重新提交)
+ * 
+ * 兼容性:.NET 原系统「已发布=2」迁移时已归一化为 1,存量数据无 0/2/3; + * 实体历史注释「2 定时发送」从未真实写入(定时靠 timingSendTime 字段表达)。 + * {@link #of(Integer)} 未知值回落 PUBLISHED 的兜底保留。 + */ +@Getter +public enum NoticeStatusEnum { + + DRAFT(0, "草稿"), + PUBLISHED(1, "已发布"), + PENDING(2, "待审批"), + REJECTED(3, "已驳回"); + + private final int code; + private final String desc; + + NoticeStatusEnum(int code, String desc) { + this.code = code; + this.desc = desc; + } + + public static NoticeStatusEnum of(Integer code) { + if (code == null) { + return PUBLISHED; + } + for (NoticeStatusEnum e : values()) { + if (e.code == code) { + return e; + } + } + return PUBLISHED; + } +} diff --git a/src/main/java/com/yunhai/notice/enums/NoticeTypeEnum.java b/src/main/java/com/yunhai/campusnotice/enums/NoticeTypeEnum.java similarity index 85% rename from src/main/java/com/yunhai/notice/enums/NoticeTypeEnum.java rename to src/main/java/com/yunhai/campusnotice/enums/NoticeTypeEnum.java index 587100d..57deb89 100644 --- a/src/main/java/com/yunhai/notice/enums/NoticeTypeEnum.java +++ b/src/main/java/com/yunhai/campusnotice/enums/NoticeTypeEnum.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.enums; +package com.yunhai.campusnotice.enums; import lombok.Getter; @@ -8,8 +8,8 @@ import lombok.Getter; @Getter public enum NoticeTypeEnum { - NORMAL(1, "普通通知"), - IMPORTANT(2, "重要通知"); + NORMAL(1, "普通公告"), + IMPORTANT(2, "重要公告"); private final int code; private final String desc; diff --git a/src/main/java/com/yunhai/notice/model/BasePageQuery.java b/src/main/java/com/yunhai/campusnotice/model/BasePageQuery.java similarity index 86% rename from src/main/java/com/yunhai/notice/model/BasePageQuery.java rename to src/main/java/com/yunhai/campusnotice/model/BasePageQuery.java index eb7b5a0..000eb69 100644 --- a/src/main/java/com/yunhai/notice/model/BasePageQuery.java +++ b/src/main/java/com/yunhai/campusnotice/model/BasePageQuery.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.model; +package com.yunhai.campusnotice.model; import lombok.Data; diff --git a/src/main/java/com/yunhai/notice/model/BasePageResult.java b/src/main/java/com/yunhai/campusnotice/model/BasePageResult.java similarity index 95% rename from src/main/java/com/yunhai/notice/model/BasePageResult.java rename to src/main/java/com/yunhai/campusnotice/model/BasePageResult.java index 660fccd..c27ad15 100644 --- a/src/main/java/com/yunhai/notice/model/BasePageResult.java +++ b/src/main/java/com/yunhai/campusnotice/model/BasePageResult.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.model; +package com.yunhai.campusnotice.model; import lombok.Data; diff --git a/src/main/java/com/yunhai/notice/model/SchTeaList.java b/src/main/java/com/yunhai/campusnotice/model/SchTeaList.java similarity index 98% rename from src/main/java/com/yunhai/notice/model/SchTeaList.java rename to src/main/java/com/yunhai/campusnotice/model/SchTeaList.java index 43b086d..916b705 100644 --- a/src/main/java/com/yunhai/notice/model/SchTeaList.java +++ b/src/main/java/com/yunhai/campusnotice/model/SchTeaList.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.model; +package com.yunhai.campusnotice.model; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/yunhai/notice/model/notice/InitializeRedVo.java b/src/main/java/com/yunhai/campusnotice/model/notice/InitializeRedVo.java similarity index 89% rename from src/main/java/com/yunhai/notice/model/notice/InitializeRedVo.java rename to src/main/java/com/yunhai/campusnotice/model/notice/InitializeRedVo.java index acbd41f..fbef6ee 100644 --- a/src/main/java/com/yunhai/notice/model/notice/InitializeRedVo.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/InitializeRedVo.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.model.notice; +package com.yunhai.campusnotice.model.notice; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticeDetailVo.java b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeDetailVo.java similarity index 51% rename from src/main/java/com/yunhai/notice/model/notice/NoticeDetailVo.java rename to src/main/java/com/yunhai/campusnotice/model/notice/NoticeDetailVo.java index 3a9587f..8eb1e70 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticeDetailVo.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeDetailVo.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.model.notice; +package com.yunhai.campusnotice.model.notice; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; @@ -55,6 +55,48 @@ public class NoticeDetailVo { @JsonProperty("tlist") private Object tlist; + // ==================== 新版发布表单扩展(改造方案 3.2 / 4.6) ==================== + + /** 重要程度:1 普通 / 2 重要(编辑回显) */ + @JsonProperty("type") + private Integer type; + + /** 是否置顶:0 否 / 1 是(编辑回显) */ + @JsonProperty("topmost") + private Integer topmost; + + /** 短信提醒标记:0 否 / 1 是(编辑回显) */ + @JsonProperty("smsFlag") + private Integer smsFlag; + + /** 状态:0 草稿 / 1 已发布 / 2 待审批 / 3 已驳回 */ + @JsonProperty("status") + private Integer status; + + /** 状态描述 */ + @JsonProperty("statusDesc") + private String statusDesc; + + /** 是否需要学员阅读回执:0 否 / 1 是 */ + @JsonProperty("receiptFlag") + private Integer receiptFlag; + + /** 推送范围快照(编辑回显) */ + @JsonProperty("pushScopes") + private List pushScopes; + + /** 审批轨迹(管理端详情展示;学员端 mobiapi 不返回) */ + @JsonProperty("approvalRecords") + private List approvalRecords; + + /** 当前绑定的审批人账号快照(待审批/已驳回通知的编辑回显,按账号勾选教师树) */ + @JsonProperty("approverAccounts") + private List approverAccounts; + + /** 审批人姓名(与 approverAccounts 一一对应,取自最新提交行 assignees 快照) */ + @JsonProperty("approverNames") + private List approverNames; + /** * 附件项(旧 ChaoSend 数组元素)。 */ diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticePageResult.java b/src/main/java/com/yunhai/campusnotice/model/notice/NoticePageResult.java similarity index 88% rename from src/main/java/com/yunhai/notice/model/notice/NoticePageResult.java rename to src/main/java/com/yunhai/campusnotice/model/notice/NoticePageResult.java index 48d66ad..95314a7 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticePageResult.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/NoticePageResult.java @@ -1,6 +1,6 @@ -package com.yunhai.notice.model.notice; +package com.yunhai.campusnotice.model.notice; -import com.yunhai.notice.model.BasePageResult; +import com.yunhai.campusnotice.model.BasePageResult; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticeQuery.java b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeQuery.java similarity index 93% rename from src/main/java/com/yunhai/notice/model/notice/NoticeQuery.java rename to src/main/java/com/yunhai/campusnotice/model/notice/NoticeQuery.java index 91b4122..d31692b 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticeQuery.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeQuery.java @@ -1,6 +1,6 @@ -package com.yunhai.notice.model.notice; +package com.yunhai.campusnotice.model.notice; -import com.yunhai.notice.model.BasePageQuery; +import com.yunhai.campusnotice.model.BasePageQuery; import lombok.Data; import lombok.EqualsAndHashCode; import org.springframework.format.annotation.DateTimeFormat; diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticeRequest.java b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeRequest.java similarity index 69% rename from src/main/java/com/yunhai/notice/model/notice/NoticeRequest.java rename to src/main/java/com/yunhai/campusnotice/model/notice/NoticeRequest.java index 2c58f89..88f1038 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticeRequest.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeRequest.java @@ -1,12 +1,13 @@ -package com.yunhai.notice.model.notice; +package com.yunhai.campusnotice.model.notice; import lombok.Data; import org.springframework.format.annotation.DateTimeFormat; -import com.yunhai.notice.common.validation.ValidationGroups; +import com.yunhai.campusnotice.common.validation.ValidationGroups; import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotNull; import java.time.LocalDateTime; +import java.util.List; /** * 通知发布/编辑入参。发布人由当前登录用户上下文填充,不在此入参中传递。 @@ -50,4 +51,18 @@ public class NoticeRequest { /** 转发源通知ID(非空时表示转发创建,对应旧 REST 壳 forward/{sourceId}) */ private Integer sourceId; + + // ==================== 新版发布表单扩展(改造方案 3.2) ==================== + + /** 推送范围列表(与 receiveList 可并存:pushScopes 解析学员,receiveList 作为教师定向名单) */ + private List pushScopes; + + /** 是否需要学员阅读回执:0 否 / 1 是 */ + private Integer receiptFlag = 0; + + /** 发布方式:direct 直接发布(默认,兼容旧链路)/ draft 存为草稿 / approve 提交审批(需携带 approverAccounts) */ + private String publishType; + + /** 提交审批时所选审批人账号(publishType=approve 时必填;独立 Submit 接口走 SubmitRequest) */ + private List approverAccounts; } diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticeStatisticsVo.java b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeStatisticsVo.java similarity index 96% rename from src/main/java/com/yunhai/notice/model/notice/NoticeStatisticsVo.java rename to src/main/java/com/yunhai/campusnotice/model/notice/NoticeStatisticsVo.java index 067a320..3ac36eb 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticeStatisticsVo.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeStatisticsVo.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.model.notice; +package com.yunhai.campusnotice.model.notice; import lombok.Data; diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticeUserListResult.java b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeUserListResult.java similarity index 95% rename from src/main/java/com/yunhai/notice/model/notice/NoticeUserListResult.java rename to src/main/java/com/yunhai/campusnotice/model/notice/NoticeUserListResult.java index 0c049a4..02edeec 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticeUserListResult.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeUserListResult.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.model.notice; +package com.yunhai.campusnotice.model.notice; import lombok.Data; diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticeVo.java b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeVo.java similarity index 75% rename from src/main/java/com/yunhai/notice/model/notice/NoticeVo.java rename to src/main/java/com/yunhai/campusnotice/model/notice/NoticeVo.java index dd74a84..08304ac 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticeVo.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeVo.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.model.notice; +package com.yunhai.campusnotice.model.notice; import lombok.Data; @@ -47,6 +47,17 @@ public class NoticeVo { /** 接收人列表(JSON) */ private String receiveList; + // ==================== 新版发布表单扩展(改造方案 3.2) ==================== + + /** 是否需要学员阅读回执:0 否 / 1 是 */ + private Integer receiptFlag; + + /** 定时发送已真正发布标记:0 未触发 / 1 已真实发布(配合 timingSendTime 区分「定时中/已发布」) */ + private Integer timingPublished; + + /** 审批人账号快照(JSON 数组串,发布流程展示用) */ + private String approverAccounts; + /** 附件ID串("1,2,3",列表页下载入口使用;对齐旧 .NET UpFile 字段) */ private String UpFile; diff --git a/src/main/java/com/yunhai/notice/model/notice/UpFileResultVo.java b/src/main/java/com/yunhai/campusnotice/model/notice/UpFileResultVo.java similarity index 92% rename from src/main/java/com/yunhai/notice/model/notice/UpFileResultVo.java rename to src/main/java/com/yunhai/campusnotice/model/notice/UpFileResultVo.java index 878c7f2..12fcd73 100644 --- a/src/main/java/com/yunhai/notice/model/notice/UpFileResultVo.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/UpFileResultVo.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.model.notice; +package com.yunhai.campusnotice.model.notice; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; diff --git a/src/main/java/com/yunhai/notice/model/notice/UpFileVo.java b/src/main/java/com/yunhai/campusnotice/model/notice/UpFileVo.java similarity index 92% rename from src/main/java/com/yunhai/notice/model/notice/UpFileVo.java rename to src/main/java/com/yunhai/campusnotice/model/notice/UpFileVo.java index b2b0c9e..4303a07 100644 --- a/src/main/java/com/yunhai/notice/model/notice/UpFileVo.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/UpFileVo.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.model.notice; +package com.yunhai.campusnotice.model.notice; import lombok.Data; diff --git a/src/main/java/com/yunhai/notice/model/notice/UserModuleVo.java b/src/main/java/com/yunhai/campusnotice/model/notice/UserModuleVo.java similarity index 98% rename from src/main/java/com/yunhai/notice/model/notice/UserModuleVo.java rename to src/main/java/com/yunhai/campusnotice/model/notice/UserModuleVo.java index bc12c21..4d448e4 100644 --- a/src/main/java/com/yunhai/notice/model/notice/UserModuleVo.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/UserModuleVo.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.model.notice; +package com.yunhai.campusnotice.model.notice; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/com/yunhai/notice/repository/ContactsGroupRepository.java b/src/main/java/com/yunhai/campusnotice/repository/ContactsGroupRepository.java similarity index 89% rename from src/main/java/com/yunhai/notice/repository/ContactsGroupRepository.java rename to src/main/java/com/yunhai/campusnotice/repository/ContactsGroupRepository.java index 9dc4f45..e9f963c 100644 --- a/src/main/java/com/yunhai/notice/repository/ContactsGroupRepository.java +++ b/src/main/java/com/yunhai/campusnotice/repository/ContactsGroupRepository.java @@ -1,6 +1,6 @@ -package com.yunhai.notice.repository; +package com.yunhai.campusnotice.repository; -import com.yunhai.notice.entity.ContactsGroup; +import com.yunhai.campusnotice.entity.ContactsGroup; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/yunhai/notice/repository/ContactsGroupUserRepository.java b/src/main/java/com/yunhai/campusnotice/repository/ContactsGroupUserRepository.java similarity index 94% rename from src/main/java/com/yunhai/notice/repository/ContactsGroupUserRepository.java rename to src/main/java/com/yunhai/campusnotice/repository/ContactsGroupUserRepository.java index 1151a25..82061f0 100644 --- a/src/main/java/com/yunhai/notice/repository/ContactsGroupUserRepository.java +++ b/src/main/java/com/yunhai/campusnotice/repository/ContactsGroupUserRepository.java @@ -1,6 +1,6 @@ -package com.yunhai.notice.repository; +package com.yunhai.campusnotice.repository; -import com.yunhai.notice.entity.ContactsGroupUser; +import com.yunhai.campusnotice.entity.ContactsGroupUser; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/yunhai/notice/repository/NoticeReadRepository.java b/src/main/java/com/yunhai/campusnotice/repository/NoticeReadRepository.java similarity index 94% rename from src/main/java/com/yunhai/notice/repository/NoticeReadRepository.java rename to src/main/java/com/yunhai/campusnotice/repository/NoticeReadRepository.java index 56a060d..9e948f0 100644 --- a/src/main/java/com/yunhai/notice/repository/NoticeReadRepository.java +++ b/src/main/java/com/yunhai/campusnotice/repository/NoticeReadRepository.java @@ -1,6 +1,6 @@ -package com.yunhai.notice.repository; +package com.yunhai.campusnotice.repository; -import com.yunhai.notice.entity.NoticeRead; +import com.yunhai.campusnotice.entity.NoticeRead; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; diff --git a/src/main/java/com/yunhai/campusnotice/repository/NoticeReceiverRepository.java b/src/main/java/com/yunhai/campusnotice/repository/NoticeReceiverRepository.java new file mode 100644 index 0000000..cb9c89c --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/repository/NoticeReceiverRepository.java @@ -0,0 +1,48 @@ +package com.yunhai.campusnotice.repository; + +import com.yunhai.campusnotice.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; +import java.util.Optional; + +/** + * 通知接收人关联表仓储。 + */ +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); + + // ==================== 新版发布流程(改造方案 2.3 / 3.2) ==================== + + /** 当前账号命中的通知ID集合(学员端列表可见性过滤:仅对存在范围的通知启用) */ + @Query("select distinct r.noticeId from NoticeReceiver r where r.account = :account") + List findNoticeIdsByAccount(@Param("account") String account); + + /** 某人某通知的接收快照行(markRead 身份快照取数:学员行优先) */ + Optional findFirstByNoticeIdAndAccountOrderByReceiveTypeDescIdDesc( + Integer noticeId, String account); + + /** 按通知ID + 接收类型删除(学员圈人重新展开时清理旧快照) */ + void deleteByNoticeIdAndReceiveType(Integer noticeId, Integer receiveType); + + /** 按通知ID + 接收类型计数(allowCount = 学员数 + 教师数) */ + long countByNoticeIdAndReceiveType(Integer noticeId, Integer receiveType); +} diff --git a/src/main/java/com/yunhai/notice/repository/NoticeRepository.java b/src/main/java/com/yunhai/campusnotice/repository/NoticeRepository.java similarity index 66% rename from src/main/java/com/yunhai/notice/repository/NoticeRepository.java rename to src/main/java/com/yunhai/campusnotice/repository/NoticeRepository.java index 78a64ae..bc4adef 100644 --- a/src/main/java/com/yunhai/notice/repository/NoticeRepository.java +++ b/src/main/java/com/yunhai/campusnotice/repository/NoticeRepository.java @@ -1,8 +1,9 @@ -package com.yunhai.notice.repository; +package com.yunhai.campusnotice.repository; -import com.yunhai.notice.entity.Notice; +import com.yunhai.campusnotice.entity.Notice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -56,6 +57,16 @@ public interface NoticeRepository extends JpaRepository, */ long countBySchId(Integer schId); + /** + * 排除指定状态计数(统计"累计发布"时剔除草稿)。 + */ + long countByStatusNot(Integer status); + + /** + * 按学校排除指定状态计数(多租户隔离统计,剔除草稿)。 + */ + long countBySchIdAndStatusNot(Integer schId, Integer status); + /** * 已发布且已到点(定时发送未到点不计入)且属于指定学校的通知数(用户端未读数统计,多租户隔离)。 */ @@ -86,4 +97,40 @@ public interface NoticeRepository extends JpaRepository, @Param("status") Integer status, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + + // ==================== 新版发布流程(改造方案 4.2 / 4.3) ==================== + + /** + * 定时发送待触发列表:已发布、已到点、尚未真正发布(timing_published=0)。 + * TimingPublishJob 每分钟扫描,逐条 CAS 抢占后执行真实发布。 + */ + @Query("select n from Notice n where n.status = 1 " + + "and n.timingSendTime is not null and n.timingSendTime <= :now " + + "and n.timingPublished = 0") + List findDueTimingPublish(@Param("now") LocalDateTime now); + + /** + * 定时发布幂等抢占:仅当 timing_published 仍为 0 时置 1,返回影响行数(0=已被其他线程/实例处理)。 + */ + @Modifying + @Query("update Notice n set n.timingPublished = 1, n.updateTime = :now " + + "where n.id = :id and n.timingPublished = 0") + int casMarkTimingPublished(@Param("id") Integer id, @Param("now") LocalDateTime now); + + /** + * 审批通过 CAS:仅当仍处于待审批(status=2)时置为已发布,返回影响行数。 + * 或签并发场景下先提交者生效,后提交者影响行数为 0,由 Service 转为冲突提示。 + */ + @Modifying + @Query("update Notice n set n.status = 1, n.updateTime = :now " + + "where n.id = :id and n.status = 2") + int casApprove(@Param("id") Integer id, @Param("now") LocalDateTime now); + + /** + * 审批驳回 CAS:仅当仍处于待审批(status=2)时置为已驳回。 + */ + @Modifying + @Query("update Notice n set n.status = 3, n.updateTime = :now " + + "where n.id = :id and n.status = 2") + int casReject(@Param("id") Integer id, @Param("now") LocalDateTime now); } diff --git a/src/main/java/com/yunhai/notice/repository/NoticeTemplateRepository.java b/src/main/java/com/yunhai/campusnotice/repository/NoticeTemplateRepository.java similarity index 86% rename from src/main/java/com/yunhai/notice/repository/NoticeTemplateRepository.java rename to src/main/java/com/yunhai/campusnotice/repository/NoticeTemplateRepository.java index 5d84fb8..b4ceb7c 100644 --- a/src/main/java/com/yunhai/notice/repository/NoticeTemplateRepository.java +++ b/src/main/java/com/yunhai/campusnotice/repository/NoticeTemplateRepository.java @@ -1,6 +1,6 @@ -package com.yunhai.notice.repository; +package com.yunhai.campusnotice.repository; -import com.yunhai.notice.entity.NoticeTemplate; +import com.yunhai.campusnotice.entity.NoticeTemplate; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/yunhai/notice/repository/SmsModuleRepository.java b/src/main/java/com/yunhai/campusnotice/repository/SmsModuleRepository.java similarity index 69% rename from src/main/java/com/yunhai/notice/repository/SmsModuleRepository.java rename to src/main/java/com/yunhai/campusnotice/repository/SmsModuleRepository.java index 414b40a..2042986 100644 --- a/src/main/java/com/yunhai/notice/repository/SmsModuleRepository.java +++ b/src/main/java/com/yunhai/campusnotice/repository/SmsModuleRepository.java @@ -1,6 +1,6 @@ -package com.yunhai.notice.repository; +package com.yunhai.campusnotice.repository; -import com.yunhai.notice.entity.SmsModule; +import com.yunhai.campusnotice.entity.SmsModule; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; diff --git a/src/main/java/com/yunhai/notice/repository/SmsSchoolConfigRepository.java b/src/main/java/com/yunhai/campusnotice/repository/SmsSchoolConfigRepository.java similarity index 90% rename from src/main/java/com/yunhai/notice/repository/SmsSchoolConfigRepository.java rename to src/main/java/com/yunhai/campusnotice/repository/SmsSchoolConfigRepository.java index 608590c..f883e4b 100644 --- a/src/main/java/com/yunhai/notice/repository/SmsSchoolConfigRepository.java +++ b/src/main/java/com/yunhai/campusnotice/repository/SmsSchoolConfigRepository.java @@ -1,6 +1,6 @@ -package com.yunhai.notice.repository; +package com.yunhai.campusnotice.repository; -import com.yunhai.notice.entity.SmsSchoolConfig; +import com.yunhai.campusnotice.entity.SmsSchoolConfig; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/yunhai/notice/repository/SysDictRepository.java b/src/main/java/com/yunhai/campusnotice/repository/SysDictRepository.java similarity index 85% rename from src/main/java/com/yunhai/notice/repository/SysDictRepository.java rename to src/main/java/com/yunhai/campusnotice/repository/SysDictRepository.java index be42e8b..5ceaa3a 100644 --- a/src/main/java/com/yunhai/notice/repository/SysDictRepository.java +++ b/src/main/java/com/yunhai/campusnotice/repository/SysDictRepository.java @@ -1,6 +1,6 @@ -package com.yunhai.notice.repository; +package com.yunhai.campusnotice.repository; -import com.yunhai.notice.entity.SysDict; +import com.yunhai.campusnotice.entity.SysDict; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; diff --git a/src/main/java/com/yunhai/notice/repository/TemplateTypeRepository.java b/src/main/java/com/yunhai/campusnotice/repository/TemplateTypeRepository.java similarity index 83% rename from src/main/java/com/yunhai/notice/repository/TemplateTypeRepository.java rename to src/main/java/com/yunhai/campusnotice/repository/TemplateTypeRepository.java index d8abbd5..9a181bd 100644 --- a/src/main/java/com/yunhai/notice/repository/TemplateTypeRepository.java +++ b/src/main/java/com/yunhai/campusnotice/repository/TemplateTypeRepository.java @@ -1,6 +1,6 @@ -package com.yunhai.notice.repository; +package com.yunhai.campusnotice.repository; -import com.yunhai.notice.entity.TemplateType; +import com.yunhai.campusnotice.entity.TemplateType; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; diff --git a/src/main/java/com/yunhai/notice/repository/UpFileRepository.java b/src/main/java/com/yunhai/campusnotice/repository/UpFileRepository.java similarity index 88% rename from src/main/java/com/yunhai/notice/repository/UpFileRepository.java rename to src/main/java/com/yunhai/campusnotice/repository/UpFileRepository.java index b00d663..d46831c 100644 --- a/src/main/java/com/yunhai/notice/repository/UpFileRepository.java +++ b/src/main/java/com/yunhai/campusnotice/repository/UpFileRepository.java @@ -1,6 +1,6 @@ -package com.yunhai.notice.repository; +package com.yunhai.campusnotice.repository; -import com.yunhai.notice.entity.UpFile; +import com.yunhai.campusnotice.entity.UpFile; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; diff --git a/src/main/java/com/yunhai/campusnotice/service/IClassService.java b/src/main/java/com/yunhai/campusnotice/service/IClassService.java new file mode 100644 index 0000000..24e1a31 --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/service/IClassService.java @@ -0,0 +1,88 @@ +package com.yunhai.campusnotice.service; + +import com.yunhai.campusnotice.model.SchTeaList; +import com.yunhai.campusnotice.model.bsch.SchClassDto; +import com.yunhai.campusnotice.model.bsch.SchGradeDto; +import com.yunhai.campusnotice.model.bsch.SchInfoDto; +import com.yunhai.campusnotice.model.bsch.SchStudentDto; +import com.yunhai.campusnotice.model.bsch.SchSystemDto; +import com.yunhai.campusnotice.model.bsch.SchTreeNodeDto; + +import java.util.List; + +/** + * 班级管理(bschclass)开放接口客户端门面。 + *

基址取配置 {@code yh.open.bschclass}(/oapi/** 无需应用签名,独立 RestTemplate 直连)。 + * 推送范围树、圈人、身份快照统一经本门面取数,不散落 RestTemplate 调用(改造方案 2.0)。 + */ +public interface IClassService { + + /** + * 获取指定学校全部教师(含所属组 GroupIds)。 + * 对应 bschclass 开放接口:GET {@code {bschclass}/oapi/DbClass/GetSchteaList?Group={schId}} + * + * @param schId 学校ID(即用户当前身份的 schId) + */ + List getSchTeaList(int schId); + + /** + * 用户中心学校ID → bschclass 学校信息(SchSerId / 学校名)。 + * 对应:GET {@code {bschclass}/oapi/SchInfo/GetUserSchFirst?UserSchID={schId}} + */ + SchInfoDto getSchFirst(int schId); + + /** + * 学校学段(学制)列表:小学部 / 初中部…。 + * 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenlist?SchSerID=} + */ + List getSchLenList(String schSerId); + + /** + * 学校年级列表(Id=gradeId、Name=gradeNickname、OwnerId=学段id)。 + * 对应:GET {@code {bschclass}/oapi/Schoolen/GetSchLenLevellist?SchSerID=} + */ + List getSchGradeList(String schSerId); + + /** + * 学校全部在读班级(ClassID/ClassName/Levell=gradeId),ScopeTree 一次取数客户端组装。 + * 对应:GET {@code {bschclass}/oapi/VSchClass/GetVClassList?SchSerID=} + */ + List getSchClassList(String schSerId); + + /** + * 按校全部学生(圈人:全校范围展开)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetSchStulist?schserid=} + */ + List getSchStuList(String schSerId); + + /** + * 按年级直查学生(圈人:年级范围免逐班聚合;bschclass 侧补缺接口)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetGradeStulist?SchSerID=&GradeId=} + */ + List getGradeStuList(String schSerId, Integer gradeId); + + /** + * 按学段直查学生(圈人:学部范围一键展开;bschclass 侧补缺接口)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetSubStulist?SchSerID=&SchSystemId=} + */ + List getSubStuList(String schSerId, Integer schSystemId); + + /** + * 按班级批量查学生(圈人:班级范围一次多班)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetClaArrStulist?ClassIDs=1,2,3} + */ + List getClaArrStuList(List classIds); + + /** + * 按学生ID批量查(圈人:指定学员范围)。 + * 对应:GET {@code {bschclass}/oapi/OAStu/GetStuIdslist?stuids=1,2,3} + */ + List getStuIdsList(List stuIds); + + /** + * 学校学生树:学校→学段→年级→班级→学生(单元素列表,根为学校节点)。 + * 节点契约:组节点 type=1、isParent=true、children 非 null;学生叶子 type=2、isParent=false、children=null。 + * 对应:GET {@code {bschclass}/oapi/SchGroup/GetStuTree?SchSerID=} + */ + List getStuTree(Integer schId); +} diff --git a/src/main/java/com/yunhai/notice/service/IContactsService.java b/src/main/java/com/yunhai/campusnotice/service/IContactsService.java similarity index 98% rename from src/main/java/com/yunhai/notice/service/IContactsService.java rename to src/main/java/com/yunhai/campusnotice/service/IContactsService.java index 6ec0b35..95971db 100644 --- a/src/main/java/com/yunhai/notice/service/IContactsService.java +++ b/src/main/java/com/yunhai/campusnotice/service/IContactsService.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.service; +package com.yunhai.campusnotice.service; import java.util.List; import java.util.Map; diff --git a/src/main/java/com/yunhai/notice/service/IDictService.java b/src/main/java/com/yunhai/campusnotice/service/IDictService.java similarity index 97% rename from src/main/java/com/yunhai/notice/service/IDictService.java rename to src/main/java/com/yunhai/campusnotice/service/IDictService.java index 84238d3..c76d2c5 100644 --- a/src/main/java/com/yunhai/notice/service/IDictService.java +++ b/src/main/java/com/yunhai/campusnotice/service/IDictService.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.service; +package com.yunhai.campusnotice.service; import java.util.List; import java.util.Map; diff --git a/src/main/java/com/yunhai/notice/service/IFileService.java b/src/main/java/com/yunhai/campusnotice/service/IFileService.java similarity index 93% rename from src/main/java/com/yunhai/notice/service/IFileService.java rename to src/main/java/com/yunhai/campusnotice/service/IFileService.java index 5e80a4c..c221cd2 100644 --- a/src/main/java/com/yunhai/notice/service/IFileService.java +++ b/src/main/java/com/yunhai/campusnotice/service/IFileService.java @@ -1,8 +1,8 @@ -package com.yunhai.notice.service; +package com.yunhai.campusnotice.service; -import com.yunhai.notice.entity.UpFile; -import com.yunhai.notice.model.notice.UpFileVo; -import com.yunhai.notice.model.notice.UpFileResultVo; +import com.yunhai.campusnotice.entity.UpFile; +import com.yunhai.campusnotice.model.notice.UpFileVo; +import com.yunhai.campusnotice.model.notice.UpFileResultVo; import org.springframework.web.multipart.MultipartFile; import javax.servlet.http.HttpServletResponse; diff --git a/src/main/java/com/yunhai/campusnotice/service/INoticeService.java b/src/main/java/com/yunhai/campusnotice/service/INoticeService.java new file mode 100644 index 0000000..76537d3 --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/service/INoticeService.java @@ -0,0 +1,222 @@ +package com.yunhai.campusnotice.service; + +import com.yunhai.campusnotice.entity.Notice; +import com.yunhai.campusnotice.model.BasePageResult; +import com.yunhai.campusnotice.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; + +/** + * 通知公告业务接口。 + */ +public interface INoticeService { + + /** + * 发通知:以当前登录用户为发布人入库。 + * 按 request.publishType 分流(改造方案 4.1): + * direct/空 = 直接发布(兼容旧 Create 链路;携带未来 timingSendTime 时置 status=1 待调度); + * draft = 存为草稿;approve = 提交审批(request.approverAccounts 选人,或签)。 + * + * @return 新建通知 ID(用于后续回填附件 TopicID 等) + */ + Integer publish(NoticeRequest request); + + /** + * 存为草稿(status=0):仅存内容 + 范围快照 + 附件,不圈人、不发短信、不落接收人。 + */ + Integer saveDraft(NoticeRequest request); + + /** + * 草稿箱列表(status=0,本人草稿):返回重要程度 type/typeDesc、定时标注与 timingSendTime。 + */ + BasePageResult draftList(NoticeQuery query, Pageable pageable); + + /** + * 提交审批(status: 0/3 → 2):发布人选定审批人(可多人,或签), + * 后端逐个校验审批人资格;写 notice_approval 提交行并 upsert 审批人记忆表。 + */ + void submit(Integer serid, List approverAccounts, String opinion); + + /** + * 审批通过(或签):任一审批人通过即 status: 2→1(CAS),并触发真实发布; + * 已被他人先行处理时抛业务冲突提示(带实际处理人与时间)。 + */ + void approve(Integer serid, String opinion); + + /** + * 审批驳回(或签):任一审批人驳回即 status: 2→3(CAS),记录意见;发布人可改后重新提交。 + */ + void reject(Integer serid, String opinion); + + /** + * 审批列表(待审/已审合一):按 schId + 审批人快照含当前账号过滤。 + * + * @param auditState pending(默认)=待审 status=2;reviewed=已审 status∈{1,3}(含或签下他人已处理) + */ + BasePageResult approvalList(String auditState, NoticeQuery query, Pageable pageable); + + /** + * 审批人记忆回显:当前发布人最近一次所选审批人组合,无记忆返回空。 + */ + ApproverSuggestVo approverSuggest(); + + /** + * 审批人解析:教师树节点ID(用户中心用户ID)与登录账号互转。 + * ids 按用户ID解析出账号,accounts 按账号解析回ID;单项解析失败跳过不中断。 + */ + List approverResolve(List ids, List accounts); + + /** + * 编辑通知:草稿/已驳回/已发布(旧链路,无推送范围)可编辑; + * 待审批禁止编辑;新版表单已发布(存在范围快照)禁止编辑(先撤回后重发)。 + */ + void edit(Integer id, NoticeRequest request); + + /** + * 批量删除通知:仅草稿、已驳回、已发布(本人)可删,待审批不可删; + * 级联清理阅读记录、接收人、推送范围、审批记录、附件关联。 + */ + void deleteBatch(List ids); + + /** + * 撤回通知:仅已发布且未撤回的通知可撤回,置 isPulled=1。 + */ + void pull(Integer id); + + /** + * 置顶/取消置顶。 + */ + void setTopmost(Integer id, Integer topmost); + + /** + * 转发通知:基于源通知创建新通知(已发布),parentId 关联源通知。 + */ + Integer forward(Integer sourceId, NoticeRequest request); + + /** + * 用户端列表:仅展示已发布、已到点、且对当前账号可见的通知。 + * 可见性(改造方案 3.2):无推送范围维持「全校广播」现状; + * 存在范围的通知须 notice_receiver 快照命中当前账号(学员 receive_type=2 / 教师 receive_type=1)。 + */ + NoticePageResult userList(NoticeQuery query, Pageable pageable); + + /** + * 管理端列表:按条件查询全部通知(管理员查看全部)。 + */ + BasePageResult adminList(NoticeQuery query, Pageable pageable); + + /** + * 学员推送范围树(学段→年级→班级 + 全校根,数据源 bschclass), + * 供表单「推送范围」胶囊按钮渲染。 + */ + List> scopeTree(); + + /** + * 按已选范围预估接收人数(学员=范围展开去重账号数 + 教师=receiveList 定向名单数)。 + */ + ScopePreviewVo scopePreview(List scopes, String receiveList); + + /** + * 已发送通知阅读回执:按 年级→班级→学生 分组返回(身份快照), + * 学生带已读/未读状态与阅读时间(前端着色),教师接收行归「教师」组。 + */ + ReadReceiptVo readReceipt(Integer serid); + + /** + * 通知详情:返回视图对象,并顺带为当前用户标记已读(打开即视为已读)。 + * 草稿/待审批/已驳回态仅发布人本人或该轮审批人可打开(改造方案 3.2 访问校验补口)。 + */ + NoticeVo detail(Integer id); + + /** + * 编辑回显详情(不标记已读,访问校验同 {@link #detail})。 + */ + NoticeVo getDetail(Integer id); + + /** + * 标记已读:为当前用户写入(幂等)该通知的阅读记录, + * 学员阅读时按接收快照(缺失时 bschclass 实时兜底)写入学校/年级/班级身份快照。 + */ + 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); + + /** + * 阅读情况分组(已读/未读,旧接口保留过渡;新版表单走 {@link #readReceipt})。 + */ + List> getReadDetailGrouped(Integer serid); + + /** + * 催收未读:仅 receiptFlag=1 的通知可用; + * 催收对象优先 notice_receiver 快照(含学员),回退 receiveList JSON(历史数据)。 + */ + void expedite(Integer serid); + + /** + * 定时发送调度(TimingPublishJob 每分钟触发): + * 扫描 status=1 且 timingSendTime<=now 且 timing_published=0 的通知, + * 逐条 CAS 抢占(幂等)后执行真实发布。 + * + * @return 本次真实发布条数 + */ + int timingPublishDue(); + + /** + * 通知统计:总数/已发布、累计阅读人次、当前用户未读、按发布人、按月。 + */ + 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/ISmsService.java b/src/main/java/com/yunhai/campusnotice/service/ISmsService.java similarity index 98% rename from src/main/java/com/yunhai/notice/service/ISmsService.java rename to src/main/java/com/yunhai/campusnotice/service/ISmsService.java index a801a81..3365c71 100644 --- a/src/main/java/com/yunhai/notice/service/ISmsService.java +++ b/src/main/java/com/yunhai/campusnotice/service/ISmsService.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.service; +package com.yunhai.campusnotice.service; import java.util.List; import java.util.Map; diff --git a/src/main/java/com/yunhai/notice/service/ITemplateService.java b/src/main/java/com/yunhai/campusnotice/service/ITemplateService.java similarity index 97% rename from src/main/java/com/yunhai/notice/service/ITemplateService.java rename to src/main/java/com/yunhai/campusnotice/service/ITemplateService.java index d5d7eb7..95c46a2 100644 --- a/src/main/java/com/yunhai/notice/service/ITemplateService.java +++ b/src/main/java/com/yunhai/campusnotice/service/ITemplateService.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.service; +package com.yunhai.campusnotice.service; import java.util.List; import java.util.Map; diff --git a/src/main/java/com/yunhai/notice/service/IUserCenterService.java b/src/main/java/com/yunhai/campusnotice/service/IUserCenterService.java similarity index 98% rename from src/main/java/com/yunhai/notice/service/IUserCenterService.java rename to src/main/java/com/yunhai/campusnotice/service/IUserCenterService.java index 400ee2c..fd4e555 100644 --- a/src/main/java/com/yunhai/notice/service/IUserCenterService.java +++ b/src/main/java/com/yunhai/campusnotice/service/IUserCenterService.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.service; +package com.yunhai.campusnotice.service; import java.util.List; import java.util.Map; diff --git a/src/main/java/com/yunhai/campusnotice/service/impl/ClassService.java b/src/main/java/com/yunhai/campusnotice/service/impl/ClassService.java new file mode 100644 index 0000000..32de273 --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/service/impl/ClassService.java @@ -0,0 +1,176 @@ +package com.yunhai.campusnotice.service.impl; + +import com.yunhai.campusnotice.model.SchTeaList; +import com.yunhai.campusnotice.model.bsch.SchClassDto; +import com.yunhai.campusnotice.model.bsch.SchGradeDto; +import com.yunhai.campusnotice.model.bsch.SchInfoDto; +import com.yunhai.campusnotice.model.bsch.SchStudentDto; +import com.yunhai.campusnotice.model.bsch.SchSystemDto; +import com.yunhai.campusnotice.model.bsch.SchTreeNodeDto; +import com.yunhai.campusnotice.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.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +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.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 班级管理(bschclass)开放接口实现。 + * bschclass 为开放接口(无需应用签名),使用独立 RestTemplate 直连。 + *

推送范围树/圈人/身份快照统一经本门面(改造方案 2.0): + * 学段 GetSchLenlist、年级 GetSchLenLevellist、班级 GetVClassList、 + * 学生 GetSchStulist / GetGradeStulist(补缺) / GetSubStulist(补缺) / GetClaArrStulist / GetStuIdslist、 + * 学生树 SchGroup/GetStuTree。 + */ +@Service +public class ClassService implements IClassService { + + private static final Logger log = LoggerFactory.getLogger(ClassService.class); + + /** 班级管理服务基址,如 http://base.yunhaixingtong.com/bschclass */ + @Value("${yh.open.bschclass:}") + private String bschClassHost; + + private final RestTemplate restTemplate; + + public ClassService() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(5000); + factory.setReadTimeout(10000); + this.restTemplate = new RestTemplate(factory); + } + + @Override + public List getSchTeaList(int schId) { + return exchangeList(bschClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId, + new ParameterizedTypeReference>() { + }, "获取教师数据", "schId=" + schId); + } + + @Override + public SchInfoDto getSchFirst(int schId) { + requireHost(); + String url = bschClassHost + "/oapi/SchInfo/GetUserSchFirst?UserSchID=" + schId; + try { + ResponseEntity resp = restTemplate.getForEntity(URI.create(url), SchInfoDto.class); + return resp.getBody(); + } catch (RestClientException e) { + log.error("[getSchFirst] 请求班级管理接口失败, url={}", url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "获取学校信息失败"); + } + } + + @Override + public List getSchLenList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenlist?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学段列表", "schSerId=" + schSerId); + } + + @Override + public List getSchGradeList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/Schoolen/GetSchLenLevellist?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取年级列表", "schSerId=" + schSerId); + } + + @Override + public List getSchClassList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/VSchClass/GetVClassList?SchSerID=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取班级列表", "schSerId=" + schSerId); + } + + @Override + public List getSchStuList(String schSerId) { + return exchangeList(bschClassHost + "/oapi/OAStu/GetSchStulist?schserid=" + schSerId, + new ParameterizedTypeReference>() { + }, "获取学校学生", "schSerId=" + schSerId); + } + + @Override + public List getGradeStuList(String schSerId, Integer gradeId) { + return exchangeList(bschClassHost + "/oapi/OAStu/GetGradeStulist?SchSerID=" + schSerId + "&GradeId=" + gradeId, + new ParameterizedTypeReference>() { + }, "获取年级学生", "schSerId=" + schSerId + ", gradeId=" + gradeId); + } + + @Override + public List getSubStuList(String schSerId, Integer schSystemId) { + return exchangeList(bschClassHost + "/oapi/OAStu/GetSubStulist?SchSerID=" + schSerId + "&SchSystemId=" + schSystemId, + new ParameterizedTypeReference>() { + }, "获取学段学生", "schSerId=" + schSerId + ", schSystemId=" + schSystemId); + } + + @Override + public List getClaArrStuList(List classIds) { + String csv = joinIds(classIds); + return exchangeList(bschClassHost + "/oapi/OAStu/GetClaArrStulist?ClassIDs=" + csv, + new ParameterizedTypeReference>() { + }, "获取班级学生", "classIds=" + csv); + } + + @Override + public List getStuIdsList(List stuIds) { + String csv = joinIds(stuIds); + return exchangeList(bschClassHost + "/oapi/OAStu/GetStuIdslist?stuids=" + csv, + new ParameterizedTypeReference>() { + }, "获取指定学生", "stuIds=" + csv); + } + + @Override + public List getStuTree(Integer schId) { + return exchangeList(bschClassHost + "/oapi/SchGroup/GetStuTree?schId=" + schId, + new ParameterizedTypeReference>() { + }, "获取学生树", "schId=" + schId); + } + + // ============================ 通用调用 ============================ + + private List exchangeList(String url, ParameterizedTypeReference> type, + String action, String arg) { + requireHost(); + try { + // GET + Accept: application/json,保持与既有 GetSchteaList 调用一致 + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + ResponseEntity> resp = restTemplate.exchange( + url, HttpMethod.GET, new HttpEntity<>(headers), type); + List list = resp.getBody(); + log.info("[bschclass] {} 完成, {} 返回 {} 条", action, arg, list == null ? 0 : list.size()); + return list != null ? list : Collections.emptyList(); + } catch (RestClientException e) { + log.error("[bschclass] {} 失败, {}, url={}", action, arg, url, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, action + "失败"); + } + } + + private void requireHost() { + if (!StringUtils.hasText(bschClassHost)) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.bschclass)"); + } + } + + private String joinIds(List ids) { + if (ids == null || ids.isEmpty()) { + return ""; + } + return ids.stream().map(String::valueOf).collect(Collectors.joining(",")); + } +} diff --git a/src/main/java/com/yunhai/notice/service/impl/ContactsService.java b/src/main/java/com/yunhai/campusnotice/service/impl/ContactsService.java similarity index 97% rename from src/main/java/com/yunhai/notice/service/impl/ContactsService.java rename to src/main/java/com/yunhai/campusnotice/service/impl/ContactsService.java index 06d342f..ea640de 100644 --- a/src/main/java/com/yunhai/notice/service/impl/ContactsService.java +++ b/src/main/java/com/yunhai/campusnotice/service/impl/ContactsService.java @@ -1,10 +1,10 @@ -package com.yunhai.notice.service.impl; +package com.yunhai.campusnotice.service.impl; -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.IContactsService; +import com.yunhai.campusnotice.entity.ContactsGroup; +import com.yunhai.campusnotice.entity.ContactsGroupUser; +import com.yunhai.campusnotice.repository.ContactsGroupRepository; +import com.yunhai.campusnotice.repository.ContactsGroupUserRepository; +import com.yunhai.campusnotice.service.IContactsService; import com.yunhai.platform.auth.LoginUser; import com.yunhai.platform.auth.UserContextHolder; import com.yunhai.platform.apistd.BizException; diff --git a/src/main/java/com/yunhai/notice/service/impl/DictService.java b/src/main/java/com/yunhai/campusnotice/service/impl/DictService.java similarity index 96% rename from src/main/java/com/yunhai/notice/service/impl/DictService.java rename to src/main/java/com/yunhai/campusnotice/service/impl/DictService.java index 7a1bd34..5ea097e 100644 --- a/src/main/java/com/yunhai/notice/service/impl/DictService.java +++ b/src/main/java/com/yunhai/campusnotice/service/impl/DictService.java @@ -1,10 +1,10 @@ -package com.yunhai.notice.service.impl; +package com.yunhai.campusnotice.service.impl; -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.IDictService; +import com.yunhai.campusnotice.entity.Notice; +import com.yunhai.campusnotice.entity.SysDict; +import com.yunhai.campusnotice.repository.NoticeRepository; +import com.yunhai.campusnotice.repository.SysDictRepository; +import com.yunhai.campusnotice.service.IDictService; import com.yunhai.platform.auth.LoginUser; import com.yunhai.platform.auth.UserContextHolder; import com.yunhai.platform.apistd.BizException; diff --git a/src/main/java/com/yunhai/notice/service/impl/FileService.java b/src/main/java/com/yunhai/campusnotice/service/impl/FileService.java similarity index 97% rename from src/main/java/com/yunhai/notice/service/impl/FileService.java rename to src/main/java/com/yunhai/campusnotice/service/impl/FileService.java index 5480832..c855460 100644 --- a/src/main/java/com/yunhai/notice/service/impl/FileService.java +++ b/src/main/java/com/yunhai/campusnotice/service/impl/FileService.java @@ -1,10 +1,10 @@ -package com.yunhai.notice.service.impl; +package com.yunhai.campusnotice.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.campusnotice.entity.UpFile; +import com.yunhai.campusnotice.model.notice.UpFileVo; +import com.yunhai.campusnotice.model.notice.UpFileResultVo; +import com.yunhai.campusnotice.repository.UpFileRepository; +import com.yunhai.campusnotice.service.IFileService; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import org.slf4j.Logger; diff --git a/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java b/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java new file mode 100644 index 0000000..c1b5d4c --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java @@ -0,0 +1,2043 @@ +package com.yunhai.campusnotice.service.impl; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.campusnotice.entity.Notice; +import com.yunhai.campusnotice.entity.NoticeApproval; +import com.yunhai.campusnotice.entity.NoticeApproverMemory; +import com.yunhai.campusnotice.entity.NoticePushScope; +import com.yunhai.campusnotice.entity.NoticeRead; +import com.yunhai.campusnotice.entity.NoticeReceiver; +import com.yunhai.campusnotice.entity.UpFile; +import com.yunhai.campusnotice.enums.ApprovalActionEnum; +import com.yunhai.campusnotice.enums.NoticeStatusEnum; +import com.yunhai.campusnotice.enums.NoticeTypeEnum; +import com.yunhai.campusnotice.enums.PushScopeTypeEnum; +import com.yunhai.campusnotice.model.BasePageResult; +import com.yunhai.campusnotice.model.bsch.SchClassDto; +import com.yunhai.campusnotice.model.bsch.SchGradeDto; +import com.yunhai.campusnotice.model.bsch.SchInfoDto; +import com.yunhai.campusnotice.model.bsch.SchStudentDto; +import com.yunhai.campusnotice.model.bsch.SchSystemDto; +import com.yunhai.campusnotice.model.notice.*; +import com.yunhai.campusnotice.repository.NoticeApprovalRepository; +import com.yunhai.campusnotice.repository.NoticeApproverMemoryRepository; +import com.yunhai.campusnotice.repository.NoticePushScopeRepository; +import com.yunhai.campusnotice.repository.NoticeReadRepository; +import com.yunhai.campusnotice.repository.NoticeReceiverRepository; +import com.yunhai.campusnotice.repository.NoticeRepository; +import com.yunhai.campusnotice.repository.UpFileRepository; +import com.yunhai.campusnotice.service.IClassService; +import com.yunhai.campusnotice.service.IFileService; +import com.yunhai.campusnotice.service.IDictService; +import com.yunhai.campusnotice.service.INoticeService; +import com.yunhai.campusnotice.service.sms.SmsSender; +import com.yunhai.campusnotice.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.beans.factory.annotation.Value; +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; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.persistence.criteria.CriteriaBuilder; +import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Predicate; +import javax.persistence.criteria.Root; +import javax.persistence.criteria.Subquery; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 通知公告业务实现。 + *

新版发布表单改造(改造方案.md):状态机(草稿/已发布/待审批/已驳回)、 + * 选人审批(多人或签 + 记忆回显)、推送范围圈人(bschclass 学员快照落库)、 + * 学员阅读回执(身份快照分组)、定时发送调度(TimingPublishJob 到点真实发布)。

+ */ +@Service +public class NoticeService implements INoticeService { + + private static final Logger log = LoggerFactory.getLogger(NoticeService.class); + + /** 审批轨迹 assignees JSON 解析(简单 bean,无需时间模块) */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Autowired + private NoticeRepository noticeRepository; + + @Autowired + private NoticeReadRepository noticeReadRepository; + + @Autowired + private NoticeReceiverRepository noticeReceiverRepository; + + @Autowired + private NoticePushScopeRepository pushScopeRepository; + + @Autowired + private NoticeApprovalRepository approvalRepository; + + @Autowired + private NoticeApproverMemoryRepository approverMemoryRepository; + + @Autowired + private UpFileRepository upFileRepository; + + @Autowired + private SmsSender smsSender; + + @Autowired + private IFileService fileService; + + @Autowired + private IDictService dictService; + + @Autowired + private IClassService classService; + + @Autowired + private CurrentBoaUser currentBoaUser; + + /** + * 审批人资格组ID(用户中心组):>0 时 Submit 逐个校验被选人属于该组; + * 0 = 仅校验用户在用户中心存在(审批资格数据源待产品确认,改造方案 5.1-#5)。 + */ + @Value("${yh.notice.approver-group-id:0}") + private int approverGroupId; + + private static final int DRAFT_CODE = NoticeStatusEnum.DRAFT.getCode(); + private static final int PUBLISHED_CODE = NoticeStatusEnum.PUBLISHED.getCode(); + private static final int PENDING_CODE = NoticeStatusEnum.PENDING.getCode(); + private static final int REJECTED_CODE = NoticeStatusEnum.REJECTED.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(); + } + + private Notice loadNotice(Integer id) { + Objects.requireNonNull(id, "通知ID不得为空"); + return noticeRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); + } + + // ============================ 发通知(发布方式分流,改造方案 4.1) ============================ + + @Override + @Transactional + public Integer publish(NoticeRequest request) { + String publishType = request.getPublishType() == null + ? "direct" : request.getPublishType().trim().toLowerCase(); + + Notice entity = new Notice(); + BeanUtils.copyProperties(request, entity); + entity.setId(null); + entity.setDeptName(resolveDeptName(request)); + normalizeReceiveList(entity, request.getReceiveList()); + entity.setPublisherAccount(currentAccount()); + entity.setPublisherName(currentName()); + entity.setSchId(currentSchId()); + entity.setIsPulled(0); + entity.setReadCount(0); + entity.setReceiptFlag(request.getReceiptFlag() == null ? 0 : request.getReceiptFlag()); + entity.setTimingPublished(0); + + if ("draft".equals(publishType)) { + entity.setStatus(DRAFT_CODE); + noticeRepository.save(entity); + savePushScopes(entity.getId(), request.getPushScopes()); + fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); + return entity.getId(); + } + if ("approve".equals(publishType)) { + doSubmitApproval(entity, request.getApproverAccounts(), null); + savePushScopes(entity.getId(), request.getPushScopes()); + fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); + return entity.getId(); + } + + // 直接发布(默认,兼容旧 Create 链路): + // 立即 → 真实发布;定时 → status=1 + timingPublished=0,等待调度器到点触发 + entity.setStatus(PUBLISHED_CODE); + noticeRepository.save(entity); + saveReceivers(entity); + savePushScopes(entity.getId(), request.getPushScopes()); + fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); + if (isImmediatePublish(entity)) { + doRealPublish(entity); + } + return entity.getId(); + } + + @Override + @Transactional + public Integer saveDraft(NoticeRequest request) { + Notice entity = new Notice(); + BeanUtils.copyProperties(request, entity); + entity.setId(null); + entity.setDeptName(resolveDeptName(request)); + normalizeReceiveList(entity, request.getReceiveList()); + entity.setStatus(DRAFT_CODE); + entity.setPublisherAccount(currentAccount()); + entity.setPublisherName(currentName()); + entity.setSchId(currentSchId()); + entity.setIsPulled(0); + entity.setReadCount(0); + entity.setReceiptFlag(request.getReceiptFlag() == null ? 0 : request.getReceiptFlag()); + entity.setTimingPublished(0); + noticeRepository.save(entity); + // 草稿仅存内容 + 范围快照 + 附件,不圈人、不发短信、不落接收人 + savePushScopes(entity.getId(), request.getPushScopes()); + fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); + return entity.getId(); + } + + @Override + public BasePageResult draftList(NoticeQuery query, Pageable pageable) { + NoticeQuery draftQuery = query == null ? new NoticeQuery() : query; + draftQuery.setStatus(DRAFT_CODE); + // 草稿箱=本人草稿(已驳回落已发列表按状态筛选,改造方案 5.1-#9 默认不合并) + draftQuery.setScope("user"); + return adminList(draftQuery, pageable); + } + + // ============================ 真实发布(publish/审批通过/定时调度三处复用,改造方案 4.1) ============================ + + /** + * 真实发布:解析推送范围 → 学员账号 + 身份快照批量写 notice_receiver(receive_type=2) + * → 初始化 allowCount(去重学员数 + 教师接收数)→ 发短信(smsFlag=1)→ 置 timingPublished=1。 + */ + private void doRealPublish(Notice entity) { + List scopes = pushScopeRepository.findByNoticeId(entity.getId()); + if (!scopes.isEmpty()) { + List students = expandScopeStudents(entity.getSchId(), scopes); + noticeReceiverRepository.deleteByNoticeIdAndReceiveType(entity.getId(), NoticeReceiver.TYPE_STUDENT); + List rows = new ArrayList<>(students.size()); + for (StuSnapshot stu : students) { + NoticeReceiver receiver = new NoticeReceiver(); + receiver.setNoticeId(entity.getId()); + receiver.setAccount(stu.account); + receiver.setReceiveType(NoticeReceiver.TYPE_STUDENT); + receiver.setStuName(stu.stuName); + receiver.setGradeId(stu.gradeId); + receiver.setGradeName(stu.gradeName); + receiver.setClassId(stu.classId); + receiver.setClassName(stu.className); + rows.add(receiver); + } + noticeReceiverRepository.saveAll(rows); + long teacherCount = noticeReceiverRepository + .countByNoticeIdAndReceiveType(entity.getId(), NoticeReceiver.TYPE_TEACHER); + entity.setAllowCount(students.size() + (int) teacherCount); + log.info("[doRealPublish] 通知 {} 范围发布: 学员 {} 人, 教师 {} 人", + entity.getId(), students.size(), teacherCount); + } + trySendSms(entity); + entity.setTimingPublished(1); + noticeRepository.save(entity); + } + + /** 学员圈人快照(发布时从 bschclass 带出,一次写入) */ + private static class StuSnapshot { + String account; + String stuName; + Integer gradeId; + String gradeName; + Integer classId; + String className; + } + + /** + * 按推送范围展开学员(按账号去重)并附身份快照(年级/班级名称经班级、年级列表一次映射)。 + * 圈人口径(改造方案 2.2):全校 GetSchStulist / 学段 GetSubStulist / 年级 GetGradeStulist / + * 班级 GetClaArrStulist(批量)/ 指定学员 GetStuIdslist;训练队数据源待确认(5.1-#3)。 + */ + private List expandScopeStudents(Integer schId, List scopes) { + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "当前用户无学校身份,无法解析推送范围"); + } + SchInfoDto sch = classService.getSchFirst(schId); + if (sch == null || !StringUtils.hasText(sch.getSchSerId())) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理服务中未找到当前学校,无法解析推送范围"); + } + String schSerId = sch.getSchSerId(); + + Map dedup = new LinkedHashMap<>(); + List singles = new ArrayList<>(); + List classIds = new ArrayList<>(); + List stuIds = new ArrayList<>(); + for (NoticePushScope scope : scopes) { + PushScopeTypeEnum type = PushScopeTypeEnum.of(scope.getScopeType()); + if (type == null) { + continue; + } + if (type == PushScopeTypeEnum.TEAM) { + throw new BizException(ErrorCode.PARAM_ERROR, + "训练队范围数据源待确认,暂不支持(" + scope.getScopeName() + ")"); + } + if (type == PushScopeTypeEnum.CLAZZ) { + if (scope.getScopeId() != null) { + classIds.add(scope.getScopeId()); + } + } else if (type == PushScopeTypeEnum.STUDENT) { + if (scope.getScopeId() != null) { + stuIds.add(scope.getScopeId()); + } + } else { + singles.add(scope); + } + } + for (NoticePushScope scope : singles) { + List part; + PushScopeTypeEnum type = PushScopeTypeEnum.of(scope.getScopeType()); + if (type == PushScopeTypeEnum.SCHOOL) { + part = classService.getSchStuList(schSerId); + } else if (type == PushScopeTypeEnum.SUB_SCHOOL) { + part = expandSubSchoolStudents(schSerId, scope.getScopeId()); + } else { + part = expandGradeStudents(schSerId, scope.getScopeId()); + } + collectStudents(dedup, part); + } + if (!classIds.isEmpty()) { + collectStudents(dedup, classService.getClaArrStuList(classIds)); + } + if (!stuIds.isEmpty()) { + collectStudents(dedup, classService.getStuIdsList(stuIds)); + } + + // 身份快照:班级(classId→className/gradeId)+ 年级(gradeId→gradeName) + Map classMap = new HashMap<>(); + Map gradeMap = new HashMap<>(); + if (!dedup.isEmpty()) { + for (SchClassDto cls : classService.getSchClassList(schSerId)) { + classMap.put(cls.getClassId(), cls); + } + for (SchGradeDto grade : classService.getSchGradeList(schSerId)) { + gradeMap.put(grade.getId(), grade); + } + } + List result = new ArrayList<>(dedup.size()); + for (SchStudentDto stu : dedup.values()) { + StuSnapshot snapshot = new StuSnapshot(); + snapshot.account = stu.getAccount(); + snapshot.stuName = stu.getStuName(); + if (stu.getClassId() != null) { + snapshot.classId = stu.getClassId(); + SchClassDto cls = classMap.get(stu.getClassId()); + if (cls != null) { + snapshot.className = cls.getClassName(); + snapshot.gradeId = cls.getGradeId(); + SchGradeDto grade = gradeMap.get(cls.getGradeId()); + if (grade != null) { + snapshot.gradeName = grade.getName(); + } + } + } + result.add(snapshot); + } + return result; + } + + private void collectStudents(Map dedup, List part) { + if (part == null) { + return; + } + for (SchStudentDto stu : part) { + if (stu == null || !StringUtils.hasText(stu.getAccount())) { + continue; + } + dedup.putIfAbsent(stu.getAccount(), stu); + } + } + + /** + * 按学段展开学员:优先补缺接口 GetSubStulist; + * bschclass 版本未升级(404/异常)时退化为按学段下年级逐个 GetGradeStulist。 + */ + private List expandSubSchoolStudents(String schSerId, Integer schSystemId) { + try { + return classService.getSubStuList(schSerId, schSystemId); + } catch (BizException e) { + log.warn("[expandSubSchool] 按学段直查学生失败,退化为按年级展开, schSystemId={}", schSystemId); + List all = new ArrayList<>(); + for (SchGradeDto grade : classService.getSchGradeList(schSerId)) { + if (schSystemId != null && schSystemId.equals(grade.getOwnerId())) { + all.addAll(expandGradeStudents(schSerId, grade.getId())); + } + } + return all; + } + } + + /** + * 按年级展开学员:优先补缺接口 GetGradeStulist; + * bschclass 版本未升级(404/异常)时退化为按年级下班级 GetClaArrStulist 聚合 + * (改造方案 2.0:补缺接口未就绪时的兜底口径)。 + */ + private List expandGradeStudents(String schSerId, Integer gradeId) { + try { + return classService.getGradeStuList(schSerId, gradeId); + } catch (BizException e) { + log.warn("[expandGrade] 按年级直查学生失败,退化为按班级展开, gradeId={}", gradeId); + List classIds = classService.getSchClassList(schSerId).stream() + .filter(c -> gradeId != null && gradeId.equals(c.getGradeId())) + .map(SchClassDto::getClassId) + .collect(Collectors.toList()); + return classIds.isEmpty() + ? Collections.emptyList() + : classService.getClaArrStuList(classIds); + } + } + + // ============================ 审批(选人、多人或签、记忆,改造方案 4.3) ============================ + + @Override + @Transactional + public void submit(Integer serid, List approverAccounts, String opinion) { + Notice notice = loadNotice(serid); + doSubmitApproval(notice, approverAccounts, opinion); + } + + /** + * 提交审批装配:资格校验 → status: 0/3→2 → 审批人快照落主表 → + * notice_approval 提交行(assignees 快照)→ upsert 记忆表。 + */ + private void doSubmitApproval(Notice entity, List approverAccounts, String opinion) { + LinkedHashMap nameByAccount = resolveAndValidateApprovers(approverAccounts); + int status = entity.getStatus() == null ? PUBLISHED_CODE : entity.getStatus(); + if (status != DRAFT_CODE && status != REJECTED_CODE) { + throw new BizException(ErrorCode.FORBIDDEN, "仅草稿或已驳回状态的通知可提交审批"); + } + entity.setStatus(PENDING_CODE); + entity.setApproverAccounts(toJsonAccounts(new ArrayList<>(nameByAccount.keySet()))); + noticeRepository.save(entity); + + saveApprovalRow(entity.getId(), NoticeApproval.ACTION_SUBMIT, + currentAccount(), currentName(), opinion, toAssigneesJson(nameByAccount)); + upsertApproverMemory(entity.getSchId(), currentAccount(), + new ArrayList<>(nameByAccount.keySet()), new ArrayList<>(nameByAccount.values())); + log.info("[submit] 通知 {} 提交审批, 发布人 {}, 审批人 {}", + entity.getId(), currentAccount(), nameByAccount.keySet()); + } + + @Override + @Transactional + public void approve(Integer serid, String opinion) { + Notice notice = loadNotice(serid); + String me = currentAccount(); + requireApprover(notice, me); + if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) { + throw alreadyHandledError(serid); + } + // 或签 CAS:任一审批人先通过即生效,后到者影响行数为 0 → 冲突提示(改造方案 4.4) + if (noticeRepository.casApprove(serid, LocalDateTime.now()) == 0) { + throw alreadyHandledError(serid); + } + notice.setStatus(PUBLISHED_CODE); + saveApprovalRow(serid, NoticeApproval.ACTION_APPROVE, me, currentName(), opinion, null); + // 通过即真实发布;定时未到点的交给调度器(timingPublished 保持 0) + if (isImmediatePublish(notice)) { + doRealPublish(notice); + } else { + noticeRepository.save(notice); + } + log.info("[approve] 通知 {} 审批通过, 审批人 {}", serid, me); + } + + @Override + @Transactional + public void reject(Integer serid, String opinion) { + if (!StringUtils.hasText(opinion)) { + throw new BizException(ErrorCode.PARAM_ERROR, "请填写驳回意见"); + } + Notice notice = loadNotice(serid); + String me = currentAccount(); + requireApprover(notice, me); + if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) { + throw alreadyHandledError(serid); + } + if (noticeRepository.casReject(serid, LocalDateTime.now()) == 0) { + throw alreadyHandledError(serid); + } + notice.setStatus(REJECTED_CODE); + noticeRepository.save(notice); + saveApprovalRow(serid, NoticeApproval.ACTION_REJECT, me, currentName(), opinion, null); + log.info("[reject] 通知 {} 审批驳回, 审批人 {}, 意见: {}", serid, me, opinion); + } + + /** 仅该轮审批人快照含当前账号可处理 */ + private void requireApprover(Notice notice, String account) { + if (!StringUtils.hasText(account)) { + throw new BizException(ErrorCode.FORBIDDEN, "未登录或会话已过期"); + } + if (!parseReceiveAccounts(notice.getApproverAccounts()).contains(account)) { + throw new BizException(ErrorCode.FORBIDDEN, "您不是本通知的审批人"); + } + } + + /** 或签「被他人先审」冲突提示:带实际处理人/动作/时间(改造方案 4.4) */ + private BizException alreadyHandledError(Integer noticeId) { + List latest = approvalRepository.findLatestByNoticeIdAndActions(noticeId, + Arrays.asList(NoticeApproval.ACTION_APPROVE, NoticeApproval.ACTION_REJECT)); + if (!latest.isEmpty()) { + NoticeApproval handled = latest.get(0); + String who = StringUtils.hasText(handled.getOperatorName()) + ? handled.getOperatorName() : handled.getOperatorAccount(); + String action = handled.getAction() == NoticeApproval.ACTION_APPROVE ? "审批通过" : "审批驳回"; + return new BizException(ErrorCode.FORBIDDEN, + "该通知已被" + who + "于" + formatTime(handled.getActionTime()) + action + ",无需重复处理"); + } + return new BizException(ErrorCode.FORBIDDEN, "该通知已离开待审批状态,无需重复处理"); + } + + /** + * 逐个校验被选审批人:用户中心存在;配置了资格组(yh.notice.approver-group-id>0)时须属于该组。 + * 记忆回显仅是默认值,提交时仍全量校验,防止记忆中的人被撤销角色后带病提交(改造方案 2.5)。 + */ + private LinkedHashMap resolveAndValidateApprovers(List accounts) { + if (accounts == null || accounts.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择审批人"); + } + LinkedHashMap result = new LinkedHashMap<>(); + for (String raw : accounts) { + String account = raw == null ? null : raw.trim(); + if (!StringUtils.hasText(account) || result.containsKey(account)) { + continue; + } + BoaUser user; + try { + user = YschBaseOpenApi.getUserInfo(account); + } catch (Exception e) { + log.error("[validateApprovers] 用户中心查询失败, account={}", account, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "审批人资格校验失败(用户中心不可用),请稍后重试"); + } + if (user == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "审批人不存在:" + account); + } + if (approverGroupId > 0 && !inGroup(user, approverGroupId)) { + throw new BizException(ErrorCode.PARAM_ERROR, + "「" + (StringUtils.hasText(user.getName()) ? user.getName() : account) + "」不具备审批资格"); + } + result.put(account, StringUtils.hasText(user.getName()) ? user.getName() : account); + } + if (result.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择审批人"); + } + return result; + } + + private boolean inGroup(BoaUser user, int groupId) { + if (user.getGroups() == null) { + return false; + } + for (BoaGroup group : user.getGroups()) { + if (group != null && group.getId() != null && group.getId() == groupId) { + return true; + } + } + return false; + } + + @Override + public BasePageResult approvalList(String auditState, NoticeQuery query, Pageable pageable) { + String me = currentAccount(); + if (!StringUtils.hasText(me)) { + return PageUtils.toResult(new PageImpl<>(Collections.emptyList(), pageable, 0)); + } + boolean reviewed = "reviewed".equalsIgnoreCase(auditState); + List statuses = reviewed + ? Arrays.asList(PUBLISHED_CODE, REJECTED_CODE) + : Collections.singletonList(PENDING_CODE); + // 审批人快照含我:JSON 串带引号精确匹配("zhang" 不会命中 "zhangsan",改造方案 4.4) + String pattern = "%\"" + me + "\"%"; + Specification spec = (root, cq, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.like(root.get("approverAccounts"), pattern)); + predicates.add(root.get("status").in(statuses)); + appendCommonPredicates(query, root, cb, predicates); + appendSchIdPredicate(root, cb, predicates); + return cq.where(predicates.toArray(new Predicate[0])).getRestriction(); + }; + Page page = noticeRepository.findAll(spec, pageable); + List notices = page.getContent(); + Map> recordsByNotice = notices.isEmpty() + ? Collections.emptyMap() + : approvalRepository.findByNoticeIds( + notices.stream().map(Notice::getId).collect(Collectors.toList())) + .stream().collect(Collectors.groupingBy(NoticeApproval::getNoticeId)); + List content = notices.stream() + .map(n -> toApprovalVo(n, recordsByNotice.getOrDefault(n.getId(), Collections.emptyList()), me)) + .collect(Collectors.toList()); + return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements())); + } + + /** 审批列表行装配:审核人姓名取该轮提交行 assignees 快照;已审行带最新处理信息(含他人已处理) */ + private ApprovalListVo toApprovalVo(Notice notice, List records, String me) { + ApprovalListVo vo = new ApprovalListVo(); + vo.setId(notice.getId()); + vo.setTitle(notice.getTitle()); + vo.setType(notice.getType()); + vo.setTypeDesc(NoticeTypeEnum.of(notice.getType()).getDesc()); + vo.setStatus(notice.getStatus()); + vo.setStatusDesc(NoticeStatusEnum.of(notice.getStatus()).getDesc()); + vo.setPublisherAccount(notice.getPublisherAccount()); + vo.setPublisherName(notice.getPublisherName()); + vo.setReceiptFlag(notice.getReceiptFlag()); + vo.setTimingSendTime(notice.getTimingSendTime()); + // records 按 id asc,顺序覆盖即取最新 + NoticeApproval submit = null; + NoticeApproval handled = null; + for (NoticeApproval record : records) { + if (record.getAction() == NoticeApproval.ACTION_SUBMIT) { + submit = record; + } else if (record.getAction() == NoticeApproval.ACTION_APPROVE + || record.getAction() == NoticeApproval.ACTION_REJECT) { + handled = record; + } + } + if (submit != null) { + vo.setSubmitTime(submit.getActionTime()); + for (ApprovalRecordVo.Assignee assignee : parseAssignees(submit.getAssignees())) { + if (StringUtils.hasText(assignee.getName())) { + vo.getApproverNames().add(assignee.getName()); + } + } + } + if (handled != null && notice.getStatus() != null && notice.getStatus() != PENDING_CODE) { + vo.setHandled(true); + vo.setHandledByMe(me.equals(handled.getOperatorAccount())); + vo.setHandledAccount(handled.getOperatorAccount()); + vo.setHandledName(handled.getOperatorName()); + vo.setHandledAction(handled.getAction()); + ApprovalActionEnum action = ApprovalActionEnum.of(handled.getAction()); + vo.setHandledActionDesc(action != null ? action.getDesc() : null); + vo.setHandledTime(handled.getActionTime()); + vo.setHandledOpinion(handled.getOpinion()); + } + return vo; + } + + @Override + public ApproverSuggestVo approverSuggest() { + ApproverSuggestVo vo = new ApproverSuggestVo(); + String me = currentAccount(); + Integer schId = currentSchId(); + if (me == null || schId == null) { + return vo; + } + approverMemoryRepository.findBySchIdAndPublisherAccount(schId, me).ifPresent(memory -> { + vo.setAccounts(parseReceiveAccounts(memory.getApproverAccounts())); + vo.setNames(parseReceiveAccounts(memory.getApproverNames())); + }); + return vo; + } + + @Override + public List approverResolve(List ids, List accounts) { + List result = new ArrayList<>(); + Set seenAccounts = new HashSet<>(); + // 教师树叶子ID → 账号(前端选人后提交前换算) + if (ids != null) { + for (Integer id : ids) { + if (id == null) { + continue; + } + ApproverResolveVo item = new ApproverResolveVo(); + item.setId(id); + try { + BoaUser user = YschBaseOpenApi.getUserById(id); + if (user != null) { + item.setAccount(user.getAccount()); + item.setName(user.getName()); + } + } catch (Exception e) { + log.warn("[approverResolve] 用户中心按ID解析失败, id={}", id, e); + } + if (item.getAccount() != null && !seenAccounts.add(item.getAccount())) { + continue; + } + result.add(item); + } + } + // 账号 → 教师树叶子ID(编辑回显按账号勾树) + if (accounts != null) { + for (String raw : accounts) { + String account = raw == null ? null : raw.trim(); + if (!StringUtils.hasText(account) || seenAccounts.contains(account)) { + continue; + } + ApproverResolveVo item = new ApproverResolveVo(); + item.setAccount(account); + try { + BoaUser user = YschBaseOpenApi.getUserByLoginName(account); + if (user != null) { + item.setId(user.getId()); + item.setName(user.getName()); + } + } catch (Exception e) { + log.warn("[approverResolve] 用户中心按账号解析失败, account={}", account, e); + } + if (item.getId() != null) { + seenAccounts.add(account); + } + result.add(item); + } + } + return result; + } + + /** 记忆表 upsert:唯一约束 (sch_id, publisher_account),每次 Submit 覆盖更新 */ + private void upsertApproverMemory(Integer schId, String publisherAccount, + List accounts, List names) { + if (schId == null || !StringUtils.hasText(publisherAccount)) { + return; + } + NoticeApproverMemory memory = approverMemoryRepository + .findBySchIdAndPublisherAccount(schId, publisherAccount) + .orElseGet(NoticeApproverMemory::new); + memory.setSchId(schId); + memory.setPublisherAccount(publisherAccount); + memory.setApproverAccounts(toJsonAccounts(accounts)); + memory.setApproverNames(toJsonAccounts(names)); + approverMemoryRepository.save(memory); + } + + private void saveApprovalRow(Integer noticeId, int action, String operatorAccount, + String operatorName, String opinion, String assignees) { + NoticeApproval row = new NoticeApproval(); + row.setNoticeId(noticeId); + row.setAction(action); + row.setOperatorAccount(operatorAccount); + row.setOperatorName(operatorName); + row.setOpinion(opinion); + row.setAssignees(assignees); + row.setActionTime(LocalDateTime.now()); + approvalRepository.save(row); + } + + // ============================ 推送范围树 / 预估(改造方案 3.1) ============================ + + @Override + public List> scopeTree() { + Integer schId = currentSchId(); + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + SchInfoDto sch = classService.getSchFirst(schId); + if (sch == null || !StringUtils.hasText(sch.getSchSerId())) { + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理服务中未找到当前学校"); + } + String schSerId = sch.getSchSerId(); + List systems = classService.getSchLenList(schSerId); + List grades = classService.getSchGradeList(schSerId); + List classes = classService.getSchClassList(schSerId); + + Map> gradesByOwner = grades.stream() + .filter(g -> g.getOwnerId() != null) + .collect(Collectors.groupingBy(SchGradeDto::getOwnerId, LinkedHashMap::new, Collectors.toList())); + Map> classesByGrade = classes.stream() + .filter(c -> c.getGradeId() != null) + .collect(Collectors.groupingBy(SchClassDto::getGradeId, LinkedHashMap::new, Collectors.toList())); + + List> tree = new ArrayList<>(); + tree.add(scopeNode(PushScopeTypeEnum.SCHOOL.getCode(), schId, + StringUtils.hasText(sch.getSchName()) ? sch.getSchName() : "全校", null)); + for (SchSystemDto system : systems) { + List> gradeNodes = new ArrayList<>(); + for (SchGradeDto grade : gradesByOwner.getOrDefault(system.getId(), Collections.emptyList())) { + List> classNodes = new ArrayList<>(); + for (SchClassDto cls : classesByGrade.getOrDefault(grade.getId(), Collections.emptyList())) { + classNodes.add(scopeNode(PushScopeTypeEnum.CLAZZ.getCode(), cls.getClassId(), + StringUtils.hasText(cls.getClassName()) ? cls.getClassName() : String.valueOf(cls.getClassId()), null)); + } + gradeNodes.add(scopeNode(PushScopeTypeEnum.GRADE.getCode(), grade.getId(), + StringUtils.hasText(grade.getName()) ? grade.getName() : String.valueOf(grade.getId()), classNodes)); + } + tree.add(scopeNode(PushScopeTypeEnum.SUB_SCHOOL.getCode(), system.getId(), + StringUtils.hasText(system.getName()) ? system.getName() : String.valueOf(system.getId()), gradeNodes)); + } + return tree; + } + + private Map scopeNode(int scopeType, Integer scopeId, String name, List> children) { + Map node = new LinkedHashMap<>(); + node.put("scopeType", scopeType); + node.put("scopeId", scopeId); + node.put("name", name); + if (children != null) { + node.put("children", children); + } + return node; + } + + @Override + public ScopePreviewVo scopePreview(List scopes, String receiveList) { + ScopePreviewVo vo = new ScopePreviewVo(); + vo.setTeacherCount(parseReceiveAccounts(receiveList).size()); + if (scopes != null && !scopes.isEmpty()) { + List tmp = scopes.stream().map(item -> { + NoticePushScope scope = new NoticePushScope(); + scope.setScopeType(item.getScopeType()); + scope.setScopeId(item.getScopeId()); + scope.setScopeName(item.getScopeName()); + return scope; + }).collect(Collectors.toList()); + vo.setStudentCount(expandScopeStudents(currentSchId(), tmp).size()); + } + vo.setTotalCount(vo.getStudentCount() + vo.getTeacherCount()); + return vo; + } + + // ============================ 编辑(状态校验,改造方案 3.2) ============================ + + @Override + @Transactional + public void edit(Integer id, NoticeRequest request) { + Objects.requireNonNull(id, "通知ID不得为空"); + Notice entity = noticeRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); + int status = entity.getStatus() == null ? PUBLISHED_CODE : entity.getStatus(); + if (status == PENDING_CODE) { + throw new BizException(ErrorCode.FORBIDDEN, "待审批状态的通知不可编辑,请等待审批处理或驳回后再修改"); + } + // 新版表单(带推送范围)已发布不可编辑;旧链路(仅 receiveList)保持可编辑(旧链路回归不受影响) + if (status == PUBLISHED_CODE && pushScopeRepository.existsByNoticeId(id)) { + throw new BizException(ErrorCode.FORBIDDEN, "已发布的通知不可编辑,请撤回后重新发送"); + } + entity.setTitle(request.getTitle()); + entity.setContent(request.getContent()); + entity.setType(request.getType()); + entity.setDeptId(request.getDeptId()); + entity.setDeptName(resolveDeptName(request)); + entity.setTopmost(request.getTopmost()); + entity.setSmsFlag(request.getSmsFlag()); + if (request.getReceiptFlag() != null) { + entity.setReceiptFlag(request.getReceiptFlag()); + } + if (request.getReceiveList() != null) { + normalizeReceiveList(entity, request.getReceiveList()); + // 更新接收人关联表 + noticeReceiverRepository.deleteByNoticeId(entity.getId()); + saveReceivers(entity); + } + if (request.getTimingSendTime() != null) { + entity.setTimingSendTime(request.getTimingSendTime()); + } + // 推送范围全量替换(草稿/已驳回重编辑场景;null 不改动)。 + // 已发布(旧链路)不允许变更范围:改范围而不重新圈人会造成可见性口径错乱 + if (request.getPushScopes() != null && status != PUBLISHED_CODE) { + pushScopeRepository.deleteByNoticeId(entity.getId()); + savePushScopes(entity.getId(), request.getPushScopes()); + } + // 附件全量替换:先解绑旧附件,再绑定本次提交的列表(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()); + } + + // ============================ 删除(状态校验 + 级联,改造方案 3.2) ============================ + + @Override + @Transactional + public void deleteBatch(List ids) { + Objects.requireNonNull(ids, "通知ID列表不得为空"); + if (ids.isEmpty()) { + return; + } + for (Integer id : ids) { + Notice notice = noticeRepository.findById(id).orElse(null); + if (notice == null) { + continue; + } + int status = notice.getStatus() == null ? PUBLISHED_CODE : notice.getStatus(); + if (status == PENDING_CODE) { + throw new BizException(ErrorCode.FORBIDDEN, "待审批状态的通知不可删除(id=" + id + ")"); + } + if (status == PUBLISHED_CODE) { + String me = currentAccount(); + if (me == null || !me.equals(notice.getPublisherAccount())) { + throw new BizException(ErrorCode.FORBIDDEN, "仅发布人本人可删除已发布的通知(id=" + id + ")"); + } + } + noticeReadRepository.deleteByNoticeId(id); + noticeReceiverRepository.deleteByNoticeId(id); + pushScopeRepository.deleteByNoticeId(id); + approvalRepository.deleteByNoticeId(id); + upFileRepository.deleteByTopicTypeAndTopicId("notice", id); + noticeRepository.deleteById(id); + } + } + + // ============================ 撤回 ============================ + + @Override + @Transactional + public void pull(Integer id) { + Objects.requireNonNull(id, "通知ID不得为空"); + Notice entity = noticeRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); + if (entity.getStatus() == null || entity.getStatus() != PUBLISHED_CODE) { + throw new BizException(ErrorCode.FORBIDDEN, "仅已发布的通知可撤回"); + } + if (entity.getIsPulled() != null && entity.getIsPulled() == 1) { + throw new BizException(ErrorCode.FORBIDDEN, "通知已被撤回,请勿重复操作"); + } + entity.setIsPulled(1); + noticeRepository.save(entity); + } + + // ============================ 置顶/取消置顶 ============================ + + @Override + @Transactional + public void setTopmost(Integer id, Integer topmost) { + Objects.requireNonNull(id, "通知ID不得为空"); + Notice entity = noticeRepository.findById(id) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); + entity.setTopmost(topmost == null ? 0 : topmost); + noticeRepository.save(entity); + } + + // ============================ 转发 ============================ + + @Override + @Transactional + public Integer forward(Integer sourceId, NoticeRequest request) { + Objects.requireNonNull(sourceId, "源通知ID不得为空"); + Notice source = noticeRepository.findById(sourceId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "源通知不存在: " + sourceId)); + Notice entity = new Notice(); + BeanUtils.copyProperties(request, entity); + entity.setId(null); + entity.setDeptName(resolveDeptName(request)); + normalizeReceiveList(entity, request.getReceiveList()); + entity.setStatus(PUBLISHED_CODE); + entity.setPublisherAccount(currentAccount()); + entity.setPublisherName(currentName()); + entity.setParentId(sourceId); + entity.setIsPulled(0); + entity.setReadCount(0); + entity.setSchId(currentSchId()); + noticeRepository.save(entity); + saveReceivers(entity); + if (isImmediatePublish(entity)) { + doRealPublish(entity); + } + return entity.getId(); + } + + // ============================ 用户端列表(可见性过滤,改造方案 3.2) ============================ + + @Override + public NoticePageResult userList(NoticeQuery query, Pageable pageable) { + String account = currentAccount(); + Set readIds = new HashSet<>(noticeReadRepository.findNoticeIdsByReaderAccount(account)); + Set receiverNoticeIds = new HashSet<>(noticeReceiverRepository.findNoticeIdsByAccount(account)); + + Page page = noticeRepository.findAll(userSpec(query, readIds, receiverNoticeIds), pageable); + + List content = page.getContent().stream() + .map(n -> toVo(n, readIds.contains(n.getId()))) + .collect(Collectors.toList()); + // 学员端(mobiapi)不返回审批信息(改造方案 4.6) + if (isMobiRequest()) { + content.forEach(vo -> vo.setApproverAccounts(null)); + } + + // 未读数与列表同口径(含范围可见性过滤),避免「数字与列表对不上」 + long unreadCount = noticeRepository.count(visibleSpec(account, true)) + - noticeReadRepository.countByReaderAccount(account); + if (unreadCount < 0) { + unreadCount = 0; + } + + return new NoticePageResult<>(content, (int) page.getTotalElements(), + page.getNumber() + 1, page.getSize(), page.getTotalPages(), page.hasNext(), unreadCount); + } + + /** + * 用户端可见规格:已发布 + 已到点(定时未到点不可见)+ 范围可见性。 + * 范围可见性(最小改动原则):无 push_scope 维持「全校广播」现状; + * 有 push_scope 的通知须 notice_receiver 快照命中当前账号(学员 receive_type=2 / 教师 receive_type=1)。 + */ + private Specification visibleSpec(String account, boolean withSchFilter) { + Set receiverNoticeIds = new HashSet<>(noticeReceiverRepository.findNoticeIdsByAccount(account)); + return (Root root, CriteriaQuery cq, CriteriaBuilder cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("status"), PUBLISHED_CODE)); + predicates.add(cb.or(cb.isNull(root.get("timingSendTime")), + cb.lessThanOrEqualTo(root.get("timingSendTime"), LocalDateTime.now()))); + if (withSchFilter) { + appendSchIdPredicate(root, cb, predicates); + } + predicates.add(scopeVisibility(root, cq, cb, receiverNoticeIds)); + return cq.where(predicates.toArray(new Predicate[0])).getRestriction(); + }; + } + + /** 无范围 → 全校广播;有范围 → 接收快照命中(快照为空集合时恒不可见) */ + private Predicate scopeVisibility(Root root, CriteriaQuery cq, CriteriaBuilder cb, + Set receiverNoticeIds) { + Subquery scopeSq = cq.subquery(NoticePushScope.class); + Root scopeRoot = scopeSq.from(NoticePushScope.class); + scopeSq.select(scopeRoot.get("id")) + .where(cb.equal(scopeRoot.get("noticeId"), root.get("id"))); + Predicate noScope = cb.not(cb.exists(scopeSq)); + Predicate scopedVisible = receiverNoticeIds.isEmpty() + ? cb.equal(root.get("id"), -1) + : root.get("id").in(receiverNoticeIds); + return cb.or(noScope, scopedVisible); + } + + /** + * 用户端动态查询:仅已发布且已到点;支持标题关键词、类型、部门、时间范围、未读/已读过滤。 + * 多租户:按当前用户学校过滤(schId 为空时不加过滤,兼容无学校归属用户)。 + */ + private Specification userSpec(NoticeQuery query, Set readIds, Set receiverNoticeIds) { + return (Root root, CriteriaQuery cq, CriteriaBuilder cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("status"), PUBLISHED_CODE)); + 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); + predicates.add(scopeVisibility(root, cq, cb, receiverNoticeIds)); + + Integer readType = query.getReadType() == null ? 0 : query.getReadType(); + if (readType == 1 && !readIds.isEmpty()) { + predicates.add(cb.not(root.get("id").in(readIds))); + } else if (readType == 2) { + if (readIds.isEmpty()) { + predicates.add(cb.equal(root.get("id"), -1)); + } else { + predicates.add(root.get("id").in(readIds)); + } + } + return cq.where(predicates.toArray(new Predicate[0])).getRestriction(); + }; + } + + // ============================ 管理端列表(全部) ============================ + + @Override + public BasePageResult adminList(NoticeQuery query, Pageable pageable) { + Page page = noticeRepository.findAll(adminSpec(query), pageable); + List content = page.getContent().stream() + .map(n -> toVo(n, false)) + .collect(Collectors.toList()); + // 批量填充附件ID串("1,2,3"):列表页"附件下载"入口依赖 UpFile 字段 + if (!content.isEmpty()) { + Map> fileMap = batchFileIds( + content.stream().map(NoticeVo::getId).collect(Collectors.toList())); + for (NoticeVo vo : content) { + List fids = fileMap.getOrDefault(vo.getId(), Collections.emptyList()); + vo.setUpFile(fids.stream().map(String::valueOf).collect(Collectors.joining(","))); + } + } + return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements())); + } + + /** + * 管理端动态查询:按状态/标题/类型/部门/发布人/时间范围过滤。 + * 多租户:按当前用户学校过滤(schId 为空时不加过滤)。 + * scope="user" 时仅看当前用户发布的通知。 + */ + private Specification adminSpec(NoticeQuery query) { + return (Root root, CriteriaQuery cq, CriteriaBuilder cb) -> { + List predicates = new ArrayList<>(); + if (query.getStatus() != null) { + predicates.add(cb.equal(root.get("status"), query.getStatus())); + } else { + // 不指定状态时默认排除草稿(草稿只在草稿箱呈现,见改造方案 5.1-#9) + predicates.add(cb.notEqual(root.get("status"), DRAFT_CODE)); + } + appendCommonPredicates(query, root, cb, predicates); + // 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())) { + predicates.add(cb.like(root.get("title"), "%" + query.getKeyword() + "%")); + } + if (query.getType() != null) { + predicates.add(cb.equal(root.get("type"), query.getType())); + } + if (StringUtils.hasText(query.getDeptId())) { + predicates.add(cb.equal(root.get("deptId"), query.getDeptId())); + } + // 前端传 yyyy-MM-dd 短日期:startTime 当天 00:00:00 起,endTime 补全到当天 23:59:59 + if (query.getStartTime() != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get("createTime"), query.getStartTime().atStartOfDay())); + } + if (query.getEndTime() != null) { + predicates.add(cb.lessThanOrEqualTo(root.get("createTime"), query.getEndTime().atTime(23, 59, 59))); + } + } + + // ============================ 详情(访问校验补口,改造方案 3.2 / 4.4) ============================ + + @Override + public NoticeVo detail(Integer id) { + Notice entity = loadNotice(id); + checkDetailAccess(entity); + String account = currentAccount(); + boolean read = noticeReadRepository.existsByNoticeIdAndReaderAccount(id, account); + NoticeVo vo = toVo(entity, read); + if (!read) { + markRead(id); + vo.setRead(true); + } + return vo; + } + + @Override + public NoticeVo getDetail(Integer id) { + Notice entity = loadNotice(id); + checkDetailAccess(entity); + NoticeVo vo = toVo(entity, false); + vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId())); + return vo; + } + + /** + * 详情访问校验:草稿/待审批/已驳回仅发布人本人或该轮审批人可打开,其余返回业务错误; + * 已发布维持现状(范围过滤靠列表层,详情不重复校验)。 + */ + private void checkDetailAccess(Notice notice) { + Integer status = notice.getStatus(); + if (status == null || status == PUBLISHED_CODE) { + return; + } + String account = currentAccount(); + if (StringUtils.hasText(account)) { + if (account.equals(notice.getPublisherAccount())) { + return; + } + if (parseReceiveAccounts(notice.getApproverAccounts()).contains(account)) { + return; + } + } + throw new BizException(ErrorCode.FORBIDDEN, "该通知尚未发布,仅发布人或本轮审批人可查看"); + } + + // ============================ 标记已读(含身份快照,改造方案 2.3) ============================ + + @Override + @Transactional + public void markRead(Integer id) { + Objects.requireNonNull(id, "通知ID不得为空"); + String account = currentAccount(); + if (noticeReadRepository.existsByNoticeIdAndReaderAccount(id, account)) { + return; + } + NoticeRead read = new NoticeRead(); + read.setNoticeId(id); + read.setReaderAccount(account); + read.setReaderName(currentName()); + read.setReadTime(LocalDateTime.now()); + fillReadSnapshot(read, id, account); + noticeReadRepository.save(read); + } + + /** + * 阅读身份快照:优先从本人 notice_receiver 快照行带出年级/班级(零额外远程调用); + * 快照缺失(存量全员广播通知)时实时查 bschclass 兜底,查不到留空。 + */ + private void fillReadSnapshot(NoticeRead read, Integer noticeId, String account) { + Notice notice = noticeRepository.findById(noticeId).orElse(null); + if (notice == null) { + return; + } + read.setSchId(notice.getSchId()); + Optional own = noticeReceiverRepository + .findFirstByNoticeIdAndAccountOrderByReceiveTypeDescIdDesc(noticeId, account); + if (own.isPresent()) { + NoticeReceiver receiver = own.get(); + if (receiver.getReceiveType() != null && receiver.getReceiveType() == NoticeReceiver.TYPE_STUDENT) { + read.setGradeId(receiver.getGradeId()); + read.setGradeName(receiver.getGradeName()); + read.setClassId(receiver.getClassId()); + read.setClassName(receiver.getClassName()); + } + return; + } + // 兜底:存量全员广播通知,实时查 bschclass(学生才命中,教师/查不到留空) + try { + if (notice.getSchId() == null || !StringUtils.hasText(account)) { + return; + } + SchInfoDto sch = classService.getSchFirst(notice.getSchId()); + if (sch == null || !StringUtils.hasText(sch.getSchSerId())) { + return; + } + SchStudentDto stu = classService.getSchStuList(sch.getSchSerId()).stream() + .filter(s -> account.equals(s.getAccount())) + .findFirst().orElse(null); + if (stu == null || stu.getClassId() == null) { + return; + } + read.setClassId(stu.getClassId()); + for (SchClassDto cls : classService.getSchClassList(sch.getSchSerId())) { + if (stu.getClassId().equals(cls.getClassId())) { + read.setClassName(cls.getClassName()); + read.setGradeId(cls.getGradeId()); + for (SchGradeDto grade : classService.getSchGradeList(sch.getSchSerId())) { + if (cls.getGradeId() != null && cls.getGradeId().equals(grade.getId())) { + read.setGradeName(grade.getName()); + } + } + break; + } + } + } catch (Exception e) { + log.warn("[markRead] 身份快照兜底查询失败, noticeId={}, account={}", noticeId, account, e); + } + } + + // ============================ 一键全部已读(批量化) ============================ + + @Override + @Transactional + public int initializeRed() { + String account = currentAccount(); + List readIds = noticeReadRepository.findNoticeIdsByReaderAccount(account); + Set readSet = new HashSet<>(readIds); + // 与用户端列表同口径(含范围可见性),避免替不可见通知记账 + List published = noticeRepository.findAll(visibleSpec(account, false)); + int count = 0; + for (Notice n : published) { + if (!readSet.contains(n.getId())) { + markRead(n.getId()); + count++; + } + } + return count; + } + + // ============================ 已发送阅读回执(年级→班级→学生,改造方案 3.1) ============================ + + @Override + public ReadReceiptVo readReceipt(Integer serid) { + Notice notice = loadNotice(serid); + ReadReceiptVo vo = new ReadReceiptVo(); + vo.setSerid(serid); + vo.setAllowCount(notice.getAllowCount() != null ? notice.getAllowCount() : 0); + vo.setReadCount(noticeReadRepository.countByNoticeId(serid)); + + // 已读集合(account → 最新阅读记录) + Map readMap = new HashMap<>(); + for (NoticeRead r : noticeReadRepository.findByNoticeIdOrderByReadTimeDesc(serid)) { + readMap.putIfAbsent(r.getReaderAccount(), r); + } + + // 学员按年级→班级分组(身份快照,缺失归「未分年级/未分班」) + Map gradeGroups = new LinkedHashMap<>(); + List teachers = new ArrayList<>(); + for (NoticeReceiver receiver : noticeReceiverRepository.findByNoticeId(serid)) { + boolean isStudent = receiver.getReceiveType() != null + && receiver.getReceiveType() == NoticeReceiver.TYPE_STUDENT; + if (!isStudent) { + teachers.add(receiver); + continue; + } + Integer gradeId = receiver.getGradeId() != null ? receiver.getGradeId() : 0; + String gradeName = StringUtils.hasText(receiver.getGradeName()) ? receiver.getGradeName() : "未分年级"; + ReadReceiptVo.GradeGroup gradeGroup = gradeGroups.computeIfAbsent( + gradeId + "_" + gradeName, + k -> { + ReadReceiptVo.GradeGroup g = new ReadReceiptVo.GradeGroup(); + g.setGradeId(gradeId); + g.setGradeName(gradeName); + return g; + }); + // 班级ID存在但名称解析失败(bschclass 数据口径漂移)时按「未分班」合并为一个桶,避免同名空组散落 + boolean classResolved = receiver.getClassId() != null && StringUtils.hasText(receiver.getClassName()); + Integer classId = classResolved ? receiver.getClassId() : 0; + String className = classResolved ? receiver.getClassName() : "未分班"; + ReadReceiptVo.ClassGroup classGroup = gradeGroup.getClasses().stream() + .filter(c -> classId.equals(c.getClassId())) + .findFirst() + .orElseGet(() -> { + ReadReceiptVo.ClassGroup c = new ReadReceiptVo.ClassGroup(); + c.setClassId(classId); + c.setClassName(className); + gradeGroup.getClasses().add(c); + return c; + }); + classGroup.getStudents().add(toReaderItem(receiver.getAccount(), + StringUtils.hasText(receiver.getStuName()) ? receiver.getStuName() : receiver.getAccount(), + readMap)); + } + // 年级/班级按 ID 升序,展示稳定 + vo.setGrades(gradeGroups.values().stream() + .sorted(Comparator.comparing(g -> g.getGradeId() == null ? 0 : g.getGradeId())) + .peek(g -> g.getClasses().sort(Comparator.comparing(c -> c.getClassId() == null ? 0 : c.getClassId()))) + .collect(Collectors.toList())); + + // 教师接收行:姓名优先阅读记录快照,其次用户中心实时解析 + for (NoticeReceiver teacher : teachers) { + String name = readMap.containsKey(teacher.getAccount()) + && StringUtils.hasText(readMap.get(teacher.getAccount()).getReaderName()) + ? readMap.get(teacher.getAccount()).getReaderName() + : resolveUserName(teacher.getAccount()); + vo.getTeachers().add(toReaderItem(teacher.getAccount(), name, readMap)); + } + return vo; + } + + private ReadReceiptVo.ReaderItem toReaderItem(String account, String name, Map readMap) { + ReadReceiptVo.ReaderItem item = new ReadReceiptVo.ReaderItem(); + item.setAccount(account); + item.setName(name); + NoticeRead read = readMap.get(account); + item.setRead(read != null); + item.setReadTime(read != null ? read.getReadTime() : null); + return item; + } + + /** 教师姓名解析:用户中心查不到时回退账号展示 */ + private String resolveUserName(String account) { + try { + BoaUser user = YschBaseOpenApi.getUserInfo(account); + if (user != null && StringUtils.hasText(user.getName())) { + return user.getName(); + } + } catch (Exception e) { + log.warn("[resolveUserName] 用户中心查询失败, account={}", account); + } + return account; + } + + // ============================ 追加接收人(事务化) ============================ + + @Override + @Transactional + public void receiveUsers(Integer noticeId, String users) { + if (!StringUtils.hasText(users)) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择追送用户!"); + } + Notice entity = noticeRepository.findById(noticeId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId)); + if (entity.getReceiveList() != null && "ALL".equalsIgnoreCase(entity.getReceiveList().trim())) { + return; + } + // 合并旧接收人 + 新接收人 + 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); + // 范围通知的应读数 = 教师定向数 + 学员快照数(saveReceivers 仅重写教师行) + if (pushScopeRepository.existsByNoticeId(noticeId)) { + long studentCount = noticeReceiverRepository + .countByNoticeIdAndReceiveType(noticeId, NoticeReceiver.TYPE_STUDENT); + entity.setAllowCount(merged.size() + (int) studentCount); + noticeRepository.save(entity); + } + log.info("[receiveUsers] 通知 {} 追送用户, 现接收人 {} 人", noticeId, merged.size()); + } + + // ============================ 上一篇/下一篇(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; + } + + // ============================ 阅读明细 ============================ + + @Override + public List getReadDetail(Integer noticeId) { + Objects.requireNonNull(noticeId, "通知ID不得为空"); + List readList = noticeReadRepository.findByNoticeIdOrderByReadTimeDesc(noticeId); + return readList.stream().map(r -> { + NoticeVo.ReadDetail detail = new NoticeVo.ReadDetail(); + detail.setAccount(r.getReaderAccount()); + detail.setName(r.getReaderName()); + detail.setRead(true); + detail.setReadTime(r.getReadTime()); + return detail; + }).collect(Collectors.toList()); + } + + // ============================ 阅读情况分组(旧接口保留过渡) ============================ + + /** + * 阅读情况按「学校组」分组(对齐旧 .NET OrgizeModify:组名 + 组内用户,绿已读/红未读)。 + *

与旧版差异:旧版接收人逐行落库(含状态),本实现已读=notice_read 明细、 + * 接收人=notice_receiver 明细;用户姓名/所属组经用户中心 byLoginName 实时查询, + * 查不到或跨校的接收人归「其他」组。全员通知(receive_list=ALL)不落接收明细, + * 仅能展示已读人员。新版表单的年级→班级→学生回执走 {@link #readReceipt}。

+ */ + @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); + } + } + + // 逐接收人查用户中心(姓名+组织链)。接收明细可能存登录名,也可能存历史数字ID + // (如 10012):先按登录名查,查不到且为纯数字再按 userId 查,保证未读人也能取到组 + Map userMap = new LinkedHashMap<>(); + Map canonicalAccount = new HashMap<>(); // 原始串 -> 规范登录名 + for (String account : receivers) { + BoaUser user = null; + try { + user = YschBaseOpenApi.getUserInfo(account); + if (user == null && account.chars().allMatch(Character::isDigit)) { + user = YschBaseOpenApi.getUserInfo(Integer.parseInt(account)); + } + } catch (Exception e) { + log.warn("[readDetailGrouped] 查询用户中心失败, account={}", account, e); + } + if (user != null) { + userMap.put(account, user); + if (StringUtils.hasText(user.getAccount())) { + canonicalAccount.put(account, user.getAccount()); + } + } + } + + // 组名=用户组织链最近端(主组),跨校/查不到归「其他」(对齐旧版逻辑) + Map>> groupUsers = new LinkedHashMap<>(); + for (String account : receivers) { + BoaUser user = userMap.get(account); + // 已读比对/姓名兜底统一使用规范登录名(接收明细可能存的是历史数字ID) + String canonical = canonicalAccount.getOrDefault(account, account); + boolean read = readAccounts.contains(canonical) || readAccounts.contains(account); + String groupName = resolveReadGroupName(user, notice.getSchId()); + String teaName = user != null && StringUtils.hasText(user.getName()) + ? user.getName() + : readNameMap.getOrDefault(canonical, canonical); + Map item = new LinkedHashMap<>(); + item.put("account", canonical); + item.put("teaname", teaName); + item.put("states", read ? "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 "其他"; + } + + // ============================ 催收未读(改造方案 3.2) ============================ + + @Override + public void expedite(Integer serid) { + Notice notice = loadNotice(serid); + if (notice.getReceiptFlag() == null || notice.getReceiptFlag() != 1) { + throw new BizException(ErrorCode.PARAM_ERROR, "该通知未开启学员阅读回执,无需催收"); + } + Set readAccounts = noticeReadRepository.findByNoticeIdOrderByReadTimeDesc(serid).stream() + .map(NoticeRead::getReaderAccount) + .collect(Collectors.toSet()); + // 优先 notice_receiver 快照(含学员),回退 receiveList JSON(历史数据) + List receivers = noticeReceiverRepository.findByNoticeId(serid).stream() + .map(NoticeReceiver::getAccount) + .distinct() + .collect(Collectors.toList()); + if (receivers.isEmpty()) { + 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 发送催办消息 + } + + // ============================ 定时发送调度(改造方案 4.2) ============================ + + @Override + @Transactional + public int timingPublishDue() { + List dueList = noticeRepository.findDueTimingPublish(LocalDateTime.now()); + int count = 0; + for (Notice notice : dueList) { + // 先 CAS 抢占幂等标记(0=已被其他线程/实例处理),再执行真实发布 + if (noticeRepository.casMarkTimingPublished(notice.getId(), LocalDateTime.now()) == 1) { + notice.setTimingPublished(1); + doRealPublish(notice); + count++; + log.info("[timingPublish] 通知 {} 到点定时发布完成", notice.getId()); + } + } + return count; + } + + // ============================ 统计 ============================ + + @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.countBySchIdAndStatusNot(schId, DRAFT_CODE) + : noticeRepository.countByStatusNot(DRAFT_CODE); + long publishedCount = schId != null ? noticeRepository.countBySchIdAndStatus(schId, PUBLISHED_CODE) + : noticeRepository.countByStatus(PUBLISHED_CODE); + long totalReadRecords = noticeReadRepository.count(); + long unreadForCurrentUser = noticeRepository.count(visibleSpec(account, true)) + - noticeReadRepository.countByReaderAccount(account); + if (unreadForCurrentUser < 0) { + unreadForCurrentUser = 0; + } + + List createTimes = schId != null + ? noticeRepository.findCreateTimesBySchIdStatusAndRange(schId, PUBLISHED_CODE, s, e) + : noticeRepository.findCreateTimesByStatusAndRange(PUBLISHED_CODE, 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_CODE, s, e) + : noticeRepository.findPublisherNamesByStatusAndRange(PUBLISHED_CODE, 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); + data.setStatus(vo.getStatus()); + data.setStatusDesc(vo.getStatusDesc()); + data.setReceiptFlag(vo.getReceiptFlag()); + // 编辑回显:重要程度/置顶/短信(发布选项三项) + data.setType(vo.getType()); + data.setTopmost(vo.getTopmost()); + data.setSmsFlag(vo.getSmsFlag()); + // 推送范围快照回显(编辑/详情展示) + List scopes = pushScopeRepository.findByNoticeId(serid); + data.setPushScopes(scopes.stream().map(scope -> { + PushScopeItem item = new PushScopeItem(); + item.setScopeType(scope.getScopeType()); + item.setScopeId(scope.getScopeId()); + item.setScopeName(scope.getScopeName()); + return item; + }).collect(Collectors.toList())); + // 审批轨迹:管理端全量返回,学员端(mobiapi)一律不返回审批信息(改造方案 4.6) + if (!isMobiRequest()) { + data.setApprovalRecords(loadApprovalRecords(serid)); + // 当前绑定审批人回显(编辑时按账号勾选教师树);姓名取最新提交行 assignees 快照 + data.setApproverAccounts(parseReceiveAccounts(vo.getApproverAccounts())); + List names = new ArrayList<>(); + List records = data.getApprovalRecords(); + for (int i = records.size() - 1; i >= 0; i--) { + ApprovalRecordVo record = records.get(i); + if (record.getAction() != null && record.getAction() == NoticeApproval.ACTION_SUBMIT) { + for (ApprovalRecordVo.Assignee assignee : record.getAssignees()) { + names.add(assignee.getName() != null ? assignee.getName() : assignee.getAccount()); + } + break; + } + } + data.setApproverNames(names); + } + 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; + } + + /** 审批轨迹装配:全部行 + 提交行 assignees 快照(前端按提交行切分轮次) */ + private List loadApprovalRecords(Integer noticeId) { + return approvalRepository.findByNoticeIdOrderByIdAsc(noticeId).stream() + .map(record -> { + ApprovalRecordVo vo = new ApprovalRecordVo(); + vo.setAction(record.getAction()); + ApprovalActionEnum action = ApprovalActionEnum.of(record.getAction()); + vo.setActionDesc(action != null ? action.getDesc() : String.valueOf(record.getAction())); + vo.setOperatorAccount(record.getOperatorAccount()); + vo.setOperatorName(record.getOperatorName()); + vo.setOpinion(record.getOpinion()); + vo.setAssignees(parseAssignees(record.getAssignees())); + vo.setActionTime(record.getActionTime()); + return vo; + }) + .collect(Collectors.toList()); + } + + /** 学员端请求判定:平台路径 mobiapi 段(/{platform}/notice/...) */ + private boolean isMobiRequest() { + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if (attributes instanceof ServletRequestAttributes) { + String uri = ((ServletRequestAttributes) attributes).getRequest().getRequestURI(); + return uri != null && uri.contains("/mobiapi/"); + } + return false; + } + + // ============================ 已发列表附件批量查询 ============================ + + @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) { + NoticeVo vo = new NoticeVo(); + 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()); + } + + // ============================ 接收人/范围/审批工具方法 ============================ + + /** + * 接收人列表归一化:前端/存量可能传 "all"/"UserAll"/"ALL"(全员),统一存 "ALL"。 + * 其余视为 JSON 数组(账号)或逗号分隔串,原样保留。 + */ + 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()); + } + + /** + * 写入教师接收人关联表(receive_type=1,非全员时)。 + * 学员接收行由 doRealPublish 圈人单独写入(receive_type=2,含身份快照)。 + */ + private void saveReceivers(Notice entity) { + String rl = entity.getReceiveList(); + if (rl == null || "ALL".equalsIgnoreCase(rl.trim())) { + return; + } + List accounts = parseReceiveAccounts(rl); + List rows = new ArrayList<>(accounts.size()); + for (String account : accounts) { + NoticeReceiver receiver = new NoticeReceiver(); + receiver.setNoticeId(entity.getId()); + receiver.setAccount(account); + receiver.setReceiveType(NoticeReceiver.TYPE_TEACHER); + rows.add(receiver); + } + noticeReceiverRepository.saveAll(rows); + } + + /** 保存推送范围快照(跳过非法行) */ + private void savePushScopes(Integer noticeId, List scopes) { + if (scopes == null || scopes.isEmpty()) { + return; + } + List rows = new ArrayList<>(); + for (PushScopeItem item : scopes) { + if (item == null || item.getScopeType() == null) { + continue; + } + NoticePushScope row = new NoticePushScope(); + row.setNoticeId(noticeId); + row.setScopeType(item.getScopeType()); + row.setScopeId(item.getScopeId()); + row.setScopeName(item.getScopeName()); + rows.add(row); + } + if (!rows.isEmpty()) { + pushScopeRepository.saveAll(rows); + } + } + + /** + * 解析接收人列表为账号集合:支持 "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; + } + + /** + * 解析接收人列表(JSON 数组字符串 -> 账号列表)。 + * 为空或解析失败时返回空列表(表示全员通知)。 + */ + private List parseReceiveList(String receiveListJson) { + return parseReceiveAccounts(receiveListJson); + } + + private String toJsonAccounts(List accounts) { + return accounts.stream() + .map(a -> "\"" + escapeJson(a) + "\"") + .collect(Collectors.joining(",", "[", "]")); + } + + /** 审批人快照 JSON:[{account,name}](提交行 assignees) */ + private String toAssigneesJson(Map nameByAccount) { + StringBuilder sb = new StringBuilder("["); + boolean first = true; + for (Map.Entry entry : nameByAccount.entrySet()) { + if (!first) { + sb.append(","); + } + first = false; + sb.append("{\"account\":\"").append(escapeJson(entry.getKey())) + .append("\",\"name\":\"").append(escapeJson(entry.getValue())).append("\"}"); + } + return sb.append("]").toString(); + } + + private List parseAssignees(String json) { + if (!StringUtils.hasText(json)) { + return new ArrayList<>(); + } + try { + return OBJECT_MAPPER.readValue(json, new TypeReference>() { + }); + } catch (IOException e) { + log.warn("[parseAssignees] 解析审批人快照失败: {}", json); + return new ArrayList<>(); + } + } + + private String escapeJson(String s) { + if (s == null) { + return ""; + } + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + + 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/SmsService.java b/src/main/java/com/yunhai/campusnotice/service/impl/SmsService.java similarity index 97% rename from src/main/java/com/yunhai/notice/service/impl/SmsService.java rename to src/main/java/com/yunhai/campusnotice/service/impl/SmsService.java index 438f466..a93c892 100644 --- a/src/main/java/com/yunhai/notice/service/impl/SmsService.java +++ b/src/main/java/com/yunhai/campusnotice/service/impl/SmsService.java @@ -1,10 +1,10 @@ -package com.yunhai.notice.service.impl; +package com.yunhai.campusnotice.service.impl; -import com.yunhai.notice.entity.SmsModule; -import com.yunhai.notice.entity.SmsSchoolConfig; -import com.yunhai.notice.repository.SmsModuleRepository; -import com.yunhai.notice.repository.SmsSchoolConfigRepository; -import com.yunhai.notice.service.ISmsService; +import com.yunhai.campusnotice.entity.SmsModule; +import com.yunhai.campusnotice.entity.SmsSchoolConfig; +import com.yunhai.campusnotice.repository.SmsModuleRepository; +import com.yunhai.campusnotice.repository.SmsSchoolConfigRepository; +import com.yunhai.campusnotice.service.ISmsService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/main/java/com/yunhai/notice/service/impl/TemplateService.java b/src/main/java/com/yunhai/campusnotice/service/impl/TemplateService.java similarity index 95% rename from src/main/java/com/yunhai/notice/service/impl/TemplateService.java rename to src/main/java/com/yunhai/campusnotice/service/impl/TemplateService.java index d51825c..c5045cc 100644 --- a/src/main/java/com/yunhai/notice/service/impl/TemplateService.java +++ b/src/main/java/com/yunhai/campusnotice/service/impl/TemplateService.java @@ -1,12 +1,12 @@ -package com.yunhai.notice.service.impl; +package com.yunhai.campusnotice.service.impl; -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.ITemplateService; +import com.yunhai.campusnotice.entity.NoticeTemplate; +import com.yunhai.campusnotice.entity.TemplateType; +import com.yunhai.campusnotice.entity.UpFile; +import com.yunhai.campusnotice.repository.NoticeTemplateRepository; +import com.yunhai.campusnotice.repository.TemplateTypeRepository; +import com.yunhai.campusnotice.repository.UpFileRepository; +import com.yunhai.campusnotice.service.ITemplateService; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import org.springframework.beans.factory.annotation.Autowired; diff --git a/src/main/java/com/yunhai/notice/service/impl/UserCenterService.java b/src/main/java/com/yunhai/campusnotice/service/impl/UserCenterService.java similarity index 98% rename from src/main/java/com/yunhai/notice/service/impl/UserCenterService.java rename to src/main/java/com/yunhai/campusnotice/service/impl/UserCenterService.java index 92f8e20..cb7acd8 100644 --- a/src/main/java/com/yunhai/notice/service/impl/UserCenterService.java +++ b/src/main/java/com/yunhai/campusnotice/service/impl/UserCenterService.java @@ -1,8 +1,8 @@ -package com.yunhai.notice.service.impl; +package com.yunhai.campusnotice.service.impl; -import com.yunhai.notice.model.SchTeaList; -import com.yunhai.notice.service.IClassService; -import com.yunhai.notice.service.IUserCenterService; +import com.yunhai.campusnotice.model.SchTeaList; +import com.yunhai.campusnotice.service.IClassService; +import com.yunhai.campusnotice.service.IUserCenterService; import com.yunhai.platform.openapi.client.YschBaseOpenApi; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; diff --git a/src/main/java/com/yunhai/notice/service/sms/DefaultSmsSender.java b/src/main/java/com/yunhai/campusnotice/service/sms/DefaultSmsSender.java similarity index 94% rename from src/main/java/com/yunhai/notice/service/sms/DefaultSmsSender.java rename to src/main/java/com/yunhai/campusnotice/service/sms/DefaultSmsSender.java index c3d85c6..bdf6bd7 100644 --- a/src/main/java/com/yunhai/notice/service/sms/DefaultSmsSender.java +++ b/src/main/java/com/yunhai/campusnotice/service/sms/DefaultSmsSender.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.service.sms; +package com.yunhai.campusnotice.service.sms; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/com/yunhai/notice/service/sms/SmsSender.java b/src/main/java/com/yunhai/campusnotice/service/sms/SmsSender.java similarity index 92% rename from src/main/java/com/yunhai/notice/service/sms/SmsSender.java rename to src/main/java/com/yunhai/campusnotice/service/sms/SmsSender.java index f1bcdd4..713cb78 100644 --- a/src/main/java/com/yunhai/notice/service/sms/SmsSender.java +++ b/src/main/java/com/yunhai/campusnotice/service/sms/SmsSender.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.service.sms; +package com.yunhai.campusnotice.service.sms; import java.util.List; diff --git a/src/main/java/com/yunhai/notice/utils/PageUtils.java b/src/main/java/com/yunhai/campusnotice/utils/PageUtils.java similarity index 87% rename from src/main/java/com/yunhai/notice/utils/PageUtils.java rename to src/main/java/com/yunhai/campusnotice/utils/PageUtils.java index 4ffff35..fa3c3c7 100644 --- a/src/main/java/com/yunhai/notice/utils/PageUtils.java +++ b/src/main/java/com/yunhai/campusnotice/utils/PageUtils.java @@ -1,7 +1,7 @@ -package com.yunhai.notice.utils; +package com.yunhai.campusnotice.utils; -import com.yunhai.notice.model.BasePageQuery; -import com.yunhai.notice.model.BasePageResult; +import com.yunhai.campusnotice.model.BasePageQuery; +import com.yunhai.campusnotice.model.BasePageResult; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; diff --git a/src/main/java/com/yunhai/notice/controller/api/NoticeController.java b/src/main/java/com/yunhai/notice/controller/api/NoticeController.java deleted file mode 100644 index 4c0d128..0000000 --- a/src/main/java/com/yunhai/notice/controller/api/NoticeController.java +++ /dev/null @@ -1,217 +0,0 @@ -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.*; -import com.yunhai.notice.service.INoticeService; -import com.yunhai.notice.utils.PageUtils; -import com.yunhai.platform.utils.model.BoaUser; -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; - -/** - * 通知公告接口(合并 NoticeApiController + 旧 NoticeController)。 - *

路径前缀:{@code Config.API_PATH + "/notice/"}(platform = api / manage / mobiapi)。 - * 方法动词式 PascalCase(对齐 UserBase);写操作一律 POST。 - * 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。 - */ -@RestController -@RequestMapping(value = Config.API_PATH + "/notice/") -public class NoticeController { - - @Autowired - private INoticeService noticeService; - - // ============================ 收件箱列表 ============================ - - /** - * 已收通知列表(用户端)。 - */ - @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)); - } - - // ============================ 已发/全部通知列表 ============================ - - /** - * 已发/全部通知列表(管理端)。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)); - } - - // ============================ 查看详情(标读) ============================ - - /** - * 查看通知详情(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); - } - - // ============================ 编辑回显(不标读) ============================ - - /** - * 获取通知详情(编辑/转发用,不标记已读)。 - */ - @GetMapping("GetDetail") - public NoticeDetailVo getDetail(@RequestParam("serid") Integer serid) { - return noticeService.getDetailMap(serid); - } - - // ============================ 新建 ============================ - - /** - * 新建通知(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/entity/NoticeReceiver.java b/src/main/java/com/yunhai/notice/entity/NoticeReceiver.java deleted file mode 100644 index 48775a0..0000000 --- a/src/main/java/com/yunhai/notice/entity/NoticeReceiver.java +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 2d9cd77..0000000 --- a/src/main/java/com/yunhai/notice/enums/NoticeStatusEnum.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.yunhai.notice.enums; - -import lombok.Getter; - -/** - * 通知状态。无草稿态:发通知一律置为「已发布」, - * 定时发送通过 timingSendTime + 查询侧「已到点」过滤控制可见性。 - * 对应原 .NET Notify_Message.status(原系统已发布=2,此处归一化为 1)。 - */ -@Getter -public enum NoticeStatusEnum { - - PUBLISHED(1, "已发布"); - - private final int code; - private final String desc; - - NoticeStatusEnum(int code, String desc) { - this.code = code; - this.desc = desc; - } - - public static NoticeStatusEnum of(Integer code) { - if (code == null) { - return PUBLISHED; - } - for (NoticeStatusEnum e : values()) { - if (e.code == code) { - return e; - } - } - return PUBLISHED; - } -} diff --git a/src/main/java/com/yunhai/notice/repository/NoticeReceiverRepository.java b/src/main/java/com/yunhai/notice/repository/NoticeReceiverRepository.java deleted file mode 100644 index ed1133b..0000000 --- a/src/main/java/com/yunhai/notice/repository/NoticeReceiverRepository.java +++ /dev/null @@ -1,31 +0,0 @@ -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/service/IClassService.java b/src/main/java/com/yunhai/notice/service/IClassService.java deleted file mode 100644 index 0a043be..0000000 --- a/src/main/java/com/yunhai/notice/service/IClassService.java +++ /dev/null @@ -1,20 +0,0 @@ -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/INoticeService.java b/src/main/java/com/yunhai/notice/service/INoticeService.java deleted file mode 100644 index b9d28f2..0000000 --- a/src/main/java/com/yunhai/notice/service/INoticeService.java +++ /dev/null @@ -1,142 +0,0 @@ -package com.yunhai.notice.service; - -import com.yunhai.notice.entity.Notice; -import com.yunhai.notice.model.BasePageResult; -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; - -/** - * 通知公告业务接口。 - */ -public interface INoticeService { - - /** - * 发通知:以当前登录用户为发布人,状态置为「已发布」入库。 - * 若 request.timingSendTime 不为空且晚于当前时间,则作为定时发送, - * 未到点前用户端收件箱不可见(到点后由查询过滤自然可见)。 - * - * @return 新建通知 ID(用于后续回填附件 TopicID 等) - */ - Integer publish(NoticeRequest request); - - /** - * 编辑通知:按 id 更新标题/内容/类型/部门/置顶,发布人与状态保持不变。 - */ - void edit(Integer id, NoticeRequest request); - - /** - * 批量删除通知:删除主记录并级联清理阅读记录、接收人、附件关联。 - */ - void deleteBatch(List ids); - - /** - * 撤回通知:仅已发布且未撤回的通知可撤回,置 isPulled=1。 - */ - void pull(Integer id); - - /** - * 置顶/取消置顶。 - */ - void setTopmost(Integer id, Integer topmost); - - /** - * 转发通知:基于源通知创建新通知(已发布),parentId 关联源通知。 - */ - Integer forward(Integer sourceId, NoticeRequest request); - - /** - * 用户端列表:仅展示已发布且已到点的通知,并标记当前用户是否已读,附未读总数。 - * 返回 NoticePageResult(扩展 unreadCount)。 - */ - NoticePageResult userList(NoticeQuery query, Pageable pageable); - - /** - * 管理端列表:按条件查询全部通知(管理员查看全部)。 - */ - BasePageResult adminList(NoticeQuery query, Pageable pageable); - - /** - * 通知详情:返回视图对象,并顺带为当前用户标记已读(打开即视为已读)。 - */ - 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/impl/ClassService.java b/src/main/java/com/yunhai/notice/service/impl/ClassService.java deleted file mode 100644 index 3b9b50e..0000000 --- a/src/main/java/com/yunhai/notice/service/impl/ClassService.java +++ /dev/null @@ -1,63 +0,0 @@ -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/NoticeService.java b/src/main/java/com/yunhai/notice/service/impl/NoticeService.java deleted file mode 100644 index 955fc64..0000000 --- a/src/main/java/com/yunhai/notice/service/impl/NoticeService.java +++ /dev/null @@ -1,957 +0,0 @@ -package com.yunhai.notice.service.impl; - -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.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; -import org.springframework.util.StringUtils; - -import javax.persistence.criteria.CriteriaBuilder; -import javax.persistence.criteria.CriteriaQuery; -import javax.persistence.criteria.Predicate; -import javax.persistence.criteria.Root; -import javax.servlet.http.HttpServletResponse; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.*; -import java.util.stream.Collectors; - -/** - * 通知公告业务实现。 - */ -@Service -public class NoticeService implements INoticeService { - - private static final Logger log = LoggerFactory.getLogger(NoticeService.class); - - @Autowired - private NoticeRepository noticeRepository; - - @Autowired - private NoticeReadRepository noticeReadRepository; - - @Autowired - private 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(); - - /** - * 当前登录人账号(来自平台 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 Integer publish(NoticeRequest request) { - Notice entity = new Notice(); - BeanUtils.copyProperties(request, entity); - entity.setId(null); - 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(); - } - - // ============================ 编辑 ============================ - - @Override - @Transactional - public void edit(Integer id, NoticeRequest request) { - Objects.requireNonNull(id, "通知ID不得为空"); - Notice entity = noticeRepository.findById(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(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 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); - } - } - - // ============================ 撤回 ============================ - - @Override - @Transactional - public void pull(Integer id) { - Objects.requireNonNull(id, "通知ID不得为空"); - Notice entity = noticeRepository.findById(id) - .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); - if (entity.getStatus() != PUBLISHED) { - throw new BizException(ErrorCode.FORBIDDEN, "仅已发布的通知可撤回"); - } - if (entity.getIsPulled() != null && entity.getIsPulled() == 1) { - throw new BizException(ErrorCode.FORBIDDEN, "通知已被撤回,请勿重复操作"); - } - entity.setIsPulled(1); - noticeRepository.save(entity); - } - - // ============================ 置顶/取消置顶 ============================ - - @Override - @Transactional - public void setTopmost(Integer id, Integer topmost) { - Objects.requireNonNull(id, "通知ID不得为空"); - Notice entity = noticeRepository.findById(id) - .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); - entity.setTopmost(topmost == null ? 0 : topmost); - noticeRepository.save(entity); - } - - // ============================ 转发 ============================ - - @Override - @Transactional - public Integer forward(Integer sourceId, NoticeRequest request) { - Objects.requireNonNull(sourceId, "源通知ID不得为空"); - Notice source = noticeRepository.findById(sourceId) - .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "源通知不存在: " + sourceId)); - Notice entity = new Notice(); - BeanUtils.copyProperties(request, entity); - entity.setId(null); - 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); - saveReceivers(entity); - trySendSms(entity); - return entity.getId(); - } - - // ============================ 用户端列表(含未读) ============================ - - @Override - 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); - - List content = page.getContent().stream() - .map(n -> toVo(n, readIds.contains(n.getId()))) - .collect(Collectors.toList()); - - 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 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)); - } else { - predicates.add(root.get("id").in(readIds)); - } - } - return cq.where(predicates.toArray(new Predicate[0])).getRestriction(); - }; - } - - // ============================ 管理端列表(全部) ============================ - - @Override - public BasePageResult adminList(NoticeQuery query, Pageable pageable) { - Page page = noticeRepository.findAll(adminSpec(query), pageable); - List content = page.getContent().stream() - .map(n -> toVo(n, false)) - .collect(Collectors.toList()); - // 批量填充附件ID串("1,2,3"):列表页"附件下载"入口依赖 UpFile 字段 - if (!content.isEmpty()) { - Map> fileMap = batchFileIds( - content.stream().map(NoticeVo::getId).collect(Collectors.toList())); - for (NoticeVo vo : content) { - List fids = fileMap.getOrDefault(vo.getId(), Collections.emptyList()); - vo.setUpFile(fids.stream().map(String::valueOf).collect(Collectors.joining(","))); - } - } - return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements())); - } - - /** - * 管理端动态查询:按状态/标题/类型/部门/发布人/时间范围过滤。 - * 多租户:按当前用户学校过滤(schId 为空时不加过滤)。 - * scope="user" 时仅看当前用户发布的通知。 - */ - private Specification adminSpec(NoticeQuery query) { - return (Root root, CriteriaQuery cq, CriteriaBuilder cb) -> { - List predicates = new ArrayList<>(); - if (query.getStatus() != null) { - predicates.add(cb.equal(root.get("status"), query.getStatus())); - } - appendCommonPredicates(query, root, cb, predicates); - // 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())) { - predicates.add(cb.like(root.get("title"), "%" + query.getKeyword() + "%")); - } - if (query.getType() != null) { - predicates.add(cb.equal(root.get("type"), query.getType())); - } - if (StringUtils.hasText(query.getDeptId())) { - predicates.add(cb.equal(root.get("deptId"), query.getDeptId())); - } - // 前端传 yyyy-MM-dd 短日期:startTime 当天 00:00:00 起,endTime 补全到当天 23:59:59 - if (query.getStartTime() != null) { - predicates.add(cb.greaterThanOrEqualTo(root.get("createTime"), query.getStartTime().atStartOfDay())); - } - if (query.getEndTime() != null) { - predicates.add(cb.lessThanOrEqualTo(root.get("createTime"), query.getEndTime().atTime(23, 59, 59))); - } - } - - // ============================ 详情(顺带标记已读) ============================ - - @Override - public NoticeVo detail(Integer id) { - Notice entity = noticeRepository.findById(id) - .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); - String account = currentAccount(); - boolean read = noticeReadRepository.existsByNoticeIdAndReaderAccount(id, account); - NoticeVo vo = toVo(entity, read); - if (!read) { - markRead(id); - vo.setRead(true); - } - 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 = currentAccount(); - if (noticeReadRepository.existsByNoticeIdAndReaderAccount(id, account)) { - return; - } - NoticeRead read = new NoticeRead(); - read.setNoticeId(id); - read.setReaderAccount(account); - read.setReaderName(currentName()); - read.setReadTime(LocalDateTime.now()); - noticeReadRepository.save(read); - } - - // ============================ 一键全部已读(批量化) ============================ - - @Override - @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; - } - - // ============================ 追加接收人(事务化) ============================ - - @Override - @Transactional - public void receiveUsers(Integer noticeId, String users) { - if (!StringUtils.hasText(users)) { - throw new BizException(ErrorCode.PARAM_ERROR, "请选择追送用户!"); - } - Notice entity = noticeRepository.findById(noticeId) - .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId)); - if (entity.getReceiveList() != null && "ALL".equalsIgnoreCase(entity.getReceiveList().trim())) { - return; - } - // 合并旧接收人 + 新接收人 - 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()); - } - - // ============================ 上一篇/下一篇(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; - } - - // ============================ 阅读明细 ============================ - - @Override - public List getReadDetail(Integer noticeId) { - Objects.requireNonNull(noticeId, "通知ID不得为空"); - List readList = noticeReadRepository.findByNoticeIdOrderByReadTimeDesc(noticeId); - return readList.stream().map(r -> { - NoticeVo.ReadDetail detail = new NoticeVo.ReadDetail(); - detail.setAccount(r.getReaderAccount()); - detail.setName(r.getReaderName()); - detail.setRead(true); - detail.setReadTime(r.getReadTime()); - return detail; - }).collect(Collectors.toList()); - } - - // ============================ 阅读情况分组 ============================ - - /** - * 阅读情况按「学校组」分组(对齐旧 .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); - } - } - - // 逐接收人查用户中心(姓名+组织链)。接收明细可能存登录名,也可能存历史数字ID - // (如 10012):先按登录名查,查不到且为纯数字再按 userId 查,保证未读人也能取到组 - Map userMap = new LinkedHashMap<>(); - Map canonicalAccount = new HashMap<>(); // 原始串 -> 规范登录名 - for (String account : receivers) { - BoaUser user = null; - try { - user = YschBaseOpenApi.getUserInfo(account); - if (user == null && account.chars().allMatch(Character::isDigit)) { - user = YschBaseOpenApi.getUserInfo(Integer.parseInt(account)); - } - } catch (Exception e) { - log.warn("[readDetailGrouped] 查询用户中心失败, account={}", account, e); - } - if (user != null) { - userMap.put(account, user); - if (StringUtils.hasText(user.getAccount())) { - canonicalAccount.put(account, user.getAccount()); - } - } - } - - // 组名=用户组织链最近端(主组),跨校/查不到归「其他」(对齐旧版逻辑) - Map>> groupUsers = new LinkedHashMap<>(); - for (String account : receivers) { - BoaUser user = userMap.get(account); - // 已读比对/姓名兜底统一使用规范登录名(接收明细可能存的是历史数字ID) - String canonical = canonicalAccount.getOrDefault(account, account); - boolean read = readAccounts.contains(canonical) || readAccounts.contains(account); - String groupName = resolveReadGroupName(user, notice.getSchId()); - String teaName = user != null && StringUtils.hasText(user.getName()) - ? user.getName() - : readNameMap.getOrDefault(canonical, canonical); - Map item = new LinkedHashMap<>(); - item.put("account", canonical); - item.put("teaname", teaName); - item.put("states", read ? "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) { - NoticeVo vo = new NoticeVo(); - 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()); - } - - // ============================ 接收人工具方法 ============================ - - /** - * 接收人列表归一化:前端/存量可能传 "all"/"UserAll"/"ALL"(全员),统一存 "ALL"。 - * 其余视为 JSON 数组(账号)或逗号分隔串,原样保留。 - */ - 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; - } - - /** - * 解析接收人列表(JSON 数组字符串 -> 账号列表)。 - * 为空或解析失败时返回空列表(表示全员通知)。 - */ - private List parseReceiveList(String receiveListJson) { - 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/resources/application.yml b/src/main/resources/application.yml index a35312e..c7ae142 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,9 +9,9 @@ spring: date-format: yyyy-MM-dd HH:mm:ss datasource: - url: jdbc:mysql://localhost:3306/bsch_notice?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai + url: jdbc:mysql://localhost:3306/campus_notice?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai username: root - password: 123456 + password: 123123 driver-class-name: com.mysql.cj.jdbc.Driver min-idle: 10 max-active: 30 @@ -48,12 +48,12 @@ file: # 短信模块配置 sms: enabled: true - module-id: notice + module-id: campusnotice # 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) yh: sso: - server-url-prefix: http://sso.yunhaixingtong.com/cas/ + server-url-prefix: http://sso.yunhaixingtong.com client-host-url: http://localhost:8101 allow-headers: all allow-origins: all @@ -69,4 +69,10 @@ yh: base-host: http://base.yunhaixingtong.com user-center-url: http://base.yunhaixingtong.com/userbase # 班级管理(bschclass)服务基址 - bschclass: http://base.yunhaixingtong.com/bschclass + bschclass: http://base.yunhaixingtong.com/userclass + + # 通知业务配置 + notice: + # 审批人资格组ID(用户中心组):>0 时 Submit 逐个校验被选人属于该组; + # 0 = 仅校验用户存在(审批资格数据源待产品确认,改造方案 5.1-#5) + approver-group-id: 0 diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml index 2724e1a..afe3ee8 100644 --- a/src/main/resources/bootstrap.yml +++ b/src/main/resources/bootstrap.yml @@ -1,6 +1,6 @@ spring: application: - name: notice + name: campusnotice profiles: active: ${SPRING_PROFILES_ACTIVE:local} cloud: @@ -11,7 +11,7 @@ spring: namespace: ${SPRING_CLOUD_NACOS_CONFIG_NAMESPACE:6196f0de-a28d-4894-8f10-e2982b57c6bc} # 应用自身配置所在分组 group: OA_APP - # 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension},即 notice.yaml + # 配置文件格式;最终主配置 dataId = ${spring.application.name}.${file-extension},即 campusnotice.yaml file-extension: yaml encode: UTF-8 # nacos 不可达时不强制退出,回退到本地 application.yml 兜底 diff --git a/src/main/resources/sql/notice_receiver_create.sql b/src/main/resources/sql/notice_receiver_create.sql index 780bfff..387a038 100644 --- a/src/main/resources/sql/notice_receiver_create.sql +++ b/src/main/resources/sql/notice_receiver_create.sql @@ -2,7 +2,7 @@ -- notice_receiver 建表(替代 notice.receive_list JSON 字符串存储) -- 决策 D2-B:拆关联表,支撑阅读情况/催收/统计 SQL 化 -- --- 对应实体:com.yunhai.notice.entity.NoticeReceiver +-- 对应实体:com.yunhai.campusnotice.entity.NoticeReceiver -- 索引:idx_notice_id(notice_id) + idx_account(account) -- -- 执行方式:由 DBA 手动在对应环境执行,AI 不代跑