feat(flow): 迁 yh-flow-callback starter + 对接抄送与条件路由判断端点
build / build-push (push) Successful in 1m8s

迁 starter(2026-09-20 方案):删手写 OapiFlowCallbackController/FlowCallbackService,改 NoticeFlowCallbackHandler 实现 SPI;验签/eventId 幂等/乱序门交 starter 管线,yml 切中央密钥链(trust-registry)
抄送(2026-09-24):flow.cc.sent 落 notice_cc 表 + GET /api/notice/cc/My 应用内「抄送给我的」;未发布通知可见守卫加第 4 条「被抄送人」
条件路由(2026-09-24):POST /oapi/notice/flow/route/dept-judge(纯读幂等;formData 优先免查库;未命中 outlets 抛错绝不默认放行);apply 的 formData 带发文部门(deptId/deptName)
IT 验签确定性:NoticeFlowApiIT/FlowCallbackTransientIT 属性覆盖回落白名单(trusted-apps.100019),不再依赖本地 Redis 密钥内容
生产 DDL:deploy/sql/20260924_add_notice_cc.sql;mvn test 114 全绿
This commit is contained in:
chenxing
2026-09-24 01:19:26 +08:00
parent df9b9e0aa5
commit 58aadaa95a
24 changed files with 1024 additions and 655 deletions
@@ -0,0 +1,96 @@
package com.yunhai.notice.controller.api;
import com.yunhai.notice.config.Config;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.NoticeCc;
import com.yunhai.notice.enums.FlowAuditStatusEnum;
import com.yunhai.notice.repository.NoticeCcRepository;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 应用内「抄送给我的」(2026-09-24):数据源是 flow.cc.sent 回调落的 notice_cc 表
* (NOTIFY 节点激活即抄送,审批过程中就可见——此时通知尚未发布,属知会不是可读)。
* 身份一律取 CAS 登录态(BoaUser),与 NoticeApprovalController 同口径,防伪造查询人。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/notice/cc/")
@RequiredArgsConstructor
public class NoticeCcController {
private final NoticeCcRepository noticeCcRepository;
private final NoticeRepository noticeRepository;
/** 抄送给我的(分页,sent_time 倒序;行内回填通知标题/部门/审核状态供列表直渲染) */
@GetMapping("My")
public Map<String, Object> my(BoaUser user,
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "size", defaultValue = "10") Integer size) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.FORBIDDEN, "未登录");
}
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "当前身份无学校归属");
}
int pageIndex = page == null || page < 1 ? 1 : page;
int pageSize = size == null || size < 1 ? 10 : Math.min(size, 100);
Page<NoticeCc> ccPage = noticeCcRepository.findBySchIdAndCcUserIdOrderBySentTimeDescIdDesc(
user.getCurrentRole().getSchId(), user.getId(), PageRequest.of(pageIndex - 1, pageSize));
// 批量回填通知信息(被删通知保留抄送行,标题置空由前端兜底)
Map<Integer, Notice> notices = ccPage.getContent().isEmpty()
? new HashMap<>()
: noticeRepository.findAllById(ccPage.getContent().stream()
.map(NoticeCc::getNoticeId).collect(Collectors.toSet())).stream()
.collect(Collectors.toMap(Notice::getId, Function.identity()));
List<Map<String, Object>> content = new ArrayList<>();
for (NoticeCc cc : ccPage.getContent()) {
Map<String, Object> row = new HashMap<>();
row.put("id", cc.getId());
row.put("noticeId", cc.getNoticeId());
row.put("flowInstanceId", cc.getFlowInstanceId());
row.put("nodeId", cc.getNodeId());
row.put("nodeName", cc.getNodeName());
row.put("sentTime", cc.getSentTime());
Notice notice = notices.get(cc.getNoticeId());
if (notice != null) {
row.put("title", notice.getTitle());
row.put("deptName", notice.getDeptName());
row.put("publisherName", notice.getPublisherName());
row.put("noticeStatus", notice.getStatus());
row.put("auditStatus", notice.getAuditStatus());
row.put("auditStatusDesc", FlowAuditStatusEnum.descOf(notice.getAuditStatus()));
} else {
row.put("title", null);
row.put("noticeStatus", null);
row.put("auditStatus", null);
row.put("auditStatusDesc", "通知已删除");
}
content.add(row);
}
Map<String, Object> result = new HashMap<>();
result.put("total", ccPage.getTotalElements());
result.put("content", content);
result.put("pageIndex", pageIndex);
result.put("pageSize", pageSize);
result.put("totalPages", ccPage.getTotalPages());
result.put("hasNext", ccPage.hasNext());
return result;
}
}
@@ -1,53 +0,0 @@
package com.yunhai.notice.controller.oapi;
import com.yunhai.notice.service.flow.FlowCallbackService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 流程中心回调接收端点(挂在开放接口域 /oapi/notice/flow/callback)。
* <p>
* <b>验签(复用平台应用验签能力)</b>:不建独立回调验签过滤器——回调路径落在
* {@code yh.app-auth.server.enforce-patterns: [/oapi/*]} 内,由平台
* {@code AppAuthFilter}(X-App-Key/X-Timestamp/X-Signature,HmacSHA256(method\npath\n ts),
* 密钥取 trusted-apps 里 flow-center 的 appKey=100019)验签 + {@code RequireAppSignFilter}
* 拒绝匿名请求。推送方 flow-center 侧由 {@code CallbackAppSigner} 用同一套 canonical 规则签名
* (注册时申报的 base-path 即本服务 context-path,见 {@link com.yunhai.notice.service.flow.NoticeFlowProperties})。
* 原来的 /callback/* + X-Flw-* 自定义过滤器已下线;X-Flw-* 头仍由 flow-center 保留发送,本服务不再校验。
* <p>
* <b>返回语义(契约冻结 D2,免对账前提)</b>:状态推进成功 / 重复投递(去重命中)/
* 协议毒消息 / 乱序丢弃 / 办件不属本应用 → <b>200</b>(毒消息回 200 防无限重放);
* 瞬时失败(DB 异常/锁冲突,{@link FlowCallbackService.TransientCallbackException})/
* 意外 RuntimeException → <b>5xx</b>——flow-center 持续重试直至送达,消费方恢复自动追平。
*/
@Slf4j
@RestController
@RequestMapping("/oapi/notice/flow")
@RequiredArgsConstructor
public class OapiFlowCallbackController {
private final FlowCallbackService callbackService;
@PostMapping("/callback")
public ResponseEntity<String> callback(@RequestBody byte[] body,
@RequestAttribute(name = "yh.app.auth", required = false) String appKey) {
try {
callbackService.handle(new String(body, java.nio.charset.StandardCharsets.UTF_8));
return ResponseEntity.ok("{\"ok\":true}");
} catch (FlowCallbackService.TransientCallbackException e) {
log.error("[notice-flow] 回调瞬时失败(回 5xx 触发流程中心重试): 调用方={} {}", appKey, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
} catch (Exception e) {
// 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件)
log.error("[notice-flow] 回调意外异常(回 5xx 触发流程中心重试)调用方={}", appKey, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
}
}
}
@@ -0,0 +1,92 @@
package com.yunhai.notice.controller.oapi;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 条件路由判断端点(2026-09-24 设计方案 §5.2):flow-center CONDITION 节点(routerKey=
* notice.dept-judge)激活时同步调用——发文部门=校长室走"校长室"线(免校长审核),其余走"其他部门"线。
* <ul>
* <li>验签:收在 /oapi/* 下由 AppAuthFilter 强制验 flow-center(100019)的应用签名,零新概念;</li>
* <li>纯读幂等:凭 bizId 查 notice 表比对发文部门(variables 里有 formData 的部门则免查库,两口径一致);
* <li>契约纪律:返回 outlet 必须命中 outlets——流程定义与端点的出口名是同一份契约,不命中抛业务错
* (flow-center 侧按判断失败挂起,绝不默认放行);</li>
* <li>轻实现:同步 3s 超时,只做一次主键查询。</li>
* </ul>
*/
@RestController
@RequestMapping("/oapi/notice/flow/route")
@RequiredArgsConstructor
public class OapiFlowRouteController {
private static final String OUTLET_PRINCIPAL = "校长室";
private static final String OUTLET_OTHER = "其他部门";
private final NoticeRepository noticeRepository;
/** 判断请求体(契约见《子应用接入流程中心规范.md》条件路由判断接口;字段忽略未知向前兼容) */
@Data
public static class JudgeBody {
private Long schId;
private Long instanceId;
private Integer instanceVersion;
private String bizType;
private String bizId;
private String nodeId;
private String nodeName;
private String routerKey;
private List<String> outlets;
private Map<String, Object> variables;
}
@PostMapping("/dept-judge")
public Map<String, Object> deptJudge(@RequestBody JudgeBody body) {
Integer noticeId;
try {
noticeId = Integer.valueOf(String.valueOf(body.getBizId()).trim());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "bizId 非法:" + body.getBizId());
}
// formData 优先(apply 时透传了发文部门则免查库),缺失回落查表——两口径同一数据源
String deptName = variablesDeptName(body);
String deptId = null;
if (!StringUtils.hasText(deptName)) {
Notice notice = noticeRepository.findById(noticeId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "通知不存在:" + noticeId));
deptId = notice.getDeptId();
deptName = notice.getDeptName();
}
String outlet = OUTLET_PRINCIPAL.equals(deptName) ? OUTLET_PRINCIPAL : OUTLET_OTHER;
List<String> outlets = body.getOutlets() == null ? Collections.emptyList() : body.getOutlets();
if (!outlets.contains(outlet)) {
// 出口契约不一致(流程定义改了出口名而端点没跟上):抛错让 flow-center 挂起,不默认放行
throw new BizException(ErrorCode.PARAM_ERROR,
"判断结果未命中出口集合:outlet=" + outlet + " outlets=" + outlets
+ "(deptId=" + deptId + ", deptName=" + deptName + ")");
}
return Collections.singletonMap("outlet", outlet);
}
/** formData 里的发文部门(apply 时 bizSnapshot 同源透传;缺字段返回空串回落查表) */
private static String variablesDeptName(JudgeBody body) {
Map<String, Object> variables = body.getVariables();
if (variables == null) {
return "";
}
Object v = variables.get("deptName");
return v == null ? "" : String.valueOf(v).trim();
}
}
@@ -0,0 +1,59 @@
package com.yunhai.notice.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* 流程抄送留痕(2026-09-24):flow-center NOTIFY 节点的 flow.cc.sent 回调逐人一行,
* 应用内「抄送给我的」列表数据源。幂等靠 starter 的 flow_callback_event(uk event_id),
* 本表只增不改——同一通知多节点抄送/退回重走重复抄送各成一行,轨迹按时间归位。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "notice_cc",
indexes = {
@Index(name = "idx_notice_cc_user", columnList = "sch_id, cc_user_id, sent_time"),
@Index(name = "idx_notice_cc_notice", columnList = "notice_id")})
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class NoticeCc extends BaseEntity {
/** 学校ID(多租户隔离,与 notice.sch_id 同域) */
@Column(name = "sch_id")
private Integer schId;
/** 关联通知(信封 bizId) */
@Column(name = "notice_id", nullable = false)
private Integer noticeId;
/** flow-center 办件 id(信封 instanceId) */
@Column(name = "flow_instance_id")
private Long flowInstanceId;
/** 抄送发生节点 id(data.nodeId) */
@Column(name = "node_id", length = 64)
private String nodeId;
/** 抄送环节名快照(data.nodeName) */
@Column(name = "node_name", length = 128)
private String nodeName;
/** 被抄送人用户 id(data.ccUserId,平台统一用户目录) */
@Column(name = "cc_user_id", nullable = false)
private Integer ccUserId;
/** 被抄送人姓名快照(data.ccUserName) */
@Column(name = "cc_user_name", length = 64)
private String ccUserName;
/** 抄送时间(事件处理时刻)。DATETIME(3) 与 flow-center flw_cc.sent_time 同口径 */
@Column(name = "sent_time", nullable = false, columnDefinition = "DATETIME(3)")
private LocalDateTime sentTime;
}
@@ -0,0 +1,19 @@
package com.yunhai.notice.repository;
import com.yunhai.notice.entity.NoticeCc;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* 流程抄送留痕仓储(「抄送给我的」主路径)。
*/
public interface NoticeCcRepository extends JpaRepository<NoticeCc, Integer> {
/** 按学校 + 被抄送人分页(sent_time 倒序,倒序索引 idx_notice_cc_user 覆盖) */
Page<NoticeCc> findBySchIdAndCcUserIdOrderBySentTimeDescIdDesc(Integer schId, Integer ccUserId,
Pageable pageable);
/** 未发布通知可见守卫第 4 条:当前用户是否被抄送该通知(NoticeFlowGuard) */
boolean existsByNoticeIdAndCcUserId(Integer noticeId, Integer ccUserId);
}
@@ -1,227 +0,0 @@
package com.yunhai.notice.service.flow;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.NoticeFlowEvent;
import com.yunhai.notice.enums.FlowAuditStatusEnum;
import com.yunhai.notice.enums.NoticeStatusEnum;
import com.yunhai.notice.repository.NoticeFlowEventRepository;
import com.yunhai.notice.repository.NoticeRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Iterator;
/**
* 流程中心回调事件处理(方案 §5.3,svc-hall FlowCallbackService 平移 + 5xx 语义修订 D2)。<br>
* 返回语义(免对账核心,契约冻结):<b>瞬时失败抛 {@link TransientCallbackException} → HTTP 5xx</b>
* (flow-center 持续重试追平);毒 JSON/缺字段/去重命中/乱序丢弃/办件不属本应用 → 正常返回(HTTP 200)。
* 事件映射:submitted/task.created/task.handled→10;completed→20(置 status=1 + trySendSms 后置);
* rejected→30;withdrawn→40。乱序闸门:instanceVersion 严格小于已消费版本即丢弃
* (等于不丢——submitted 与 task.created 天然同版本,且不同事件同版本幂等安全)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FlowCallbackService {
/** 瞬时失败标记(DB 异常/锁冲突等可重试治愈的故障)——controller 据此回 5xx */
public static class TransientCallbackException extends RuntimeException {
public TransientCallbackException(String message, Throwable cause) {
super(message, cause);
}
}
private final NoticeRepository noticeRepository;
private final NoticeFlowEventRepository eventRepository;
private final com.yunhai.notice.service.sms.SmsSendService smsSendService;
private final ObjectMapper objectMapper;
/** 处理一条回调推送体(已过验签过滤器)。 */
@Transactional
public void handle(String body) {
JsonNode payload;
try {
payload = objectMapper.readTree(body);
} catch (Exception e) {
// 协议毒消息:不可重试,回 200 防无限重放
log.warn("[notice-flow] 回调体非 JSON(丢弃):{}",
body != null && body.length() > 200 ? body.substring(0, 200) + "..." : body);
return;
}
String eventId = text(payload, "eventId");
String event = text(payload, "event");
// 注册探活回声:ping 体只有 eventId/event/flowGuid,无 bizType/bizId,必须在必填校验之前返回
// (原实现靠回调验签过滤器短路;迁到 /oapi 后 ping 会真到 controller,由应用验签保证来源可信)
if ("ping".equals(event)) {
return;
}
String bizType = text(payload, "bizType");
String bizId = text(payload, "bizId");
Long instanceId = longVal(payload, "instanceId");
Integer instanceVersion = intVal(payload, "instanceVersion");
if (!StringUtils.hasText(eventId) || !StringUtils.hasText(event)
|| !StringUtils.hasText(bizType) || !StringUtils.hasText(bizId)) {
log.warn("[notice-flow] 回调缺必要字段(丢弃):event={} eventId={}", event, eventId);
return; // 协议毒消息 → 200
}
if (!FlowCenterClient.BIZ_TYPE.equals(bizType)) {
log.warn("[notice-flow] 回调办件不属本应用(丢弃):bizType={} eventId={}", bizType, eventId);
return; // 不可重试事件 → 200
}
// eventId 去重(uk 兜底并发):重复投递在持续重试语义下必然发生,幂等跳过
if (eventRepository.existsByEventId(eventId)) {
log.debug("[notice-flow] 重复投递跳过:eventId={}", eventId);
return;
}
try {
NoticeFlowEvent row = new NoticeFlowEvent();
row.setEventId(eventId);
row.setEvent(event);
row.setSchId(intVal(payload, "schId"));
row.setInstanceId(instanceId);
row.setReceiveTime(LocalDateTime.now());
row.setPayloadJson(body);
eventRepository.saveAndFlush(row);
} catch (DataIntegrityViolationException e) {
log.debug("[notice-flow] 并发重复投递(uk 命中)跳过:eventId={}", eventId);
return; // 事务回滚无副作用(尚未写 notice),重复投递 → 200
}
Integer noticeId;
try {
noticeId = Integer.valueOf(bizId.trim());
} catch (NumberFormatException e) {
log.warn("[notice-flow] 回调 bizId 非法(丢弃):{}", bizId);
return; // 协议毒消息 → 200
}
// 行锁串行化:并发推送(flow-center 推送池)对同一通知的处理排队,防丢更新
Notice notice = noticeRepository.findByIdForUpdate(noticeId).orElse(null);
if (notice == null || !StringUtils.hasText(notice.getFlowGuid())) {
log.warn("[notice-flow] 回调通知不存在或未绑流程(丢弃):notice={}", bizId);
return; // 不可重试 → 200
}
// 乱序闸门:早于已消费版本的迟到事件直接丢弃(重复eventId已被上一道闸拦下)
if (instanceVersion != null && notice.getLastInstanceVersion() != null
&& instanceVersion < notice.getLastInstanceVersion()) {
log.info("[notice-flow] 乱序事件丢弃:notice={} eventVersion={} last={}",
noticeId, instanceVersion, notice.getLastInstanceVersion());
return;
}
try {
advance(notice, event, instanceVersion, payload);
} catch (TransientCallbackException e) {
throw e;
} catch (Exception e) {
// DB 故障/锁冲突等瞬时失败 → 5xx 让 flow-center 重试追平(免对账核心语义)
throw new TransientCallbackException("回调状态推进失败:notice=" + noticeId + " event=" + event, e);
}
}
/** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */
private void advance(Notice notice, String event, Integer instanceVersion, JsonNode payload) {
Integer target;
switch (event) {
case "flow.completed":
target = FlowAuditStatusEnum.APPROVED.getCode();
break;
case "flow.rejected":
target = FlowAuditStatusEnum.REJECTED.getCode();
break;
case "flow.withdrawn":
target = FlowAuditStatusEnum.WITHDRAWN.getCode();
break;
case "flow.submitted":
case "flow.task.created":
case "flow.task.handled":
target = FlowAuditStatusEnum.APPROVING.getCode();
break;
default:
log.warn("[notice-flow] 未知事件(丢弃):{}", event);
return; // 未知事件码:不可重试 → 200
}
// P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled
// 在 completed/rejected 之后落库(推送池并发不保序),无守卫会把 20/30 打回 10 并覆盖结果。
// 命中守卫记 log 后正常返回(200,不触发 flow-center 重试——重试也改变不了完成顺序)。
// R4 重提不冲突:编辑保存先本地置 audit=0,新一轮事件到达时行已非终态。
if (isTerminal(notice.getAuditStatus())) {
if (!isTerminal(target)) {
log.warn("[notice-flow] 终态不回退:notice={} audit={} 拒绝非终态事件 {}(v{})",
notice.getId(), notice.getAuditStatus(), event, instanceVersion);
return;
}
if (StringUtils.hasText(notice.getFlowResultJson())) {
log.warn("[notice-flow] 终态结果已落定:notice={} audit={} 拒绝覆盖 flow_result_json(事件 {})",
notice.getId(), notice.getAuditStatus(), event);
return;
}
}
notice.setAuditStatus(target);
if (instanceVersion != null) {
notice.setLastInstanceVersion(instanceVersion);
}
// 终态明细快照(result/opinion/rejectNode,详情页进度卡片展示)
if (target == FlowAuditStatusEnum.APPROVED.getCode()
|| target == FlowAuditStatusEnum.REJECTED.getCode()
|| target == FlowAuditStatusEnum.WITHDRAWN.getCode()) {
JsonNode data = payload.get("data");
String snapshot = data == null || data.isNull() ? null : data.toString();
notice.setFlowResultJson(snapshot);
}
boolean justPublished = false;
if (target == FlowAuditStatusEnum.APPROVED.getCode()) {
// 审核通过 = 正式发布:置 status=1(用户端自此可见)+ 发布后置(短信后移到此刻)
if (notice.getStatus() == null || notice.getStatus() != NoticeStatusEnum.PUBLISHED.getCode()) {
notice.setStatus(NoticeStatusEnum.PUBLISHED.getCode());
justPublished = true;
}
}
noticeRepository.saveAndFlush(notice);
if (justPublished) {
sendSmsAfterPublish(notice);
}
}
/** 审核通过后的短信后置(绑流程通知 create 时不发,方案 §5.2 短信时机后移) */
private void sendSmsAfterPublish(Notice notice) {
if (notice.getSmsFlag() == null || notice.getSmsFlag() != 1) {
return;
}
// 与直发链路 trySendSms 的 isImmediatePublish 语义对齐:定时未到点的通知此刻不发
// (可见性已生效),到点由 SmsCatchupJob 补发(§3.2 修 BP-R5-2 短信永久丢失缺口)
if (notice.getTimingSendTime() != null
&& notice.getTimingSendTime().isAfter(LocalDateTime.now())) {
return;
}
smsSendService.sendOnce(notice);
}
/** 审批终态(20 通过 / 30 驳回 / 40 撤回)——终态行的状态与结果明细只允许被新一轮(本地先置 0)改写 */
private static boolean isTerminal(Integer auditStatus) {
return auditStatus != null
&& (auditStatus == FlowAuditStatusEnum.APPROVED.getCode()
|| auditStatus == FlowAuditStatusEnum.REJECTED.getCode()
|| auditStatus == FlowAuditStatusEnum.WITHDRAWN.getCode());
}
private static String text(JsonNode node, String field) {
JsonNode v = node.get(field);
return v == null || v.isNull() ? null : v.asText();
}
private static Long longVal(JsonNode node, String field) {
JsonNode v = node.get(field);
return v == null || v.isNull() ? null : v.asLong();
}
private static Integer intVal(JsonNode node, String field) {
JsonNode v = node.get(field);
return v == null || v.isNull() ? null : v.asInt();
}
}
@@ -0,0 +1,187 @@
package com.yunhai.notice.service.flow;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.enums.FlowAuditStatusEnum;
import com.yunhai.notice.enums.NoticeStatusEnum;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.platform.flowcallback.FlowCallbackHandler;
import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 流程中心回调业务映射(2026-09-20 迁 yh-flow-callback-spring-boot-starter):
* 验签(平台 X-App-*,/oapi/* 强制)、ping、必填校验、bizType 过滤、eventId 幂等、
* instanceVersion 乱序门由 starter 接收管线承担;本类只做通知侧状态机推进——
* <br>事件映射:submitted/task.created/task.handled→10;completed→20(置 status=1 发布
* + 短信后置);rejected→30;withdrawn→40;cc.sent→只落 notice_cc 抄送行(不动状态机,
* 2026-09-24)。行锁串行化并发推送,终态不回退守卫,终态 data 快照落 flow_result_json(详情页进度卡片)。
* <br>应答语义不变:本方法抛任何异常 → starter 回 5xx(flow-center 持续重试追平,免对账);
* 正常返回(含通知不存在/未绑流程/未知事件等不可重试场景)→ 200。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class NoticeFlowCallbackHandler implements FlowCallbackHandler {
private final NoticeRepository noticeRepository;
private final com.yunhai.notice.service.sms.SmsSendService smsSendService;
private final ObjectMapper objectMapper;
private final com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
@Override
public void onEvent(FlowCallbackEvent event) {
Integer noticeId;
try {
noticeId = Integer.valueOf(event.getBizId().trim());
} catch (NumberFormatException e) {
log.warn("[notice-flow] 回调 bizId 非法(丢弃):{}", event.getBizId());
return;
}
// 行锁串行化:并发推送(flow-center 推送池)对同一通知的处理排队,防丢更新
Notice notice = noticeRepository.findByIdForUpdate(noticeId).orElse(null);
if (notice == null || !StringUtils.hasText(notice.getFlowGuid())) {
log.warn("[notice-flow] 回调通知不存在或未绑流程(丢弃):notice={}", event.getBizId());
return;
}
// 单据级乱序闸门(starter 表级闸门之外的第二道:以行锁后读到的 notice 版本为准)
if (event.getInstanceVersion() != null && notice.getLastInstanceVersion() != null
&& event.getInstanceVersion() < notice.getLastInstanceVersion()) {
log.info("[notice-flow] 乱序事件丢弃:notice={} eventVersion={} last={}",
noticeId, event.getInstanceVersion(), notice.getLastInstanceVersion());
return;
}
advance(notice, event);
}
/** 状态推进(幂等:同一事件重放由 eventId 去重拦截,此处只需正确覆盖目标态) */
private void advance(Notice notice, FlowCallbackEvent event) {
String code = event.getEvent();
Integer instanceVersion = event.getInstanceVersion();
Integer target;
switch (code) {
case "flow.cc.sent":
recordCc(notice, event);
return;
case "flow.completed":
target = FlowAuditStatusEnum.APPROVED.getCode();
break;
case "flow.rejected":
target = FlowAuditStatusEnum.REJECTED.getCode();
break;
case "flow.withdrawn":
target = FlowAuditStatusEnum.WITHDRAWN.getCode();
break;
case "flow.submitted":
case "flow.task.created":
case "flow.task.handled":
target = FlowAuditStatusEnum.APPROVING.getCode();
break;
default:
log.warn("[notice-flow] 未知事件(丢弃):{}", code);
return;
}
// P1-3 终态不回退守卫:终态事件与最后一个 task.handled 同 instanceVersion,若 handled
// 在 completed/rejected 之后落库(推送池并发不保序),无守卫会把 20/30 打回 10 并覆盖结果。
// R4 重提不冲突:编辑保存先本地置 audit=0,新一轮事件到达时行已非终态。
if (isTerminal(notice.getAuditStatus())) {
if (!isTerminal(target)) {
log.warn("[notice-flow] 终态不回退:notice={} audit={} 拒绝非终态事件 {}(v{})",
notice.getId(), notice.getAuditStatus(), code, instanceVersion);
return;
}
if (StringUtils.hasText(notice.getFlowResultJson())) {
log.warn("[notice-flow] 终态结果已落定:notice={} audit={} 拒绝覆盖 flow_result_json(事件 {})",
notice.getId(), notice.getAuditStatus(), code);
return;
}
}
notice.setAuditStatus(target);
if (instanceVersion != null) {
notice.setLastInstanceVersion(instanceVersion);
}
// 终态明细快照(result/opinion/rejectNode,详情页进度卡片展示)
if (isTerminal(target)) {
notice.setFlowResultJson(snapshot(event));
}
boolean justPublished = false;
if (target == FlowAuditStatusEnum.APPROVED.getCode()) {
// 审核通过 = 正式发布:置 status=1(用户端自此可见)+ 发布后置(短信后移到此刻)
if (notice.getStatus() == null || notice.getStatus() != NoticeStatusEnum.PUBLISHED.getCode()) {
notice.setStatus(NoticeStatusEnum.PUBLISHED.getCode());
justPublished = true;
}
}
noticeRepository.saveAndFlush(notice);
if (justPublished) {
sendSmsAfterPublish(notice);
}
}
/**
* 抄送留痕(2026-09-24,flow.cc.sent):逐人一条事件逐条落 notice_cc,供应用内
* 「抄送给我的」列表渲染、点击跳通知详情。不动审核状态机(抄送是知会不是审批,
* taskId 恒 null 也不落任务);已通过行锁与乱序闸门,重放由 eventId 幂等拦截。
* data 缺 ccUserId 属上游脏数据:记日志跳过(回 200),落库异常照旧外抛回 5xx 重试。
*/
private void recordCc(Notice notice, FlowCallbackEvent event) {
Map<String, Object> data = event.getData();
Object ccUserId = data == null ? null : data.get("ccUserId");
if (ccUserId == null || String.valueOf(ccUserId).isEmpty()) {
log.warn("[notice-flow] cc.sent 缺 ccUserId(丢弃):notice={} eventId={}",
notice.getId(), event.getEventId());
return;
}
com.yunhai.notice.entity.NoticeCc cc = new com.yunhai.notice.entity.NoticeCc();
cc.setSchId(notice.getSchId());
cc.setNoticeId(notice.getId());
cc.setFlowInstanceId(event.getInstanceId());
cc.setNodeId(data.get("nodeId") == null ? null : String.valueOf(data.get("nodeId")));
cc.setNodeName(data.get("nodeName") == null ? null : String.valueOf(data.get("nodeName")));
cc.setCcUserId(Integer.valueOf(String.valueOf(ccUserId)));
cc.setCcUserName(data.get("ccUserName") == null ? null : String.valueOf(data.get("ccUserName")));
cc.setSentTime(LocalDateTime.now());
noticeCcRepository.save(cc);
log.info("[notice-flow] 抄送留痕:notice={} instance={} ccUser={} node={}",
notice.getId(), event.getInstanceId(), cc.getCcUserId(), cc.getNodeName());
}
private String snapshot(FlowCallbackEvent event) { if (event.getData() == null) {
return null;
}
try {
return objectMapper.writeValueAsString(event.getData());
} catch (Exception e) {
log.warn("[notice-flow] 终态快照序列化失败(置 null 不阻断推进):{}", e.toString());
return null;
}
}
/** 审核通过后的短信后置(绑流程通知 create 时不发,方案 §5.2 短信时机后移) */
private void sendSmsAfterPublish(Notice notice) {
if (notice.getSmsFlag() == null || notice.getSmsFlag() != 1) {
return;
}
// 与直发链路 trySendSms 的 isImmediatePublish 语义对齐:定时未到点的通知此刻不发
// (可见性已生效),到点由 SmsCatchupJob 补发(§3.2 修 BP-R5-2 短信永久丢失缺口)
if (notice.getTimingSendTime() != null
&& notice.getTimingSendTime().isAfter(LocalDateTime.now())) {
return;
}
smsSendService.sendOnce(notice);
}
/** 审批终态(20 通过 / 30 驳回 / 40 撤回)——终态行的状态与结果明细只允许被新一轮(本地先置 0)改写 */
private static boolean isTerminal(Integer auditStatus) {
return auditStatus != null
&& (auditStatus == FlowAuditStatusEnum.APPROVED.getCode()
|| auditStatus == FlowAuditStatusEnum.REJECTED.getCode()
|| auditStatus == FlowAuditStatusEnum.WITHDRAWN.getCode());
}
}
@@ -17,8 +17,9 @@ import java.util.concurrent.ConcurrentHashMap;
/**
* 未发布通知(status=0,审核载体)的详情放行守卫(方案 §5.5,V2.0 B.4#8 同构)。
* 仅三类人可见:提交人本人(publisherAccount)/ 本校管理员(type=1 或 roleId∈{2,5,7})/
* 该办件流程办理人(instance.assigneeIds 含当前用户,60s 短缓存——审批中途换人不实时,可接受)。
* 四类人可见:提交人本人(publisherAccount)/ 本校管理员(type=1 或 roleId∈{2,5,7})/
* 该办件流程办理人(instance.assigneeIds 含当前用户,60s 短缓存——审批中途换人不实时,可接受)/
* 该办件被抄送人(notice_cc 有行,2026-09-24——抄送是知会,应用内「抄送给我的」点开必须可见)。
* 其余用户查询即"通知不存在或未发布"(查不到语义,不泄露存在性)。
*/
@Slf4j
@@ -33,6 +34,7 @@ public class NoticeFlowGuard {
private final FlowClient flowClient;
private final NoticeFlowProperties properties;
private final com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
private final Map<Long, CachedAssignees> assigneeCache = new ConcurrentHashMap<>();
@@ -68,6 +70,10 @@ public class NoticeFlowGuard {
&& currentAssignees(notice).contains(user.getId())) {
return;
}
// 4) 该办件被抄送人(notice_cc 本地表有行即放行——抄送发生即知会,无需查流程中心)
if (noticeCcRepository.existsByNoticeIdAndCcUserId(notice.getId(), user.getId())) {
return;
}
throw notFound();
}
@@ -4,15 +4,12 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.notice.entity.Notice;
import com.yunhai.notice.entity.NoticeFlowBinding;
import com.yunhai.notice.entity.NoticeFlowReg;
import com.yunhai.notice.entity.UpFile;
import com.yunhai.notice.enums.FlowAuditStatusEnum;
import com.yunhai.notice.model.flow.FlowModels;
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
import com.yunhai.notice.repository.NoticeFlowRegRepository;
import com.yunhai.notice.repository.NoticeRepository;
import com.yunhai.notice.repository.UpFileRepository;
import com.yunhai.platform.appauth.AppAuthProperties;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.auth.spring.CurrentBoaUser;
import com.yunhai.platform.utils.exception.ErrorCode;
@@ -41,13 +38,12 @@ import java.util.Map;
public class NoticeFlowService {
private final NoticeRepository noticeRepository;
private final NoticeFlowRegRepository regRepository;
private final NoticeFlowBindingRepository bindingRepository;
private final UpFileRepository upFileRepository;
private final FlowClient flowClient;
private final NoticeFlowProperties properties;
private final ObjectMapper objectMapper;
private final AppAuthProperties appAuthProperties;
private final com.yunhai.platform.flowcallback.FlowCallbackRegistrar callbackRegistrar;
private final CurrentBoaUser currentBoaUser;
private final NoticeFlowGuard noticeFlowGuard;
@@ -124,39 +120,16 @@ public class NoticeFlowService {
}
/**
* 回调注册:仅 reg 缺失时注册并落 secret。<b>绝不重复调用</b>——flow-center 重复注册会轮换
* secret 导致已存 secret 验签失配;失配恢复手段=管理端重新保存绑定(删除 reg 行后重提)。
* 回调注册(2026-09-20 迁 starter 的 {@link com.yunhai.platform.flowcallback.FlowCallbackRegistrar}):
* 仅本地镜像行(flow_callback_reg)缺失/失效时注册并落 secret。<b>绝不重复调用</b>——
* flow-center 重复注册会轮换 secret;失配恢复手段=删除镜像行后重新保存绑定。
*/
void ensureRegistered(Notice notice) {
Integer schId = notice.getSchId();
String flowGuid = notice.getFlowGuid();
String appKey = appAuthProperties.getAppKey();
NoticeFlowReg reg = regRepository
.findBySchIdAndAppKeyAndFlowGuid(schId, appKey, flowGuid)
.orElse(null);
if (reg != null && StringUtils.hasText(reg.getSecret()) && reg.getStatus() == 0) {
return;
}
FlowModels.CallbackRegBody body = new FlowModels.CallbackRegBody();
body.setFlowGuid(flowGuid);
body.setCallbackUrl(callbackUrl());
// 申报本服务 context-path:推送方按它剥离后再签应用签名(与平台 AppAuthFilter 剥 contextPath 对齐)
body.setBasePath(properties.getCallbackBasePath());
FlowModels.CallbackRegResultVo result = flowClient.registerCallback(schId, body);
if (reg == null) {
reg = new NoticeFlowReg();
reg.setSchId(schId);
reg.setAppKey(appKey);
reg.setFlowGuid(flowGuid);
}
reg.setCallbackUrl(callbackUrl());
reg.setEventTypes(null);
reg.setSecret(result.getSecret());
reg.setStatus(0);
regRepository.save(reg);
callbackRegistrar.ensureRegistered(notice.getSchId(), notice.getFlowGuid());
}
/** apply 出参组装(契约冻结:bizId=notice.id、bizUrl 前端详情、快照只收摘要不收富文本正文) */
/** apply 出参组装(契约冻结:bizId=notice.id、bizUrl 前端详情、快照只收摘要不收富文本正文;
* formData 带发文部门(2026-09-24)——条件路由判断端点优先读它免查库,缺字段回落查表同口径) */
FlowModels.ApplyBody buildApplyBody(Notice notice, BoaUser user) {
FlowModels.ApplyBody body = new FlowModels.ApplyBody();
body.setFlowGuid(notice.getFlowGuid());
@@ -165,6 +138,10 @@ public class NoticeFlowService {
body.setBizTitle(notice.getTitle());
body.setBizUrl(properties.getFrontBaseUrl() + "/#/list/detail?serid=" + notice.getId());
body.setBizSnapshot(buildSnapshot(notice));
Map<String, Object> formData = new LinkedHashMap<>();
formData.put("deptId", notice.getDeptId());
formData.put("deptName", notice.getDeptName());
body.setFormData(formData);
if (user != null) {
body.setSubmitterId(user.getId());
body.setSubmitterName(StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount());
@@ -396,19 +373,7 @@ public class NoticeFlowService {
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
}
private String callbackUrl() {
String base = properties.getCallbackBase();
if (base == null) {
base = "";
}
while (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
return base + properties.getCallbackPath();
}
private JsonNode parseJson(String json) {
if (!StringUtils.hasText(json)) {
private JsonNode parseJson(String json) { if (!StringUtils.hasText(json)) {
return null;
}
try {
@@ -235,7 +235,7 @@ public class NoticeService implements INoticeService {
// 沿用该通知已有流程快照(在途/已通过的沿用原流程重审,不受期间改绑影响),无则取当前绑定
entity.setFlowGuid(wasFlowBound ? entity.getFlowGuid() : boundFlowGuid);
entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode());
// 清上一轮驳回意见:Notice 侧 flow_result_json 只在终态事件写入(FlowCallbackService:173),
// 清上一轮驳回意见:Notice 侧 flow_result_json 只在终态事件写入(NoticeFlowCallbackHandler 终态快照),
// 不清则重提后整段新审批期都挂着旧驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能)
entity.setFlowResultJson(null);
entity.setStatus(UNPUBLISHED); // 审核通过前对用户不可见