From 5fa8462a9c92a33ddaab22a6cc209233020cce8a Mon Sep 17 00:00:00 2001 From: wanghu Date: Tue, 15 Sep 2026 09:08:01 +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/DictController.java | 24 +- .../controller/api/NoticeController.java | 40 +- .../campusnotice/entity/NoticeApproval.java | 7 + .../campusnotice/model/notice/NoticeVo.java | 7 + .../campusnotice/service/IDictService.java | 15 +- .../campusnotice/service/INoticeService.java | 36 +- .../service/impl/DictService.java | 43 +- .../service/impl/NoticeService.java | 753 ++++++++++++++++-- src/main/resources/application.yml | 4 +- src/main/resources/static/api-test.html | 29 +- 10 files changed, 841 insertions(+), 117 deletions(-) diff --git a/src/main/java/com/yunhai/campusnotice/controller/api/DictController.java b/src/main/java/com/yunhai/campusnotice/controller/api/DictController.java index a50b5e7..e95b06a 100644 --- a/src/main/java/com/yunhai/campusnotice/controller/api/DictController.java +++ b/src/main/java/com/yunhai/campusnotice/controller/api/DictController.java @@ -99,23 +99,28 @@ public class DictController { } /** - * 新增审批人(JSON body:account/name/deptId)。 + * 新增审批人(JSON body:account/name/deptId/teaId;account 可空,空时后端按 teaId 经用户中心解析)。 */ @PostMapping("AddApprover") - public void addApprover(@RequestBody Map body) { - dictService.addApprover(body.get("account"), body.get("name"), body.get("deptId")); + public void addApprover(@RequestBody Map body) { + dictService.addApprover( + asString(body.get("account")), + asString(body.get("name")), + asString(body.get("deptId")), + toInteger(body.get("teaId"))); } /** - * 更新审批人(JSON body:id + 可选 name/deptId/enabled)。 + * 更新审批人(JSON body:id + 可选 name/deptId/enabled/teaId;teaId=0 清空教师绑定)。 */ @PostMapping("UpdateApprover") public void updateApprover(@RequestBody Map body) { dictService.updateApprover( toInteger(body.get("id")), - (String) body.get("name"), - (String) body.get("deptId"), - toInteger(body.get("enabled"))); + asString(body.get("name")), + asString(body.get("deptId")), + toInteger(body.get("enabled")), + toInteger(body.get("teaId"))); } /** @@ -140,4 +145,9 @@ public class DictController { return null; } } + + /** Map 值转 String(null 返回 null,避免 String.valueOf(null) 产出 "null" 串) */ + private String asString(Object value) { + return value == null ? null : String.valueOf(value); + } } diff --git a/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java b/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java index 3d6ff9c..077e95d 100644 --- a/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java +++ b/src/main/java/com/yunhai/campusnotice/controller/api/NoticeController.java @@ -53,6 +53,18 @@ public class NoticeController { return noticeService.userList(query, PageUtils.toPageable(query, sort)); } + // ============================ 收件箱三 tab 统计 ============================ + + /** + * 「已收到」全部 / 未读 / 已读 三个 tab 的条数(v17-B1)。 + *

入参与 List 相同(keyword/type/deptId/startTime/endTime;分页与 readType 字段被忽略), + * 统计结果与对应 tab 下列表的 total 一一对应,恒满足 {@code all = unread + read}。 + */ + @GetMapping("ReadTabCount") + public ReadTabCountVo readTabCount(NoticeQuery query) { + return noticeService.readTabCount(query); + } + // ============================ 已发/全部通知列表 ============================ /** @@ -182,11 +194,22 @@ public class NoticeController { /** * 上一篇/下一篇通知ID。 + *

v17:已收序列(type=1)沿用与 List 相同的 keyword / 部门 / 时间范围筛选, + * 公告重要程度在本接口用 noticeType 传——同一请求里 `type` 已被「序列类型」占用, + * 若让 NoticeQuery 直接绑定同名 type 会把 "0/1" 误当成重要程度筛选。 + * 参数均可选,不传即整条可见已收序列,旧调用方行为不变。 */ @GetMapping("GetNextPrev") public Map getNextPrev(@RequestParam("serid") Integer serid, - @RequestParam("type") String type) { - return noticeService.getNextPrev(serid, type); + @RequestParam("type") String type, + @RequestParam(value = "noticeType", required = false) Integer noticeType, + NoticeQuery query) { + if (noticeType != null) { + query.setType(noticeType); + } + // 已收序列刻意不受未读/已读影响(v17 决策 2);服务层也已固定按「全部」桶取序列 + query.setReadType(null); + return noticeService.getNextPrev(serid, type, query); } // ============================ 附件打包下载 ============================ @@ -346,7 +369,7 @@ public class NoticeController { // ============================ 审批列表(待审/已审合一) ============================ /** - * 审批列表:auditState/status=pending(默认) 待我审批 / approved 走流程已发布 / + * 审批列表:auditState/status=pending(默认) 待审批(当前步审批人快照含我) / approved 走流程已发布 / * rejected 已驳回 / all|reviewed 已审合一。行 VO 为 NoticeVo,多步审批字段: * approvalStatus/approvalStatusDesc(进度文本)/approvalLevel/canApprove。 */ @@ -370,6 +393,17 @@ public class NoticeController { return noticeService.approvalCount(); } + // ============================ 审批中心 tab 统计数字 ============================ + + /** + * 审批中心四 tab 统计数字(v13-E3):{pending,approved,rejected,all}, + * 口径与 ApprovalList 各 tab 默认过滤一致(不含 keyword 搜索条件)。 + */ + @GetMapping("ApprovalTabCount") + public ApprovalTabCountVo approvalTabCount() { + return noticeService.approvalTabCount(); + } + /** 解析 scopes JSON 参数为范围列表(空/非法返回 null = 无范围) */ private List parseScopes(String scopes) { if (scopes == null || scopes.trim().isEmpty()) { diff --git a/src/main/java/com/yunhai/campusnotice/entity/NoticeApproval.java b/src/main/java/com/yunhai/campusnotice/entity/NoticeApproval.java index 314e007..68a7018 100644 --- a/src/main/java/com/yunhai/campusnotice/entity/NoticeApproval.java +++ b/src/main/java/com/yunhai/campusnotice/entity/NoticeApproval.java @@ -45,6 +45,13 @@ public class NoticeApproval extends BaseEntity { @Column(name = "operator_name", length = 64) private String operatorName; + /** + * 操作人同步教师ID(用户中心 teaId,与教师树叶子、{@code notice_approval_step.approver_tea_ids} 同一 ID 空间; + * 非本表/池表自增 id)。v15 新增:审批轨迹按人名展示、按教师ID留痕,账号仅作登录标识。 + */ + @Column(name = "operator_tea_id") + private Integer operatorTeaId; + /** 审批意见 */ @Column(name = "opinion", length = 1000) private String opinion; diff --git a/src/main/java/com/yunhai/campusnotice/model/notice/NoticeVo.java b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeVo.java index 61ec7f9..c7345d8 100644 --- a/src/main/java/com/yunhai/campusnotice/model/notice/NoticeVo.java +++ b/src/main/java/com/yunhai/campusnotice/model/notice/NoticeVo.java @@ -3,6 +3,7 @@ package com.yunhai.campusnotice.model.notice; import lombok.Data; import java.time.LocalDateTime; +import java.util.List; /** * 通知视图对象(列表/详情返回)。 @@ -74,6 +75,12 @@ public class NoticeVo { /** 当前用户是否为本单当前步待签审批人(审批列表/详情操作按钮用) */ private Boolean canApprove; + /** 当前步待签审批人姓名(审批列表「当前审批人」列;非待审批态为 null,对齐详情 approvalFlow[].pending) */ + private List pendingApproverNames; + + /** 最新轮末条处理意见(驳回行=驳回意见、通过行=末步通过意见;待审批/免审为 null,审批列表「审批意见」列) */ + private String lastOpinion; + /** 附件ID串("1,2,3",列表页下载入口使用;对齐旧 .NET UpFile 字段) */ private String UpFile; diff --git a/src/main/java/com/yunhai/campusnotice/service/IDictService.java b/src/main/java/com/yunhai/campusnotice/service/IDictService.java index a822810..45b112f 100644 --- a/src/main/java/com/yunhai/campusnotice/service/IDictService.java +++ b/src/main/java/com/yunhai/campusnotice/service/IDictService.java @@ -84,19 +84,26 @@ public interface IDictService { /** * 审批人管理列表(含停用)。 * - * @return List<Map> 每项 {id, account, name, deptId, deptName, enabled} + * @return List<Map> 每项 {id, account, name, deptId, deptName, enabled, teaId} */ List> getApproverList(); /** * 新增审批人(本校唯一账号校验,部门名称按字典解析快照)。 + *

v7:teaId 非空时落教师ID绑定;account 为空且 teaId 非空时按教师ID经用户中心解析登录账号, + * 解析失败报错提示手填账号。 + * + * @param account 登录账号(可空,空时按 teaId 自动解析) + * @param name 姓名(可空,回退账号展示) + * @param deptId 发文部门字典 key(可空) + * @param teaId 教师树叶子ID(可空;审核设置「选择教师」带入) */ - void addApprover(String account, String name, String deptId); + void addApprover(String account, String name, String deptId, Integer teaId); /** - * 更新审批人(null 参数不改动;enabled 仅接受 0/1)。 + * 更新审批人(null 参数不改动;enabled 仅接受 0/1;teaId 传 0 表示清空教师绑定)。 */ - void updateApprover(Integer id, String name, String deptId, Integer enabled); + void updateApprover(Integer id, String name, String deptId, Integer enabled, Integer teaId); /** * 删除审批人(仅本校记录)。 diff --git a/src/main/java/com/yunhai/campusnotice/service/INoticeService.java b/src/main/java/com/yunhai/campusnotice/service/INoticeService.java index 75a4f38..9c5d1f7 100644 --- a/src/main/java/com/yunhai/campusnotice/service/INoticeService.java +++ b/src/main/java/com/yunhai/campusnotice/service/INoticeService.java @@ -54,7 +54,7 @@ public interface INoticeService { void reject(Integer serid, String opinion); /** - * 审批列表:pending(默认)=待我审批 status=2;approved=走流程已发布;rejected=已驳回; + * 审批列表:pending(默认)=待审批(当前步审批人快照含我)status=2;approved=走流程已发布;rejected=已驳回; * all/reviewed=已审合一。行 VO 为 NoticeVo(多步审批字段:approvalStatus/approvalStatusDesc/ * approvalLevel/canApprove)。 */ @@ -65,6 +65,12 @@ public interface INoticeService { */ int approvalCount(); + /** + * 审批中心四 tab 统计数字(v13-E3):pending/approved/rejected/all, + * 口径与 ApprovalList 各 tab 默认过滤一致(不含 keyword)。 + */ + ApprovalTabCountVo approvalTabCount(); + /** * 审批人记忆回显:当前发布人最近一次所选审批教师ID组合,无记忆返回空。 */ @@ -110,6 +116,13 @@ public interface INoticeService { */ NoticePageResult userList(NoticeQuery query, Pageable pageable); + /** + * 「已收到」三 tab 统计(v17-B1):全部 / 未读 / 已读。 + * 口径与 {@link #userList} 同源(同一可见性谓词 + 同一筛选条件), + * readType 不作为入参(它就是被统计的三个桶),故恒满足 {@code all = unread + read}。 + */ + ReadTabCountVo readTabCount(NoticeQuery query); + /** * 管理端列表:按条件查询全部通知(管理员查看全部)。 */ @@ -165,10 +178,14 @@ public interface INoticeService { * 上一篇/下一篇通知ID(SQL 相邻查询)。 * * @param serid 当前通知ID - * @param type 0 已发(当前用户发布的序列)/ 1 已收(阅读记录序列) - * @return {prevId, nextId} + * @param type 0 已发(当前用户发布的序列,遗留 E1:仍无筛选且整表加载)/ 1 已收 + * @param query 已收序列的筛选条件(keyword/type/deptId/时间范围,与 List 同源,可为 null)。 + * 已收序列 v17 起为「当前用户可见的已收公告」(含未读), + * 刻意忽略 query.readType:未读公告点开即被标读, + * 若序列带未读过滤会把当前条挤出集合、上下篇同时置灰。 + * @return {prevId, nextId},0 表示该方向没有可跳的公告 */ - Map getNextPrev(Integer serid, String type); + Map getNextPrev(Integer serid, String type, NoticeQuery query); /** * 获取某通知的阅读明细列表。 @@ -195,6 +212,17 @@ public interface INoticeService { */ int timingPublishDue(); + /** + * 历史审批身份回填(v15):把「姓名=登录名」的历史留痕换成用户中心真实姓名, + * 并补齐 {@code notice_approval.operator_tea_id}、{@code notice_approval_record.approver_tea_id} + * 与 {@code notice_approval_step.approver_names}(按 teaId 对齐本校教师名单)。 + *

幂等:仅改「姓名为空/等于账号」与「teaId 为 NULL」的行,可重复执行。 + * 由启动开关 {@code campus-notice.backfill-approver-identity=true} 触发一次。 + * + * @return 各表更新条数统计(trail/record/step/notice/unresolved) + */ + Map backfillApproverIdentity(); + /** * 通知统计:总数/已发布、累计阅读人次、当前用户未读、按发布人、按月。 */ diff --git a/src/main/java/com/yunhai/campusnotice/service/impl/DictService.java b/src/main/java/com/yunhai/campusnotice/service/impl/DictService.java index 7f784bd..a5c5048 100644 --- a/src/main/java/com/yunhai/campusnotice/service/impl/DictService.java +++ b/src/main/java/com/yunhai/campusnotice/service/impl/DictService.java @@ -11,6 +11,7 @@ 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.BoaUser; import org.slf4j.Logger; @@ -290,6 +291,8 @@ public class DictService implements IDictService { item.put("name", StringUtils.hasText(approver.getName()) ? approver.getName() : approver.getAccount()); item.put("account", approver.getAccount()); item.put("deptName", approver.getDeptName()); + // 教师ID:ApproverSuggest 记忆回显按 teaId 映射回池内账号(新版前端账号路径选人) + item.put("teaId", approver.getTeaId()); item.put("isParent", false); result.add(item); } @@ -308,6 +311,8 @@ public class DictService implements IDictService { item.put("deptId", approver.getDeptId()); item.put("deptName", approver.getDeptName()); item.put("enabled", approver.getEnabled()); + // v7:教师ID绑定(审核设置「选择教师」落库;白名单过滤与回显用) + item.put("teaId", approver.getTeaId()); result.add(item); } return result; @@ -315,12 +320,19 @@ public class DictService implements IDictService { @Override @Transactional - public void addApprover(String account, String name, String deptId) { + public void addApprover(String account, String name, String deptId, Integer teaId) { Integer schId = requireSchId(); - if (!StringUtils.hasText(account)) { - throw new BizException(ErrorCode.PARAM_ERROR, "请填写审批人账号"); + String acc = account == null ? "" : account.trim(); + if (!StringUtils.hasText(acc) && teaId != null && teaId > 0) { + // v7:选择教师带入 teaId 但未填账号 → 按教师ID经用户中心解析登录账号 + acc = resolveAccountByTeaId(teaId); + if (!StringUtils.hasText(acc)) { + throw new BizException(ErrorCode.PARAM_ERROR, "无法自动获取教师登录账号(teaId=" + teaId + "),请手动填写账号"); + } + } + if (!StringUtils.hasText(acc)) { + throw new BizException(ErrorCode.PARAM_ERROR, "请填写审批人账号,或选择本校教师自动关联"); } - String acc = account.trim(); if (sysApproverRepository.findBySchIdAndAccount(schId, acc).isPresent()) { throw new BizException(ErrorCode.PARAM_ERROR, "审批人「" + acc + "」已存在"); } @@ -330,13 +342,14 @@ public class DictService implements IDictService { approver.setName(StringUtils.hasText(name) ? name.trim() : acc); approver.setDeptId(StringUtils.hasText(deptId) ? deptId.trim() : null); approver.setDeptName(resolveDeptName(approver.getDeptId())); + approver.setTeaId(teaId != null && teaId > 0 ? teaId : null); approver.setEnabled(1); sysApproverRepository.save(approver); } @Override @Transactional - public void updateApprover(Integer id, String name, String deptId, Integer enabled) { + public void updateApprover(Integer id, String name, String deptId, Integer enabled, Integer teaId) { Integer schId = requireSchId(); SysApprover approver = requireOwnSchool(schId, id); if (StringUtils.hasText(name)) { @@ -349,9 +362,29 @@ public class DictService implements IDictService { if (enabled != null) { approver.setEnabled(enabled == 0 ? 0 : 1); } + if (teaId != null) { + // v7:teaId=0 语义为清空教师绑定(存量记录补绑/解绑) + approver.setTeaId(teaId > 0 ? teaId : null); + } sysApproverRepository.save(approver); } + /** + * 按教师ID经用户中心解析登录账号(v7)。 + *

bschclass 教师名单(SchTeaList)无登录账号字段,故走用户中心;取 {@code BoaUser.getAccount()}。解析失败返回 null。 + */ + private String resolveAccountByTeaId(Integer teaId) { + try { + BoaUser user = YschBaseOpenApi.getUserById(String.valueOf(teaId)); + if (user != null && StringUtils.hasText(user.getAccount())) { + return user.getAccount().trim(); + } + } catch (Exception e) { + log.warn("[addApprover] 用户中心解析教师账号失败, teaId={}", teaId, e); + } + return null; + } + @Override @Transactional public void delApprover(Integer id) { diff --git a/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java b/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java index 35664a9..fb30bff 100644 --- a/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java +++ b/src/main/java/com/yunhai/campusnotice/service/impl/NoticeService.java @@ -54,6 +54,7 @@ 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.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; @@ -66,6 +67,7 @@ import org.springframework.web.context.request.ServletRequestAttributes; import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaQuery; +import javax.persistence.criteria.Expression; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; import javax.persistence.criteria.Subquery; @@ -73,6 +75,7 @@ import javax.servlet.http.HttpServletResponse; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; /** @@ -147,6 +150,14 @@ public class NoticeService implements INoticeService { private static final int PENDING_CODE = NoticeStatusEnum.PENDING.getCode(); private static final int REJECTED_CODE = NoticeStatusEnum.REJECTED.getCode(); + /** + * 阅读桶({@link NoticeQuery#getReadType()} 的取值,v17 用于 tab 统计与已收相邻查询): + * 0 全部 / 1 未读 / 2 已读。 + */ + private static final int ALL = 0; + private static final int UNREAD = 1; + private static final int READ = 2; + /** * 当前登录人账号(来自平台 UserContextHolder,无登录态时为 null)。 */ @@ -156,11 +167,132 @@ public class NoticeService implements INoticeService { } /** - * 当前登录人姓名(平台默认仅提供登录名,展示姓名后续可接用户中心补齐)。 + * 当前登录人姓名。 + *

平台 {@code DefaultLoginUserProvider} 用 token 串同时喂 loginName/displayName/userId, + * 故 displayName 常等于登录名(历史数据里 {@code operator_name=lijing} 即由此落库)。 + * v15:此时回退用户中心真实姓名,保证发布人/审批轨迹/审批流程一律落人名;解析失败保持原值。 */ private static String currentName() { LoginUser user = UserContextHolder.get(); - return user == null ? null : user.getDisplayName(); + if (user == null) { + return null; + } + String loginName = user.getLoginName(); + String display = user.getDisplayName(); + if (!StringUtils.hasText(display) || display.equals(loginName)) { + return cachedUserName(loginName); + } + return display; + } + + /** 账号→真实姓名缓存(v15):姓名属低频变更数据,进程内缓存避免同账号反复外呼用户中心 */ + private static final Map ACCOUNT_NAME_CACHE = new ConcurrentHashMap<>(); + + /** 按账号取用户中心姓名,查不到回退账号本身(结果含回退值一并缓存) */ + private static String cachedUserName(String account) { + if (!StringUtils.hasText(account)) { + return account; + } + if (ACCOUNT_NAME_CACHE.size() > 2000) { + ACCOUNT_NAME_CACHE.clear(); + } + return ACCOUNT_NAME_CACHE.computeIfAbsent(account, acc -> { + try { + // 先按登录账号反查(v15 原路径) + BoaUser boa = YschBaseOpenApi.getUserInfo(acc); + // 发布人落库值可能是数字型教师ID而非登录账号:byLoginName 查不到时回退 byId + if ((boa == null || !StringUtils.hasText(boa.getName())) && isNumericId(acc)) { + boa = YschBaseOpenApi.getUserById(acc); + } + if (boa != null && StringUtils.hasText(boa.getName())) { + return boa.getName(); + } + } catch (Exception e) { + log.warn("[cachedUserName] 用户中心姓名解析失败, account={}", acc); + } + return acc; + }); + } + + /** 值是否为纯数字(用于区分「教师ID」与「登录账号」两种落库形态) */ + private static boolean isNumericId(String value) { + if (!StringUtils.hasText(value)) { + return false; + } + for (int i = 0; i < value.length(); i++) { + if (!Character.isDigit(value.charAt(i))) { + return false; + } + } + return true; + } + + /** + * 展示用发布人姓名解析(v16):兼容三类落库形态—— + * ①正常人名快照直接返回;②快照等于账号(早期未解析);③快照为数字型教师ID。 + * 后两类经用户中心(byLoginName→byId 兜底)解析为真实姓名,解析失败保持原值。 + */ + private static String displayPublisherName(String snapshot, String account) { + boolean snapshotIsName = StringUtils.hasText(snapshot) + && !snapshot.equals(account) && !isNumericId(snapshot); + if (snapshotIsName) { + return snapshot; + } + String primary = StringUtils.hasText(account) ? account : snapshot; + String resolved = cachedUserName(primary); + if (StringUtils.hasText(resolved) && !resolved.equals(primary) && !isNumericId(resolved)) { + return resolved; + } + if (StringUtils.hasText(snapshot) && !snapshot.equals(primary)) { + resolved = cachedUserName(snapshot); + if (StringUtils.hasText(resolved) && !resolved.equals(snapshot) && !isNumericId(resolved)) { + return resolved; + } + } + return StringUtils.hasText(snapshot) ? snapshot : account; + } + + /** 本校 teaId→姓名 map(一次教师名单外呼;失败返回空 map,调用方回退账号路径) */ + private Map teaNameMap(Integer schId) { + Map map = new HashMap<>(); + if (schId == null) { + return map; + } + try { + for (SchTeaList tea : classService.getSchTeaList(schId)) { + if (tea.getUserTeaSerID() != null && StringUtils.hasText(tea.getTeaName())) { + map.putIfAbsent(tea.getUserTeaSerID(), tea.getTeaName()); + } + } + } catch (Exception e) { + log.warn("[teaNameMap] 教师名单拉取失败, schId={}", schId); + } + return map; + } + + /** + * 展示姓名兜底(v15):快照姓名缺失或等于账号时,先按同步教师ID查本校教师名单, + * 再按账号查用户中心(带缓存),仍解析不到才退回快照/账号原值。 + * + * @param teaNameById 调用方懒加载一次的本校 teaId→姓名 map(可为空 map) + */ + private String displayApproverName(String snapshot, String account, Integer teaId, Map teaNameById) { + if (StringUtils.hasText(snapshot) && !snapshot.equals(account)) { + return snapshot; + } + if (teaId != null && teaNameById != null) { + String name = teaNameById.get(teaId); + if (StringUtils.hasText(name)) { + return name; + } + } + if (StringUtils.hasText(account)) { + String name = cachedUserName(account); + if (StringUtils.hasText(name) && !name.equals(account)) { + return name; + } + } + return StringUtils.hasText(snapshot) ? snapshot : account; } /** @@ -172,9 +304,18 @@ public class NoticeService implements INoticeService { if (user == null || !StringUtils.hasText(user.getUserId())) { return null; } + String raw = user.getUserId().trim(); try { - return Integer.valueOf(user.getUserId().trim()); - } catch (NumberFormatException e) { + return Integer.valueOf(raw); + } catch (NumberFormatException ignored) { + // v13-E1:平台默认 DefaultLoginUserProvider 把登录名放进 userId(非数字), + // 回退用户中心按账号反查用户中心ID(与教师树叶子 teaId 同 ID 空间) + } + try { + BoaUser boa = YschBaseOpenApi.getUserInfo(raw); + return boa == null ? null : boa.getId(); + } catch (Exception e) { + log.warn("[currentTeaId] 用户中心按账号反查失败, account={}", raw); return null; } } @@ -538,14 +679,14 @@ public class NoticeService implements INoticeService { if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) { throw alreadyHandledError(serid); } - List steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid); + List steps = normalizeSteps(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)); + NoticeApprovalStep current = resolveCurrentStep(steps, level); if (current.getStatus() != null && current.getStatus() != NoticeApprovalStep.STATUS_ACTIVE) { throw alreadyHandledError(serid); } @@ -636,7 +777,7 @@ public class NoticeService implements INoticeService { if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) { throw alreadyHandledError(serid); } - List steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid); + List steps = normalizeSteps(approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid)); if (steps.isEmpty()) { // 存量单步数据按旧语义驳回 requireApprover(notice, meTeaId); @@ -650,7 +791,7 @@ public class NoticeService implements INoticeService { return; } int level = notice.getApprovalLevel() == null ? 0 : notice.getApprovalLevel(); - NoticeApprovalStep current = steps.get(Math.min(level, steps.size() - 1)); + NoticeApprovalStep current = resolveCurrentStep(steps, level); if (current.getStatus() != null && current.getStatus() != NoticeApprovalStep.STATUS_ACTIVE) { throw alreadyHandledError(serid); } @@ -660,6 +801,9 @@ public class NoticeService implements INoticeService { throw alreadyHandledError(serid); } saveRecordRow(serid, round, current, meAccount, meName(meAccount), NoticeApprovalRecord.ACTION_REJECT, opinion); + // 多步驳回同步写 notice_approval:前端「审批轨迹」= loadApprovalRecords 仅读该表, + // 不补此行则驳回在轨迹里无痕,alreadyHandledError 也会误报「已离开待审批状态」 + saveApprovalRow(serid, NoticeApproval.ACTION_REJECT, meAccount, meName(meAccount), opinion, null); current.setStatus(NoticeApprovalStep.STATUS_REJECTED); current.setHandleTime(LocalDateTime.now()); approvalStepRepository.save(current); @@ -668,6 +812,34 @@ public class NoticeService implements INoticeService { log.info("[reject] 通知 {} 第 {} 步驳回, 审批人 {}, 意见: {}", serid, current.getStepNo(), meAccount, opinion); } + /** + * 步骤列表规范化:按 step_no 去重(每步仅保留 id 最大 = 最近一次提交写入的行,丢弃历史脏行), + * 再按 step_no 升序返回。防御陈旧/重复步骤行使基于下标的 level/steps.size() 流转判断踩到同一步的脏行。 + */ + private List normalizeSteps(List raw) { + java.util.LinkedHashMap byStepNo = new java.util.LinkedHashMap<>(); + for (NoticeApprovalStep step : raw) { + Integer no = step.getStepNo(); + NoticeApprovalStep prev = byStepNo.get(no); + if (prev == null || (step.getId() != null && prev.getId() != null && step.getId() > prev.getId())) { + byStepNo.put(no, step); + } + } + List result = new ArrayList<>(byStepNo.values()); + result.sort(java.util.Comparator.comparingInt(s -> s.getStepNo() == null ? 0 : s.getStepNo())); + return result; + } + + /** 当前审批步定位:优先取状态为「审批中」的步骤,规避陈旧/重复步骤行按下标错位;无则回退下标原逻辑 */ + private NoticeApprovalStep resolveCurrentStep(List steps, int level) { + for (NoticeApprovalStep step : steps) { + if (step.getStatus() != null && step.getStatus() == NoticeApprovalStep.STATUS_ACTIVE) { + return step; + } + } + return steps.get(Math.min(level, steps.size() - 1)); + } + /** 当前步审批人校验:登录账号命中账号快照,或教师ID命中旧 teaId 快照 */ private void requireStepApprover(NoticeApprovalStep step, String meAccount, Integer meTeaId) { boolean hit = (meAccount != null && parseAccounts(step.getApproverAccounts()).contains(meAccount)) @@ -783,11 +955,13 @@ public class NoticeService implements INoticeService { } /** - * 构建并绑定审批步骤:账号路径按本校启用审批人池校验(姓名随池快照); - * 旧 teaId 路径按学校教师名单校验。每步至少一人,整体至少一步。 + * 构建并绑定审批步骤:账号路径按本校启用审批人池校验(姓名随池快照,遗留兼容); + * 旧 teaId 路径按学校教师名单校验(v18:审核设置下线,不再附加审批人池白名单校验)。 + * 每步至少一人,整体至少一步。 * 草稿已存定义(全待流转步)且本次未重选人时直接复用存量定义。 */ - private List buildSteps(Notice entity, List flowItems, List legacyTeaIds) { + private List buildSteps(Notice entity, List flowItems, + List legacyTeaIds) { if ((flowItems == null || flowItems.isEmpty()) && entity.getId() != null) { List existing = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(entity.getId()); // 仅「全待流转」视为草稿流程定义可复用;已驳回单的步骤含审批中状态,必须重建 @@ -823,21 +997,47 @@ public class NoticeService implements INoticeService { step.setApproverTeaIds(toJsonTeaIds(teaIds)); } } else if (item.getApproverTeaIds() != null && !item.getApproverTeaIds().isEmpty()) { - // 教师ID路径(api-test 测试台教师树选人):按学校教师名单绑定 + // 教师ID路径(教师树选人):按学校教师名单绑定(v18 起不再附加审批人池校验) LinkedHashMap nameById = bindApprovers(entity.getSchId(), item.getApproverTeaIds()); step.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet()))); step.setApproverNames(toJsonStringList(new ArrayList<>(nameById.values()))); + // v13-E2:补账号快照,主表当前步快照随首步带上后读侧可直接按登录名匹配 + bindAccountsByTeaIds(step, nameById.keySet()); } else { - // 请求级旧 approverTeaIds 兼容包装步(单步或签) + LinkedHashMap nameById = bindApprovers(entity.getSchId(), legacyTeaIds); step.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet()))); step.setApproverNames(toJsonStringList(new ArrayList<>(nameById.values()))); + bindAccountsByTeaIds(step, nameById.keySet()); } steps.add(step); } return steps; } + /** + * v13-E2:教师ID路径补账号快照——逐个 teaId 经用户中心反查登录账号写入 + * {@code approverAccounts}(与读侧 {@code approverMatchPredicate} 的账号分支同源)。 + * 反查失败或无账号的个体跳过;全部落空时保持 null(读侧仍可走 teaId 匹配兜底)。 + */ + private void bindAccountsByTeaIds(NoticeApprovalStep step, Collection teaIds) { + List accounts = new ArrayList<>(); + for (Integer teaId : teaIds) { + if (teaId == null) { + continue; + } + try { + BoaUser user = YschBaseOpenApi.getUserById(String.valueOf(teaId)); + if (user != null && StringUtils.hasText(user.getAccount())) { + accounts.add(user.getAccount()); + } + } catch (Exception e) { + log.warn("[bindAccountsByTeaIds] 用户中心反查账号失败, teaId={}", teaId); + } + } + step.setApproverAccounts(toJsonStringList(accounts)); + } + /** * 按本校启用审批人池绑定账号:账号必须存在于池(多租户隔离)且启用, * 保持选择顺序去重;姓名取池快照(池未维护姓名时回退账号展示)。 @@ -890,6 +1090,7 @@ public class NoticeService implements INoticeService { row.setVote(step.getVote()); row.setApproverAccount(account); row.setApproverName(name); + row.setApproverTeaId(currentTeaId()); row.setAction(action); row.setOpinion(opinion); row.setActionTime(LocalDateTime.now()); @@ -927,6 +1128,7 @@ public class NoticeService implements INoticeService { * 按学校教师列表绑定被选审批教师:bschclass 教师名单按 UserTeaSerID 匹配(保持选择顺序、去重); * 未选人、或存在不属于本校的ID时报错。配置了资格组(yh.notice.approver-group-id>0)时 * 另经用户中心校验所属组。记忆回显仅是默认值,提交时仍全量校验,防止记忆中的教师离职后带病提交。 + * v18:审核设置功能下线,不再按审批人池(sys_approver)白名单校验。 */ private LinkedHashMap bindApprovers(Integer schId, List teaIds) { if (teaIds == null || teaIds.isEmpty()) { @@ -998,27 +1200,7 @@ public class NoticeService implements INoticeService { 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; - } + List predicates = approvalStatePredicates(state, root, cb, meAccount, meTeaId); appendCommonPredicates(query, root, cb, predicates); appendSchIdPredicate(root, cb, predicates); return cq.where(predicates.toArray(new Predicate[0])).getRestriction(); @@ -1031,6 +1213,59 @@ public class NoticeService implements INoticeService { return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements())); } + /** + * v13-E3:审批中心四 tab 的状态谓词(approvalList 与 approvalTabCount 共用,保证数字口径一致)。 + * pending=待审批(当前步审批人快照含我)/ approved=走流程已发布 / rejected=已驳回 / + * all|reviewed=已审合一。 + */ + private List approvalStatePredicates(String state, Root root, CriteriaBuilder cb, + String meAccount, Integer meTeaId) { + 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; + } + return predicates; + } + + @Override + public ApprovalTabCountVo approvalTabCount() { + String meAccount = currentAccount(); + Integer meTeaId = currentTeaId(); + ApprovalTabCountVo vo = new ApprovalTabCountVo(); + vo.setPending(countApprovalState("pending", meAccount, meTeaId)); + vo.setApproved(countApprovalState("approved", meAccount, meTeaId)); + vo.setRejected(countApprovalState("rejected", meAccount, meTeaId)); + vo.setAll(countApprovalState("all", meAccount, meTeaId)); + return vo; + } + + /** 单 tab 计数:状态谓词 + 多租户过滤,不带 keyword 等列表查询条件(与列表明细各自成立) */ + private int countApprovalState(String state, String meAccount, Integer meTeaId) { + Specification spec = (root, cq, cb) -> { + List predicates = approvalStatePredicates(state, root, cb, meAccount, meTeaId); + appendSchIdPredicate(root, cb, predicates); + return cq.where(predicates.toArray(new Predicate[0])).getRestriction(); + }; + return (int) noticeRepository.count(spec); + } + @Override public int approvalCount() { String meAccount = currentAccount(); @@ -1104,6 +1339,9 @@ public class NoticeService implements INoticeService { 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)); } } @@ -1197,6 +1435,83 @@ public class NoticeService implements INoticeService { 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)、 * 全部轮次审核记录、当前步待签人姓名;同时用于发布/编辑表单回显审批人配置。 @@ -1208,6 +1523,8 @@ public class NoticeService implements INoticeService { } int level = stepLevelOf(status, steps); List flow = new ArrayList<>(); + // v15:出现姓名快照不可用的历史数据时,懒加载一次本校教师名单做批量补名 + Map teaNameById = null; for (int i = 0; i < steps.size(); i++) { NoticeApprovalStep step = steps.get(i); ApprovalFlowVo vo = new ApprovalFlowVo(); @@ -1215,14 +1532,26 @@ public class NoticeService implements INoticeService { vo.setVote(StringUtils.hasText(step.getVote()) ? step.getVote() : "any"); List accounts = parseAccounts(step.getApproverAccounts()); List names = parseAccounts(step.getApproverNames()); + List stepTeaIds = parseTeaIds(step.getApproverTeaIds()); vo.setApprovers(accounts); - vo.setApproverTeaIds(parseTeaIds(step.getApproverTeaIds())); + vo.setApproverTeaIds(stepTeaIds); // 账号路径姓名与账号对齐展示;教师ID路径仅有姓名快照 - if (!accounts.isEmpty()) { - vo.setApproverNames(names.size() == accounts.size() ? names : accounts); - } else { - vo.setApproverNames(names); + List displayNames = new ArrayList<>(); + boolean alignedTeaId = stepTeaIds.size() == names.size(); + for (int j = 0; j < names.size(); j++) { + String account = j < accounts.size() ? accounts.get(j) : null; + if (nameMissing(names.get(j), account)) { + if (teaNameById == null) { + teaNameById = teaNameMap(currentSchId()); + } + } + displayNames.add(displayApproverName(names.get(j), account, + alignedTeaId ? stepTeaIds.get(j) : null, teaNameById)); } + if (displayNames.isEmpty()) { + displayNames = accounts; + } + vo.setApproverNames(displayNames); List stepRecords = new ArrayList<>(); for (NoticeApprovalRecord r : records) { if (step.getStepNo() == null || !step.getStepNo().equals(r.getStepNo())) { @@ -1230,7 +1559,11 @@ public class NoticeService implements INoticeService { } ApprovalFlowVo.Record item = new ApprovalFlowVo.Record(); item.setAccount(r.getApproverAccount()); - item.setName(r.getApproverName()); + if (nameMissing(r.getApproverName(), r.getApproverAccount()) && teaNameById == null) { + teaNameById = teaNameMap(currentSchId()); + } + item.setName(displayApproverName(r.getApproverName(), r.getApproverAccount(), + r.getApproverTeaId(), teaNameById)); item.setResult(r.getAction() != null && r.getAction() == NoticeApprovalRecord.ACTION_REJECT ? "rejected" : "approved"); item.setTime(r.getActionTime()); @@ -1249,14 +1582,10 @@ public class NoticeService implements INoticeService { vo.setStatus("waiting"); } if ("pending".equals(vo.getStatus())) { - Set acted = stepRecords.stream() - .map(ApprovalFlowVo.Record::getAccount) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - for (int j = 0; j < accounts.size(); j++) { - if (!acted.contains(accounts.get(j))) { - vo.getPending().add(j < vo.getApproverNames().size() ? vo.getApproverNames().get(j) : accounts.get(j)); - } + // v7:与列表「当前审批人」列同源;修复教师ID路径无账号快照待签恒空、旧轮记录计入已处理两缺陷 + List pending = pendingNamesOf(status, steps, records); + if (pending != null) { + vo.getPending().addAll(pending); } } flow.add(vo); @@ -1343,6 +1672,7 @@ public class NoticeService implements INoticeService { row.setAction(action); row.setOperatorAccount(operatorAccount); row.setOperatorName(operatorName); + row.setOperatorTeaId(currentTeaId()); row.setOpinion(opinion); row.setAssigneeTeaIds(assigneeTeaIds); row.setActionTime(LocalDateTime.now()); @@ -1624,17 +1954,46 @@ public class NoticeService implements INoticeService { content.forEach(vo -> vo.setApproverTeaIds(null)); } - // 未读数与列表同口径(含范围可见性过滤),避免「数字与列表对不上」 - long unreadCount = noticeRepository.count(visibleSpec(account, true)) - - noticeReadRepository.countByReaderAccount(account); - if (unreadCount < 0) { - unreadCount = 0; - } + // 未读数与列表、tab 统计同一口径(v17:直接按 userSpec 的 readType=1 计数, + // 不再用「可见总数 − 该账号全部阅读记录数」相减——那会把已删除/已撤回/跨校的已读记录算进减数) + long unreadCount = countUnreadForCurrentUser(); return new NoticePageResult<>(content, (int) page.getTotalElements(), page.getNumber() + 1, page.getSize(), page.getTotalPages(), page.hasNext(), unreadCount); } + /** + * 当前用户「可见且未读」的公告条数(v17 统一口径)。 + *

不带 keyword/type 等筛选(全局未读数,用于列表页顶部徽标与统计页); + * 与 {@link #userList} 的差异仅在于筛选条件,谓词基座同为 {@link #userSpec}。 + */ + private long countUnreadForCurrentUser() { + String account = currentAccount(); + Set readIds = new HashSet<>(noticeReadRepository.findNoticeIdsByReaderAccount(account)); + Set receiverNoticeIds = new HashSet<>(noticeReceiverRepository.findNoticeIdsByAccount(account)); + return noticeRepository.count(userSpec(new NoticeQuery(), readIds, receiverNoticeIds, UNREAD)); + } + + /** + * 已收到三 tab 统计(v17-B1):全部 / 未读 / 已读。 + *

口径 = {@code GET /api/notice/List} 在同一 keyword/type/deptId/时间范围过滤下 + * 分别取 readType=0/1/2 的总数;readType 不作为入参(它就是被统计的三个桶), + * 因此恒满足 {@code all = unread + read}。 + */ + @Override + public ReadTabCountVo readTabCount(NoticeQuery query) { + NoticeQuery filter = query == null ? new NoticeQuery() : query; + String account = currentAccount(); + Set readIds = new HashSet<>(noticeReadRepository.findNoticeIdsByReaderAccount(account)); + Set receiverNoticeIds = new HashSet<>(noticeReceiverRepository.findNoticeIdsByAccount(account)); + + ReadTabCountVo vo = new ReadTabCountVo(); + vo.setAll((int) noticeRepository.count(userSpec(filter, readIds, receiverNoticeIds, ALL))); + vo.setUnread((int) noticeRepository.count(userSpec(filter, readIds, receiverNoticeIds, UNREAD))); + vo.setRead((int) noticeRepository.count(userSpec(filter, readIds, receiverNoticeIds, READ))); + return vo; + } + /** * 用户端可见规格:已发布 + 已到点(定时未到点不可见)+ 范围可见性。 * 范围可见性(最小改动原则):无 push_scope 维持「全校广播」现状; @@ -1674,6 +2033,19 @@ public class NoticeService implements INoticeService { * 多租户:按当前用户学校过滤(schId 为空时不加过滤,兼容无学校归属用户)。 */ private Specification userSpec(NoticeQuery query, Set readIds, Set receiverNoticeIds) { + Integer readType = query.getReadType() == null ? ALL : query.getReadType(); + return userSpec(query, readIds, receiverNoticeIds, readType); + } + + /** + * 同 {@link #userSpec(NoticeQuery, Set, Set)},但显式指定阅读桶(v17-B3①)。 + *

tab 统计与已收相邻查询需要在同一套过滤条件下分别取 0/1/2 三个桶, + * 故把 readType 从 query 里提出来做参数,避免复制一份过滤逻辑造成口径漂移。 + * + * @param readType {@link #ALL} 全部 / {@link #UNREAD} 未读 / {@link #READ} 已读 + */ + private Specification userSpec(NoticeQuery query, Set readIds, Set receiverNoticeIds, + int readType) { return (Root root, CriteriaQuery cq, CriteriaBuilder cb) -> { List predicates = new ArrayList<>(); predicates.add(cb.equal(root.get("status"), PUBLISHED_CODE)); @@ -1683,10 +2055,9 @@ public class NoticeService implements INoticeService { appendSchIdPredicate(root, cb, predicates); predicates.add(scopeVisibility(root, cq, cb, receiverNoticeIds)); - Integer readType = query.getReadType() == null ? 0 : query.getReadType(); - if (readType == 1 && !readIds.isEmpty()) { + if (readType == UNREAD && !readIds.isEmpty()) { predicates.add(cb.not(root.get("id").in(readIds))); - } else if (readType == 2) { + } else if (readType == READ) { if (readIds.isEmpty()) { predicates.add(cb.equal(root.get("id"), -1)); } else { @@ -2058,8 +2429,17 @@ public class NoticeService implements INoticeService { // ============================ 上一篇/下一篇(SQL 相邻查询) ============================ + /** + * 上一篇/下一篇通知ID。 + * + * @param type "0"=已发序列(本人发布,遗留 E1 未改);其余=已收序列 + * @param query 已收序列的筛选条件(v17-B3④:keyword/type/deptId/时间范围,与 List 同源)。 + * 刻意忽略其中的 readType——未读公告点开即被标读,若序列带 readType=1 + * 会把刚打开的这条挤出集合,导致上下篇同时置灰、用户卡在原地; + * 因此未读/已读两个 tab 共用同一条「全部可见已收」序列。传 null 等价于无筛选。 + */ @Override - public Map getNextPrev(Integer serid, String type) { + public Map getNextPrev(Integer serid, String type, NoticeQuery query) { Map data = new LinkedHashMap<>(); Notice current = noticeRepository.findById(serid).orElse(null); if (current == null) { @@ -2070,23 +2450,75 @@ public class NoticeService implements INoticeService { if ("0".equals(type)) { // 已发序列:按当前用户发布的通知,按 createTime 排序 List published = noticeRepository.findAll( - (root, query, cb) -> cb.equal(root.get("publisherAccount"), currentAccount()), + (root, q, 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); + // 已收序列(v17):与 GET /api/notice/List 同一可见集合 + 同一筛选条件 + String account = currentAccount(); + Set readIds = new HashSet<>(noticeReadRepository.findNoticeIdsByReaderAccount(account)); + Set receiverNoticeIds = new HashSet<>(noticeReceiverRepository.findNoticeIdsByAccount(account)); + Specification base = userSpec(query == null ? new NoticeQuery() : query, + readIds, receiverNoticeIds, ALL); + data.put("prevId", adjacentReceivedId(base, current, true)); + data.put("nextId", adjacentReceivedId(base, current, false)); } return data; } + /** + * 已收序列中与 current 相邻的一条(v17-B3④)。 + *

排序键与 List 的 {@code @SortDefault({"topmost","createTime"} DESC)} 对齐, + * 并补 id 作稳定 tie-break(列表在同置顶+同创建时间下顺序本就不确定); + * topmost 经 coalesce 归 0,使历史 NULL 行与 0 同档(两者在列表 ORDER BY 里的先后同样不确定,同源风险)。 + *

只取 1 行({@code PageRequest.of(0, 1, sort)}),不再像旧实现那样为找两个邻居把整表实体 + * (含 LONGTEXT 正文)读进内存。 + * + * @param newer true=列表中位于当前上一条(排序键更大者里取最小);false=下一条(排序键更小者里取最大) + * @return 相邻公告ID;当前条不在筛选集合内或该方向无邻居时返回 0 + */ + private int adjacentReceivedId(Specification base, Notice current, boolean newer) { + if (current.getCreateTime() == null || current.getId() == null) { + return 0; + } + // 当前条必须也落在同一筛选集合内,否则不给跳(如改范围后不再可见、或不属于当前关键字结果) + long selfInSet = noticeRepository.count( + base.and((root, cq, cb) -> cb.equal(root.get("id"), current.getId()))); + if (selfInSet == 0) { + return 0; + } + Specification side = (root, cq, cb) -> noticeKeySidePredicate(root, cb, current, newer); + Sort sort = newer + ? Sort.by(Sort.Order.asc("topmost"), Sort.Order.asc("createTime"), Sort.Order.asc("id")) + : Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("createTime"), Sort.Order.desc("id")); + Page neighbors = noticeRepository.findAll(base.and(side), PageRequest.of(0, 1, sort)); + List hits = neighbors.getContent(); + return hits.isEmpty() || hits.get(0).getId() == null ? 0 : hits.get(0).getId(); + } + + /** 排序键 {@code (coalesce(topmost,0), createTime, id)} 与 current 的严格大小比较(字典序三档 OR)。 */ + private Predicate noticeKeySidePredicate(Root root, CriteriaBuilder cb, Notice current, boolean greater) { + Expression top = cb.coalesce(root.get("topmost"), 0); + int curTop = current.getTopmost() == null ? 0 : current.getTopmost(); + Expression time = root.get("createTime"); + Expression id = root.get("id"); + LocalDateTime curTime = current.getCreateTime(); + Integer curId = current.getId(); + if (greater) { + return cb.or( + cb.greaterThan(top, curTop), + cb.and(cb.equal(top, curTop), cb.greaterThan(time, curTime)), + cb.and(cb.equal(top, curTop), cb.equal(time, curTime), cb.greaterThan(id, curId))); + } + return cb.or( + cb.lessThan(top, curTop), + cb.and(cb.equal(top, curTop), cb.lessThan(time, curTime)), + cb.and(cb.equal(top, curTop), cb.equal(time, curTime), cb.lessThan(id, curId))); + } + // ============================ 阅读明细 ============================ @Override @@ -2260,11 +2692,144 @@ public class NoticeService implements INoticeService { return count; } + /** + * 历史审批身份回填(v15,见 INoticeService 注释)。 + *

数据量小(审批留痕行数有限),直接全表逐行处理;账号解析结果按进程缓存,避免重复外呼用户中心。

+ */ + @Override + @Transactional + public Map backfillApproverIdentity() { + Map stat = new LinkedHashMap<>(); + int trail = 0, record = 0, stepUpd = 0, noticeUpd = 0, unresolved = 0; + Map userByAccount = new HashMap<>(); + + // 1) 审批轨迹 notice_approval:姓名=登录名 → 真名;operator_tea_id 为空 → 补同步教师ID + for (NoticeApproval row : approvalRepository.findAll()) { + String acc = row.getOperatorAccount(); + if (!StringUtils.hasText(acc)) { + continue; + } + BoaUser u = resolveUserOnce(userByAccount, acc); + boolean changed = false; + if (u != null) { + if (nameMissing(row.getOperatorName(), acc) && StringUtils.hasText(u.getName())) { + row.setOperatorName(u.getName()); + changed = true; + } + if (row.getOperatorTeaId() == null && u.getId() != null) { + row.setOperatorTeaId(u.getId()); + changed = true; + } + } else { + unresolved++; + } + if (changed) { + approvalRepository.save(row); + trail++; + } + } + + // 2) 每步审核记录 notice_approval_record:同 1) 口径 + for (NoticeApprovalRecord row : approvalRecordRepository.findAll()) { + String acc = row.getApproverAccount(); + if (!StringUtils.hasText(acc)) { + continue; + } + BoaUser u = resolveUserOnce(userByAccount, acc); + boolean changed = false; + if (u != null) { + if (nameMissing(row.getApproverName(), acc) && StringUtils.hasText(u.getName())) { + row.setApproverName(u.getName()); + changed = true; + } + if (row.getApproverTeaId() == null && u.getId() != null) { + row.setApproverTeaId(u.getId()); + changed = true; + } + } else { + unresolved++; + } + if (changed) { + approvalRecordRepository.save(row); + record++; + } + } + + // 3) 审批人姓名快照 notice_approval_step:按 teaId 用本校教师名单的权威姓名覆盖(教师名单缺失时保留原值) + Map> teaNamesBySch = new HashMap<>(); + for (NoticeApprovalStep stepRow : approvalStepRepository.findAll()) { + Notice owner = noticeRepository.findById(stepRow.getNoticeId()).orElse(null); + if (owner == null || owner.getSchId() == null) { + continue; + } + Map namesById = teaNamesBySch.computeIfAbsent(owner.getSchId(), this::teaNameMap); + List teaIds = parseTeaIds(stepRow.getApproverTeaIds()); + List names = parseAccounts(stepRow.getApproverNames()); + if (teaIds.isEmpty() || names.size() != teaIds.size()) { + continue; + } + List fixed = new ArrayList<>(); + boolean changed = false; + for (int i = 0; i < names.size(); i++) { + String authoritative = namesById.get(teaIds.get(i)); + String nv = StringUtils.hasText(authoritative) ? authoritative : names.get(i); + if (!nv.equals(names.get(i))) { + changed = true; + } + fixed.add(nv); + } + if (changed) { + try { + stepRow.setApproverNames(objectMapper.writeValueAsString(fixed)); + approvalStepRepository.save(stepRow); + stepUpd++; + } catch (Exception e) { + log.warn("[backfillApproverIdentity] 步骤 {} 姓名快照序列化失败", stepRow.getId()); + } + } + } + + // 4) 发布人姓名 notice.publisher_name:姓名=登录名 → 真名 + for (Notice n : noticeRepository.findAll()) { + String acc = n.getPublisherAccount(); + if (!StringUtils.hasText(acc)) { + continue; + } + BoaUser u = resolveUserOnce(userByAccount, acc); + if (u != null && nameMissing(n.getPublisherName(), acc) && StringUtils.hasText(u.getName())) { + n.setPublisherName(u.getName()); + noticeRepository.save(n); + noticeUpd++; + } + } + + stat.put("trail", trail); + stat.put("record", record); + stat.put("step", stepUpd); + stat.put("notice", noticeUpd); + stat.put("unresolved", unresolved); + return stat; + } + + /** 回填用:按账号解析用户中心用户(BoaUser 含 id/name),失败缓存 null 避免同账号反复外呼 */ + private BoaUser resolveUserOnce(Map cache, String account) { + if (cache.containsKey(account)) { + return cache.get(account); + } + BoaUser u = null; + try { + u = YschBaseOpenApi.getUserInfo(account); + } catch (Exception e) { + log.warn("[backfillApproverIdentity] 用户中心解析失败, account={}", account); + } + cache.put(account, u); + return u; + } + // ============================ 统计 ============================ @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; @@ -2275,11 +2840,8 @@ public class NoticeService implements INoticeService { 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; - } + // v17:与「已收到」列表/tab 统计同一口径(原先也是「可见总数 − 全部已读记录」相减) + long unreadForCurrentUser = countUnreadForCurrentUser(); List createTimes = schId != null ? noticeRepository.findCreateTimesBySchIdStatusAndRange(schId, PUBLISHED_CODE, s, e) @@ -2302,7 +2864,8 @@ public class NoticeService implements INoticeService { : noticeRepository.findPublisherNamesByStatusAndRange(PUBLISHED_CODE, s, e); Map pubMap = new LinkedHashMap<>(); for (String p : publishers) { - pubMap.merge(p == null ? "未知" : p, 1L, Long::sum); + String publisher = displayPublisherName(p, p); + pubMap.merge(StringUtils.hasText(publisher) ? publisher : "未知", 1L, Long::sum); } List byPublisher = pubMap.entrySet().stream() .map(en -> new NoticeStatisticsVo.PublisherStat(en.getKey(), en.getValue())) @@ -2358,7 +2921,7 @@ public class NoticeService implements INoticeService { // 当前绑定审批教师回显(编辑时按ID勾选教师树,姓名由前端经 ApproverResolve 绑定) data.setApproverTeaIds(parseTeaIds(vo.getApproverTeaIds())); // 多步审批流(步骤表 + 审核记录表) - List steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid); + List steps = normalizeSteps(approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid)); List flowRecords = approvalRecordRepository.findByNoticeIdOrderByIdAsc(serid); data.setApprovalEnabled(steps.isEmpty() ? 0 : 1); data.setApprovalStatus(approvalStatusOf(vo.getStatus(), steps)); @@ -2389,20 +2952,34 @@ public class NoticeService implements INoticeService { /** 审批轨迹装配:全部行 + 提交行 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()); + List rows = approvalRepository.findByNoticeIdOrderByIdAsc(noticeId); + List list = new ArrayList<>(rows.size()); + // v15:历史行 operator_name 落成登录名(displayName==loginName 时代写入),出现脏数据时懒加载一次教师名单补名 + Map teaNameById = Collections.emptyMap(); + boolean teaNameLoaded = false; + for (NoticeApproval record : rows) { + 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()); + if (!teaNameLoaded && nameMissing(record.getOperatorName(), record.getOperatorAccount())) { + teaNameById = teaNameMap(currentSchId()); + teaNameLoaded = true; + } + vo.setOperatorName(displayApproverName(record.getOperatorName(), record.getOperatorAccount(), + record.getOperatorTeaId(), teaNameById)); + vo.setOpinion(record.getOpinion()); + vo.setAssigneeTeaIds(parseTeaIds(record.getAssigneeTeaIds())); + vo.setActionTime(record.getActionTime()); + list.add(vo); + } + return list; + } + + /** 姓名快照是否不可用(v15):为空或等于登录账号(历史上 displayName 恒等于 loginName) */ + private static boolean nameMissing(String snapshot, String account) { + return !StringUtils.hasText(snapshot) || snapshot.equals(account); } /** 学员端请求判定:平台路径 mobiapi 段(/{platform}/notice/...) */ @@ -2475,6 +3052,8 @@ public class NoticeService implements INoticeService { if (!StringUtils.hasText(vo.getDeptName())) { vo.setDeptName(dictService.resolveDeptName(vo.getDeptId())); } + // v16:发布人快照可能落成账号或教师ID,展示前统一解析为真实姓名(列表/详情共用 toVo) + vo.setPublisherName(displayPublisherName(vo.getPublisherName(), vo.getPublisherAccount())); vo.setRead(read); vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId())); return vo; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c7ae142..ec4380e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -62,8 +62,8 @@ yh: app-auth: enabled: true # 本应用自身身份(客户端签名用) - app-key: 100001 - app-secret: ec4e860206374c80938719fff8e14a86 + app-key: 100006 + app-secret: e053f7d98518407ab0e69f0500820d17 open: enabled: true base-host: http://base.yunhaixingtong.com diff --git a/src/main/resources/static/api-test.html b/src/main/resources/static/api-test.html index 938ca69..3fb85ee 100644 --- a/src/main/resources/static/api-test.html +++ b/src/main/resources/static/api-test.html @@ -47,6 +47,8 @@ .seg { display: inline-flex; background: #e2e8f0; border-radius: 8px; padding: 3px; } .seg button { border: none; background: none; padding: 5px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--text2); } .seg button.on { background: #fff; color: var(--primary); font-weight: 600; box-shadow: var(--shadow); } + /* tab 条数(v17 ReadTabCount):空内容时不占位 */ + .seg .seg-cnt { margin-left: 5px; font-size: 12px; color: var(--text2); font-weight: 400; } .input { border: 1px solid var(--border); border-radius: 8px; padding: 7px 12px; font-size: 13px; background: #fff; color: var(--text); outline: none; } .input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,.1); } select.input { cursor: pointer; } @@ -270,9 +272,9 @@
- - - + + +