修改
build / build-push (push) Failing after 27s

This commit is contained in:
wanghu
2026-09-15 09:08:01 +08:00
parent 6d7d1c9d90
commit 5fa8462a9c
10 changed files with 841 additions and 117 deletions
@@ -99,23 +99,28 @@ public class DictController {
} }
/** /**
* 新增审批人(JSON body:account/name/deptId)。 * 新增审批人(JSON body:account/name/deptId/teaId;account 可空,空时后端按 teaId 经用户中心解析)。
*/ */
@PostMapping("AddApprover") @PostMapping("AddApprover")
public void addApprover(@RequestBody Map<String, String> body) { public void addApprover(@RequestBody Map<String, Object> body) {
dictService.addApprover(body.get("account"), body.get("name"), body.get("deptId")); 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") @PostMapping("UpdateApprover")
public void updateApprover(@RequestBody Map<String, Object> body) { public void updateApprover(@RequestBody Map<String, Object> body) {
dictService.updateApprover( dictService.updateApprover(
toInteger(body.get("id")), toInteger(body.get("id")),
(String) body.get("name"), asString(body.get("name")),
(String) body.get("deptId"), asString(body.get("deptId")),
toInteger(body.get("enabled"))); toInteger(body.get("enabled")),
toInteger(body.get("teaId")));
} }
/** /**
@@ -140,4 +145,9 @@ public class DictController {
return null; return null;
} }
} }
/** Map 值转 String(null 返回 null,避免 String.valueOf(null) 产出 "null" 串) */
private String asString(Object value) {
return value == null ? null : String.valueOf(value);
}
} }
@@ -53,6 +53,18 @@ public class NoticeController {
return noticeService.userList(query, PageUtils.toPageable(query, sort)); return noticeService.userList(query, PageUtils.toPageable(query, sort));
} }
// ============================ 收件箱三 tab 统计 ============================
/**
* 「已收到」全部 / 未读 / 已读 三个 tab 的条数(v17-B1)。
* <p>入参与 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。 * 上一篇/下一篇通知ID。
* <p>v17:已收序列(type=1)沿用与 List 相同的 keyword / 部门 / 时间范围筛选,
* <b>公告重要程度在本接口用 noticeType 传</b>——同一请求里 `type` 已被「序列类型」占用,
* 若让 NoticeQuery 直接绑定同名 type 会把 "0/1" 误当成重要程度筛选。
* 参数均可选,不传即整条可见已收序列,旧调用方行为不变。
*/ */
@GetMapping("GetNextPrev") @GetMapping("GetNextPrev")
public Map<String, Object> getNextPrev(@RequestParam("serid") Integer serid, public Map<String, Object> getNextPrev(@RequestParam("serid") Integer serid,
@RequestParam("type") String type) { @RequestParam("type") String type,
return noticeService.getNextPrev(serid, 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,多步审批字段: * rejected 已驳回 / all|reviewed 已审合一。行 VO 为 NoticeVo,多步审批字段:
* approvalStatus/approvalStatusDesc(进度文本)/approvalLevel/canApprove。 * approvalStatus/approvalStatusDesc(进度文本)/approvalLevel/canApprove。
*/ */
@@ -370,6 +393,17 @@ public class NoticeController {
return noticeService.approvalCount(); 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 = 无范围) */ /** 解析 scopes JSON 参数为范围列表(空/非法返回 null = 无范围) */
private List<PushScopeItem> parseScopes(String scopes) { private List<PushScopeItem> parseScopes(String scopes) {
if (scopes == null || scopes.trim().isEmpty()) { if (scopes == null || scopes.trim().isEmpty()) {
@@ -45,6 +45,13 @@ public class NoticeApproval extends BaseEntity {
@Column(name = "operator_name", length = 64) @Column(name = "operator_name", length = 64)
private String operatorName; 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) @Column(name = "opinion", length = 1000)
private String opinion; private String opinion;
@@ -3,6 +3,7 @@ package com.yunhai.campusnotice.model.notice;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** /**
* 通知视图对象(列表/详情返回)。 * 通知视图对象(列表/详情返回)。
@@ -74,6 +75,12 @@ public class NoticeVo {
/** 当前用户是否为本单当前步待签审批人(审批列表/详情操作按钮用) */ /** 当前用户是否为本单当前步待签审批人(审批列表/详情操作按钮用) */
private Boolean canApprove; private Boolean canApprove;
/** 当前步待签审批人姓名(审批列表「当前审批人」列;非待审批态为 null,对齐详情 approvalFlow[].pending) */
private List<String> pendingApproverNames;
/** 最新轮末条处理意见(驳回行=驳回意见、通过行=末步通过意见;待审批/免审为 null,审批列表「审批意见」列) */
private String lastOpinion;
/** 附件ID串("1,2,3",列表页下载入口使用;对齐旧 .NET UpFile 字段) */ /** 附件ID串("1,2,3",列表页下载入口使用;对齐旧 .NET UpFile 字段) */
private String UpFile; private String UpFile;
@@ -84,19 +84,26 @@ public interface IDictService {
/** /**
* 审批人管理列表(含停用)。 * 审批人管理列表(含停用)。
* *
* @return List&lt;Map&gt; 每项 {id, account, name, deptId, deptName, enabled} * @return List&lt;Map&gt; 每项 {id, account, name, deptId, deptName, enabled, teaId}
*/ */
List<Map<String, Object>> getApproverList(); List<Map<String, Object>> getApproverList();
/** /**
* 新增审批人(本校唯一账号校验,部门名称按字典解析快照)。 * 新增审批人(本校唯一账号校验,部门名称按字典解析快照)。
* <p>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);
/** /**
* 删除审批人(仅本校记录)。 * 删除审批人(仅本校记录)。
@@ -54,7 +54,7 @@ public interface INoticeService {
void reject(Integer serid, String opinion); void reject(Integer serid, String opinion);
/** /**
* 审批列表:pending(默认)=待我审批 status=2;approved=走流程已发布;rejected=已驳回; * 审批列表:pending(默认)=待审批(当前步审批人快照含我)status=2;approved=走流程已发布;rejected=已驳回;
* all/reviewed=已审合一。行 VO 为 NoticeVo(多步审批字段:approvalStatus/approvalStatusDesc/ * all/reviewed=已审合一。行 VO 为 NoticeVo(多步审批字段:approvalStatus/approvalStatusDesc/
* approvalLevel/canApprove)。 * approvalLevel/canApprove)。
*/ */
@@ -65,6 +65,12 @@ public interface INoticeService {
*/ */
int approvalCount(); int approvalCount();
/**
* 审批中心四 tab 统计数字(v13-E3):pending/approved/rejected/all,
* 口径与 ApprovalList 各 tab 默认过滤一致(不含 keyword)。
*/
ApprovalTabCountVo approvalTabCount();
/** /**
* 审批人记忆回显:当前发布人最近一次所选审批教师ID组合,无记忆返回空。 * 审批人记忆回显:当前发布人最近一次所选审批教师ID组合,无记忆返回空。
*/ */
@@ -110,6 +116,13 @@ public interface INoticeService {
*/ */
NoticePageResult<NoticeVo> userList(NoticeQuery query, Pageable pageable); NoticePageResult<NoticeVo> userList(NoticeQuery query, Pageable pageable);
/**
* 「已收到」三 tab 统计(v17-B1):全部 / 未读 / 已读。
* 口径与 {@link #userList} 同源(同一可见性谓词 + 同一筛选条件),
* <b>readType 不作为入参</b>(它就是被统计的三个桶),故恒满足 {@code all = unread + read}。
*/
ReadTabCountVo readTabCount(NoticeQuery query);
/** /**
* 管理端列表:按条件查询全部通知(管理员查看全部)。 * 管理端列表:按条件查询全部通知(管理员查看全部)。
*/ */
@@ -165,10 +178,14 @@ public interface INoticeService {
* 上一篇/下一篇通知ID(SQL 相邻查询)。 * 上一篇/下一篇通知ID(SQL 相邻查询)。
* *
* @param serid 当前通知ID * @param serid 当前通知ID
* @param type 0 已发(当前用户发布的序列)/ 1 已收(阅读记录序列) * @param type 0 已发(当前用户发布的序列,遗留 E1:仍无筛选且整表加载)/ 1 已收
* @return {prevId, nextId} * @param query 已收序列的筛选条件(keyword/type/deptId/时间范围,与 List 同源,可为 null)。
* 已收序列 v17 起为「当前用户可见的已收公告」(含未读),
* <b>刻意忽略 query.readType</b>:未读公告点开即被标读,
* 若序列带未读过滤会把当前条挤出集合、上下篇同时置灰。
* @return {prevId, nextId},0 表示该方向没有可跳的公告
*/ */
Map<String, Object> getNextPrev(Integer serid, String type); Map<String, Object> getNextPrev(Integer serid, String type, NoticeQuery query);
/** /**
* 获取某通知的阅读明细列表。 * 获取某通知的阅读明细列表。
@@ -195,6 +212,17 @@ public interface INoticeService {
*/ */
int timingPublishDue(); int timingPublishDue();
/**
* 历史审批身份回填(v15):把「姓名=登录名」的历史留痕换成用户中心真实姓名,
* 并补齐 {@code notice_approval.operator_tea_id}、{@code notice_approval_record.approver_tea_id}
* 与 {@code notice_approval_step.approver_names}(按 teaId 对齐本校教师名单)。
* <p>幂等:仅改「姓名为空/等于账号」与「teaId 为 NULL」的行,可重复执行。
* 由启动开关 {@code campus-notice.backfill-approver-identity=true} 触发一次。
*
* @return 各表更新条数统计(trail/record/step/notice/unresolved)
*/
Map<String, Integer> backfillApproverIdentity();
/** /**
* 通知统计:总数/已发布、累计阅读人次、当前用户未读、按发布人、按月。 * 通知统计:总数/已发布、累计阅读人次、当前用户未读、按发布人、按月。
*/ */
@@ -11,6 +11,7 @@ import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder; import com.yunhai.platform.auth.UserContextHolder;
import com.yunhai.platform.auth.spring.CurrentBoaUser; import com.yunhai.platform.auth.spring.CurrentBoaUser;
import com.yunhai.platform.apistd.BizException; 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.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import org.slf4j.Logger; 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("name", StringUtils.hasText(approver.getName()) ? approver.getName() : approver.getAccount());
item.put("account", approver.getAccount()); item.put("account", approver.getAccount());
item.put("deptName", approver.getDeptName()); item.put("deptName", approver.getDeptName());
// 教师ID:ApproverSuggest 记忆回显按 teaId 映射回池内账号(新版前端账号路径选人)
item.put("teaId", approver.getTeaId());
item.put("isParent", false); item.put("isParent", false);
result.add(item); result.add(item);
} }
@@ -308,6 +311,8 @@ public class DictService implements IDictService {
item.put("deptId", approver.getDeptId()); item.put("deptId", approver.getDeptId());
item.put("deptName", approver.getDeptName()); item.put("deptName", approver.getDeptName());
item.put("enabled", approver.getEnabled()); item.put("enabled", approver.getEnabled());
// v7:教师ID绑定(审核设置「选择教师」落库;白名单过滤与回显用)
item.put("teaId", approver.getTeaId());
result.add(item); result.add(item);
} }
return result; return result;
@@ -315,12 +320,19 @@ public class DictService implements IDictService {
@Override @Override
@Transactional @Transactional
public void addApprover(String account, String name, String deptId) { public void addApprover(String account, String name, String deptId, Integer teaId) {
Integer schId = requireSchId(); Integer schId = requireSchId();
if (!StringUtils.hasText(account)) { String acc = account == null ? "" : account.trim();
throw new BizException(ErrorCode.PARAM_ERROR, "请填写审批人账号"); 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()) { if (sysApproverRepository.findBySchIdAndAccount(schId, acc).isPresent()) {
throw new BizException(ErrorCode.PARAM_ERROR, "审批人「" + acc + "」已存在"); 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.setName(StringUtils.hasText(name) ? name.trim() : acc);
approver.setDeptId(StringUtils.hasText(deptId) ? deptId.trim() : null); approver.setDeptId(StringUtils.hasText(deptId) ? deptId.trim() : null);
approver.setDeptName(resolveDeptName(approver.getDeptId())); approver.setDeptName(resolveDeptName(approver.getDeptId()));
approver.setTeaId(teaId != null && teaId > 0 ? teaId : null);
approver.setEnabled(1); approver.setEnabled(1);
sysApproverRepository.save(approver); sysApproverRepository.save(approver);
} }
@Override @Override
@Transactional @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(); Integer schId = requireSchId();
SysApprover approver = requireOwnSchool(schId, id); SysApprover approver = requireOwnSchool(schId, id);
if (StringUtils.hasText(name)) { if (StringUtils.hasText(name)) {
@@ -349,9 +362,29 @@ public class DictService implements IDictService {
if (enabled != null) { if (enabled != null) {
approver.setEnabled(enabled == 0 ? 0 : 1); approver.setEnabled(enabled == 0 ? 0 : 1);
} }
if (teaId != null) {
// v7:teaId=0 语义为清空教师绑定(存量记录补绑/解绑)
approver.setTeaId(teaId > 0 ? teaId : null);
}
sysApproverRepository.save(approver); sysApproverRepository.save(approver);
} }
/**
* 按教师ID经用户中心解析登录账号(v7)。
* <p>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 @Override
@Transactional @Transactional
public void delApprover(Integer id) { public void delApprover(Integer id) {
@@ -54,6 +54,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification; 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.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery; import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Expression;
import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root; import javax.persistence.criteria.Root;
import javax.persistence.criteria.Subquery; import javax.persistence.criteria.Subquery;
@@ -73,6 +75,7 @@ import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors; 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 PENDING_CODE = NoticeStatusEnum.PENDING.getCode();
private static final int REJECTED_CODE = NoticeStatusEnum.REJECTED.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)。 * 当前登录人账号(来自平台 UserContextHolder,无登录态时为 null)。
*/ */
@@ -156,11 +167,132 @@ public class NoticeService implements INoticeService {
} }
/** /**
* 当前登录人姓名(平台默认仅提供登录名,展示姓名后续可接用户中心补齐)。 * 当前登录人姓名。
* <p>平台 {@code DefaultLoginUserProvider} 用 token 串同时喂 loginName/displayName/userId,
* 故 displayName 常等于登录名(历史数据里 {@code operator_name=lijing} 即由此落库)。
* v15:此时回退用户中心真实姓名,保证发布人/审批轨迹/审批流程一律落人名;解析失败保持原值。
*/ */
private static String currentName() { private static String currentName() {
LoginUser user = UserContextHolder.get(); 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<String, String> 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<Integer, String> teaNameMap(Integer schId) {
Map<Integer, String> 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<Integer, String> 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())) { if (user == null || !StringUtils.hasText(user.getUserId())) {
return null; return null;
} }
String raw = user.getUserId().trim();
try { try {
return Integer.valueOf(user.getUserId().trim()); return Integer.valueOf(raw);
} catch (NumberFormatException e) { } 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; return null;
} }
} }
@@ -538,14 +679,14 @@ public class NoticeService implements INoticeService {
if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) { if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) {
throw alreadyHandledError(serid); throw alreadyHandledError(serid);
} }
List<NoticeApprovalStep> steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid); List<NoticeApprovalStep> steps = normalizeSteps(approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid));
if (steps.isEmpty()) { if (steps.isEmpty()) {
// 存量单步数据(步骤表改造前提交)按旧或签语义走通 // 存量单步数据(步骤表改造前提交)按旧或签语义走通
approveLegacy(notice, meTeaId, opinion); approveLegacy(notice, meTeaId, opinion);
return; return;
} }
int level = notice.getApprovalLevel() == null ? 0 : notice.getApprovalLevel(); 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) { if (current.getStatus() != null && current.getStatus() != NoticeApprovalStep.STATUS_ACTIVE) {
throw alreadyHandledError(serid); throw alreadyHandledError(serid);
} }
@@ -636,7 +777,7 @@ public class NoticeService implements INoticeService {
if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) { if (notice.getStatus() == null || notice.getStatus() != PENDING_CODE) {
throw alreadyHandledError(serid); throw alreadyHandledError(serid);
} }
List<NoticeApprovalStep> steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid); List<NoticeApprovalStep> steps = normalizeSteps(approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid));
if (steps.isEmpty()) { if (steps.isEmpty()) {
// 存量单步数据按旧语义驳回 // 存量单步数据按旧语义驳回
requireApprover(notice, meTeaId); requireApprover(notice, meTeaId);
@@ -650,7 +791,7 @@ public class NoticeService implements INoticeService {
return; return;
} }
int level = notice.getApprovalLevel() == null ? 0 : notice.getApprovalLevel(); 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) { if (current.getStatus() != null && current.getStatus() != NoticeApprovalStep.STATUS_ACTIVE) {
throw alreadyHandledError(serid); throw alreadyHandledError(serid);
} }
@@ -660,6 +801,9 @@ public class NoticeService implements INoticeService {
throw alreadyHandledError(serid); throw alreadyHandledError(serid);
} }
saveRecordRow(serid, round, current, meAccount, meName(meAccount), NoticeApprovalRecord.ACTION_REJECT, opinion); 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.setStatus(NoticeApprovalStep.STATUS_REJECTED);
current.setHandleTime(LocalDateTime.now()); current.setHandleTime(LocalDateTime.now());
approvalStepRepository.save(current); approvalStepRepository.save(current);
@@ -668,6 +812,34 @@ public class NoticeService implements INoticeService {
log.info("[reject] 通知 {} 第 {} 步驳回, 审批人 {}, 意见: {}", serid, current.getStepNo(), meAccount, opinion); log.info("[reject] 通知 {} 第 {} 步驳回, 审批人 {}, 意见: {}", serid, current.getStepNo(), meAccount, opinion);
} }
/**
* 步骤列表规范化:按 step_no 去重(每步仅保留 id 最大 = 最近一次提交写入的行,丢弃历史脏行),
* 再按 step_no 升序返回。防御陈旧/重复步骤行使基于下标的 level/steps.size() 流转判断踩到同一步的脏行。
*/
private List<NoticeApprovalStep> normalizeSteps(List<NoticeApprovalStep> raw) {
java.util.LinkedHashMap<Integer, NoticeApprovalStep> 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<NoticeApprovalStep> result = new ArrayList<>(byStepNo.values());
result.sort(java.util.Comparator.comparingInt(s -> s.getStepNo() == null ? 0 : s.getStepNo()));
return result;
}
/** 当前审批步定位:优先取状态为「审批中」的步骤,规避陈旧/重复步骤行按下标错位;无则回退下标原逻辑 */
private NoticeApprovalStep resolveCurrentStep(List<NoticeApprovalStep> 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 快照 */ /** 当前步审批人校验:登录账号命中账号快照,或教师ID命中旧 teaId 快照 */
private void requireStepApprover(NoticeApprovalStep step, String meAccount, Integer meTeaId) { private void requireStepApprover(NoticeApprovalStep step, String meAccount, Integer meTeaId) {
boolean hit = (meAccount != null && parseAccounts(step.getApproverAccounts()).contains(meAccount)) boolean hit = (meAccount != null && parseAccounts(step.getApproverAccounts()).contains(meAccount))
@@ -783,11 +955,13 @@ public class NoticeService implements INoticeService {
} }
/** /**
* 构建并绑定审批步骤:账号路径按本校启用审批人池校验(姓名随池快照); * 构建并绑定审批步骤:账号路径按本校启用审批人池校验(姓名随池快照,遗留兼容);
* 旧 teaId 路径按学校教师名单校验。每步至少一人,整体至少一步。 * 旧 teaId 路径按学校教师名单校验(v18:审核设置下线,不再附加审批人池白名单校验)。
* 每步至少一人,整体至少一步。
* 草稿已存定义(全待流转步)且本次未重选人时直接复用存量定义。 * 草稿已存定义(全待流转步)且本次未重选人时直接复用存量定义。
*/ */
private List<NoticeApprovalStep> buildSteps(Notice entity, List<FlowStepItem> flowItems, List<Integer> legacyTeaIds) { private List<NoticeApprovalStep> buildSteps(Notice entity, List<FlowStepItem> flowItems,
List<Integer> legacyTeaIds) {
if ((flowItems == null || flowItems.isEmpty()) && entity.getId() != null) { if ((flowItems == null || flowItems.isEmpty()) && entity.getId() != null) {
List<NoticeApprovalStep> existing = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(entity.getId()); List<NoticeApprovalStep> existing = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(entity.getId());
// 仅「全待流转」视为草稿流程定义可复用;已驳回单的步骤含审批中状态,必须重建 // 仅「全待流转」视为草稿流程定义可复用;已驳回单的步骤含审批中状态,必须重建
@@ -823,21 +997,47 @@ public class NoticeService implements INoticeService {
step.setApproverTeaIds(toJsonTeaIds(teaIds)); step.setApproverTeaIds(toJsonTeaIds(teaIds));
} }
} else if (item.getApproverTeaIds() != null && !item.getApproverTeaIds().isEmpty()) { } else if (item.getApproverTeaIds() != null && !item.getApproverTeaIds().isEmpty()) {
// 教师ID路径(api-test 测试台教师树选人):按学校教师名单绑定 // 教师ID路径(教师树选人):按学校教师名单绑定(v18 起不再附加审批人池校验)
LinkedHashMap<Integer, String> nameById = bindApprovers(entity.getSchId(), item.getApproverTeaIds()); LinkedHashMap<Integer, String> nameById = bindApprovers(entity.getSchId(), item.getApproverTeaIds());
step.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet()))); step.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet())));
step.setApproverNames(toJsonStringList(new ArrayList<>(nameById.values()))); step.setApproverNames(toJsonStringList(new ArrayList<>(nameById.values())));
// v13-E2:补账号快照,主表当前步快照随首步带上后读侧可直接按登录名匹配
bindAccountsByTeaIds(step, nameById.keySet());
} else { } else {
// 请求级旧 approverTeaIds 兼容包装步(单步或签)
LinkedHashMap<Integer, String> nameById = bindApprovers(entity.getSchId(), legacyTeaIds); LinkedHashMap<Integer, String> nameById = bindApprovers(entity.getSchId(), legacyTeaIds);
step.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet()))); step.setApproverTeaIds(toJsonTeaIds(new ArrayList<>(nameById.keySet())));
step.setApproverNames(toJsonStringList(new ArrayList<>(nameById.values()))); step.setApproverNames(toJsonStringList(new ArrayList<>(nameById.values())));
bindAccountsByTeaIds(step, nameById.keySet());
} }
steps.add(step); steps.add(step);
} }
return steps; return steps;
} }
/**
* v13-E2:教师ID路径补账号快照——逐个 teaId 经用户中心反查登录账号写入
* {@code approverAccounts}(与读侧 {@code approverMatchPredicate} 的账号分支同源)。
* 反查失败或无账号的个体跳过;全部落空时保持 null(读侧仍可走 teaId 匹配兜底)。
*/
private void bindAccountsByTeaIds(NoticeApprovalStep step, Collection<Integer> teaIds) {
List<String> 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.setVote(step.getVote());
row.setApproverAccount(account); row.setApproverAccount(account);
row.setApproverName(name); row.setApproverName(name);
row.setApproverTeaId(currentTeaId());
row.setAction(action); row.setAction(action);
row.setOpinion(opinion); row.setOpinion(opinion);
row.setActionTime(LocalDateTime.now()); row.setActionTime(LocalDateTime.now());
@@ -927,6 +1128,7 @@ public class NoticeService implements INoticeService {
* 按学校教师列表绑定被选审批教师:bschclass 教师名单按 UserTeaSerID 匹配(保持选择顺序、去重); * 按学校教师列表绑定被选审批教师:bschclass 教师名单按 UserTeaSerID 匹配(保持选择顺序、去重);
* 未选人、或存在不属于本校的ID时报错。配置了资格组(yh.notice.approver-group-id>0)时 * 未选人、或存在不属于本校的ID时报错。配置了资格组(yh.notice.approver-group-id>0)时
* 另经用户中心校验所属组。记忆回显仅是默认值,提交时仍全量校验,防止记忆中的教师离职后带病提交。 * 另经用户中心校验所属组。记忆回显仅是默认值,提交时仍全量校验,防止记忆中的教师离职后带病提交。
* v18:审核设置功能下线,不再按审批人池(sys_approver)白名单校验。
*/ */
private LinkedHashMap<Integer, String> bindApprovers(Integer schId, List<Integer> teaIds) { private LinkedHashMap<Integer, String> bindApprovers(Integer schId, List<Integer> teaIds) {
if (teaIds == null || teaIds.isEmpty()) { if (teaIds == null || teaIds.isEmpty()) {
@@ -998,6 +1200,26 @@ public class NoticeService implements INoticeService {
String meAccount = currentAccount(); String meAccount = currentAccount();
Integer meTeaId = currentTeaId(); Integer meTeaId = currentTeaId();
Specification<Notice> spec = (root, cq, cb) -> { Specification<Notice> spec = (root, cq, cb) -> {
List<Predicate> 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();
};
Page<Notice> page = noticeRepository.findAll(spec, pageable);
List<NoticeVo> content = page.getContent().stream()
.map(n -> toVo(n, false))
.collect(Collectors.toList());
fillApprovalFields(content);
return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements()));
}
/**
* v13-E3:审批中心四 tab 的状态谓词(approvalList 与 approvalTabCount 共用,保证数字口径一致)。
* pending=待审批(当前步审批人快照含我)/ approved=走流程已发布 / rejected=已驳回 /
* all|reviewed=已审合一。
*/
private List<Predicate> approvalStatePredicates(String state, Root<Notice> root, CriteriaBuilder cb,
String meAccount, Integer meTeaId) {
List<Predicate> predicates = new ArrayList<>(); List<Predicate> predicates = new ArrayList<>();
switch (state) { switch (state) {
case "approved": case "approved":
@@ -1019,16 +1241,29 @@ public class NoticeService implements INoticeService {
predicates.add(approverMatchPredicate(root, cb, meAccount, meTeaId)); predicates.add(approverMatchPredicate(root, cb, meAccount, meTeaId));
break; break;
} }
appendCommonPredicates(query, root, cb, predicates); 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<Notice> spec = (root, cq, cb) -> {
List<Predicate> predicates = approvalStatePredicates(state, root, cb, meAccount, meTeaId);
appendSchIdPredicate(root, cb, predicates); appendSchIdPredicate(root, cb, predicates);
return cq.where(predicates.toArray(new Predicate[0])).getRestriction(); return cq.where(predicates.toArray(new Predicate[0])).getRestriction();
}; };
Page<Notice> page = noticeRepository.findAll(spec, pageable); return (int) noticeRepository.count(spec);
List<NoticeVo> content = page.getContent().stream()
.map(n -> toVo(n, false))
.collect(Collectors.toList());
fillApprovalFields(content);
return PageUtils.toResult(new PageImpl<>(content, pageable, page.getTotalElements()));
} }
@Override @Override
@@ -1104,6 +1339,9 @@ public class NoticeService implements INoticeService {
vo.setApprovalLevel(stepLevelOf(vo.getStatus(), steps)); vo.setApprovalLevel(stepLevelOf(vo.getStatus(), steps));
vo.setApprovalStatusDesc(progressDesc(vo.getStatus(), steps, records)); vo.setApprovalStatusDesc(progressDesc(vo.getStatus(), steps, records));
vo.setCanApprove(canApproveNow(vo.getStatus(), steps, records, meAccount, meTeaId)); 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); return records.stream().mapToInt(r -> r.getRound() == null ? 1 : r.getRound()).max().orElse(1);
} }
/**
* 当前步待签审批人姓名(v7,列表「当前审批人」列与详情 approvalFlow[].pending 同源)。
* <p>账号路径按账号快照比对已处理人;教师ID路径无账号快照,按姓名快照比对(仅统计最新轮,
* 驳回重提后旧轮记录不计入)。非待审批态返回 null。
*/
private List<String> pendingNamesOf(Integer status, List<NoticeApprovalStep> steps,
List<NoticeApprovalRecord> 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<String> actedAccounts = new HashSet<>();
Set<String> 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<String> accounts = parseAccounts(current.getApproverAccounts());
List<String> names = parseAccounts(current.getApproverNames());
List<String> 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<NoticeApprovalStep> steps,
List<NoticeApprovalRecord> 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)、 * 详情多步审批流装配:每步含审批人账号/姓名、步骤状态(approved/pending/rejected/waiting)、
* 全部轮次审核记录、当前步待签人姓名;同时用于发布/编辑表单回显审批人配置。 * 全部轮次审核记录、当前步待签人姓名;同时用于发布/编辑表单回显审批人配置。
@@ -1208,6 +1523,8 @@ public class NoticeService implements INoticeService {
} }
int level = stepLevelOf(status, steps); int level = stepLevelOf(status, steps);
List<ApprovalFlowVo> flow = new ArrayList<>(); List<ApprovalFlowVo> flow = new ArrayList<>();
// v15:出现姓名快照不可用的历史数据时,懒加载一次本校教师名单做批量补名
Map<Integer, String> teaNameById = null;
for (int i = 0; i < steps.size(); i++) { for (int i = 0; i < steps.size(); i++) {
NoticeApprovalStep step = steps.get(i); NoticeApprovalStep step = steps.get(i);
ApprovalFlowVo vo = new ApprovalFlowVo(); ApprovalFlowVo vo = new ApprovalFlowVo();
@@ -1215,14 +1532,26 @@ public class NoticeService implements INoticeService {
vo.setVote(StringUtils.hasText(step.getVote()) ? step.getVote() : "any"); vo.setVote(StringUtils.hasText(step.getVote()) ? step.getVote() : "any");
List<String> accounts = parseAccounts(step.getApproverAccounts()); List<String> accounts = parseAccounts(step.getApproverAccounts());
List<String> names = parseAccounts(step.getApproverNames()); List<String> names = parseAccounts(step.getApproverNames());
List<Integer> stepTeaIds = parseTeaIds(step.getApproverTeaIds());
vo.setApprovers(accounts); vo.setApprovers(accounts);
vo.setApproverTeaIds(parseTeaIds(step.getApproverTeaIds())); vo.setApproverTeaIds(stepTeaIds);
// 账号路径姓名与账号对齐展示;教师ID路径仅有姓名快照 // 账号路径姓名与账号对齐展示;教师ID路径仅有姓名快照
if (!accounts.isEmpty()) { List<String> displayNames = new ArrayList<>();
vo.setApproverNames(names.size() == accounts.size() ? names : accounts); boolean alignedTeaId = stepTeaIds.size() == names.size();
} else { for (int j = 0; j < names.size(); j++) {
vo.setApproverNames(names); 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<ApprovalFlowVo.Record> stepRecords = new ArrayList<>(); List<ApprovalFlowVo.Record> stepRecords = new ArrayList<>();
for (NoticeApprovalRecord r : records) { for (NoticeApprovalRecord r : records) {
if (step.getStepNo() == null || !step.getStepNo().equals(r.getStepNo())) { if (step.getStepNo() == null || !step.getStepNo().equals(r.getStepNo())) {
@@ -1230,7 +1559,11 @@ public class NoticeService implements INoticeService {
} }
ApprovalFlowVo.Record item = new ApprovalFlowVo.Record(); ApprovalFlowVo.Record item = new ApprovalFlowVo.Record();
item.setAccount(r.getApproverAccount()); 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 item.setResult(r.getAction() != null && r.getAction() == NoticeApprovalRecord.ACTION_REJECT
? "rejected" : "approved"); ? "rejected" : "approved");
item.setTime(r.getActionTime()); item.setTime(r.getActionTime());
@@ -1249,14 +1582,10 @@ public class NoticeService implements INoticeService {
vo.setStatus("waiting"); vo.setStatus("waiting");
} }
if ("pending".equals(vo.getStatus())) { if ("pending".equals(vo.getStatus())) {
Set<String> acted = stepRecords.stream() // v7:与列表「当前审批人」列同源;修复教师ID路径无账号快照待签恒空、旧轮记录计入已处理两缺陷
.map(ApprovalFlowVo.Record::getAccount) List<String> pending = pendingNamesOf(status, steps, records);
.filter(Objects::nonNull) if (pending != null) {
.collect(Collectors.toSet()); vo.getPending().addAll(pending);
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));
}
} }
} }
flow.add(vo); flow.add(vo);
@@ -1343,6 +1672,7 @@ public class NoticeService implements INoticeService {
row.setAction(action); row.setAction(action);
row.setOperatorAccount(operatorAccount); row.setOperatorAccount(operatorAccount);
row.setOperatorName(operatorName); row.setOperatorName(operatorName);
row.setOperatorTeaId(currentTeaId());
row.setOpinion(opinion); row.setOpinion(opinion);
row.setAssigneeTeaIds(assigneeTeaIds); row.setAssigneeTeaIds(assigneeTeaIds);
row.setActionTime(LocalDateTime.now()); row.setActionTime(LocalDateTime.now());
@@ -1624,17 +1954,46 @@ public class NoticeService implements INoticeService {
content.forEach(vo -> vo.setApproverTeaIds(null)); content.forEach(vo -> vo.setApproverTeaIds(null));
} }
// 未读数与列表同口径(含范围可见性过滤),避免「数字与列表对不上」 // 未读数与列表、tab 统计同一口径(v17:直接按 userSpec 的 readType=1 计数,
long unreadCount = noticeRepository.count(visibleSpec(account, true)) // 不再用「可见总数 − 该账号全部阅读记录数」相减——那会把已删除/已撤回/跨校的已读记录算进减数)
- noticeReadRepository.countByReaderAccount(account); long unreadCount = countUnreadForCurrentUser();
if (unreadCount < 0) {
unreadCount = 0;
}
return new NoticePageResult<>(content, (int) page.getTotalElements(), return new NoticePageResult<>(content, (int) page.getTotalElements(),
page.getNumber() + 1, page.getSize(), page.getTotalPages(), page.hasNext(), unreadCount); page.getNumber() + 1, page.getSize(), page.getTotalPages(), page.hasNext(), unreadCount);
} }
/**
* 当前用户「可见且未读」的公告条数(v17 统一口径)。
* <p>不带 keyword/type 等筛选(全局未读数,用于列表页顶部徽标与统计页);
* 与 {@link #userList} 的差异仅在于筛选条件,谓词基座同为 {@link #userSpec}。
*/
private long countUnreadForCurrentUser() {
String account = currentAccount();
Set<Integer> readIds = new HashSet<>(noticeReadRepository.findNoticeIdsByReaderAccount(account));
Set<Integer> receiverNoticeIds = new HashSet<>(noticeReceiverRepository.findNoticeIdsByAccount(account));
return noticeRepository.count(userSpec(new NoticeQuery(), readIds, receiverNoticeIds, UNREAD));
}
/**
* 已收到三 tab 统计(v17-B1):全部 / 未读 / 已读。
* <p>口径 = {@code GET /api/notice/List} 在同一 keyword/type/deptId/时间范围过滤下
* 分别取 readType=0/1/2 的总数;<b>readType 不作为入参</b>(它就是被统计的三个桶),
* 因此恒满足 {@code all = unread + read}。
*/
@Override
public ReadTabCountVo readTabCount(NoticeQuery query) {
NoticeQuery filter = query == null ? new NoticeQuery() : query;
String account = currentAccount();
Set<Integer> readIds = new HashSet<>(noticeReadRepository.findNoticeIdsByReaderAccount(account));
Set<Integer> 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 维持「全校广播」现状; * 范围可见性(最小改动原则):无 push_scope 维持「全校广播」现状;
@@ -1674,6 +2033,19 @@ public class NoticeService implements INoticeService {
* 多租户:按当前用户学校过滤(schId 为空时不加过滤,兼容无学校归属用户)。 * 多租户:按当前用户学校过滤(schId 为空时不加过滤,兼容无学校归属用户)。
*/ */
private Specification<Notice> userSpec(NoticeQuery query, Set<Integer> readIds, Set<Integer> receiverNoticeIds) { private Specification<Notice> userSpec(NoticeQuery query, Set<Integer> readIds, Set<Integer> receiverNoticeIds) {
Integer readType = query.getReadType() == null ? ALL : query.getReadType();
return userSpec(query, readIds, receiverNoticeIds, readType);
}
/**
* 同 {@link #userSpec(NoticeQuery, Set, Set)},但显式指定阅读桶(v17-B3①)。
* <p>tab 统计与已收相邻查询需要在<b>同一套过滤条件</b>下分别取 0/1/2 三个桶,
* 故把 readType 从 query 里提出来做参数,避免复制一份过滤逻辑造成口径漂移。
*
* @param readType {@link #ALL} 全部 / {@link #UNREAD} 未读 / {@link #READ} 已读
*/
private Specification<Notice> userSpec(NoticeQuery query, Set<Integer> readIds, Set<Integer> receiverNoticeIds,
int readType) {
return (Root<Notice> root, CriteriaQuery<?> cq, CriteriaBuilder cb) -> { return (Root<Notice> root, CriteriaQuery<?> cq, CriteriaBuilder cb) -> {
List<Predicate> predicates = new ArrayList<>(); List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("status"), PUBLISHED_CODE)); predicates.add(cb.equal(root.get("status"), PUBLISHED_CODE));
@@ -1683,10 +2055,9 @@ public class NoticeService implements INoticeService {
appendSchIdPredicate(root, cb, predicates); appendSchIdPredicate(root, cb, predicates);
predicates.add(scopeVisibility(root, cq, cb, receiverNoticeIds)); predicates.add(scopeVisibility(root, cq, cb, receiverNoticeIds));
Integer readType = query.getReadType() == null ? 0 : query.getReadType(); if (readType == UNREAD && !readIds.isEmpty()) {
if (readType == 1 && !readIds.isEmpty()) {
predicates.add(cb.not(root.get("id").in(readIds))); predicates.add(cb.not(root.get("id").in(readIds)));
} else if (readType == 2) { } else if (readType == READ) {
if (readIds.isEmpty()) { if (readIds.isEmpty()) {
predicates.add(cb.equal(root.get("id"), -1)); predicates.add(cb.equal(root.get("id"), -1));
} else { } else {
@@ -2058,8 +2429,17 @@ public class NoticeService implements INoticeService {
// ============================ 上一篇/下一篇(SQL 相邻查询) ============================ // ============================ 上一篇/下一篇(SQL 相邻查询) ============================
/**
* 上一篇/下一篇通知ID。
*
* @param type "0"=已发序列(本人发布,遗留 E1 未改);其余=已收序列
* @param query 已收序列的筛选条件(v17-B3④:keyword/type/deptId/时间范围,与 List 同源)。
* <b>刻意忽略其中的 readType</b>——未读公告点开即被标读,若序列带 readType=1
* 会把刚打开的这条挤出集合,导致上下篇同时置灰、用户卡在原地;
* 因此未读/已读两个 tab 共用同一条「全部可见已收」序列。传 null 等价于无筛选。
*/
@Override @Override
public Map<String, Object> getNextPrev(Integer serid, String type) { public Map<String, Object> getNextPrev(Integer serid, String type, NoticeQuery query) {
Map<String, Object> data = new LinkedHashMap<>(); Map<String, Object> data = new LinkedHashMap<>();
Notice current = noticeRepository.findById(serid).orElse(null); Notice current = noticeRepository.findById(serid).orElse(null);
if (current == null) { if (current == null) {
@@ -2070,23 +2450,75 @@ public class NoticeService implements INoticeService {
if ("0".equals(type)) { if ("0".equals(type)) {
// 已发序列:按当前用户发布的通知,按 createTime 排序 // 已发序列:按当前用户发布的通知,按 createTime 排序
List<Notice> published = noticeRepository.findAll( List<Notice> 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")); Sort.by(Sort.Direction.DESC, "createTime"));
List<Integer> ids = published.stream().map(Notice::getId).collect(Collectors.toList()); List<Integer> ids = published.stream().map(Notice::getId).collect(Collectors.toList());
int index = ids.indexOf(serid); int index = ids.indexOf(serid);
data.put("prevId", index > 0 ? ids.get(index - 1) : 0); data.put("prevId", index > 0 ? ids.get(index - 1) : 0);
data.put("nextId", index >= 0 && index < ids.size() - 1 ? ids.get(index + 1) : 0); data.put("nextId", index >= 0 && index < ids.size() - 1 ? ids.get(index + 1) : 0);
} else { } else {
// 已收序列:按阅读记录 // 已收序列(v17):与 GET /api/notice/List 同一可见集合 + 同一筛选条件
List<Integer> ids = noticeReadRepository.findNoticeIdsByReaderAccount(currentAccount()); String account = currentAccount();
Collections.reverse(ids); Set<Integer> readIds = new HashSet<>(noticeReadRepository.findNoticeIdsByReaderAccount(account));
int index = ids.indexOf(serid); Set<Integer> receiverNoticeIds = new HashSet<>(noticeReceiverRepository.findNoticeIdsByAccount(account));
data.put("prevId", index > 0 ? ids.get(index - 1) : 0); Specification<Notice> base = userSpec(query == null ? new NoticeQuery() : query,
data.put("nextId", index >= 0 && index < ids.size() - 1 ? ids.get(index + 1) : 0); readIds, receiverNoticeIds, ALL);
data.put("prevId", adjacentReceivedId(base, current, true));
data.put("nextId", adjacentReceivedId(base, current, false));
} }
return data; return data;
} }
/**
* 已收序列中与 current 相邻的一条(v17-B3④)。
* <p>排序键与 List 的 {@code @SortDefault({"topmost","createTime"} DESC)} 对齐,
* 并补 id 作稳定 tie-break(列表在同置顶+同创建时间下顺序本就不确定);
* topmost 经 coalesce 归 0,使历史 NULL 行与 0 同档(两者在列表 ORDER BY 里的先后同样不确定,同源风险)。
* <p>只取 1 行({@code PageRequest.of(0, 1, sort)}),不再像旧实现那样为找两个邻居把整表实体
* (含 LONGTEXT 正文)读进内存。
*
* @param newer true=列表中位于当前<b>上一条</b>(排序键更大者里取最小);false=<b>下一条</b>(排序键更小者里取最大)
* @return 相邻公告ID;当前条不在筛选集合内或该方向无邻居时返回 0
*/
private int adjacentReceivedId(Specification<Notice> 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.<Integer>get("id"), current.getId())));
if (selfInSet == 0) {
return 0;
}
Specification<Notice> 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<Notice> neighbors = noticeRepository.findAll(base.and(side), PageRequest.of(0, 1, sort));
List<Notice> 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<Notice> root, CriteriaBuilder cb, Notice current, boolean greater) {
Expression<Integer> top = cb.coalesce(root.<Integer>get("topmost"), 0);
int curTop = current.getTopmost() == null ? 0 : current.getTopmost();
Expression<LocalDateTime> time = root.<LocalDateTime>get("createTime");
Expression<Integer> id = root.<Integer>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 @Override
@@ -2260,11 +2692,144 @@ public class NoticeService implements INoticeService {
return count; return count;
} }
/**
* 历史审批身份回填(v15,见 INoticeService 注释)。
* <p>数据量小(审批留痕行数有限),直接全表逐行处理;账号解析结果按进程缓存,避免重复外呼用户中心。</p>
*/
@Override
@Transactional
public Map<String, Integer> backfillApproverIdentity() {
Map<String, Integer> stat = new LinkedHashMap<>();
int trail = 0, record = 0, stepUpd = 0, noticeUpd = 0, unresolved = 0;
Map<String, BoaUser> 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<Integer, Map<Integer, String>> teaNamesBySch = new HashMap<>();
for (NoticeApprovalStep stepRow : approvalStepRepository.findAll()) {
Notice owner = noticeRepository.findById(stepRow.getNoticeId()).orElse(null);
if (owner == null || owner.getSchId() == null) {
continue;
}
Map<Integer, String> namesById = teaNamesBySch.computeIfAbsent(owner.getSchId(), this::teaNameMap);
List<Integer> teaIds = parseTeaIds(stepRow.getApproverTeaIds());
List<String> names = parseAccounts(stepRow.getApproverNames());
if (teaIds.isEmpty() || names.size() != teaIds.size()) {
continue;
}
List<String> 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<String, BoaUser> 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 @Override
public NoticeStatisticsVo statistics(NoticeQuery query, LocalDateTime start, LocalDateTime end) { public NoticeStatisticsVo statistics(NoticeQuery query, LocalDateTime start, LocalDateTime end) {
String account = currentAccount();
Integer schId = currentSchId(); Integer schId = currentSchId();
LocalDateTime s = (start == null) ? LocalDateTime.of(1970, 1, 1, 0, 0) : start; LocalDateTime s = (start == null) ? LocalDateTime.of(1970, 1, 1, 0, 0) : start;
LocalDateTime e = (end == null) ? LocalDateTime.now().plusDays(1) : end; 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) long publishedCount = schId != null ? noticeRepository.countBySchIdAndStatus(schId, PUBLISHED_CODE)
: noticeRepository.countByStatus(PUBLISHED_CODE); : noticeRepository.countByStatus(PUBLISHED_CODE);
long totalReadRecords = noticeReadRepository.count(); long totalReadRecords = noticeReadRepository.count();
long unreadForCurrentUser = noticeRepository.count(visibleSpec(account, true)) // v17:与「已收到」列表/tab 统计同一口径(原先也是「可见总数 − 全部已读记录」相减)
- noticeReadRepository.countByReaderAccount(account); long unreadForCurrentUser = countUnreadForCurrentUser();
if (unreadForCurrentUser < 0) {
unreadForCurrentUser = 0;
}
List<LocalDateTime> createTimes = schId != null List<LocalDateTime> createTimes = schId != null
? noticeRepository.findCreateTimesBySchIdStatusAndRange(schId, PUBLISHED_CODE, s, e) ? noticeRepository.findCreateTimesBySchIdStatusAndRange(schId, PUBLISHED_CODE, s, e)
@@ -2302,7 +2864,8 @@ public class NoticeService implements INoticeService {
: noticeRepository.findPublisherNamesByStatusAndRange(PUBLISHED_CODE, s, e); : noticeRepository.findPublisherNamesByStatusAndRange(PUBLISHED_CODE, s, e);
Map<String, Long> pubMap = new LinkedHashMap<>(); Map<String, Long> pubMap = new LinkedHashMap<>();
for (String p : publishers) { 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<NoticeStatisticsVo.PublisherStat> byPublisher = pubMap.entrySet().stream() List<NoticeStatisticsVo.PublisherStat> byPublisher = pubMap.entrySet().stream()
.map(en -> new NoticeStatisticsVo.PublisherStat(en.getKey(), en.getValue())) .map(en -> new NoticeStatisticsVo.PublisherStat(en.getKey(), en.getValue()))
@@ -2358,7 +2921,7 @@ public class NoticeService implements INoticeService {
// 当前绑定审批教师回显(编辑时按ID勾选教师树,姓名由前端经 ApproverResolve 绑定) // 当前绑定审批教师回显(编辑时按ID勾选教师树,姓名由前端经 ApproverResolve 绑定)
data.setApproverTeaIds(parseTeaIds(vo.getApproverTeaIds())); data.setApproverTeaIds(parseTeaIds(vo.getApproverTeaIds()));
// 多步审批流(步骤表 + 审核记录表) // 多步审批流(步骤表 + 审核记录表)
List<NoticeApprovalStep> steps = approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid); List<NoticeApprovalStep> steps = normalizeSteps(approvalStepRepository.findByNoticeIdOrderByStepNoAsc(serid));
List<NoticeApprovalRecord> flowRecords = approvalRecordRepository.findByNoticeIdOrderByIdAsc(serid); List<NoticeApprovalRecord> flowRecords = approvalRecordRepository.findByNoticeIdOrderByIdAsc(serid);
data.setApprovalEnabled(steps.isEmpty() ? 0 : 1); data.setApprovalEnabled(steps.isEmpty() ? 0 : 1);
data.setApprovalStatus(approvalStatusOf(vo.getStatus(), steps)); data.setApprovalStatus(approvalStatusOf(vo.getStatus(), steps));
@@ -2389,20 +2952,34 @@ public class NoticeService implements INoticeService {
/** 审批轨迹装配:全部行 + 提交行 assignees 快照(前端按提交行切分轮次) */ /** 审批轨迹装配:全部行 + 提交行 assignees 快照(前端按提交行切分轮次) */
private List<ApprovalRecordVo> loadApprovalRecords(Integer noticeId) { private List<ApprovalRecordVo> loadApprovalRecords(Integer noticeId) {
return approvalRepository.findByNoticeIdOrderByIdAsc(noticeId).stream() List<NoticeApproval> rows = approvalRepository.findByNoticeIdOrderByIdAsc(noticeId);
.map(record -> { List<ApprovalRecordVo> list = new ArrayList<>(rows.size());
// v15:历史行 operator_name 落成登录名(displayName==loginName 时代写入),出现脏数据时懒加载一次教师名单补名
Map<Integer, String> teaNameById = Collections.emptyMap();
boolean teaNameLoaded = false;
for (NoticeApproval record : rows) {
ApprovalRecordVo vo = new ApprovalRecordVo(); ApprovalRecordVo vo = new ApprovalRecordVo();
vo.setAction(record.getAction()); vo.setAction(record.getAction());
ApprovalActionEnum action = ApprovalActionEnum.of(record.getAction()); ApprovalActionEnum action = ApprovalActionEnum.of(record.getAction());
vo.setActionDesc(action != null ? action.getDesc() : String.valueOf(record.getAction())); vo.setActionDesc(action != null ? action.getDesc() : String.valueOf(record.getAction()));
vo.setOperatorAccount(record.getOperatorAccount()); vo.setOperatorAccount(record.getOperatorAccount());
vo.setOperatorName(record.getOperatorName()); 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.setOpinion(record.getOpinion());
vo.setAssigneeTeaIds(parseTeaIds(record.getAssigneeTeaIds())); vo.setAssigneeTeaIds(parseTeaIds(record.getAssigneeTeaIds()));
vo.setActionTime(record.getActionTime()); vo.setActionTime(record.getActionTime());
return vo; list.add(vo);
}) }
.collect(Collectors.toList()); return list;
}
/** 姓名快照是否不可用(v15):为空或等于登录账号(历史上 displayName 恒等于 loginName) */
private static boolean nameMissing(String snapshot, String account) {
return !StringUtils.hasText(snapshot) || snapshot.equals(account);
} }
/** 学员端请求判定:平台路径 mobiapi 段(/{platform}/notice/...) */ /** 学员端请求判定:平台路径 mobiapi 段(/{platform}/notice/...) */
@@ -2475,6 +3052,8 @@ public class NoticeService implements INoticeService {
if (!StringUtils.hasText(vo.getDeptName())) { if (!StringUtils.hasText(vo.getDeptName())) {
vo.setDeptName(dictService.resolveDeptName(vo.getDeptId())); vo.setDeptName(dictService.resolveDeptName(vo.getDeptId()));
} }
// v16:发布人快照可能落成账号或教师ID,展示前统一解析为真实姓名(列表/详情共用 toVo)
vo.setPublisherName(displayPublisherName(vo.getPublisherName(), vo.getPublisherAccount()));
vo.setRead(read); vo.setRead(read);
vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId())); vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId()));
return vo; return vo;
+2 -2
View File
@@ -62,8 +62,8 @@ yh:
app-auth: app-auth:
enabled: true enabled: true
# 本应用自身身份(客户端签名用) # 本应用自身身份(客户端签名用)
app-key: 100001 app-key: 100006
app-secret: ec4e860206374c80938719fff8e14a86 app-secret: e053f7d98518407ab0e69f0500820d17
open: open:
enabled: true enabled: true
base-host: http://base.yunhaixingtong.com base-host: http://base.yunhaixingtong.com
+24 -5
View File
@@ -47,6 +47,8 @@
.seg { display: inline-flex; background: #e2e8f0; border-radius: 8px; padding: 3px; } .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 { 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); } .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 { 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); } .input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
select.input { cursor: pointer; } select.input { cursor: pointer; }
@@ -270,9 +272,9 @@
</div> </div>
<div class="toolbar"> <div class="toolbar">
<div class="seg" id="readSeg"> <div class="seg" id="readSeg">
<button class="on" data-v="0" onclick="inboxFilter('readType',0,this)">全部</button> <button class="on" data-v="0" onclick="inboxFilter('readType',0,this)">全部<span class="seg-cnt" id="cntAll"></span></button>
<button data-v="1" onclick="inboxFilter('readType',1,this)">未读</button> <button data-v="1" onclick="inboxFilter('readType',1,this)">未读<span class="seg-cnt" id="cntUnread"></span></button>
<button data-v="2" onclick="inboxFilter('readType',2,this)">已读</button> <button data-v="2" onclick="inboxFilter('readType',2,this)">已读<span class="seg-cnt" id="cntRead"></span></button>
</div> </div>
<select class="input" id="inboxType" onchange="reloadInbox()"> <select class="input" id="inboxType" onchange="reloadInbox()">
<option value="">全部类型</option> <option value="">全部类型</option>
@@ -727,6 +729,17 @@ function renderUnreadBadge() {
const n = inboxState.unreadCount; const n = inboxState.unreadCount;
$('unreadBadge').textContent = n > 0 ? (n > 99 ? '99+' : n) : ''; $('unreadBadge').textContent = n > 0 ? (n > 99 ? '99+' : n) : '';
} }
/* 三 tab 条数(v17 ReadTabCount):与列表同一 keyword/type 过滤,readType 不参与 */
async function loadInboxCounts() {
const put = (id, n) => { const el = $(id); if (el) el.textContent = n > 0 ? '(' + n + ')' : ''; };
try {
const c = await api('/notice/ReadTabCount', {
params: { type: $('inboxType').value, keyword: $('inboxKw').value.trim() }
});
if (!c) return;
put('cntAll', c.all); put('cntUnread', c.unread); put('cntRead', c.read);
} catch (e) { /* 统计失败不影响列表 */ }
}
async function loadInbox() { async function loadInbox() {
const box = $('inboxList'); const box = $('inboxList');
box.innerHTML = '<div class="empty">加载中…</div>'; box.innerHTML = '<div class="empty">加载中…</div>';
@@ -743,6 +756,7 @@ async function loadInbox() {
const list = (r && r.content) || []; const list = (r && r.content) || [];
inboxState.unreadCount = (r && r.unreadCount) || inboxState.unreadCount; inboxState.unreadCount = (r && r.unreadCount) || inboxState.unreadCount;
renderUnreadBadge(); renderUnreadBadge();
loadInboxCounts();
$('inboxSub').textContent = r ? ('共 ' + r.total + ' 条 · 未读 ' + r.unreadCount + ' 条') : ''; $('inboxSub').textContent = r ? ('共 ' + r.total + ' 条 · 未读 ' + r.unreadCount + ' 条') : '';
if (!list.length) { if (!list.length) {
@@ -819,9 +833,14 @@ async function openDetail(serid, from) {
$('dFiles').style.display = 'none'; $('dFiles').style.display = 'none';
} }
// 上一篇/下一篇 // 上一篇/下一篇(v17:已收序列沿用收件箱当前筛选;重要程度走 noticeType,`type` 已被序列类型占用)
let np = {}; let np = {};
try { np = await api('/notice/GetNextPrev', { params: { serid, type: 1 } }) || {}; } catch (e) { /* 忽略 */ } const navParams = { serid, type: 1 };
if (from === 'inbox') {
navParams.keyword = $('inboxKw').value.trim();
if ($('inboxType').value) navParams.noticeType = $('inboxType').value;
}
try { np = await api('/notice/GetNextPrev', { params: navParams }) || {}; } catch (e) { /* 忽略 */ }
setupNavBtn('dPrev', np.prev || np.preId || np.prevId, '上一篇'); setupNavBtn('dPrev', np.prev || np.preId || np.prevId, '上一篇');
setupNavBtn('dNext', np.next || np.nextId, '下一篇'); setupNavBtn('dNext', np.next || np.nextId, '下一篇');