修改
build / build-push (push) Successful in 1m1s

This commit is contained in:
wanghu
2026-09-15 10:22:27 +08:00
parent 5fa8462a9c
commit bf4a5ab550
14 changed files with 3876 additions and 0 deletions
@@ -0,0 +1,385 @@
package com.yunhai.campusnotice.controller.api;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.campusnotice.config.Config;
import com.yunhai.campusnotice.common.validation.ValidationGroups;
import com.yunhai.campusnotice.model.BasePageResult;
import com.yunhai.campusnotice.model.notice.*;
import com.yunhai.campusnotice.service.INoticeService;
import com.yunhai.campusnotice.utils.OtherUtil;
import com.yunhai.campusnotice.utils.PageUtils;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.validation.annotation.Validated;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import javax.print.attribute.standard.MediaSize;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
/**
* 通知公告接口(合并 NoticeApiController + 旧 NoticeController)。
* <p>路径前缀:{@code Config.API_PATH + "/notice/"}(platform = api / manage / mobiapi)。
* 方法动词式 PascalCase(对齐 UserBase);写操作一律 POST。
* 返回裸对象,由 ApiResponseAdvice 自动包装为 {code, msg, data}。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/notice/")
public class NoticeController {
@Autowired
private INoticeService noticeService;
// ============================ 收件箱列表 ============================
/**
* 已收通知列表(用户端)。
*/
@GetMapping("List")
public NoticePageResult<NoticeVo> list(NoticeQuery query,
@SortDefault(sort = {"topmost", "createTime"},
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.userList(query, PageUtils.toPageable(query, sort));
}
// ============================ 已发/全部通知列表 ============================
/**
* 已发/全部通知列表(管理端)。type=user 只看自己。
*/
@GetMapping("SendList")
public BasePageResult<NoticeVo> sendList(NoticeQuery query,
@SortDefault(sort = "createTime",
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.adminList(query, PageUtils.toPageable(query, sort));
}
// ============================ 查看详情(标读) ============================
/**
* 查看通知详情(type=1 普通查看标记已读, type=0 编辑模式不标记已读)。
*/
@GetMapping("Detail")
public NoticeDetailVo detail(@RequestParam("serid") Integer serid,
@RequestParam(value = "type", defaultValue = "1") Integer type) {
return noticeService.detailMap(serid, type);
}
// ============================ 编辑回显(不标读) ============================
/**
* 获取通知详情(编辑/转发用,不标记已读)。
*/
@GetMapping("GetDetail")
public NoticeDetailVo getDetail(@RequestParam("serid") Integer serid) {
return noticeService.getDetailMap(serid);
}
// ============================ 新建 ============================
/**
* 新建通知(JSON body)。fileIds 为附件ID逗号串,smsFlag 为短信标记。
*/
@PostMapping("Create")
public Integer create(@RequestBody @Validated(ValidationGroups.Create.class) NoticeRequest request) {
return noticeService.publish(request);
}
// ============================ 编辑 ============================
/**
* 编辑通知(JSON body,含 id)。
*/
@PostMapping("Edit")
public void edit(@RequestBody @Validated(ValidationGroups.Update.class) NoticeRequest request) {
noticeService.edit(request.getSourceId() != null ? request.getSourceId() : 0, request);
}
// ============================ 批量删除 ============================
/**
* 批量删除通知(ids 数组)。
*/
@PostMapping("Delete")
public void delete(@RequestBody List<Integer> ids) {
noticeService.deleteBatch(ids);
}
// ============================ 撤回 ============================
/**
* 撤回通知。
*/
@PostMapping("UserPull")
public void userPull(@RequestParam("serid") Integer serid) {
noticeService.pull(serid);
}
// ============================ 置顶 ============================
/**
* 置顶/取消置顶。
*/
@PostMapping("SetTopmost")
public void setTopmost(@RequestParam("serid") Integer serid,
@RequestParam(value = "topmost", defaultValue = "1") Integer topmost) {
noticeService.setTopmost(serid, topmost);
}
// ============================ 追加接收人 ============================
/**
* 追加接收人。
*/
@PostMapping("ReceiveUsers")
public void receiveUsers(@RequestParam("serid") Integer serid,
@RequestParam("users") String users) {
noticeService.receiveUsers(serid, users);
}
// ============================ 催收 ============================
/**
* 催收未读。
*/
@PostMapping("Expedite")
public void expedite(@RequestParam("serid") Integer serid) {
noticeService.expedite(serid);
}
// ============================ 一键已读 ============================
/**
* 一键全部已读(POST 化,原为 GET 写操作)。
*/
@PostMapping("InitializeRed")
public InitializeRedVo initializeRed() {
return noticeService.initializeRedResult();
}
// ============================ 阅读情况 ============================
/**
* 阅读情况(按已读/未读分组)。
*/
@GetMapping("ReadDetail")
public List<Map<String, Object>> readDetail(@RequestParam("serid") Integer serid) {
return noticeService.getReadDetailGrouped(serid);
}
// ============================ 上一篇/下一篇 ============================
/**
* 上一篇/下一篇通知ID。
*/
@GetMapping("GetNextPrev")
public Map<String, Object> getNextPrev(@RequestParam("serid") Integer serid,
@RequestParam("type") String type) {
return noticeService.getNextPrev(serid, type);
}
// ============================ 附件打包下载 ============================
/**
* 批量下载通知全部附件(打包 zip)。
*/
@GetMapping("AllFileDown")
public void allFileDown(@RequestParam("dataid") Integer dataid,
HttpServletResponse response) {
noticeService.downloadAllFiles(dataid, response);
}
// ============================ 统计 ============================
/**
* 通知统计。
*/
@GetMapping("Statis")
public NoticeStatisticsVo statis(
@RequestParam(value = "starttime", required = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime start,
@RequestParam(value = "endtime", required = false)
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime end) {
return noticeService.statistics(new NoticeQuery(), start, end);
}
// ============================ 转发(合并旧 REST 壳 forward) ============================
/**
* 转发通知(基于源通知创建新通知)。sourceId 在 request body 中传递。
*/
@PostMapping("Forward")
public Integer forward(@RequestBody @Validated(ValidationGroups.Create.class) NoticeRequest request) {
return noticeService.forward(request.getSourceId(), request);
}
// ============================ 推送范围树(新版发布表单,改造方案 3.1) ============================
/**
* 当前学校学员推送范围树:全校根 + 学段→年级→班级(数据源 bschclass),
* 供表单「推送范围」胶囊按钮渲染。节点结构:{scopeType, scopeId, name, children}。
*/
@GetMapping("ScopeTree")
public List<Map<String, Object>> scopeTree() {
return noticeService.scopeTree();
}
// ============================ 推送范围预估 ============================
/**
* 按已选范围预估接收人数(学员 = 范围展开去重账号数 + 教师 = receiveList 定向名单数)。
* scopes 为 JSON 数组:[{"scopeType":3,"scopeId":5,"scopeName":"初一"}]
*/
@GetMapping("ScopePreview")
public ScopePreviewVo scopePreview(@RequestParam(value = "scopes", required = false) String scopes,
@RequestParam(value = "receiveList", required = false) String receiveList) {
return noticeService.scopePreview(parseScopes(scopes), receiveList);
}
// ============================ 存为草稿 ============================
/**
* 存为草稿(status=0):不圈人、不发短信、不落接收人,仅存内容 + 范围快照 + 附件。
*/
@PostMapping("SaveDraft")
public Integer saveDraft(@RequestBody NoticeRequest request) {
return noticeService.saveDraft(request);
}
// ============================ 草稿箱列表 ============================
/**
* 草稿箱列表(status=0 本人草稿):重要程度 type/typeDesc、定时标注与 timingSendTime。
*/
@GetMapping("DraftList")
public BasePageResult<NoticeVo> draftList(NoticeQuery query,
@SortDefault(sort = "updateTime",
direction = Sort.Direction.DESC) Sort sort) {
return noticeService.draftList(query, PageUtils.toPageable(query, sort));
}
// ============================ 已发送阅读回执 ============================
/**
* 已发送通知阅读回执:按 年级→班级→学生 分组返回(身份快照),
* 学生带已读/未读状态与阅读时间(前端着色),教师接收行归「教师」组。
*/
@GetMapping("ReadReceipt")
public ReadReceiptVo readReceipt(@RequestParam("serid") Integer serid) {
return noticeService.readReceipt(serid);
}
// ============================ 审批人记忆回显 ============================
/**
* 审批人记忆回显:当前发布人最近一次所选审批人组合(无记忆返回空)。
*/
@GetMapping("ApproverSuggest")
public ApproverSuggestVo approverSuggest() {
return noticeService.approverSuggest();
}
// ============================ 审批教师绑定(教师树ID→姓名/头像) ============================
/**
* 审批教师绑定:按学校教师列表(bschclass)绑定教师树选中ID → 姓名/头像。
* ids=101,102(教师树叶子节点ID);schId 缺省取当前登录人学校;
* 未命中的ID保留、name 为 null,由前端提示。
*/
@GetMapping("ApproverResolve")
public List<ApproverResolveVo> approverResolve(
@RequestParam(value = "ids", required = false) String ids,
@RequestParam(value = "schId", required = false) Integer schId) {
if (ids == null || ids.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "ids 不允许为空");
}
return noticeService.approverResolve(Arrays.asList(OtherUtil.convertToArray(ids)), schId);
}
// ============================ 提交审批(多步流程) ============================
/**
* 提交审批(status: 0/3→2):steps 多步审批流 [{name, vote, approvers:[登录账号]}] 优先,
* 缺省时旧 approverTeaIds 包装为单步或签。步骤落 notice_approval_step,
* 处理动作落 notice_approval_record,并 upsert 审批人记忆表。
*/
@PostMapping("Submit")
public void submit(@RequestBody SubmitRequest request) {
noticeService.submit(request);
}
// ============================ 审批通过(当前步) ============================
/**
* 审批通过:或签任一通过/会签全部通过后流转下一步,末步通过即真实发布;
* 被他人先审或已处理过时返回冲突提示。兼容 form 提交(serid/opinion)。
*/
@PostMapping("Approve")
public void approve(@RequestParam("serid") Integer serid,
@RequestParam(value = "opinion", required = false) String opinion) {
noticeService.approve(serid, opinion);
}
// ============================ 审批驳回(当前步) ============================
/**
* 审批驳回:任一步驳回即 status→3,意见必填(remark 为前端别名);发布人可改后重新提交。
*/
@PostMapping("Reject")
public void reject(@RequestParam("serid") Integer serid,
@RequestParam(value = "opinion", required = false) String opinion,
@RequestParam(value = "remark", required = false) String remark) {
noticeService.reject(serid, StringUtils.hasText(opinion) ? opinion : remark);
}
// ============================ 审批列表(待审/已审合一) ============================
/**
* 审批列表:auditState/status=pending(默认) 待我审批 / approved 走流程已发布 /
* rejected 已驳回 / all|reviewed 已审合一。行 VO 为 NoticeVo,多步审批字段:
* approvalStatus/approvalStatusDesc(进度文本)/approvalLevel/canApprove。
*/
@GetMapping("ApprovalList")
public BasePageResult<NoticeVo> approvalList(
@RequestParam(value = "auditState", required = false) String auditState,
@RequestParam(value = "status", required = false) String status,
NoticeQuery query,
@SortDefault(sort = "updateTime", direction = Sort.Direction.DESC) Sort sort) {
return noticeService.approvalList(
StringUtils.hasText(auditState) ? auditState : status, query, PageUtils.toPageable(query, sort));
}
// ============================ 待我审批数量(菜单角标) ============================
/**
* 待我审批数量:待审批且当前步审批人快照含当前登录人(账号或教师ID)。
*/
@GetMapping("ApprovalCount")
public int approvalCount() {
return noticeService.approvalCount();
}
/** 解析 scopes JSON 参数为范围列表(空/非法返回 null = 无范围) */
private List<PushScopeItem> parseScopes(String scopes) {
if (scopes == null || scopes.trim().isEmpty()) {
return null;
}
try {
return new ObjectMapper().readValue(scopes, new TypeReference<List<PushScopeItem>>() {
});
} catch (IOException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "scopes 参数格式错误,应为 JSON 数组:[{\"scopeType\":3,\"scopeId\":5}]");
}
}
}
@@ -0,0 +1,228 @@
package com.yunhai.campusnotice.service;
import com.yunhai.campusnotice.entity.Notice;
import com.yunhai.campusnotice.model.BasePageResult;
import com.yunhai.campusnotice.model.notice.*;
import org.springframework.data.domain.Pageable;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 通知公告业务接口。
*/
public interface INoticeService {
/**
* 发通知:以当前登录用户为发布人入库。
* 按 request.publishType 分流(改造方案 4.1):
* direct/空 = 直接发布(兼容旧 Create 链路;携带未来 timingSendTime 时置 status=1 待调度);
* draft = 存为草稿;approve = 提交审批(request.approverTeaIds 选教师,或签)。
*
* @return 新建通知 ID(用于后续回填附件 TopicID 等)
*/
Integer publish(NoticeRequest request);
/**
* 存为草稿(status=0):仅存内容 + 范围快照 + 附件,不圈人、不发短信、不落接收人。
*/
Integer saveDraft(NoticeRequest request);
/**
* 草稿箱列表(status=0,本人草稿):返回重要程度 type/typeDesc、定时标注与 timingSendTime。
*/
BasePageResult<NoticeVo> draftList(NoticeQuery query, Pageable pageable);
/**
* 提交审批(status: 0/3 → 2):request.steps 多步审批流([{name, vote, approvers:[登录账号]}])
* 优先;缺省时旧 approverTeaIds 包装为单步或签(兼容链路)。
* 步骤落 notice_approval_step,处理动作落 notice_approval_record。
*/
void submit(SubmitRequest request);
/**
* 审批通过:当前步审批人处理;或签任一通过/会签全部通过后流转下一步,
* 末步通过 status: 2→1(CAS)并触发真实发布。已被他人先行处理时抛业务冲突提示。
*/
void approve(Integer serid, String opinion);
/**
* 审批驳回:任一步驳回即 status: 2→3(CAS),记录意见;发布人可改后重新提交。
*/
void reject(Integer serid, String opinion);
/**
* 审批列表:pending(默认)=待我审批 status=2;approved=走流程已发布;rejected=已驳回;
* all/reviewed=已审合一。行 VO 为 NoticeVo(多步审批字段:approvalStatus/approvalStatusDesc/
* approvalLevel/canApprove)。
*/
BasePageResult<NoticeVo> approvalList(String auditState, NoticeQuery query, Pageable pageable);
/**
* 待我审批数量(菜单角标):待审批且当前步审批人快照含当前登录人。
*/
int approvalCount();
/**
* 审批人记忆回显:当前发布人最近一次所选审批教师ID组合,无记忆返回空。
*/
ApproverSuggestVo approverSuggest();
/**
* 审批教师绑定:按学校教师列表(bschclass)绑定教师树选中ID → 姓名/头像。
* schId 为空取当前登录人学校;未命中的ID保留、name 为 null 由调用方提示。
*/
List<ApproverResolveVo> approverResolve(List<Integer> ids, Integer schId);
/**
* 编辑通知:草稿/已驳回/已发布(旧链路,无推送范围)可编辑;
* 待审批禁止编辑;新版表单已发布(存在范围快照)禁止编辑(先撤回后重发)。
*/
void edit(Integer id, NoticeRequest request);
/**
* 批量删除通知:仅草稿、已驳回、已发布(本人)可删,待审批不可删;
* 级联清理阅读记录、接收人、推送范围、审批记录、附件关联。
*/
void deleteBatch(List<Integer> ids);
/**
* 撤回通知:仅已发布且未撤回的通知可撤回,置 isPulled=1。
*/
void pull(Integer id);
/**
* 置顶/取消置顶。
*/
void setTopmost(Integer id, Integer topmost);
/**
* 转发通知:基于源通知创建新通知(已发布),parentId 关联源通知。
*/
Integer forward(Integer sourceId, NoticeRequest request);
/**
* 用户端列表:仅展示已发布、已到点、且对当前账号可见的通知。
* 可见性(改造方案 3.2):无推送范围维持「全校广播」现状;
* 存在范围的通知须 notice_receiver 快照命中当前账号(学员 receive_type=2 / 教师 receive_type=1)。
*/
NoticePageResult<NoticeVo> userList(NoticeQuery query, Pageable pageable);
/**
* 管理端列表:按条件查询全部通知(管理员查看全部)。
*/
BasePageResult<NoticeVo> adminList(NoticeQuery query, Pageable pageable);
/**
* 学员推送范围树(学段→年级→班级 + 全校根,数据源 bschclass),
* 供表单「推送范围」胶囊按钮渲染。
*/
List<Map<String, Object>> scopeTree();
/**
* 按已选范围预估接收人数(学员=范围展开去重账号数 + 教师=receiveList 定向名单数)。
*/
ScopePreviewVo scopePreview(List<PushScopeItem> scopes, String receiveList);
/**
* 已发送通知阅读回执:按 年级→班级→学生 分组返回(身份快照),
* 学生带已读/未读状态与阅读时间(前端着色),教师接收行归「教师」组。
*/
ReadReceiptVo readReceipt(Integer serid);
/**
* 通知详情:返回视图对象,并顺带为当前用户标记已读(打开即视为已读)。
* 草稿/待审批/已驳回态仅发布人本人或该轮审批人可打开(改造方案 3.2 访问校验补口)。
*/
NoticeVo detail(Integer id);
/**
* 编辑回显详情(不标记已读,访问校验同 {@link #detail})。
*/
NoticeVo getDetail(Integer id);
/**
* 标记已读:为当前用户写入(幂等)该通知的阅读记录,
* 学员阅读时按接收快照(缺失时 bschclass 实时兜底)写入学校/年级/班级身份快照。
*/
void markRead(Integer id);
/**
* 一键全部已读(批量化):当前用户所有可见的已发布通知批量插入阅读记录。
*
* @return 已读条数
*/
int initializeRed();
/**
* 追加接收人(事务化):并入接收人列表并重算 allowcount(范围通知含学员数)。
*/
void receiveUsers(Integer noticeId, String users);
/**
* 上一篇/下一篇通知ID(SQL 相邻查询)。
*
* @param serid 当前通知ID
* @param type 0 已发(当前用户发布的序列)/ 1 已收(阅读记录序列)
* @return {prevId, nextId}
*/
Map<String, Object> getNextPrev(Integer serid, String type);
/**
* 获取某通知的阅读明细列表。
*/
List<NoticeVo.ReadDetail> getReadDetail(Integer noticeId);
/**
* 阅读情况分组(已读/未读,旧接口保留过渡;新版表单走 {@link #readReceipt})。
*/
List<Map<String, Object>> getReadDetailGrouped(Integer serid);
/**
* 催收未读:仅 receiptFlag=1 的通知可用;
* 催收对象优先 notice_receiver 快照(含学员),回退 receiveList JSON(历史数据)。
*/
void expedite(Integer serid);
/**
* 定时发送调度(TimingPublishJob 每分钟触发):
* 扫描 status=1 且 timingSendTime<=now 且 timing_published=0 的通知,
* 逐条 CAS 抢占(幂等)后执行真实发布。
*
* @return 本次真实发布条数
*/
int timingPublishDue();
/**
* 通知统计:总数/已发布、累计阅读人次、当前用户未读、按发布人、按月。
*/
NoticeStatisticsVo statistics(NoticeQuery query, LocalDateTime start, LocalDateTime end);
/**
* 通知详情视图(含附件、接收人、推送范围、审批轨迹),供 Controller 返回。
*/
NoticeDetailVo getDetailMap(Integer serid);
/**
* 已发列表附件批量查询(消除 N+1)。
*/
Map<Integer, List<Integer>> batchFileIds(List<Integer> noticeIds);
/**
* 查看通知详情视图(含附件、接收人)。
* type=1 时先标记已读再返回,type=0 时直接返回不标记。
*/
NoticeDetailVo detailMap(Integer serid, Integer type);
/**
* 一键全部已读并返回结果(count = 已读条数)。
*/
InitializeRedVo initializeRedResult();
/**
* 批量下载通知全部附件(打包 zip):查通知标题后调文件域打包下载。
*/
void downloadAllFiles(Integer dataid, HttpServletResponse response);
}
@@ -0,0 +1,78 @@
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 通知审核记录表 notice_approval_record。
* <p>
* 每位审批人在每一步的通过/驳回动作各落一行(会签逐步留痕),含步骤名/表决方式快照,
* 驳回重提产生新一轮(round 递增),历史轮次永久保留。唯一约束防同一人同轮同步重复处理。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_approval_record",
indexes = @Index(name = "idx_approval_record_notice", columnList = "notice_id"),
uniqueConstraints = @UniqueConstraint(
name = "uk_approval_record_round_step_account",
columnNames = {"notice_id", "round", "step_no", "approver_account"}))
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticeApprovalRecord extends BaseEntity {
/** 审批动作:1 通过 / 2 驳回 */
public static final int ACTION_APPROVE = 1;
public static final int ACTION_REJECT = 2;
/** 通知ID(关联 notice.id) */
@Column(name = "notice_id", nullable = false)
private Integer noticeId;
/** 送审轮次(1 起,每次提交审批递增) */
@Column(name = "round", nullable = false)
private Integer round;
/** 步骤序号(1 起) */
@Column(name = "step_no", nullable = false)
private Integer stepNo;
/** 步骤名称快照 */
@Column(name = "step_name", length = 64)
private String stepName;
/** 步骤表决方式快照:any 或签 / all 会签 */
@Column(name = "vote", length = 8)
private String vote;
/** 审批人登录账号 */
@Column(name = "approver_account", length = 64, nullable = false)
private String approverAccount;
/** 审批人姓名 */
@Column(name = "approver_name", length = 64)
private String approverName;
/** 审批人同步教师ID(用户中心 teaId,与 notice_approval_step.approver_tea_ids 同一 ID 空间;v15 新增) */
@Column(name = "approver_tea_id")
private Integer approverTeaId;
/** 审批动作:1 通过 / 2 驳回 */
@Column(name = "action", nullable = false)
private Integer action;
/** 审批意见 */
@Column(name = "opinion", length = 1000)
private String opinion;
/** 操作时间 */
@Column(name = "action_time")
private LocalDateTime actionTime;
}
@@ -0,0 +1,69 @@
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 通知审批步骤表 notice_approval_step。
* <p>
* 一次送审的有序多步流程快照:每步一个审批人组(会签=全部通过 / 或签=任一通过),
* 依次流转,任一步驳回整单退回发布人。驳回重提时旧步骤行删除重建(当前流程定义),
* 历史处理痕迹由 {@link NoticeApprovalRecord}(含步骤名/表决方式快照)永久保留。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_approval_step",
indexes = @Index(name = "idx_approval_step_notice", columnList = "notice_id"))
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticeApprovalStep extends BaseEntity {
/** 步骤状态:0 待流转 / 1 审批中 / 2 已通过 / 3 已驳回 */
public static final int STATUS_WAITING = 0;
public static final int STATUS_ACTIVE = 1;
public static final int STATUS_APPROVED = 2;
public static final int STATUS_REJECTED = 3;
/** 通知ID(关联 notice.id) */
@Column(name = "notice_id", nullable = false)
private Integer noticeId;
/** 步骤序号(1 起,按流转顺序) */
@Column(name = "step_no", nullable = false)
private Integer stepNo;
/** 步骤名称(如「校级审核」,前端未填时按「第N步」兜底) */
@Column(name = "step_name", length = 64)
private String stepName;
/** 表决方式:any 或签(任一通过)/ all 会签(全部通过) */
@Column(name = "vote", length = 8, nullable = false)
private String vote = "any";
/** 审批人登录账号快照 JSON ["zhd","lwj"] */
@Column(name = "approver_accounts", columnDefinition = "LONGTEXT")
private String approverAccounts;
/** 审批人教师ID快照 JSON [101,102](旧 teaId 链路兼容,账号不可得时允许为空) */
@Column(name = "approver_tea_ids", columnDefinition = "LONGTEXT")
private String approverTeaIds;
/** 审批人姓名快照 JSON(提交时点姓名,展示不受后续改名影响) */
@Column(name = "approver_names", columnDefinition = "LONGTEXT")
private String approverNames;
/** 状态:0 待流转 / 1 审批中 / 2 已通过 / 3 已驳回 */
@Column(name = "status", nullable = false)
private Integer status = STATUS_WAITING;
/** 步骤办结时间(通过/驳回落定时间) */
@Column(name = "handle_time")
private LocalDateTime handleTime;
}
@@ -0,0 +1,56 @@
package com.yunhai.campusnotice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 审批人池 sys_approver(审核设置页维护,发布选人数据源)。
* <p>
* 按学校维护可被选为审批人的名单(登录账号为身份主键),停用后不出现在
* 「发公告」审批人候选池,已有流程不受影响。Submit 时按本校启用名单校验,
* 防止表单传入池外账号。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "sys_approver",
uniqueConstraints = @UniqueConstraint(
name = "uk_approver_sch_account",
columnNames = {"sch_id", "account"}))
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysApprover extends BaseEntity {
/** 学校ID(多租户隔离) */
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** 登录账号(审批身份主键,Approve/Reject 按当前登录账号匹配) */
@Column(name = "account", length = 64, nullable = false)
private String account;
/** 姓名 */
@Column(name = "name", length = 64)
private String name;
/** 所属部门ID(发文部门字典 sys_dict) */
@Column(name = "dept_id", length = 64)
private String deptId;
/** 所属部门名称快照 */
@Column(name = "dept_name", length = 128)
private String deptName;
/** 教师用户ID(用户中心,兼容旧 teaId 链路,可空) */
@Column(name = "tea_id")
private Integer teaId;
/** 状态:0 停用 / 1 启用 */
@Column(name = "enabled", nullable = false)
private Integer enabled = 1;
}
@@ -0,0 +1,49 @@
package com.yunhai.campusnotice.job;
import com.yunhai.campusnotice.service.INoticeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 历史审批身份回填(v15 一次性任务,默认不执行)。
*
* <p>背景:平台 {@code DefaultLoginUserProvider} 曾把登录名同时当作 displayName 落库,
* 历史审批轨迹/审核记录里 {@code operator_name / approver_name / publisher_name} 全是登录名,
* 且留痕表没有教师ID列。v15 已修写入链并补列,本任务负责把存量数据一次性换过来。</p>
*
* <p>触发方式(二选一,跑完即可去掉):</p>
* <ul>
* <li>IDEA 运行配置 VM options:{@code -Dcampus-notice.backfill-approver-identity=true}</li>
* <li>环境变量:{@code CAMPUS_NOTICE_BACKFILL_APPROVER_IDENTITY=true}</li>
* </ul>
*
* <p>幂等:只改「姓名为空/等于登录名」与「teaId 为 NULL」的行,重复执行无副作用;
* devtools 热重启时重复执行同样安全。</p>
*/
@Component
@ConditionalOnProperty(name = "campus-notice.backfill-approver-identity", havingValue = "true")
public class ApproverIdentityBackfillRunner implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(ApproverIdentityBackfillRunner.class);
@Autowired
private INoticeService noticeService;
@Override
public void run(ApplicationArguments args) {
try {
Map<String, Integer> stat = noticeService.backfillApproverIdentity();
log.info("[ApproverIdentityBackfill] 回填完成: {}", stat);
} catch (Exception e) {
// 回填失败不阻断应用启动;修正数据源(用户中心可达)后可再次执行
log.error("[ApproverIdentityBackfill] 回填失败,请检查用户中心连通性后重跑", e);
}
}
}
@@ -0,0 +1,65 @@
package com.yunhai.campusnotice.model.notice;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 多步审批流步骤 VO(详情 approvalFlow / 编辑回显,对齐前端 ApprovalFlowStep 契约)。
* <p>
* status:approved 已通过 / pending 审批中 / rejected 已驳回 / waiting 待流转;
* records 为该步全部审核动作(会签多人逐步留痕);pending 为当前步尚未处理姓名。
*/
@Data
@JsonInclude(JsonInclude.Include.ALWAYS)
public class ApprovalFlowVo {
/** 步骤名称 */
private String name;
/** 表决方式:any 或签 / all 会签 */
private String vote = "any";
/** 审批人账号(编辑回显用) */
private List<String> approvers = new ArrayList<>();
/** 审批人教师ID(教师树选人回显用,如 api-test 测试台) */
private List<Integer> approverTeaIds = new ArrayList<>();
/** 审批人姓名(展示用,与 approvers 顺序一致) */
private List<String> approverNames = new ArrayList<>();
/** 步骤状态:approved / pending / rejected / waiting */
private String status;
/** 该步审核记录 */
private List<Record> records = new ArrayList<>();
/** 当前步待签人姓名(仅审批中步骤返回,其余为空列表) */
private List<String> pending = new ArrayList<>();
/** 单步审核动作 */
@Data
public static class Record {
/** 审批人账号 */
private String account;
/** 审批人姓名 */
private String name;
/** 结果:approved 通过 / rejected 驳回 */
private String result;
/** 操作时间(yyyy-MM-dd HH:mm:ss) */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime time;
/** 审批意见 */
private String remark;
}
}
@@ -0,0 +1,26 @@
package com.yunhai.campusnotice.model.notice;
import lombok.Data;
/**
* 审批中心标签页统计数字(v13-E3)。
* <p>
* 口径与 GET /api/notice/ApprovalList 各 tab 的默认过滤一致(不含 keyword 搜索条件):
* pending=待审批(当前步审批人快照含当前登录人)/ approved=走审批流已发布 /
* rejected=已驳回 / all=已通过+已驳回合一。
*/
@Data
public class ApprovalTabCountVo {
/** 待审批(需我处理)条数 */
private int pending;
/** 已通过(走流程发布)条数 */
private int approved;
/** 已驳回条数 */
private int rejected;
/** 全部(已审合一)条数 */
private int all;
}
@@ -0,0 +1,29 @@
package com.yunhai.campusnotice.model.notice;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 审批流程步骤入参(NoticeRequest.flow / SubmitRequest.steps 元素)。
* <p>
* 前端「发公告」多步审批流编辑器产出:每步一个审批人组(登录账号列表),
* vote=any 或签(任一通过进入下一步)/ all 会签(全部通过进入下一步);
* name 可缺省,后端按「第N步」兜底。
*/
@Data
public class FlowStepItem {
/** 步骤名称(可空,缺省「第N步」) */
private String name;
/** 表决方式:any 或签(默认)/ all 会签 */
private String vote = "any";
/** 审批人登录账号列表(与审批人池配合;api-test 测试台走教师树时用 approverTeaIds) */
private List<String> approvers = new ArrayList<>();
/** 审批人教师ID列表(教师树叶子节点ID;与 approvers 二选一,按学校教师名单绑定) */
private List<Integer> approverTeaIds;
}
@@ -0,0 +1,25 @@
package com.yunhai.campusnotice.model.notice;
import lombok.Data;
/**
* 「已收到」三 tab 统计数字(v17-B1)。
* <p>
* 口径 = {@code GET /api/notice/List} 在<b>同一</b> keyword / type / deptId / 时间范围过滤下,
* 分别取 readType=0(全部)、1(未读)、2(已读)的总数;readType 不作为入参(它就是被统计的三个桶),
* 因此恒满足 {@code all = unread + read}。
* <p>
* 与 {@link ApprovalTabCountVo}(v13,审批中心四 tab)同范式:三者都来自列表谓词,不另立统计 SQL。
*/
@Data
public class ReadTabCountVo {
/** 全部(可见已收公告总数) */
private int all;
/** 未读(可见且无本人阅读记录) */
private int unread;
/** 已读(可见且已有本人阅读记录) */
private int read;
}
@@ -0,0 +1,28 @@
package com.yunhai.campusnotice.repository;
import com.yunhai.campusnotice.entity.NoticeApprovalRecord;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
/**
* 通知审核记录仓储。
*/
public interface NoticeApprovalRecordRepository extends JpaRepository<NoticeApprovalRecord, Integer> {
/** 某通知全量审核记录(按落库顺序,跨轮次时间线) */
List<NoticeApprovalRecord> findByNoticeIdOrderByIdAsc(Integer noticeId);
/** 批量查 N 个通知的审核记录(列表页批量装配) */
@Query("select r from NoticeApprovalRecord r where r.noticeId in :noticeIds order by r.id asc")
List<NoticeApprovalRecord> findByNoticeIds(@Param("noticeIds") Collection<Integer> noticeIds);
/** 某轮某步已处理记录(会签进度判定) */
List<NoticeApprovalRecord> findByNoticeIdAndRoundAndStepNo(Integer noticeId, Integer round, Integer stepNo);
/** 按通知ID删除全部审核记录(通知删除时级联清理) */
void deleteByNoticeId(Integer noticeId);
}
@@ -0,0 +1,30 @@
package com.yunhai.campusnotice.repository;
import com.yunhai.campusnotice.entity.NoticeApprovalStep;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
/**
* 通知审批步骤仓储。
*/
public interface NoticeApprovalStepRepository extends JpaRepository<NoticeApprovalStep, Integer> {
/** 某通知全部步骤(按序号升序) */
List<NoticeApprovalStep> findByNoticeIdOrderByStepNoAsc(Integer noticeId);
/** 批量查 N 个通知的步骤(列表页批量装配) */
@Query("select s from NoticeApprovalStep s where s.noticeId in :noticeIds order by s.noticeId asc, s.stepNo asc")
List<NoticeApprovalStep> findByNoticeIds(@Param("noticeIds") Collection<Integer> noticeIds);
/** 某通知某步 */
NoticeApprovalStep findByNoticeIdAndStepNo(Integer noticeId, Integer stepNo);
/** 删除某通知全部步骤(重新送审流程重置 / 通知删除级联) */
@Modifying
void deleteByNoticeId(Integer noticeId);
}
@@ -0,0 +1,22 @@
package com.yunhai.campusnotice.repository;
import com.yunhai.campusnotice.entity.SysApprover;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
/**
* 审批人池仓储(审核设置页维护,发布选人数据源)。
*/
public interface SysApproverRepository extends JpaRepository<SysApprover, Integer> {
/** 某学校全部审批人(管理页,按姓名排序) */
List<SysApprover> findBySchIdOrderByNameAsc(Integer schId);
/** 某学校启用中的审批人(发布选人候选池) */
List<SysApprover> findBySchIdAndEnabledOrderByNameAsc(Integer schId, Integer enabled);
/** 按学校+账号定位(送审绑定校验) */
Optional<SysApprover> findBySchIdAndAccount(Integer schId, String account);
}