From bf4a5ab550f94a69a3224bcbe5c25a1aaad29acd Mon Sep 17 00:00:00 2001 From: wanghu Date: Tue, 15 Sep 2026 10:22:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/api/NoticeController.java | 385 +++ .../campusnotice/service/INoticeService.java | 228 ++ .../service/impl/NoticeService.java | 2786 +++++++++++++++++ .../entity/NoticeApprovalRecord.java | 78 + .../entity/NoticeApprovalStep.java | 69 + .../campusnotice/entity/SysApprover.java | 56 + .../job/ApproverIdentityBackfillRunner.java | 49 + .../model/notice/ApprovalFlowVo.java | 65 + .../model/notice/ApprovalTabCountVo.java | 26 + .../model/notice/FlowStepItem.java | 29 + .../model/notice/ReadTabCountVo.java | 25 + .../NoticeApprovalRecordRepository.java | 28 + .../NoticeApprovalStepRepository.java | 30 + .../repository/SysApproverRepository.java | 22 + 14 files changed, 3876 insertions(+) create mode 100644 .backup/20260914-v13-approval-tab-count/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java create mode 100644 .backup/20260914-v13-approval-tab-count/src/main/java/com/yunhai/campusnotice/service/INoticeService.java create mode 100644 .backup/20260914-v13-approval-tab-count/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java create mode 100644 src/main/java/com/yunhai/campusnotice/entity/NoticeApprovalRecord.java create mode 100644 src/main/java/com/yunhai/campusnotice/entity/NoticeApprovalStep.java create mode 100644 src/main/java/com/yunhai/campusnotice/entity/SysApprover.java create mode 100644 src/main/java/com/yunhai/campusnotice/job/ApproverIdentityBackfillRunner.java create mode 100644 src/main/java/com/yunhai/campusnotice/model/notice/ApprovalFlowVo.java create mode 100644 src/main/java/com/yunhai/campusnotice/model/notice/ApprovalTabCountVo.java create mode 100644 src/main/java/com/yunhai/campusnotice/model/notice/FlowStepItem.java create mode 100644 src/main/java/com/yunhai/campusnotice/model/notice/ReadTabCountVo.java create mode 100644 src/main/java/com/yunhai/campusnotice/repository/NoticeApprovalRecordRepository.java create mode 100644 src/main/java/com/yunhai/campusnotice/repository/NoticeApprovalStepRepository.java create mode 100644 src/main/java/com/yunhai/campusnotice/repository/SysApproverRepository.java diff --git a/.backup/20260914-v13-approval-tab-count/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java b/.backup/20260914-v13-approval-tab-count/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java new file mode 100644 index 0000000..3d6ff9c --- /dev/null +++ b/.backup/20260914-v13-approval-tab-count/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java @@ -0,0 +1,385 @@ +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.OtherUtil; +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.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import javax.print.attribute.standard.MediaSize; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +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→姓名/头像) ============================ + + /** + * 审批教师绑定:按学校教师列表(bschclass)绑定教师树选中ID → 姓名/头像。 + * ids=101,102(教师树叶子节点ID);schId 缺省取当前登录人学校; + * 未命中的ID保留、name 为 null,由前端提示。 + */ + @GetMapping("ApproverResolve") + public List approverResolve( + @RequestParam(value = "ids", required = false) String ids, + @RequestParam(value = "schId", required = false) Integer schId) { + if (ids == null || ids.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "ids 不允许为空"); + } + return noticeService.approverResolve(Arrays.asList(OtherUtil.convertToArray(ids)), schId); + } + + // ============================ 提交审批(多步流程) ============================ + + /** + * 提交审批(status: 0/3→2):steps 多步审批流 [{name, vote, approvers:[登录账号]}] 优先, + * 缺省时旧 approverTeaIds 包装为单步或签。步骤落 notice_approval_step, + * 处理动作落 notice_approval_record,并 upsert 审批人记忆表。 + */ + @PostMapping("Submit") + public void submit(@RequestBody SubmitRequest request) { + noticeService.submit(request); + } + + // ============================ 审批通过(当前步) ============================ + + /** + * 审批通过:或签任一通过/会签全部通过后流转下一步,末步通过即真实发布; + * 被他人先审或已处理过时返回冲突提示。兼容 form 提交(serid/opinion)。 + */ + @PostMapping("Approve") + public void approve(@RequestParam("serid") Integer serid, + @RequestParam(value = "opinion", required = false) String opinion) { + noticeService.approve(serid, opinion); + } + + // ============================ 审批驳回(当前步) ============================ + + /** + * 审批驳回:任一步驳回即 status→3,意见必填(remark 为前端别名);发布人可改后重新提交。 + */ + @PostMapping("Reject") + public void reject(@RequestParam("serid") Integer serid, + @RequestParam(value = "opinion", required = false) String opinion, + @RequestParam(value = "remark", required = false) String remark) { + noticeService.reject(serid, StringUtils.hasText(opinion) ? opinion : remark); + } + + // ============================ 审批列表(待审/已审合一) ============================ + + /** + * 审批列表:auditState/status=pending(默认) 待我审批 / approved 走流程已发布 / + * rejected 已驳回 / all|reviewed 已审合一。行 VO 为 NoticeVo,多步审批字段: + * approvalStatus/approvalStatusDesc(进度文本)/approvalLevel/canApprove。 + */ + @GetMapping("ApprovalList") + public BasePageResult approvalList( + @RequestParam(value = "auditState", required = false) String auditState, + @RequestParam(value = "status", required = false) String status, + NoticeQuery query, + @SortDefault(sort = "updateTime", direction = Sort.Direction.DESC) Sort sort) { + return noticeService.approvalList( + StringUtils.hasText(auditState) ? auditState : status, query, PageUtils.toPageable(query, sort)); + } + + // ============================ 待我审批数量(菜单角标) ============================ + + /** + * 待我审批数量:待审批且当前步审批人快照含当前登录人(账号或教师ID)。 + */ + @GetMapping("ApprovalCount") + public int approvalCount() { + return noticeService.approvalCount(); + } + + /** 解析 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/.backup/20260914-v13-approval-tab-count/src/main/java/com/yunhai/campusnotice/service/INoticeService.java b/.backup/20260914-v13-approval-tab-count/src/main/java/com/yunhai/campusnotice/service/INoticeService.java new file mode 100644 index 0000000..75a4f38 --- /dev/null +++ b/.backup/20260914-v13-approval-tab-count/src/main/java/com/yunhai/campusnotice/service/INoticeService.java @@ -0,0 +1,228 @@ +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.approverTeaIds 选教师,或签)。 + * + * @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):request.steps 多步审批流([{name, vote, approvers:[登录账号]}]) + * 优先;缺省时旧 approverTeaIds 包装为单步或签(兼容链路)。 + * 步骤落 notice_approval_step,处理动作落 notice_approval_record。 + */ + void submit(SubmitRequest request); + + /** + * 审批通过:当前步审批人处理;或签任一通过/会签全部通过后流转下一步, + * 末步通过 status: 2→1(CAS)并触发真实发布。已被他人先行处理时抛业务冲突提示。 + */ + void approve(Integer serid, String opinion); + + /** + * 审批驳回:任一步驳回即 status: 2→3(CAS),记录意见;发布人可改后重新提交。 + */ + void reject(Integer serid, String opinion); + + /** + * 审批列表:pending(默认)=待我审批 status=2;approved=走流程已发布;rejected=已驳回; + * all/reviewed=已审合一。行 VO 为 NoticeVo(多步审批字段:approvalStatus/approvalStatusDesc/ + * approvalLevel/canApprove)。 + */ + BasePageResult approvalList(String auditState, NoticeQuery query, Pageable pageable); + + /** + * 待我审批数量(菜单角标):待审批且当前步审批人快照含当前登录人。 + */ + int approvalCount(); + + /** + * 审批人记忆回显:当前发布人最近一次所选审批教师ID组合,无记忆返回空。 + */ + ApproverSuggestVo approverSuggest(); + + /** + * 审批教师绑定:按学校教师列表(bschclass)绑定教师树选中ID → 姓名/头像。 + * schId 为空取当前登录人学校;未命中的ID保留、name 为 null 由调用方提示。 + */ + List approverResolve(List ids, Integer schId); + + /** + * 编辑通知:草稿/已驳回/已发布(旧链路,无推送范围)可编辑; + * 待审批禁止编辑;新版表单已发布(存在范围快照)禁止编辑(先撤回后重发)。 + */ + 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/.backup/20260914-v13-approval-tab-count/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java b/.backup/20260914-v13-approval-tab-count/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java new file mode 100644 index 0000000..b93270d --- /dev/null +++ b/.backup/20260914-v13-approval-tab-count/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java @@ -0,0 +1,2786 @@ +package com.yunhai.campusnotice.service.impl; + +import com.yunhai.campusnotice.entity.Notice; +import com.yunhai.campusnotice.entity.NoticeApproval; +import com.yunhai.campusnotice.entity.NoticeApprovalRecord; +import com.yunhai.campusnotice.entity.NoticeApprovalStep; +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.SysApprover; +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.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.notice.*; +import com.yunhai.campusnotice.repository.NoticeApprovalRecordRepository; +import com.yunhai.campusnotice.repository.NoticeApprovalRepository; +import com.yunhai.campusnotice.repository.NoticeApprovalStepRepository; +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.SysApproverRepository; +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 com.fasterxml.jackson.databind.ObjectMapper; +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.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,无需时间模块) */ + @Autowired + private NoticeRepository noticeRepository; + + @Autowired + private NoticeReadRepository noticeReadRepository; + + @Autowired + private NoticeReceiverRepository noticeReceiverRepository; + + @Autowired + private NoticePushScopeRepository pushScopeRepository; + + @Autowired + private NoticeApprovalRepository approvalRepository; + + @Autowired + private NoticeApprovalStepRepository approvalStepRepository; + + @Autowired + private NoticeApprovalRecordRepository approvalRecordRepository; + + @Autowired + private SysApproverRepository sysApproverRepository; + + @Autowired + private NoticeApproverMemoryRepository approverMemoryRepository; + + @Autowired + private ObjectMapper objectMapper; + + @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(用户中心用户ID,与教师树叶子节点 id 同一 ID 空间)。 + * 取登录态 userId(字符串数字),无登录态或非数字时为 null。 + */ + private static Integer currentTeaId() { + LoginUser user = UserContextHolder.get(); + if (user == null || !StringUtils.hasText(user.getUserId())) { + return null; + } + try { + return Integer.valueOf(user.getUserId().trim()); + } catch (NumberFormatException e) { + return null; + } + } + + /** + * 当前登录人学校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(); + // 选人即送审:带审批流(flow 多步 / approvers 单步 / 旧 approverTeaIds)即进审批, + // 不依赖 publishType(对齐前端「勾选审批才发 flow」的行为) + List flow = resolveNoticeFlow(request); + + 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()); + saveFlowDefinition(entity, flow, request.getApproverTeaIds()); + fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); + return entity.getId(); + } + if ("approve".equals(publishType) || !flow.isEmpty()) { + doSubmitApproval(entity, flow, null, 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()); + saveFlowDefinition(entity, resolveNoticeFlow(request), request.getApproverTeaIds()); + 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); + } + } + + // ============================ 审批(多步流程:步骤表 + 审核记录表) ============================ + + @Override + @Transactional + public void submit(SubmitRequest request) { + Notice notice = loadNotice(request.getSerid()); + doSubmitApproval(notice, resolveRequestFlow(request.getSteps(), request.getApproverTeaIds()), + request.getApproverTeaIds(), request.getOpinion()); + } + + /** + * 提交审批装配:流程绑定校验 → status: 0/3→2 → 主表落库拿通知ID → + * 步骤落 notice_approval_step(第 1 步审批中,其余待流转)→ 当前步快照落主表 → + * notice_approval 提交行 → upsert 记忆表。 + * 草稿已存流程定义(全待流转步)且本次未重选人时,直接激活存量定义。 + */ + private void doSubmitApproval(Notice entity, List flowItems, List legacyTeaIds, String opinion) { + int status = entity.getStatus() == null ? PUBLISHED_CODE : entity.getStatus(); + if (status != DRAFT_CODE && status != REJECTED_CODE) { + throw new BizException(ErrorCode.FORBIDDEN, "仅草稿或已驳回状态的通知可提交审批"); + } + List steps = buildSteps(entity, flowItems, legacyTeaIds, true); + if (steps.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择审批人"); + } + // 全待流转 = 草稿流程定义复用;否则视为新构建流程(清掉上一轮旧步骤) + boolean reuseDefinition = steps.stream() + .allMatch(s -> s.getStatus() != null && s.getStatus() == NoticeApprovalStep.STATUS_WAITING); + NoticeApprovalStep first = steps.get(0); + entity.setStatus(PENDING_CODE); + entity.setApprovalLevel(0); + entity.setApproverAccounts(first.getApproverAccounts()); + entity.setApproverTeaIds(first.getApproverTeaIds()); + noticeRepository.save(entity); + Integer noticeId = entity.getId(); + + if (!reuseDefinition && entity.getId() != null) { + approvalStepRepository.deleteByNoticeId(noticeId); + approvalStepRepository.flush(); + } + for (int i = 0; i < steps.size(); i++) { + NoticeApprovalStep step = steps.get(i); + step.setNoticeId(noticeId); + step.setStepNo(i + 1); + step.setStatus(i == 0 ? NoticeApprovalStep.STATUS_ACTIVE : NoticeApprovalStep.STATUS_WAITING); + approvalStepRepository.save(step); + } + + saveApprovalRow(noticeId, NoticeApproval.ACTION_SUBMIT, + currentAccount(), currentName(), opinion, first.getApproverTeaIds()); + List firstTeaIds = parseTeaIds(first.getApproverTeaIds()); + if (!firstTeaIds.isEmpty()) { + upsertApproverMemory(entity.getSchId(), currentTeaId(), firstTeaIds); + } + log.info("[submit] 通知 {} 提交审批, 发布人 {}, 共 {} 步, 第 1 步审批人 {}", + noticeId, currentAccount(), steps.size(), parseAccounts(first.getApproverAccounts())); + } + + @Override + @Transactional + public void approve(Integer serid, String opinion) { + Notice notice = loadNotice(serid); + String meAccount = currentAccount(); + Integer meTeaId = currentTeaId(); + if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) { + throw alreadyHandledError(serid); + } + List steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid); + if (steps.isEmpty()) { + // 存量单步数据(步骤表改造前提交)按旧或签语义走通 + approveLegacy(notice, meTeaId, opinion); + return; + } + int level = notice.getApprovalLevel() == null ? 0 : notice.getApprovalLevel(); + NoticeApprovalStep current = steps.get(Math.min(level, steps.size() - 1)); + if (current.getStatus() != null && current.getStatus() != NoticeApprovalStep.STATUS_ACTIVE) { + throw alreadyHandledError(serid); + } + requireStepApprover(current, meAccount, meTeaId); + int round = (int) approvalRepository.countByNoticeIdAndAction(serid, NoticeApproval.ACTION_SUBMIT); + boolean acted = approvalRecordRepository.findByNoticeIdAndRoundAndStepNo(serid, round, current.getStepNo()).stream() + .anyMatch(r -> meAccount != null && meAccount.equals(r.getApproverAccount())); + if (acted) { + throw new BizException(ErrorCode.FORBIDDEN, "您已审批过本步骤,请等待其他审批人处理"); + } + saveRecordRow(serid, round, current, meAccount, meName(meAccount), NoticeApprovalRecord.ACTION_APPROVE, opinion); + + // 会签:本步全部通过才流转;或签:任一通过即流转 + Set actedAccounts = approvalRecordRepository.findByNoticeIdAndRoundAndStepNo(serid, round, current.getStepNo()).stream() + .map(NoticeApprovalRecord::getApproverAccount) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + List stepAccounts = parseAccounts(current.getApproverAccounts()); + boolean stepDone = "all".equals(current.getVote()) + ? !stepAccounts.isEmpty() && actedAccounts.containsAll(stepAccounts) + : true; + if (!stepDone) { + log.info("[approve] 通知 {} 第 {} 步会签进度 {}/{}", serid, current.getStepNo(), + actedAccounts.size(), stepAccounts.size()); + return; + } + current.setStatus(NoticeApprovalStep.STATUS_APPROVED); + current.setHandleTime(LocalDateTime.now()); + approvalStepRepository.save(current); + + if (level + 1 < steps.size()) { + NoticeApprovalStep next = steps.get(level + 1); + next.setStatus(NoticeApprovalStep.STATUS_ACTIVE); + approvalStepRepository.save(next); + notice.setApprovalLevel(level + 1); + notice.setApproverAccounts(next.getApproverAccounts()); + notice.setApproverTeaIds(next.getApproverTeaIds()); + noticeRepository.save(notice); + log.info("[approve] 通知 {} 第 {} 步通过, 流转至第 {} 步", serid, current.getStepNo(), next.getStepNo()); + } else { + publishAfterFinalApproval(notice, steps.size()); + } + } + + /** 存量无步骤数据(旧或签单步)通过:CAS 发布,保留旧冲突提示语义 */ + private void approveLegacy(Notice notice, Integer meTeaId, String opinion) { + requireApprover(notice, meTeaId); + if (noticeRepository.casApprove(notice.getId(), LocalDateTime.now()) == 0) { + throw alreadyHandledError(notice.getId()); + } + notice.setStatus(PUBLISHED_CODE); + saveApprovalRow(notice.getId(), NoticeApproval.ACTION_APPROVE, currentAccount(), currentName(), opinion, null); + if (isImmediatePublish(notice)) { + doRealPublish(notice); + } else { + noticeRepository.save(notice); + } + log.info("[approve] 通知 {} 审批通过(旧单步), 审批教师 {}", notice.getId(), meTeaId); + } + + /** 末步通过 → status→1(CAS 防并发重复发布)→ 真实发布(定时未到点交调度器) */ + private void publishAfterFinalApproval(Notice notice, int stepCount) { + if (noticeRepository.casApprove(notice.getId(), LocalDateTime.now()) == 0) { + throw alreadyHandledError(notice.getId()); + } + notice.setStatus(PUBLISHED_CODE); + notice.setApprovalLevel(stepCount); + notice.setApproverAccounts(null); + notice.setApproverTeaIds(null); + saveApprovalRow(notice.getId(), NoticeApproval.ACTION_APPROVE, currentAccount(), currentName(), null, null); + if (isImmediatePublish(notice)) { + doRealPublish(notice); + } else { + noticeRepository.save(notice); + } + log.info("[approve] 通知 {} 全部步骤通过, 真实发布", notice.getId()); + } + + @Override + @Transactional + public void reject(Integer serid, String opinion) { + if (!StringUtils.hasText(opinion)) { + throw new BizException(ErrorCode.PARAM_ERROR, "请填写驳回意见"); + } + Notice notice = loadNotice(serid); + String meAccount = currentAccount(); + Integer meTeaId = currentTeaId(); + if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) { + throw alreadyHandledError(serid); + } + List steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid); + if (steps.isEmpty()) { + // 存量单步数据按旧语义驳回 + requireApprover(notice, meTeaId); + if (noticeRepository.casReject(serid, LocalDateTime.now()) == 0) { + throw alreadyHandledError(serid); + } + notice.setStatus(REJECTED_CODE); + noticeRepository.save(notice); + saveApprovalRow(serid, NoticeApproval.ACTION_REJECT, meAccount, currentName(), opinion, null); + log.info("[reject] 通知 {} 审批驳回(旧单步), 意见: {}", serid, opinion); + return; + } + int level = notice.getApprovalLevel() == null ? 0 : notice.getApprovalLevel(); + NoticeApprovalStep current = steps.get(Math.min(level, steps.size() - 1)); + if (current.getStatus() != null && current.getStatus() != NoticeApprovalStep.STATUS_ACTIVE) { + throw alreadyHandledError(serid); + } + requireStepApprover(current, meAccount, meTeaId); + int round = (int) approvalRepository.countByNoticeIdAndAction(serid, NoticeApproval.ACTION_SUBMIT); + if (noticeRepository.casReject(serid, LocalDateTime.now()) == 0) { + throw alreadyHandledError(serid); + } + saveRecordRow(serid, round, current, meAccount, meName(meAccount), NoticeApprovalRecord.ACTION_REJECT, opinion); + current.setStatus(NoticeApprovalStep.STATUS_REJECTED); + current.setHandleTime(LocalDateTime.now()); + approvalStepRepository.save(current); + notice.setStatus(REJECTED_CODE); + noticeRepository.save(notice); + log.info("[reject] 通知 {} 第 {} 步驳回, 审批人 {}, 意见: {}", serid, current.getStepNo(), meAccount, opinion); + } + + /** 当前步审批人校验:登录账号命中账号快照,或教师ID命中旧 teaId 快照 */ + private void requireStepApprover(NoticeApprovalStep step, String meAccount, Integer meTeaId) { + boolean hit = (meAccount != null && parseAccounts(step.getApproverAccounts()).contains(meAccount)) + || (meTeaId != null && parseTeaIds(step.getApproverTeaIds()).contains(meTeaId)); + if (!hit) { + throw new BizException(ErrorCode.FORBIDDEN, "您不是本步骤的审批人"); + } + } + + /** 仅存量单步快照含当前教师ID可处理(旧链路兜底) */ + private void requireApprover(Notice notice, Integer teaId) { + if (teaId == null) { + throw new BizException(ErrorCode.FORBIDDEN, "未登录或会话已过期"); + } + if (!parseTeaIds(notice.getApproverTeaIds()).contains(teaId)) { + 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, "该通知已离开待审批状态,无需重复处理"); + } + + /** + * 归一化请求里的审批流:steps 优先;缺省时旧 approverTeaIds 包装为单步或签 + * (教师绑定兼容链路)。每步账号(approvers)与教师ID(approverTeaIds)二选一, + * 两者皆空的步丢弃;整体为空返回空列表。 + */ + private List resolveRequestFlow(List steps, List legacyTeaIds) { + List result = new ArrayList<>(); + if (steps != null) { + for (FlowStepItem step : steps) { + if (step == null) { + continue; + } + List accounts = new ArrayList<>(); + if (step.getApprovers() != null) { + for (String account : step.getApprovers()) { + if (StringUtils.hasText(account) && !accounts.contains(account.trim())) { + accounts.add(account.trim()); + } + } + } + List teaIds = step.getApproverTeaIds() == null ? new ArrayList<>() + : step.getApproverTeaIds().stream().filter(Objects::nonNull).collect(Collectors.toList()); + if (accounts.isEmpty() && teaIds.isEmpty()) { + continue; + } + FlowStepItem item = new FlowStepItem(); + item.setName(StringUtils.hasText(step.getName()) ? step.getName().trim() : null); + item.setVote("all".equalsIgnoreCase(step.getVote()) ? "all" : "any"); + item.setApprovers(accounts); + item.setApproverTeaIds(teaIds); + result.add(item); + } + } + if (result.isEmpty() && legacyTeaIds != null && !legacyTeaIds.isEmpty()) { + FlowStepItem legacy = new FlowStepItem(); + legacy.setVote("any"); + result.add(legacy); + } + return result; + } + + /** 发布入参的审批流解析:flow 多步优先 → approvers+vote 单步简写 → 旧 approverTeaIds 兼容包装 */ + private List resolveNoticeFlow(NoticeRequest request) { + if (request.getFlow() != null && !request.getFlow().isEmpty()) { + return resolveRequestFlow(request.getFlow(), null); + } + if (request.getApprovers() != null && !request.getApprovers().isEmpty()) { + FlowStepItem single = new FlowStepItem(); + single.setVote(request.getVote()); + single.setApprovers(request.getApprovers()); + return resolveRequestFlow(Collections.singletonList(single), null); + } + return resolveRequestFlow(null, request.getApproverTeaIds()); + } + + /** + * 保存审批流定义(草稿/编辑态):全步骤置「待流转」,提交审批时激活第 1 步。 + * 全量替换语义:flow 非空重建;flow 空且 legacyTeaIds 非 null 时仅清空旧定义; + * 两者皆无(未携带流程字段)时不改动。 + */ + private void saveFlowDefinition(Notice entity, List flow, List legacyTeaIds) { + boolean flowCarried = flow != null && !flow.isEmpty(); + if (!flowCarried && legacyTeaIds == null) { + return; + } + approvalStepRepository.deleteByNoticeId(entity.getId()); + approvalStepRepository.flush(); + if (!flowCarried) { + return; + } + List steps = buildSteps(entity, flow, legacyTeaIds, false); + for (int i = 0; i < steps.size(); i++) { + NoticeApprovalStep step = steps.get(i); + step.setNoticeId(entity.getId()); + step.setStepNo(i + 1); + step.setStatus(NoticeApprovalStep.STATUS_WAITING); + approvalStepRepository.save(step); + } + } + + /** + * 构建并绑定审批步骤:账号路径按本校启用审批人池校验(姓名随池快照); + * 旧 teaId 路径按学校教师名单校验,validatePool=true 时(正式送审)再按审批人池白名单校验。 + * 每步至少一人,整体至少一步。 + * 草稿已存定义(全待流转步)且本次未重选人时直接复用存量定义。 + */ + private List buildSteps(Notice entity, List flowItems, + List legacyTeaIds, boolean validatePool) { + if ((flowItems == null || flowItems.isEmpty()) && entity.getId() != null) { + List existing = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(entity.getId()); + // 仅「全待流转」视为草稿流程定义可复用;已驳回单的步骤含审批中状态,必须重建 + boolean hasDefinition = !existing.isEmpty() && existing.stream() + .allMatch(s -> s.getStatus() != null && s.getStatus() == NoticeApprovalStep.STATUS_WAITING); + if (hasDefinition) { + // v7:送审沿用草稿已存流程时同样校验池白名单(草稿暂存不校验、送审必校验) + if (validatePool) { + LinkedHashMap resolved = new LinkedHashMap<>(); + for (NoticeApprovalStep s : existing) { + List ids = parseTeaIds(s.getApproverTeaIds()); + List names = parseAccounts(s.getApproverNames()); + for (int j = 0; j < ids.size(); j++) { + resolved.putIfAbsent(ids.get(j), j < names.size() ? names.get(j) : String.valueOf(ids.get(j))); + } + } + if (!resolved.isEmpty()) { + requireInApproverPool(entity.getSchId(), resolved); + } + } + return existing; + } + } + if (flowItems == null || flowItems.isEmpty()) { + return Collections.emptyList(); + } + List steps = new ArrayList<>(); + for (int i = 0; i < flowItems.size(); i++) { + FlowStepItem item = flowItems.get(i); + NoticeApprovalStep step = new NoticeApprovalStep(); + step.setStepName(StringUtils.hasText(item.getName()) ? item.getName() + : "第" + (i + 1) + "步"); + step.setVote("all".equals(item.getVote()) ? "all" : "any"); + if (item.getApprovers() != null && !item.getApprovers().isEmpty()) { + // 账号路径:按本校启用审批人池绑定 + LinkedHashMap nameByAccount = bindAccounts(entity.getSchId(), item.getApprovers()); + List accounts = new ArrayList<>(nameByAccount.keySet()); + step.setApproverAccounts(toJsonStringList(accounts)); + step.setApproverNames(toJsonStringList(new ArrayList<>(nameByAccount.values()))); + List teaIds = new ArrayList<>(); + for (String account : accounts) { + sysApproverRepository.findBySchIdAndAccount(entity.getSchId(), account) + .filter(a -> a.getTeaId() != null) + .ifPresent(a -> teaIds.add(a.getTeaId())); + } + if (!teaIds.isEmpty()) { + step.setApproverTeaIds(toJsonTeaIds(teaIds)); + } + } else if (item.getApproverTeaIds() != null && !item.getApproverTeaIds().isEmpty()) { + // 教师ID路径(api-test 测试台教师树选人):按学校教师名单绑定,送审时加池白名单校验 + LinkedHashMap nameById = bindApprovers(entity.getSchId(), item.getApproverTeaIds(), validatePool); + step.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet()))); + step.setApproverNames(toJsonStringList(new ArrayList<>(nameById.values()))); + } else { + // 请求级旧 approverTeaIds 兼容包装步(单步或签) + LinkedHashMap nameById = bindApprovers(entity.getSchId(), legacyTeaIds, validatePool); + step.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet()))); + step.setApproverNames(toJsonStringList(new ArrayList<>(nameById.values()))); + } + steps.add(step); + } + return steps; + } + + /** + * 按本校启用审批人池绑定账号:账号必须存在于池(多租户隔离)且启用, + * 保持选择顺序去重;姓名取池快照(池未维护姓名时回退账号展示)。 + */ + private LinkedHashMap bindAccounts(Integer schId, List accounts) { + if (accounts == null || accounts.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择审批人"); + } + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + Map poolByAccount = new LinkedHashMap<>(); + for (SysApprover approver : sysApproverRepository.findBySchIdAndEnabledOrderByNameAsc(schId, 1)) { + poolByAccount.putIfAbsent(approver.getAccount(), approver); + } + LinkedHashMap result = new LinkedHashMap<>(); + List missing = new ArrayList<>(); + for (String account : accounts) { + if (result.containsKey(account)) { + continue; + } + SysApprover pool = poolByAccount.get(account); + if (pool == null) { + missing.add(account); + continue; + } + result.put(account, StringUtils.hasText(pool.getName()) ? pool.getName() : account); + } + if (!missing.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "审批人不在本校审批人名单或已停用(" + String.join("、", missing) + "),请在「审核设置」中维护"); + } + return result; + } + + /** 当前审批人姓名:上下文 displayName 优先,缺省回退登录账号 */ + private String meName(String meAccount) { + String name = currentName(); + return StringUtils.hasText(name) ? name : meAccount; + } + + /** 审核记录落行(步骤名/表决方式随行快照,历史轮次永久保留) */ + private void saveRecordRow(Integer noticeId, int round, NoticeApprovalStep step, + String account, String name, int action, String opinion) { + NoticeApprovalRecord row = new NoticeApprovalRecord(); + row.setNoticeId(noticeId); + row.setRound(round); + row.setStepNo(step.getStepNo()); + row.setStepName(step.getStepName()); + row.setVote(step.getVote()); + row.setApproverAccount(account); + row.setApproverName(name); + row.setAction(action); + row.setOpinion(opinion); + row.setActionTime(LocalDateTime.now()); + approvalRecordRepository.save(row); + } + + /** 账号 JSON 解析(["a","b"]),空/非法返回空列表 */ + private List parseAccounts(String json) { + if (!StringUtils.hasText(json)) { + return Collections.emptyList(); + } + try { + String[] arr = objectMapper.readValue(json, String[].class); + return arr == null ? Collections.emptyList() : new ArrayList<>(Arrays.asList(arr)); + } catch (Exception e) { + log.warn("[parseAccounts] 审批人账号快照解析失败: {}", json, e); + return Collections.emptyList(); + } + } + + /** 字符串列表转 JSON 数组串 */ + private String toJsonStringList(List values) { + if (values == null || values.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(values); + } catch (Exception e) { + log.warn("[toJsonStringList] 序列化失败: {}", values, e); + return null; + } + } + + /** + * 按学校教师列表绑定被选审批教师:bschclass 教师名单按 UserTeaSerID 匹配(保持选择顺序、去重); + * 未选人、或存在不属于本校的ID时报错。配置了资格组(yh.notice.approver-group-id>0)时 + * 另经用户中心校验所属组。记忆回显仅是默认值,提交时仍全量校验,防止记忆中的教师离职后带病提交。 + */ + private LinkedHashMap bindApprovers(Integer schId, List teaIds) { + return bindApprovers(schId, teaIds, false); + } + + /** + * v7:validatePool=true 时(正式送审)额外按本校启用审批人池校验 teaId 白名单; + * 草稿/编辑暂存(saveFlowDefinition)传 false,允许暂存池外教师,提交时再拦截。 + */ + private LinkedHashMap bindApprovers(Integer schId, List teaIds, boolean validatePool) { + if (teaIds == null || teaIds.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择审批教师"); + } + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + Map teacherById = new LinkedHashMap<>(); + for (SchTeaList tea : classService.getSchTeaList(schId)) { + if (tea.getUserTeaSerID() != null) { + teacherById.putIfAbsent(tea.getUserTeaSerID(), tea); + } + } + LinkedHashMap result = new LinkedHashMap<>(); + List missing = new ArrayList<>(); + for (Integer id : teaIds) { + if (id == null || result.containsKey(id)) { + continue; + } + SchTeaList tea = teacherById.get(id); + if (tea == null) { + missing.add(id); + continue; + } + String name = StringUtils.hasText(tea.getTeaName()) ? tea.getTeaName() : String.valueOf(id); + if (approverGroupId > 0 && !inApproverGroup(id, name)) { + throw new BizException(ErrorCode.PARAM_ERROR, "「" + name + "」不具备审批资格"); + } + result.put(id, name); + } + if (!missing.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "审批教师不存在或不属于本校(ID: " + missing.stream().map(String::valueOf).collect(Collectors.joining(",")) + ")"); + } + if (result.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "请选择审批教师"); + } + if (validatePool) { + requireInApproverPool(schId, result); + } + return result; + } + + /** + * v7 池白名单校验:resolved(teaId→姓名)必须全部属于本校启用审批人池(sys_approver enabled=1 且 teaId 已绑定), + * 池外/停用/未绑定 teaId 的报错并提示去「审核设置」维护。 + */ + private void requireInApproverPool(Integer schId, LinkedHashMap resolved) { + Set poolTeaIds = new HashSet<>(); + for (SysApprover pool : sysApproverRepository.findBySchIdAndEnabledOrderByNameAsc(schId, 1)) { + if (pool.getTeaId() != null) { + poolTeaIds.add(pool.getTeaId()); + } + } + List outside = new ArrayList<>(); + for (Map.Entry entry : resolved.entrySet()) { + if (!poolTeaIds.contains(entry.getKey())) { + outside.add(entry.getValue()); + } + } + if (!outside.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "审批人不在本校审批人名单或已停用(" + String.join("、", outside) + "),请在「审核设置」中维护"); + } + } + + /** 资格组校验:用户中心按教师ID查用户后判断所属组(仅 approver-group-id 配置时启用) */ + private boolean inApproverGroup(int teaId, String name) { + BoaUser user; + try { + user = YschBaseOpenApi.getUserById(String.valueOf(teaId)); + } catch (Exception e) { + log.error("[validateApprovers] 用户中心查询失败, teaId={}", teaId, e); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "审批教师资格校验失败(用户中心不可用),请稍后重试"); + } + return user != null && inGroup(user, approverGroupId); + } + + 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 state = StringUtils.hasText(auditState) ? auditState.trim().toLowerCase() : "pending"; + String meAccount = currentAccount(); + Integer meTeaId = currentTeaId(); + Specification spec = (root, cq, cb) -> { + List predicates = new ArrayList<>(); + switch (state) { + case "approved": + // 走审批流发布的(直接发布的 approvalLevel 为空,不算审批通过) + predicates.add(cb.equal(root.get("status"), PUBLISHED_CODE)); + predicates.add(root.get("approvalLevel").isNotNull()); + break; + case "rejected": + predicates.add(cb.equal(root.get("status"), REJECTED_CODE)); + break; + case "all": + case "reviewed": + predicates.add(root.get("status").in(Arrays.asList(PUBLISHED_CODE, REJECTED_CODE))); + predicates.add(cb.or(root.get("approvalLevel").isNotNull(), + cb.equal(root.get("status"), REJECTED_CODE))); + break; + default: + predicates.add(cb.equal(root.get("status"), PENDING_CODE)); + predicates.add(approverMatchPredicate(root, cb, meAccount, meTeaId)); + break; + } + 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 content = page.getContent().stream() + .map(n -> toVo(n, false)) + .collect(Collectors.toList()); + fillApprovalFields(content); + return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements())); + } + + @Override + public int approvalCount() { + String meAccount = currentAccount(); + Integer meTeaId = currentTeaId(); + if (!StringUtils.hasText(meAccount) && meTeaId == null) { + return 0; + } + Specification spec = (root, cq, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.equal(root.get("status"), PENDING_CODE)); + predicates.add(approverMatchPredicate(root, cb, meAccount, meTeaId)); + appendSchIdPredicate(root, cb, predicates); + return cq.where(predicates.toArray(new Predicate[0])).getRestriction(); + }; + return (int) noticeRepository.count(spec); + } + + /** 「待我审批」匹配:当前步账号快照 / 旧教师ID快照任一命中(JSON 数组四段边界匹配,101 不会命中 1101) */ + private Predicate approverMatchPredicate(Root root, CriteriaBuilder cb, String meAccount, Integer meTeaId) { + List or = new ArrayList<>(); + if (StringUtils.hasText(meAccount)) { + String acc = meAccount.replace("%", "").replace("_", ""); + if (StringUtils.hasText(acc)) { + or.add(cb.like(root.get("approverAccounts"), "[\"" + acc + "\"]")); + or.add(cb.like(root.get("approverAccounts"), "[\"" + acc + "\",%")); + or.add(cb.like(root.get("approverAccounts"), "%,\"" + acc + "\",%")); + or.add(cb.like(root.get("approverAccounts"), "%,\"" + acc + "\"]")); + or.add(cb.like(root.get("approverAccounts"), "[" + acc + "]")); + or.add(cb.like(root.get("approverAccounts"), "[" + acc + ",%")); + or.add(cb.like(root.get("approverAccounts"), "%," + acc + ",%")); + or.add(cb.like(root.get("approverAccounts"), "%," + acc + "]")); + } + } + if (meTeaId != null) { + String idStr = String.valueOf(meTeaId); + or.add(cb.like(root.get("approverTeaIds"), "[" + idStr + "]")); + or.add(cb.like(root.get("approverTeaIds"), "[" + idStr + ",%")); + or.add(cb.like(root.get("approverTeaIds"), "%," + idStr + ",%")); + or.add(cb.like(root.get("approverTeaIds"), "%," + idStr + "]")); + } + return cb.or(or.toArray(new Predicate[0])); + } + + /** + * 列表行批量装配多步审批字段:审批状态(none/pending/approved/rejected)、 + * 进度文本(第x/y步 名·会签 done/total)、当前步下标、当前用户可否审批。 + */ + private void fillApprovalFields(List vos) { + if (vos == null || vos.isEmpty()) { + return; + } + List ids = vos.stream().map(NoticeVo::getId).collect(Collectors.toList()); + Map> stepsByNotice = approvalStepRepository.findByNoticeIds(ids).stream() + .collect(Collectors.groupingBy(NoticeApprovalStep::getNoticeId)); + Map> recordsByNotice = approvalRecordRepository.findByNoticeIds(ids).stream() + .collect(Collectors.groupingBy(NoticeApprovalRecord::getNoticeId)); + String meAccount = currentAccount(); + Integer meTeaId = currentTeaId(); + for (NoticeVo vo : vos) { + List steps = stepsByNotice.getOrDefault(vo.getId(), Collections.emptyList()); + if (steps.isEmpty()) { + boolean pending = vo.getStatus() != null && vo.getStatus() == PENDING_CODE; + vo.setApprovalStatus(pending ? "pending" : "none"); + vo.setApprovalStatusDesc(vo.getStatusDesc()); + vo.setApprovalLevel(0); + vo.setCanApprove(false); + continue; + } + List records = recordsByNotice.getOrDefault(vo.getId(), Collections.emptyList()); + vo.setApprovalStatus(approvalStatusOf(vo.getStatus(), steps)); + vo.setApprovalLevel(stepLevelOf(vo.getStatus(), steps)); + vo.setApprovalStatusDesc(progressDesc(vo.getStatus(), steps, records)); + vo.setCanApprove(canApproveNow(vo.getStatus(), steps, records, meAccount, meTeaId)); + // v7:列表行直接带出当前步待签人姓名与最新轮末条处理意见(审批中心「当前审批人」「审批意见」列) + vo.setPendingApproverNames(pendingNamesOf(vo.getStatus(), steps, records)); + vo.setLastOpinion(lastOpinionOf(vo.getStatus(), steps, records)); + } + } + + /** 前端审批状态枚举映射:none 免审 / pending / approved(走流程发布)/ rejected */ + private String approvalStatusOf(Integer status, List steps) { + if (status == null) { + return "none"; + } + if (status == PENDING_CODE) { + return "pending"; + } + if (status == REJECTED_CODE) { + return "rejected"; + } + if (status == PUBLISHED_CODE) { + return steps.isEmpty() ? "none" : "approved"; + } + return "none"; + } + + /** 当前步下标(0 起):待审批=首个审批中步骤;已驳回=驳回发生步骤;其余=步数(全部走完) */ + private int stepLevelOf(Integer status, List steps) { + if (status != null && status == PENDING_CODE) { + for (int i = 0; i < steps.size(); i++) { + Integer st = steps.get(i).getStatus(); + if (st != null && st == NoticeApprovalStep.STATUS_ACTIVE) { + return i; + } + } + return 0; + } + if (status != null && status == REJECTED_CODE) { + for (int i = 0; i < steps.size(); i++) { + Integer st = steps.get(i).getStatus(); + if (st != null && st == NoticeApprovalStep.STATUS_REJECTED) { + return i; + } + } + } + return steps.size(); + } + + /** 进度文本(对齐前端展示):待审批「第1/2步 校级审核·会签 0/2」,或签不带计数 */ + private String progressDesc(Integer status, List steps, List records) { + if (status == null) { + return ""; + } + if (status == REJECTED_CODE) { + return "已驳回"; + } + if (status != PENDING_CODE) { + return "已发布"; + } + int level = stepLevelOf(status, steps); + NoticeApprovalStep current = steps.get(level); + String name = StringUtils.hasText(current.getStepName()) ? current.getStepName() : "第" + (level + 1) + "步"; + String prefix = "第" + (level + 1) + "/" + steps.size() + "步 " + name; + if ("all".equals(current.getVote())) { + int round = currentRound(records); + long done = records.stream() + .filter(r -> r.getRound() != null && r.getRound() == round + && current.getStepNo() != null && current.getStepNo().equals(r.getStepNo()) + && r.getAction() != null && r.getAction() == NoticeApprovalRecord.ACTION_APPROVE) + .count(); + return prefix + "·会签 " + done + "/" + parseAccounts(current.getApproverAccounts()).size(); + } + return prefix + "·或签"; + } + + /** 当前用户是否本单当前步待签审批人(在步内且本轮本步未处理过) */ + private boolean canApproveNow(Integer status, List steps, + List records, String meAccount, Integer meTeaId) { + if (status == null || status != PENDING_CODE || steps.isEmpty()) { + return false; + } + int level = stepLevelOf(status, steps); + NoticeApprovalStep current = steps.get(level); + boolean inStep = (meAccount != null && parseAccounts(current.getApproverAccounts()).contains(meAccount)) + || (meTeaId != null && parseTeaIds(current.getApproverTeaIds()).contains(meTeaId)); + if (!inStep) { + return false; + } + int round = currentRound(records); + return records.stream().noneMatch(r -> r.getRound() != null && r.getRound() == round + && current.getStepNo() != null && current.getStepNo().equals(r.getStepNo()) + && meAccount != null && meAccount.equals(r.getApproverAccount())); + } + + /** 最新送审轮次(无记录按 1) */ + private int currentRound(List records) { + return records.stream().mapToInt(r -> r.getRound() == null ? 1 : r.getRound()).max().orElse(1); + } + + /** + * 当前步待签审批人姓名(v7,列表「当前审批人」列与详情 approvalFlow[].pending 同源)。 + *

账号路径按账号快照比对已处理人;教师ID路径无账号快照,按姓名快照比对(仅统计最新轮, + * 驳回重提后旧轮记录不计入)。非待审批态返回 null。 + */ + private List pendingNamesOf(Integer status, List steps, + List records) { + if (status == null || status != PENDING_CODE || steps.isEmpty()) { + return null; + } + int level = stepLevelOf(status, steps); + if (level >= steps.size()) { + return null; + } + NoticeApprovalStep current = steps.get(level); + int round = currentRound(records); + Set actedAccounts = new HashSet<>(); + Set actedNames = new HashSet<>(); + for (NoticeApprovalRecord r : records) { + if (r.getRound() != null && r.getRound() == round + && current.getStepNo() != null && current.getStepNo().equals(r.getStepNo())) { + if (r.getApproverAccount() != null) { + actedAccounts.add(r.getApproverAccount()); + } + if (r.getApproverName() != null) { + actedNames.add(r.getApproverName()); + } + } + } + List accounts = parseAccounts(current.getApproverAccounts()); + List names = parseAccounts(current.getApproverNames()); + List pending = new ArrayList<>(); + if (!accounts.isEmpty()) { + for (int j = 0; j < accounts.size(); j++) { + if (!actedAccounts.contains(accounts.get(j))) { + pending.add(j < names.size() ? names.get(j) : accounts.get(j)); + } + } + } else { + // 教师ID路径:步骤仅姓名快照,按姓名比对已处理人 + for (String n : names) { + if (!actedNames.contains(n)) { + pending.add(n); + } + } + } + return pending; + } + + /** + * 最新轮末条处理意见(v7,列表「审批意见」列):驳回行=驳回意见、通过行=末步通过意见; + * 待审批/免审/无步骤返回 null(前端显示「—」)。 + */ + private String lastOpinionOf(Integer status, List steps, + List records) { + if (status == null || steps.isEmpty()) { + return null; + } + if (status != REJECTED_CODE && status != PUBLISHED_CODE) { + return null; + } + int round = currentRound(records); + NoticeApprovalRecord last = null; + for (NoticeApprovalRecord r : records) { + if (r.getRound() == null || r.getRound() != round || r.getAction() == null) { + continue; + } + if (r.getAction() != NoticeApprovalRecord.ACTION_APPROVE && r.getAction() != NoticeApprovalRecord.ACTION_REJECT) { + continue; + } + if (last == null || (r.getId() != null && (last.getId() == null || r.getId() > last.getId()))) { + last = r; + } + } + return last == null ? null : last.getOpinion(); + } + + /** + * 详情多步审批流装配:每步含审批人账号/姓名、步骤状态(approved/pending/rejected/waiting)、 + * 全部轮次审核记录、当前步待签人姓名;同时用于发布/编辑表单回显审批人配置。 + */ + private List toFlowVos(Integer status, List steps, + List records) { + if (steps.isEmpty()) { + return Collections.emptyList(); + } + int level = stepLevelOf(status, steps); + List flow = new ArrayList<>(); + for (int i = 0; i < steps.size(); i++) { + NoticeApprovalStep step = steps.get(i); + ApprovalFlowVo vo = new ApprovalFlowVo(); + vo.setName(StringUtils.hasText(step.getStepName()) ? step.getStepName() : "第" + (i + 1) + "步"); + vo.setVote(StringUtils.hasText(step.getVote()) ? step.getVote() : "any"); + List accounts = parseAccounts(step.getApproverAccounts()); + List names = parseAccounts(step.getApproverNames()); + vo.setApprovers(accounts); + vo.setApproverTeaIds(parseTeaIds(step.getApproverTeaIds())); + // 账号路径姓名与账号对齐展示;教师ID路径仅有姓名快照 + if (!accounts.isEmpty()) { + vo.setApproverNames(names.size() == accounts.size() ? names : accounts); + } else { + vo.setApproverNames(names); + } + List stepRecords = new ArrayList<>(); + for (NoticeApprovalRecord r : records) { + if (step.getStepNo() == null || !step.getStepNo().equals(r.getStepNo())) { + continue; + } + ApprovalFlowVo.Record item = new ApprovalFlowVo.Record(); + item.setAccount(r.getApproverAccount()); + item.setName(r.getApproverName()); + item.setResult(r.getAction() != null && r.getAction() == NoticeApprovalRecord.ACTION_REJECT + ? "rejected" : "approved"); + item.setTime(r.getActionTime()); + item.setRemark(r.getOpinion()); + stepRecords.add(item); + } + vo.setRecords(stepRecords); + Integer st = step.getStatus(); + if (st != null && st == NoticeApprovalStep.STATUS_APPROVED) { + vo.setStatus("approved"); + } else if (st != null && st == NoticeApprovalStep.STATUS_REJECTED) { + vo.setStatus("rejected"); + } else if (status != null && status == PENDING_CODE && i == level) { + vo.setStatus("pending"); + } else { + vo.setStatus("waiting"); + } + if ("pending".equals(vo.getStatus())) { + // v7:与列表「当前审批人」列同源;修复教师ID路径无账号快照待签恒空、旧轮记录计入已处理两缺陷 + List pending = pendingNamesOf(status, steps, records); + if (pending != null) { + vo.getPending().addAll(pending); + } + } + flow.add(vo); + } + return flow; + } + + @Override + public ApproverSuggestVo approverSuggest() { + ApproverSuggestVo vo = new ApproverSuggestVo(); + Integer me = currentTeaId(); + Integer schId = currentSchId(); + if (me == null || schId == null) { + return vo; + } + approverMemoryRepository.findBySchIdAndPublisherTeaId(schId, me).ifPresent(memory -> + vo.setTeaIds(parseTeaIds(memory.getApproverTeaIds()))); + return vo; + } + + @Override + public List approverResolve(List ids, Integer schId) { + List result = new ArrayList<>(); + // 学校教师名单一次拉取,按 UserTeaSerID(教师树叶子节点 id)绑定姓名/头像 + Integer realSchId = schId != null ? schId : currentSchId(); + if (realSchId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "获取学校信息失败"); + } + Map teacherById = new LinkedHashMap<>(); + for (SchTeaList tea : classService.getSchTeaList(realSchId)) { + if (tea.getUserTeaSerID() != null) { + teacherById.putIfAbsent(tea.getUserTeaSerID(), tea); + } + } + if (ids == null) { + return result; + } + for (Integer id : ids) { + if (id == null) { + continue; + } + ApproverResolveVo item = new ApproverResolveVo(); + item.setId(id); + SchTeaList tea = teacherById.get(id); + if (tea != null) { + item.setPhoto(""); + item.setName(StringUtils.hasText(tea.getTeaName()) ? tea.getTeaName() : String.valueOf(id)); + } else { + // 教师名单未命中(已离职/转校/名单接口缺数据):兜底查用户中心, + // userTeaSerID 即用户中心用户ID,仍能绑定出姓名,避免前端降级为「ID x」。 + try { + BoaUser user = YschBaseOpenApi.getUserInfo(id); + if (user != null) { + item.setPhoto(""); + item.setName(StringUtils.hasText(user.getName()) ? user.getName() : String.valueOf(id)); + } + } catch (Exception e) { + log.warn("[approverResolve] 用户中心兜底查询失败, id={}", id, e); + } + } + result.add(item); + } + return result; + } + + /** 记忆表 upsert:唯一约束 (sch_id, publisher_tea_id),每次 Submit 覆盖更新 */ + private void upsertApproverMemory(Integer schId, Integer publisherTeaId, List approverTeaIds) { + if (schId == null || publisherTeaId == null) { + return; + } + NoticeApproverMemory memory = approverMemoryRepository + .findBySchIdAndPublisherTeaId(schId, publisherTeaId) + .orElseGet(NoticeApproverMemory::new); + memory.setSchId(schId); + memory.setPublisherTeaId(publisherTeaId); + memory.setApproverTeaIds(toJsonTeaIds(approverTeaIds)); + approverMemoryRepository.save(memory); + } + + private void saveApprovalRow(Integer noticeId, int action, String operatorAccount, + String operatorName, String opinion, String assigneeTeaIds) { + NoticeApproval row = new NoticeApproval(); + row.setNoticeId(noticeId); + row.setAction(action); + row.setOperatorAccount(operatorAccount); + row.setOperatorName(operatorName); + row.setOpinion(opinion); + row.setAssigneeTeaIds(assigneeTeaIds); + 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.getApproverTeaIds() != null) { + if (request.getApproverTeaIds().isEmpty()) { + entity.setApproverTeaIds(null); + } else { + LinkedHashMap nameById = bindApprovers(entity.getSchId(), request.getApproverTeaIds()); + entity.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet()))); + } + } + // 审批流定义(草稿/已驳回重编辑):flow 或 approvers 字段携带即全量替换(空数组=清空); + // 已发布不可变更流程(与内容同理),待审批已被上方拦截 + if (status != PUBLISHED_CODE && (request.getFlow() != null || request.getApprovers() != null)) { + saveFlowDefinition(entity, resolveNoticeFlow(request), request.getApproverTeaIds()); + } + // 推送范围全量替换(草稿/已驳回重编辑场景;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); + approvalStepRepository.deleteByNoticeId(id); + approvalRecordRepository.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.setPublisherAccount(currentAccount()); + entity.setPublisherName(currentName()); + entity.setParentId(sourceId); + entity.setIsPulled(0); + entity.setReadCount(0); + entity.setSchId(currentSchId()); + // 转发同发布分流:带审批流(或显式 approve)先送审,否则直接发布 + List flow = resolveNoticeFlow(request); + if ("approve".equalsIgnoreCase(String.valueOf(request.getPublishType())) || !flow.isEmpty()) { + doSubmitApproval(entity, flow, null, null); + return entity.getId(); + } + entity.setStatus(PUBLISHED_CODE); + 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.setApproverTeaIds(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(","))); + } + } + // 批量填充多步审批字段(审批状态/进度文本/当前步下标/可否审批) + fillApprovalFields(content); + 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; + } + // 审批人放行:当前步账号快照(多步流程)或教师ID快照(旧链路)命中 + if (parseAccounts(notice.getApproverAccounts()).contains(account) + || parseTeaIds(notice.getApproverTeaIds()).contains(currentTeaId())) { + return; + } + // 走过流程的审批人(历史轮次/已办步骤)也可查看 + if (approvalRecordRepository.findByNoticeIdOrderByIdAsc(notice.getId()).stream() + .anyMatch(r -> account.equals(r.getApproverAccount()))) { + 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)); + // 当前绑定审批教师回显(编辑时按ID勾选教师树,姓名由前端经 ApproverResolve 绑定) + data.setApproverTeaIds(parseTeaIds(vo.getApproverTeaIds())); + // 多步审批流(步骤表 + 审核记录表) + List steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid); + List flowRecords = approvalRecordRepository.findByNoticeIdOrderByIdAsc(serid); + data.setApprovalEnabled(steps.isEmpty() ? 0 : 1); + data.setApprovalStatus(approvalStatusOf(vo.getStatus(), steps)); + data.setApprovalStatusDesc(progressDesc(vo.getStatus(), steps, flowRecords)); + data.setApprovalLevel(stepLevelOf(vo.getStatus(), steps)); + data.setCanApprove(canApproveNow(vo.getStatus(), steps, flowRecords, currentAccount(), currentTeaId())); + data.setApprovalFlow(toFlowVos(vo.getStatus(), steps, flowRecords)); + } + 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.setAssigneeTeaIds(parseTeaIds(record.getAssigneeTeaIds())); + 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(",", "[", "]")); + } + + /** 审批教师ID快照 JSON:[101,102](主表 approver_tea_ids / 提交行 assignee_tea_ids / 记忆表) */ + private String toJsonTeaIds(List teaIds) { + return teaIds.stream().map(String::valueOf).collect(Collectors.joining(",", "[", "]")); + } + + /** 解析审批教师ID快照([101,102] / ["101","102"] 均兼容);空或解析失败返回空列表 */ + private List parseTeaIds(String json) { + if (!StringUtils.hasText(json)) { + return new ArrayList<>(); + } + String rl = json.trim(); + if (!rl.startsWith("[")) { + return new ArrayList<>(); + } + List result = new ArrayList<>(); + for (String part : rl.substring(1, rl.length() - 1).split(",")) { + String s = part.trim().replace("\"", ""); + if (s.isEmpty()) { + continue; + } + try { + result.add(Integer.valueOf(s)); + } catch (NumberFormatException e) { + log.warn("[parseTeaIds] 非教师ID片段忽略: {}", part); + } + } + return result; + } + + 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/campusnotice/entity/NoticeApprovalRecord.java b/src/main/java/com/yunhai/campusnotice/entity/NoticeApprovalRecord.java new file mode 100644 index 0000000..4351777 --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/entity/NoticeApprovalRecord.java @@ -0,0 +1,78 @@ +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; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 通知审核记录表 notice_approval_record。 + *

+ * 每位审批人在每一步的通过/驳回动作各落一行(会签逐步留痕),含步骤名/表决方式快照, + * 驳回重提产生新一轮(round 递增),历史轮次永久保留。唯一约束防同一人同轮同步重复处理。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "notice_approval_record", + indexes = @Index(name = "idx_approval_record_notice", columnList = "notice_id"), + uniqueConstraints = @UniqueConstraint( + name = "uk_approval_record_round_step_account", + columnNames = {"notice_id", "round", "step_no", "approver_account"})) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class NoticeApprovalRecord extends BaseEntity { + + /** 审批动作:1 通过 / 2 驳回 */ + public static final int ACTION_APPROVE = 1; + public static final int ACTION_REJECT = 2; + + /** 通知ID(关联 notice.id) */ + @Column(name = "notice_id", nullable = false) + private Integer noticeId; + + /** 送审轮次(1 起,每次提交审批递增) */ + @Column(name = "round", nullable = false) + private Integer round; + + /** 步骤序号(1 起) */ + @Column(name = "step_no", nullable = false) + private Integer stepNo; + + /** 步骤名称快照 */ + @Column(name = "step_name", length = 64) + private String stepName; + + /** 步骤表决方式快照:any 或签 / all 会签 */ + @Column(name = "vote", length = 8) + private String vote; + + /** 审批人登录账号 */ + @Column(name = "approver_account", length = 64, nullable = false) + private String approverAccount; + + /** 审批人姓名 */ + @Column(name = "approver_name", length = 64) + private String approverName; + + /** 审批人同步教师ID(用户中心 teaId,与 notice_approval_step.approver_tea_ids 同一 ID 空间;v15 新增) */ + @Column(name = "approver_tea_id") + private Integer approverTeaId; + + /** 审批动作:1 通过 / 2 驳回 */ + @Column(name = "action", nullable = false) + private Integer action; + + /** 审批意见 */ + @Column(name = "opinion", length = 1000) + private String opinion; + + /** 操作时间 */ + @Column(name = "action_time") + private LocalDateTime actionTime; +} diff --git a/src/main/java/com/yunhai/campusnotice/entity/NoticeApprovalStep.java b/src/main/java/com/yunhai/campusnotice/entity/NoticeApprovalStep.java new file mode 100644 index 0000000..386fd48 --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/entity/NoticeApprovalStep.java @@ -0,0 +1,69 @@ +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; +import java.time.LocalDateTime; + +/** + * 通知审批步骤表 notice_approval_step。 + *

+ * 一次送审的有序多步流程快照:每步一个审批人组(会签=全部通过 / 或签=任一通过), + * 依次流转,任一步驳回整单退回发布人。驳回重提时旧步骤行删除重建(当前流程定义), + * 历史处理痕迹由 {@link NoticeApprovalRecord}(含步骤名/表决方式快照)永久保留。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "notice_approval_step", + indexes = @Index(name = "idx_approval_step_notice", columnList = "notice_id")) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class NoticeApprovalStep extends BaseEntity { + + /** 步骤状态:0 待流转 / 1 审批中 / 2 已通过 / 3 已驳回 */ + public static final int STATUS_WAITING = 0; + public static final int STATUS_ACTIVE = 1; + public static final int STATUS_APPROVED = 2; + public static final int STATUS_REJECTED = 3; + + /** 通知ID(关联 notice.id) */ + @Column(name = "notice_id", nullable = false) + private Integer noticeId; + + /** 步骤序号(1 起,按流转顺序) */ + @Column(name = "step_no", nullable = false) + private Integer stepNo; + + /** 步骤名称(如「校级审核」,前端未填时按「第N步」兜底) */ + @Column(name = "step_name", length = 64) + private String stepName; + + /** 表决方式:any 或签(任一通过)/ all 会签(全部通过) */ + @Column(name = "vote", length = 8, nullable = false) + private String vote = "any"; + + /** 审批人登录账号快照 JSON ["zhd","lwj"] */ + @Column(name = "approver_accounts", columnDefinition = "LONGTEXT") + private String approverAccounts; + + /** 审批人教师ID快照 JSON [101,102](旧 teaId 链路兼容,账号不可得时允许为空) */ + @Column(name = "approver_tea_ids", columnDefinition = "LONGTEXT") + private String approverTeaIds; + + /** 审批人姓名快照 JSON(提交时点姓名,展示不受后续改名影响) */ + @Column(name = "approver_names", columnDefinition = "LONGTEXT") + private String approverNames; + + /** 状态:0 待流转 / 1 审批中 / 2 已通过 / 3 已驳回 */ + @Column(name = "status", nullable = false) + private Integer status = STATUS_WAITING; + + /** 步骤办结时间(通过/驳回落定时间) */ + @Column(name = "handle_time") + private LocalDateTime handleTime; +} diff --git a/src/main/java/com/yunhai/campusnotice/entity/SysApprover.java b/src/main/java/com/yunhai/campusnotice/entity/SysApprover.java new file mode 100644 index 0000000..7f35bf8 --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/entity/SysApprover.java @@ -0,0 +1,56 @@ +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.Table; +import javax.persistence.UniqueConstraint; + +/** + * 审批人池 sys_approver(审核设置页维护,发布选人数据源)。 + *

+ * 按学校维护可被选为审批人的名单(登录账号为身份主键),停用后不出现在 + * 「发公告」审批人候选池,已有流程不受影响。Submit 时按本校启用名单校验, + * 防止表单传入池外账号。 + */ +@NoArgsConstructor +@Data +@Entity +@Table(name = "sys_approver", + uniqueConstraints = @UniqueConstraint( + name = "uk_approver_sch_account", + columnNames = {"sch_id", "account"})) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class SysApprover extends BaseEntity { + + /** 学校ID(多租户隔离) */ + @Column(name = "sch_id", nullable = false) + private Integer schId; + + /** 登录账号(审批身份主键,Approve/Reject 按当前登录账号匹配) */ + @Column(name = "account", length = 64, nullable = false) + private String account; + + /** 姓名 */ + @Column(name = "name", length = 64) + private String name; + + /** 所属部门ID(发文部门字典 sys_dict) */ + @Column(name = "dept_id", length = 64) + private String deptId; + + /** 所属部门名称快照 */ + @Column(name = "dept_name", length = 128) + private String deptName; + + /** 教师用户ID(用户中心,兼容旧 teaId 链路,可空) */ + @Column(name = "tea_id") + private Integer teaId; + + /** 状态:0 停用 / 1 启用 */ + @Column(name = "enabled", nullable = false) + private Integer enabled = 1; +} diff --git a/src/main/java/com/yunhai/campusnotice/job/ApproverIdentityBackfillRunner.java b/src/main/java/com/yunhai/campusnotice/job/ApproverIdentityBackfillRunner.java new file mode 100644 index 0000000..46c6a69 --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/job/ApproverIdentityBackfillRunner.java @@ -0,0 +1,49 @@ +package com.yunhai.campusnotice.job; + +import com.yunhai.campusnotice.service.INoticeService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 历史审批身份回填(v15 一次性任务,默认不执行)。 + * + *

背景:平台 {@code DefaultLoginUserProvider} 曾把登录名同时当作 displayName 落库, + * 历史审批轨迹/审核记录里 {@code operator_name / approver_name / publisher_name} 全是登录名, + * 且留痕表没有教师ID列。v15 已修写入链并补列,本任务负责把存量数据一次性换过来。

+ * + *

触发方式(二选一,跑完即可去掉):

+ *
    + *
  • IDEA 运行配置 VM options:{@code -Dcampus-notice.backfill-approver-identity=true}
  • + *
  • 环境变量:{@code CAMPUS_NOTICE_BACKFILL_APPROVER_IDENTITY=true}
  • + *
+ * + *

幂等:只改「姓名为空/等于登录名」与「teaId 为 NULL」的行,重复执行无副作用; + * devtools 热重启时重复执行同样安全。

+ */ +@Component +@ConditionalOnProperty(name = "campus-notice.backfill-approver-identity", havingValue = "true") +public class ApproverIdentityBackfillRunner implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(ApproverIdentityBackfillRunner.class); + + @Autowired + private INoticeService noticeService; + + @Override + public void run(ApplicationArguments args) { + try { + Map stat = noticeService.backfillApproverIdentity(); + log.info("[ApproverIdentityBackfill] 回填完成: {}", stat); + } catch (Exception e) { + // 回填失败不阻断应用启动;修正数据源(用户中心可达)后可再次执行 + log.error("[ApproverIdentityBackfill] 回填失败,请检查用户中心连通性后重跑", e); + } + } +} diff --git a/src/main/java/com/yunhai/campusnotice/model/notice/ApprovalFlowVo.java b/src/main/java/com/yunhai/campusnotice/model/notice/ApprovalFlowVo.java new file mode 100644 index 0000000..e4f8a4f --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/model/notice/ApprovalFlowVo.java @@ -0,0 +1,65 @@ +package com.yunhai.campusnotice.model.notice; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 多步审批流步骤 VO(详情 approvalFlow / 编辑回显,对齐前端 ApprovalFlowStep 契约)。 + *

+ * status:approved 已通过 / pending 审批中 / rejected 已驳回 / waiting 待流转; + * records 为该步全部审核动作(会签多人逐步留痕);pending 为当前步尚未处理姓名。 + */ +@Data +@JsonInclude(JsonInclude.Include.ALWAYS) +public class ApprovalFlowVo { + + /** 步骤名称 */ + private String name; + + /** 表决方式:any 或签 / all 会签 */ + private String vote = "any"; + + /** 审批人账号(编辑回显用) */ + private List approvers = new ArrayList<>(); + + /** 审批人教师ID(教师树选人回显用,如 api-test 测试台) */ + private List approverTeaIds = new ArrayList<>(); + + /** 审批人姓名(展示用,与 approvers 顺序一致) */ + private List approverNames = new ArrayList<>(); + + /** 步骤状态:approved / pending / rejected / waiting */ + private String status; + + /** 该步审核记录 */ + private List records = new ArrayList<>(); + + /** 当前步待签人姓名(仅审批中步骤返回,其余为空列表) */ + private List pending = new ArrayList<>(); + + /** 单步审核动作 */ + @Data + public static class Record { + + /** 审批人账号 */ + private String account; + + /** 审批人姓名 */ + private String name; + + /** 结果:approved 通过 / rejected 驳回 */ + private String result; + + /** 操作时间(yyyy-MM-dd HH:mm:ss) */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime time; + + /** 审批意见 */ + private String remark; + } +} diff --git a/src/main/java/com/yunhai/campusnotice/model/notice/ApprovalTabCountVo.java b/src/main/java/com/yunhai/campusnotice/model/notice/ApprovalTabCountVo.java new file mode 100644 index 0000000..a36da93 --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/model/notice/ApprovalTabCountVo.java @@ -0,0 +1,26 @@ +package com.yunhai.campusnotice.model.notice; + +import lombok.Data; + +/** + * 审批中心标签页统计数字(v13-E3)。 + *

+ * 口径与 GET /api/notice/ApprovalList 各 tab 的默认过滤一致(不含 keyword 搜索条件): + * pending=待审批(当前步审批人快照含当前登录人)/ approved=走审批流已发布 / + * rejected=已驳回 / all=已通过+已驳回合一。 + */ +@Data +public class ApprovalTabCountVo { + + /** 待审批(需我处理)条数 */ + private int pending; + + /** 已通过(走流程发布)条数 */ + private int approved; + + /** 已驳回条数 */ + private int rejected; + + /** 全部(已审合一)条数 */ + private int all; +} diff --git a/src/main/java/com/yunhai/campusnotice/model/notice/FlowStepItem.java b/src/main/java/com/yunhai/campusnotice/model/notice/FlowStepItem.java new file mode 100644 index 0000000..f789746 --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/model/notice/FlowStepItem.java @@ -0,0 +1,29 @@ +package com.yunhai.campusnotice.model.notice; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 审批流程步骤入参(NoticeRequest.flow / SubmitRequest.steps 元素)。 + *

+ * 前端「发公告」多步审批流编辑器产出:每步一个审批人组(登录账号列表), + * vote=any 或签(任一通过进入下一步)/ all 会签(全部通过进入下一步); + * name 可缺省,后端按「第N步」兜底。 + */ +@Data +public class FlowStepItem { + + /** 步骤名称(可空,缺省「第N步」) */ + private String name; + + /** 表决方式:any 或签(默认)/ all 会签 */ + private String vote = "any"; + + /** 审批人登录账号列表(与审批人池配合;api-test 测试台走教师树时用 approverTeaIds) */ + private List approvers = new ArrayList<>(); + + /** 审批人教师ID列表(教师树叶子节点ID;与 approvers 二选一,按学校教师名单绑定) */ + private List approverTeaIds; +} diff --git a/src/main/java/com/yunhai/campusnotice/model/notice/ReadTabCountVo.java b/src/main/java/com/yunhai/campusnotice/model/notice/ReadTabCountVo.java new file mode 100644 index 0000000..44ba135 --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/model/notice/ReadTabCountVo.java @@ -0,0 +1,25 @@ +package com.yunhai.campusnotice.model.notice; + +import lombok.Data; + +/** + * 「已收到」三 tab 统计数字(v17-B1)。 + *

+ * 口径 = {@code GET /api/notice/List} 在同一 keyword / type / deptId / 时间范围过滤下, + * 分别取 readType=0(全部)、1(未读)、2(已读)的总数;readType 不作为入参(它就是被统计的三个桶), + * 因此恒满足 {@code all = unread + read}。 + *

+ * 与 {@link ApprovalTabCountVo}(v13,审批中心四 tab)同范式:三者都来自列表谓词,不另立统计 SQL。 + */ +@Data +public class ReadTabCountVo { + + /** 全部(可见已收公告总数) */ + private int all; + + /** 未读(可见且无本人阅读记录) */ + private int unread; + + /** 已读(可见且已有本人阅读记录) */ + private int read; +} diff --git a/src/main/java/com/yunhai/campusnotice/repository/NoticeApprovalRecordRepository.java b/src/main/java/com/yunhai/campusnotice/repository/NoticeApprovalRecordRepository.java new file mode 100644 index 0000000..776a64d --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/repository/NoticeApprovalRecordRepository.java @@ -0,0 +1,28 @@ +package com.yunhai.campusnotice.repository; + +import com.yunhai.campusnotice.entity.NoticeApprovalRecord; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; + +/** + * 通知审核记录仓储。 + */ +public interface NoticeApprovalRecordRepository extends JpaRepository { + + /** 某通知全量审核记录(按落库顺序,跨轮次时间线) */ + List findByNoticeIdOrderByIdAsc(Integer noticeId); + + /** 批量查 N 个通知的审核记录(列表页批量装配) */ + @Query("select r from NoticeApprovalRecord r where r.noticeId in :noticeIds order by r.id asc") + List findByNoticeIds(@Param("noticeIds") Collection noticeIds); + + /** 某轮某步已处理记录(会签进度判定) */ + List findByNoticeIdAndRoundAndStepNo(Integer noticeId, Integer round, Integer stepNo); + + /** 按通知ID删除全部审核记录(通知删除时级联清理) */ + void deleteByNoticeId(Integer noticeId); +} diff --git a/src/main/java/com/yunhai/campusnotice/repository/NoticeApprovalStepRepository.java b/src/main/java/com/yunhai/campusnotice/repository/NoticeApprovalStepRepository.java new file mode 100644 index 0000000..8d99c15 --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/repository/NoticeApprovalStepRepository.java @@ -0,0 +1,30 @@ +package com.yunhai.campusnotice.repository; + +import com.yunhai.campusnotice.entity.NoticeApprovalStep; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; + +/** + * 通知审批步骤仓储。 + */ +public interface NoticeApprovalStepRepository extends JpaRepository { + + /** 某通知全部步骤(按序号升序) */ + List findByNoticeIdOrderByStepNoAsc(Integer noticeId); + + /** 批量查 N 个通知的步骤(列表页批量装配) */ + @Query("select s from NoticeApprovalStep s where s.noticeId in :noticeIds order by s.noticeId asc, s.stepNo asc") + List findByNoticeIds(@Param("noticeIds") Collection noticeIds); + + /** 某通知某步 */ + NoticeApprovalStep findByNoticeIdAndStepNo(Integer noticeId, Integer stepNo); + + /** 删除某通知全部步骤(重新送审流程重置 / 通知删除级联) */ + @Modifying + void deleteByNoticeId(Integer noticeId); +} diff --git a/src/main/java/com/yunhai/campusnotice/repository/SysApproverRepository.java b/src/main/java/com/yunhai/campusnotice/repository/SysApproverRepository.java new file mode 100644 index 0000000..96d7ffc --- /dev/null +++ b/src/main/java/com/yunhai/campusnotice/repository/SysApproverRepository.java @@ -0,0 +1,22 @@ +package com.yunhai.campusnotice.repository; + +import com.yunhai.campusnotice.entity.SysApprover; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +/** + * 审批人池仓储(审核设置页维护,发布选人数据源)。 + */ +public interface SysApproverRepository extends JpaRepository { + + /** 某学校全部审批人(管理页,按姓名排序) */ + List findBySchIdOrderByNameAsc(Integer schId); + + /** 某学校启用中的审批人(发布选人候选池) */ + List findBySchIdAndEnabledOrderByNameAsc(Integer schId, Integer enabled); + + /** 按学校+账号定位(送审绑定校验) */ + Optional findBySchIdAndAccount(Integer schId, String account); +}