迁 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:
@@ -120,6 +120,13 @@
|
|||||||
<artifactId>yh-spring-boot-starter</artifactId>
|
<artifactId>yh-spring-boot-starter</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 流程中心回调接收端 starter(2026-09-20 迁移):验签外置(/oapi/* 平台验签),
|
||||||
|
接收管线(幂等/乱序门/同事务推进)+ 登记注册器组件化,业务映射见 NoticeFlowCallbackHandler -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.yunhai.platform</groupId>
|
||||||
|
<artifactId>yh-flow-callback-spring-boot-starter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。
|
<!-- CAS 客户端(浏览器 SSO 重定向 / ticket 校验 / getRemoteUser 包装)。
|
||||||
版本 3.5.1 已在本地 .m2 仓库。 -->
|
版本 3.5.1 已在本地 .m2 仓库。 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -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 同构)。
|
* 未发布通知(status=0,审核载体)的详情放行守卫(方案 §5.5,V2.0 B.4#8 同构)。
|
||||||
* 仅三类人可见:提交人本人(publisherAccount)/ 本校管理员(type=1 或 roleId∈{2,5,7})/
|
* 四类人可见:提交人本人(publisherAccount)/ 本校管理员(type=1 或 roleId∈{2,5,7})/
|
||||||
* 该办件流程办理人(instance.assigneeIds 含当前用户,60s 短缓存——审批中途换人不实时,可接受)。
|
* 该办件流程办理人(instance.assigneeIds 含当前用户,60s 短缓存——审批中途换人不实时,可接受)/
|
||||||
|
* 该办件被抄送人(notice_cc 有行,2026-09-24——抄送是知会,应用内「抄送给我的」点开必须可见)。
|
||||||
* 其余用户查询即"通知不存在或未发布"(查不到语义,不泄露存在性)。
|
* 其余用户查询即"通知不存在或未发布"(查不到语义,不泄露存在性)。
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -33,6 +34,7 @@ public class NoticeFlowGuard {
|
|||||||
|
|
||||||
private final FlowClient flowClient;
|
private final FlowClient flowClient;
|
||||||
private final NoticeFlowProperties properties;
|
private final NoticeFlowProperties properties;
|
||||||
|
private final com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
|
||||||
|
|
||||||
private final Map<Long, CachedAssignees> assigneeCache = new ConcurrentHashMap<>();
|
private final Map<Long, CachedAssignees> assigneeCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
@@ -68,6 +70,10 @@ public class NoticeFlowGuard {
|
|||||||
&& currentAssignees(notice).contains(user.getId())) {
|
&& currentAssignees(notice).contains(user.getId())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 4) 该办件被抄送人(notice_cc 本地表有行即放行——抄送发生即知会,无需查流程中心)
|
||||||
|
if (noticeCcRepository.existsByNoticeIdAndCcUserId(notice.getId(), user.getId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw notFound();
|
throw notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,12 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.yunhai.notice.entity.Notice;
|
import com.yunhai.notice.entity.Notice;
|
||||||
import com.yunhai.notice.entity.NoticeFlowBinding;
|
import com.yunhai.notice.entity.NoticeFlowBinding;
|
||||||
import com.yunhai.notice.entity.NoticeFlowReg;
|
|
||||||
import com.yunhai.notice.entity.UpFile;
|
import com.yunhai.notice.entity.UpFile;
|
||||||
import com.yunhai.notice.enums.FlowAuditStatusEnum;
|
import com.yunhai.notice.enums.FlowAuditStatusEnum;
|
||||||
import com.yunhai.notice.model.flow.FlowModels;
|
import com.yunhai.notice.model.flow.FlowModels;
|
||||||
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
|
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
|
||||||
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
|
||||||
import com.yunhai.notice.repository.NoticeRepository;
|
import com.yunhai.notice.repository.NoticeRepository;
|
||||||
import com.yunhai.notice.repository.UpFileRepository;
|
import com.yunhai.notice.repository.UpFileRepository;
|
||||||
import com.yunhai.platform.appauth.AppAuthProperties;
|
|
||||||
import com.yunhai.platform.apistd.BizException;
|
import com.yunhai.platform.apistd.BizException;
|
||||||
import com.yunhai.platform.auth.spring.CurrentBoaUser;
|
import com.yunhai.platform.auth.spring.CurrentBoaUser;
|
||||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||||
@@ -41,13 +38,12 @@ import java.util.Map;
|
|||||||
public class NoticeFlowService {
|
public class NoticeFlowService {
|
||||||
|
|
||||||
private final NoticeRepository noticeRepository;
|
private final NoticeRepository noticeRepository;
|
||||||
private final NoticeFlowRegRepository regRepository;
|
|
||||||
private final NoticeFlowBindingRepository bindingRepository;
|
private final NoticeFlowBindingRepository bindingRepository;
|
||||||
private final UpFileRepository upFileRepository;
|
private final UpFileRepository upFileRepository;
|
||||||
private final FlowClient flowClient;
|
private final FlowClient flowClient;
|
||||||
private final NoticeFlowProperties properties;
|
private final NoticeFlowProperties properties;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final AppAuthProperties appAuthProperties;
|
private final com.yunhai.platform.flowcallback.FlowCallbackRegistrar callbackRegistrar;
|
||||||
private final CurrentBoaUser currentBoaUser;
|
private final CurrentBoaUser currentBoaUser;
|
||||||
private final NoticeFlowGuard noticeFlowGuard;
|
private final NoticeFlowGuard noticeFlowGuard;
|
||||||
|
|
||||||
@@ -124,39 +120,16 @@ public class NoticeFlowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 回调注册:仅 reg 缺失时注册并落 secret。<b>绝不重复调用</b>——flow-center 重复注册会轮换
|
* 回调注册(2026-09-20 迁 starter 的 {@link com.yunhai.platform.flowcallback.FlowCallbackRegistrar}):
|
||||||
* secret 导致已存 secret 验签失配;失配恢复手段=管理端重新保存绑定(删除 reg 行后重提)。
|
* 仅本地镜像行(flow_callback_reg)缺失/失效时注册并落 secret。<b>绝不重复调用</b>——
|
||||||
|
* flow-center 重复注册会轮换 secret;失配恢复手段=删除镜像行后重新保存绑定。
|
||||||
*/
|
*/
|
||||||
void ensureRegistered(Notice notice) {
|
void ensureRegistered(Notice notice) {
|
||||||
Integer schId = notice.getSchId();
|
callbackRegistrar.ensureRegistered(notice.getSchId(), notice.getFlowGuid());
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** apply 出参组装(契约冻结:bizId=notice.id、bizUrl 前端详情、快照只收摘要不收富文本正文) */
|
/** apply 出参组装(契约冻结:bizId=notice.id、bizUrl 前端详情、快照只收摘要不收富文本正文;
|
||||||
|
* formData 带发文部门(2026-09-24)——条件路由判断端点优先读它免查库,缺字段回落查表同口径) */
|
||||||
FlowModels.ApplyBody buildApplyBody(Notice notice, BoaUser user) {
|
FlowModels.ApplyBody buildApplyBody(Notice notice, BoaUser user) {
|
||||||
FlowModels.ApplyBody body = new FlowModels.ApplyBody();
|
FlowModels.ApplyBody body = new FlowModels.ApplyBody();
|
||||||
body.setFlowGuid(notice.getFlowGuid());
|
body.setFlowGuid(notice.getFlowGuid());
|
||||||
@@ -165,6 +138,10 @@ public class NoticeFlowService {
|
|||||||
body.setBizTitle(notice.getTitle());
|
body.setBizTitle(notice.getTitle());
|
||||||
body.setBizUrl(properties.getFrontBaseUrl() + "/#/list/detail?serid=" + notice.getId());
|
body.setBizUrl(properties.getFrontBaseUrl() + "/#/list/detail?serid=" + notice.getId());
|
||||||
body.setBizSnapshot(buildSnapshot(notice));
|
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) {
|
if (user != null) {
|
||||||
body.setSubmitterId(user.getId());
|
body.setSubmitterId(user.getId());
|
||||||
body.setSubmitterName(StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount());
|
body.setSubmitterName(StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount());
|
||||||
@@ -396,19 +373,7 @@ public class NoticeFlowService {
|
|||||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
|
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
|
||||||
}
|
}
|
||||||
|
|
||||||
private String callbackUrl() {
|
private JsonNode parseJson(String json) { if (!StringUtils.hasText(json)) {
|
||||||
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)) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ public class NoticeService implements INoticeService {
|
|||||||
// 沿用该通知已有流程快照(在途/已通过的沿用原流程重审,不受期间改绑影响),无则取当前绑定
|
// 沿用该通知已有流程快照(在途/已通过的沿用原流程重审,不受期间改绑影响),无则取当前绑定
|
||||||
entity.setFlowGuid(wasFlowBound ? entity.getFlowGuid() : boundFlowGuid);
|
entity.setFlowGuid(wasFlowBound ? entity.getFlowGuid() : boundFlowGuid);
|
||||||
entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode());
|
entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode());
|
||||||
// 清上一轮驳回意见:Notice 侧 flow_result_json 只在终态事件写入(FlowCallbackService:173),
|
// 清上一轮驳回意见:Notice 侧 flow_result_json 只在终态事件写入(NoticeFlowCallbackHandler 终态快照),
|
||||||
// 不清则重提后整段新审批期都挂着旧驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能)
|
// 不清则重提后整段新审批期都挂着旧驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能)
|
||||||
entity.setFlowResultJson(null);
|
entity.setFlowResultJson(null);
|
||||||
entity.setStatus(UNPUBLISHED); // 审核通过前对用户不可见
|
entity.setStatus(UNPUBLISHED); // 审核通过前对用户不可见
|
||||||
|
|||||||
@@ -102,6 +102,13 @@ yh:
|
|||||||
# trusted-apps:
|
# trusted-apps:
|
||||||
# "100009": "dcc36c98299a4d36bb0ec39d42008e8c"
|
# "100009": "dcc36c98299a4d36bb0ec39d42008e8c"
|
||||||
trust-registry: true
|
trust-registry: true
|
||||||
|
flow-callback:
|
||||||
|
# 流程中心回调接收端 starter(2026-09-20 迁移):端点/幂等/乱序门由 starter 承担,
|
||||||
|
# 业务映射见 NoticeFlowCallbackHandler;验签仍由上方 /oapi/* 平台强制验签负责(X-Flw 不启用)
|
||||||
|
path: /oapi/notice/flow/callback
|
||||||
|
biz-types: [notice]
|
||||||
|
# 注册用回调地址=对外基址+端点路径(占位符组合,防两处配置漂移;base-path 本地无 context-path 不填)
|
||||||
|
callback-url: ${notice.flow.callback-base}${yh.flow-callback.path}
|
||||||
open:
|
open:
|
||||||
# 四域服务目录零配置:userbase/userclass/usermsg/flowcenter 枚举兜底 = base-host + 默认 ctx
|
# 四域服务目录零配置:userbase/userclass/usermsg/flowcenter 枚举兜底 = base-host + 默认 ctx
|
||||||
base-host: http://base.yunhaixingtong.com
|
base-host: http://base.yunhaixingtong.com
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- 流程抄送留痕(2026-09-24):flow.cc.sent 回调逐人一行,应用内「抄送给我的」数据源。
|
||||||
|
-- 幂等靠 yh-flow-callback starter 的 flow_callback_event(uk event_id),本表只增不改。
|
||||||
|
-- 本地 ddl-auto=update 会自动建表;生产执行 deploy/sql/20260924_add_notice_cc.sql。
|
||||||
|
CREATE TABLE IF NOT EXISTS `notice_cc` (
|
||||||
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
|
`sch_id` INT NULL COMMENT '学校ID(多租户隔离)',
|
||||||
|
`notice_id` INT NOT NULL COMMENT '关联通知(信封 bizId)',
|
||||||
|
`flow_instance_id` BIGINT NULL COMMENT 'flow-center 办件 id(信封 instanceId)',
|
||||||
|
`node_id` VARCHAR(64) NULL COMMENT '抄送发生节点 id(data.nodeId)',
|
||||||
|
`node_name` VARCHAR(128) NULL COMMENT '抄送环节名快照(data.nodeName)',
|
||||||
|
`cc_user_id` INT NOT NULL COMMENT '被抄送人用户 id(data.ccUserId)',
|
||||||
|
`cc_user_name` VARCHAR(64) NULL COMMENT '被抄送人姓名快照(data.ccUserName)',
|
||||||
|
`sent_time` DATETIME(3) NOT NULL COMMENT '抄送时间(事件处理时刻,毫秒精度与 flw_cc 同口径)',
|
||||||
|
`create_by` VARCHAR(64) NULL COMMENT '创建人',
|
||||||
|
`create_time` DATETIME NULL COMMENT '创建时间',
|
||||||
|
`update_by` VARCHAR(64) NULL COMMENT '更新人',
|
||||||
|
`update_time` DATETIME NULL COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_notice_cc_user` (`sch_id`, `cc_user_id`, `sent_time`),
|
||||||
|
KEY `idx_notice_cc_notice` (`notice_id`)
|
||||||
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '流程抄送留痕(flow.cc.sent 回调落行)';
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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 org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 条件路由判断端点单测(2026-09-24 §5.2):部门比对两分支、formData 优先免查库、
|
||||||
|
* 未命中 outlets 抛错(挂起而非默认放行)、通知缺失/脏 bizId 抛错。
|
||||||
|
* 验签(/oapi/* 强制 100019 应用签名)由 AppAuthFilter 承担,FlowCallbackTransientIT 同款管线已覆盖。
|
||||||
|
*/
|
||||||
|
class OapiFlowRouteControllerTest {
|
||||||
|
|
||||||
|
private NoticeRepository noticeRepository;
|
||||||
|
private OapiFlowRouteController controller;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
noticeRepository = mock(NoticeRepository.class);
|
||||||
|
controller = new OapiFlowRouteController(noticeRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OapiFlowRouteController.JudgeBody body(String bizId, Map<String, Object> variables) {
|
||||||
|
OapiFlowRouteController.JudgeBody b = new OapiFlowRouteController.JudgeBody();
|
||||||
|
b.setSchId(1L);
|
||||||
|
b.setInstanceId(9527L);
|
||||||
|
b.setBizType("notice");
|
||||||
|
b.setBizId(bizId);
|
||||||
|
b.setOutlets(Arrays.asList("校长室", "其他部门"));
|
||||||
|
b.setVariables(variables);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void principalDept_routesToPrincipalOutlet() {
|
||||||
|
Notice n = new Notice();
|
||||||
|
n.setId(42);
|
||||||
|
n.setDeptId("xzbx");
|
||||||
|
n.setDeptName("校长室");
|
||||||
|
when(noticeRepository.findById(42)).thenReturn(Optional.of(n));
|
||||||
|
|
||||||
|
Map<String, Object> out = controller.deptJudge(body("42", null));
|
||||||
|
|
||||||
|
assertEquals("校长室", out.get("outlet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void otherDept_routesToOtherOutlet() {
|
||||||
|
Notice n = new Notice();
|
||||||
|
n.setId(43);
|
||||||
|
n.setDeptId("jwc");
|
||||||
|
n.setDeptName("教务处");
|
||||||
|
when(noticeRepository.findById(43)).thenReturn(Optional.of(n));
|
||||||
|
|
||||||
|
assertEquals("其他部门", controller.deptJudge(body("43", null)).get("outlet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void variablesDeptName_winsWithoutDbHit() {
|
||||||
|
// formData 优先:variables 带部门名时不查库(纯读轻实现),口径与查表一致
|
||||||
|
Map<String, Object> variables = new HashMap<>();
|
||||||
|
variables.put("deptName", "校长室");
|
||||||
|
when(noticeRepository.findById(anyInt())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertEquals("校长室", controller.deptJudge(body("456", variables)).get("outlet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void outletNotInContract_throws_neverDefaults() {
|
||||||
|
Notice n = new Notice();
|
||||||
|
n.setId(44);
|
||||||
|
n.setDeptName("校长室");
|
||||||
|
when(noticeRepository.findById(44)).thenReturn(Optional.of(n));
|
||||||
|
OapiFlowRouteController.JudgeBody b = body("44", null);
|
||||||
|
b.setOutlets(Collections.singletonList("其他部门")); // 流程定义与端点出口契约不一致
|
||||||
|
|
||||||
|
BizException e = assertThrows(BizException.class, () -> controller.deptJudge(b));
|
||||||
|
assertEquals(true, e.getMessage().contains("未命中出口"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingNotice_throws() {
|
||||||
|
when(noticeRepository.findById(99)).thenReturn(Optional.empty());
|
||||||
|
assertThrows(BizException.class, () -> controller.deptJudge(body("99", null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void illegalBizId_throws() {
|
||||||
|
assertThrows(BizException.class, () -> controller.deptJudge(body("abc", null)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package com.yunhai.notice.flow;
|
package com.yunhai.notice.flow;
|
||||||
|
|
||||||
import com.yunhai.notice.entity.Notice;
|
import com.yunhai.notice.entity.Notice;
|
||||||
import com.yunhai.notice.entity.NoticeFlowReg;
|
|
||||||
import com.yunhai.notice.repository.NoticeFlowEventRepository;
|
|
||||||
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
|
||||||
import com.yunhai.notice.repository.NoticeRepository;
|
import com.yunhai.notice.repository.NoticeRepository;
|
||||||
import com.yunhai.notice.service.sms.SmsSender;
|
import com.yunhai.notice.service.sms.SmsSender;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -30,10 +27,12 @@ import static org.mockito.ArgumentMatchers.anyString;
|
|||||||
*/
|
*/
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
|
||||||
"spring.datasource.url=jdbc:mysql://localhost:3306/bsch_notice_test?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai",
|
"spring.datasource.url=jdbc:mysql://localhost:3306/bsch_notice_test?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&serverTimezone=Asia/Shanghai",
|
||||||
"spring.jpa.hibernate.ddl-auto=update"})
|
"spring.jpa.hibernate.ddl-auto=update",
|
||||||
|
// 验签确定性(同 NoticeFlowApiIT):本地 Redis 无 sign:app:100019 时回落白名单,勿依赖环境
|
||||||
|
"yh.app-auth.trust-registry=false",
|
||||||
|
"yh.app-auth.trusted-apps.100019=flow-center-dev-secret-100019"})
|
||||||
class FlowCallbackTransientIT {
|
class FlowCallbackTransientIT {
|
||||||
|
|
||||||
private static final String REG_SECRET = "it-transient-secret-0123456789";
|
|
||||||
private static final String FLOW_GUID = "it-transient-guid-01";
|
private static final String FLOW_GUID = "it-transient-guid-01";
|
||||||
|
|
||||||
@LocalServerPort
|
@LocalServerPort
|
||||||
@@ -42,9 +41,7 @@ class FlowCallbackTransientIT {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private NoticeRepository noticeRepository;
|
private NoticeRepository noticeRepository;
|
||||||
@Autowired
|
@Autowired
|
||||||
private NoticeFlowRegRepository regRepository;
|
private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
|
||||||
@Autowired
|
|
||||||
private NoticeFlowEventRepository eventRepository;
|
|
||||||
|
|
||||||
@MockBean
|
@MockBean
|
||||||
private SmsSender smsSender;
|
private SmsSender smsSender;
|
||||||
@@ -53,19 +50,9 @@ class FlowCallbackTransientIT {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
eventRepository.deleteAll();
|
jdbcTemplate.update("DELETE FROM flow_callback_event");
|
||||||
regRepository.deleteAll();
|
|
||||||
noticeRepository.deleteAll();
|
noticeRepository.deleteAll();
|
||||||
|
|
||||||
NoticeFlowReg reg = new NoticeFlowReg();
|
|
||||||
reg.setSchId(1);
|
|
||||||
reg.setAppKey("100617");
|
|
||||||
reg.setFlowGuid(FLOW_GUID);
|
|
||||||
reg.setCallbackUrl("http://localhost:" + port + "/oapi/notice/flow/callback");
|
|
||||||
reg.setSecret(REG_SECRET);
|
|
||||||
reg.setStatus(0);
|
|
||||||
regRepository.save(reg);
|
|
||||||
|
|
||||||
notice = new Notice();
|
notice = new Notice();
|
||||||
notice.setTitle("瞬时失败通知");
|
notice.setTitle("瞬时失败通知");
|
||||||
notice.setType(1);
|
notice.setType(1);
|
||||||
@@ -90,7 +77,7 @@ class FlowCallbackTransientIT {
|
|||||||
Notice after = reload();
|
Notice after = reload();
|
||||||
assertEquals(10, after.getAuditStatus(), "失败事务整体回滚");
|
assertEquals(10, after.getAuditStatus(), "失败事务整体回滚");
|
||||||
assertEquals(0, after.getStatus());
|
assertEquals(0, after.getStatus());
|
||||||
assertEquals(0L, eventRepository.count(), "事件去重行随事务回滚(重投不撞 uk)");
|
assertEquals(0L, callbackEventCount(), "事件去重行随事务回滚(重投不撞 uk)");
|
||||||
|
|
||||||
// 阶段二:恢复后重投同一事件 → 200 + 状态追平(flow-center 持续重试语义闭环)
|
// 阶段二:恢复后重投同一事件 → 200 + 状态追平(flow-center 持续重试语义闭环)
|
||||||
Mockito.doNothing().when(smsSender).sendNoticeSms(anyInt(), anyString(), anyList());
|
Mockito.doNothing().when(smsSender).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||||
@@ -99,7 +86,7 @@ class FlowCallbackTransientIT {
|
|||||||
Notice recovered = reload();
|
Notice recovered = reload();
|
||||||
assertEquals(20, recovered.getAuditStatus());
|
assertEquals(20, recovered.getAuditStatus());
|
||||||
assertEquals(1, recovered.getStatus(), "恢复后审核通过正常发布");
|
assertEquals(1, recovered.getStatus(), "恢复后审核通过正常发布");
|
||||||
assertEquals(1L, eventRepository.count());
|
assertEquals(1L, callbackEventCount());
|
||||||
// 短信共尝试 2 次:故障期 1 次(抛异常、随事务回滚不算已发)+ 恢复后成功 1 次
|
// 短信共尝试 2 次:故障期 1 次(抛异常、随事务回滚不算已发)+ 恢复后成功 1 次
|
||||||
Mockito.verify(smsSender, Mockito.times(2)).sendNoticeSms(anyInt(), anyString(), anyList());
|
Mockito.verify(smsSender, Mockito.times(2)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||||
}
|
}
|
||||||
@@ -124,4 +111,10 @@ class FlowCallbackTransientIT {
|
|||||||
private static String uuid() {
|
private static String uuid() {
|
||||||
return UUID.randomUUID().toString().replace("-", "");
|
return UUID.randomUUID().toString().replace("-", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** starter 幂等表行数(2026-09-20 迁移后事件留痕在 flow_callback_event) */
|
||||||
|
private long callbackEventCount() {
|
||||||
|
Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM flow_callback_event", Long.class);
|
||||||
|
return c == null ? 0 : c;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
*/
|
*/
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
|
||||||
"spring.datasource.url=jdbc:mysql://localhost:3306/bsch_notice_test?createDatabaseIfNotExist=true&useUnicode=true&connectionCollation=utf8mb4_general_ci&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai",
|
"spring.datasource.url=jdbc:mysql://localhost:3306/bsch_notice_test?createDatabaseIfNotExist=true&useUnicode=true&connectionCollation=utf8mb4_general_ci&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai",
|
||||||
"spring.jpa.hibernate.ddl-auto=update"})
|
"spring.jpa.hibernate.ddl-auto=update",
|
||||||
|
// 验签确定性(2026-09-24):主 yml 已切中央密钥链(trust-registry,Redis sign:app:* 单一真源),
|
||||||
|
// 本地 Redis 无 100019 密钥时合法签名也会 401。IT 勿依赖环境:回落白名单直配本用例的身份对
|
||||||
|
// (等价 yml 注释里"本地跑 oapi 回调可临时打开白名单"的口径,只作用于测试上下文)。
|
||||||
|
"yh.app-auth.trust-registry=false",
|
||||||
|
"yh.app-auth.trusted-apps.100019=flow-center-dev-secret-100019"})
|
||||||
class NoticeFlowApiIT {
|
class NoticeFlowApiIT {
|
||||||
|
|
||||||
/** 回调端点(开放接口域;验签由 yh.app-auth enforce-patterns=/oapi/* 强制) */
|
/** 回调端点(开放接口域;验签由 yh.app-auth enforce-patterns=/oapi/* 强制) */
|
||||||
@@ -52,7 +57,7 @@ class NoticeFlowApiIT {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private NoticeFlowRegRepository regRepository;
|
private NoticeFlowRegRepository regRepository;
|
||||||
@Autowired
|
@Autowired
|
||||||
private NoticeFlowEventRepository eventRepository;
|
private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
|
||||||
@Autowired
|
@Autowired
|
||||||
private NoticeFlowBindingRepository bindingRepository;
|
private NoticeFlowBindingRepository bindingRepository;
|
||||||
@Autowired
|
@Autowired
|
||||||
@@ -62,7 +67,8 @@ class NoticeFlowApiIT {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
eventRepository.deleteAll();
|
jdbcTemplate.update("DELETE FROM flow_callback_event");
|
||||||
|
jdbcTemplate.update("DELETE FROM flow_callback_reg");
|
||||||
regRepository.deleteAll();
|
regRepository.deleteAll();
|
||||||
bindingRepository.deleteAll();
|
bindingRepository.deleteAll();
|
||||||
noticeRepository.deleteAll();
|
noticeRepository.deleteAll();
|
||||||
@@ -106,7 +112,7 @@ class NoticeFlowApiIT {
|
|||||||
assertNotNull(updated.getFlowResultJson());
|
assertNotNull(updated.getFlowResultJson());
|
||||||
assertTrue(updated.getFlowResultJson().contains("同意"));
|
assertTrue(updated.getFlowResultJson().contains("同意"));
|
||||||
// 事件去重行落库(事务提交)
|
// 事件去重行落库(事务提交)
|
||||||
assertEquals(1L, eventRepository.count());
|
assertEquals(1L, callbackEventCount());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -116,7 +122,7 @@ class NoticeFlowApiIT {
|
|||||||
assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(),
|
assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(),
|
||||||
"重复投递必须回 200(flow-center 持续重试语义下的常态)");
|
"重复投递必须回 200(flow-center 持续重试语义下的常态)");
|
||||||
assertEquals(20, reload().getAuditStatus());
|
assertEquals(20, reload().getAuditStatus());
|
||||||
assertEquals(1L, eventRepository.count(), "eventId 唯一:重复投递不落第二行");
|
assertEquals(1L, callbackEventCount(), "eventId 唯一:重复投递不落第二行");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -153,7 +159,7 @@ class NoticeFlowApiIT {
|
|||||||
"无签名 ping 应被 401 拦下");
|
"无签名 ping 应被 401 拦下");
|
||||||
assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(),
|
assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(),
|
||||||
"带签名的 ping 回 200(注册探活只判可达)");
|
"带签名的 ping 回 200(注册探活只判可达)");
|
||||||
assertEquals(0L, eventRepository.count(), "ping 不落事件行");
|
assertEquals(0L, callbackEventCount(), "ping 不落事件行");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== ② /oapi/notice/getDict 三态 ====================
|
// ==================== ② /oapi/notice/getDict 三态 ====================
|
||||||
@@ -257,4 +263,10 @@ class NoticeFlowApiIT {
|
|||||||
private static String uuid() {
|
private static String uuid() {
|
||||||
return UUID.randomUUID().toString().replace("-", "");
|
return UUID.randomUUID().toString().replace("-", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** starter 幂等表行数(2026-09-20 迁移后事件留痕在 flow_callback_event) */
|
||||||
|
private long callbackEventCount() {
|
||||||
|
Long c = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM flow_callback_event", Long.class);
|
||||||
|
return c == null ? 0 : c;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,251 +0,0 @@
|
|||||||
package com.yunhai.notice.service.flow;
|
|
||||||
|
|
||||||
import com.yunhai.notice.entity.Notice;
|
|
||||||
import com.yunhai.notice.entity.NoticeFlowEvent;
|
|
||||||
import com.yunhai.notice.repository.NoticeFlowEventRepository;
|
|
||||||
import com.yunhai.notice.repository.NoticeRepository;
|
|
||||||
import com.yunhai.notice.service.sms.SmsSender;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.mockito.Mockito;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyList;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
|
||||||
import static org.mockito.Mockito.doThrow;
|
|
||||||
import static org.mockito.Mockito.mock;
|
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
import static org.mockito.Mockito.times;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 回调事件处理单测(T2.3 5xx 语义核心断言):六事件映射;completed → status=1 + sms 恰一次;
|
|
||||||
* eventId 重复跳过;version 早于已消费丢弃;毒 JSON/缺字段/不属本应用静默 200(不抛);
|
|
||||||
* DB 异常 → TransientCallbackException(→ controller 回 5xx)。
|
|
||||||
*/
|
|
||||||
class FlowCallbackServiceTest {
|
|
||||||
|
|
||||||
private NoticeRepository noticeRepository;
|
|
||||||
private NoticeFlowEventRepository eventRepository;
|
|
||||||
private SmsSender smsSender;
|
|
||||||
private FlowCallbackService service;
|
|
||||||
private Notice notice;
|
|
||||||
private final AtomicInteger saveCount = new AtomicInteger();
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
noticeRepository = mock(NoticeRepository.class);
|
|
||||||
eventRepository = mock(NoticeFlowEventRepository.class);
|
|
||||||
smsSender = mock(SmsSender.class);
|
|
||||||
// 统一发送链(§3.2):认领桩=1 表示未发过(首次),0=已发(重复闸门)
|
|
||||||
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1);
|
|
||||||
service = new FlowCallbackService(noticeRepository, eventRepository,
|
|
||||||
new com.yunhai.notice.service.sms.SmsSendService(noticeRepository, smsSender),
|
|
||||||
new ObjectMapper());
|
|
||||||
|
|
||||||
notice = new Notice();
|
|
||||||
notice.setId(42);
|
|
||||||
notice.setTitle("审核通知标题");
|
|
||||||
notice.setSchId(1);
|
|
||||||
notice.setStatus(0);
|
|
||||||
notice.setFlowGuid("fg-1");
|
|
||||||
notice.setAuditStatus(10);
|
|
||||||
notice.setSmsFlag(1);
|
|
||||||
notice.setReceiveList("[\"tea1\"]");
|
|
||||||
when(noticeRepository.findById(42)).thenReturn(Optional.of(notice));
|
|
||||||
when(noticeRepository.findByIdForUpdate(42)).thenReturn(Optional.of(notice));
|
|
||||||
when(noticeRepository.saveAndFlush(any(Notice.class))).thenAnswer(inv -> {
|
|
||||||
saveCount.incrementAndGet();
|
|
||||||
return inv.getArgument(0);
|
|
||||||
});
|
|
||||||
when(eventRepository.saveAndFlush(any(NoticeFlowEvent.class))).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String event(String eventId, String event, int version, String dataJson) {
|
|
||||||
return "{"
|
|
||||||
+ "\"eventId\":\"" + eventId + "\",\"event\":\"" + event + "\","
|
|
||||||
+ "\"schId\":1,\"flowGuid\":\"fg-1\",\"instanceId\":9527,"
|
|
||||||
+ "\"instanceVersion\":" + version + ","
|
|
||||||
+ "\"bizType\":\"notice\",\"bizId\":\"42\",\"bizTitle\":\"t\","
|
|
||||||
+ "\"data\":" + (dataJson == null ? "{}" : dataJson) + "}";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void inFlightEvents_mapToApproving() {
|
|
||||||
service.handle(event("e1", "flow.submitted", 1, null));
|
|
||||||
assertEquals(10, notice.getAuditStatus());
|
|
||||||
service.handle(event("e2", "flow.task.created", 1, null));
|
|
||||||
assertEquals(10, notice.getAuditStatus());
|
|
||||||
service.handle(event("e3", "flow.task.handled", 2, null));
|
|
||||||
assertEquals(10, notice.getAuditStatus());
|
|
||||||
assertEquals(0, notice.getStatus(), "审批中事件不得提前发布");
|
|
||||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void completed_publishesAndSendsSmsOnce() {
|
|
||||||
service.handle(event("e4", "flow.completed", 3,
|
|
||||||
"{\"result\":\"APPROVED\",\"opinion\":\"同意\"}"));
|
|
||||||
assertEquals(20, notice.getAuditStatus());
|
|
||||||
assertEquals(1, notice.getStatus(), "审核通过置 status=1 正式发布");
|
|
||||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
|
||||||
assertTrue(notice.getFlowResultJson().contains("APPROVED"), "终态明细落 flow_result_json");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void completed_beforeTimingPoint_smsDeferredToCatchup() {
|
|
||||||
// §3.2:审批通过早于定时点——短信此刻不发也不认领,留给 SmsCatchupJob 到点补发
|
|
||||||
notice.setTimingSendTime(java.time.LocalDateTime.now().plusHours(1));
|
|
||||||
service.handle(event("e-df", "flow.completed", 3, "{\"result\":\"APPROVED\"}"));
|
|
||||||
assertEquals(20, notice.getAuditStatus());
|
|
||||||
assertEquals(1, notice.getStatus(), "可见性即时生效不受影响");
|
|
||||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
|
||||||
verify(noticeRepository, never()).markSmsSent(anyInt(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void completedTwice_smsOnlyOnce() {
|
|
||||||
// 重置 audit 初始为 20 再收到迟到 completed:status 已 1 → justPublished=false → 不再发 sms
|
|
||||||
service.handle(event("e4", "flow.completed", 3, "{\"result\":\"APPROVED\"}"));
|
|
||||||
notice.setStatus(1);
|
|
||||||
notice.setAuditStatus(20);
|
|
||||||
service.handle(event("e4x", "flow.completed", 4, "{\"result\":\"APPROVED\"}"));
|
|
||||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejected_mapsTo30_withResultJson() {
|
|
||||||
service.handle(event("e5", "flow.rejected", 3,
|
|
||||||
"{\"result\":\"REJECTED\",\"opinion\":\"内容不妥\",\"rejectNode\":\"终审\"}"));
|
|
||||||
assertEquals(30, notice.getAuditStatus());
|
|
||||||
assertEquals(0, notice.getStatus(), "驳回不发布");
|
|
||||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
|
||||||
assertTrue(notice.getFlowResultJson().contains("内容不妥"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void withdrawn_mapsTo40() {
|
|
||||||
service.handle(event("e6", "flow.withdrawn", 2, null));
|
|
||||||
assertEquals(40, notice.getAuditStatus());
|
|
||||||
assertEquals(0, notice.getStatus());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void duplicateEventId_skipped() {
|
|
||||||
when(eventRepository.existsByEventId("dup")).thenReturn(true);
|
|
||||||
service.handle(event("dup", "flow.completed", 3, null));
|
|
||||||
assertEquals(10, notice.getAuditStatus(), "重复投递不得推进状态");
|
|
||||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void staleVersion_dropped() {
|
|
||||||
notice.setLastInstanceVersion(5);
|
|
||||||
service.handle(event("e7", "flow.completed", 3, null)); // 迟到的低版本事件
|
|
||||||
assertEquals(10, notice.getAuditStatus(), "乱序事件丢弃");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void sameVersionDifferentEvent_processed() {
|
|
||||||
// submitted 与 task.created 天然同版本:等值不丢(幂等映射)
|
|
||||||
notice.setLastInstanceVersion(1);
|
|
||||||
service.handle(event("e8", "flow.task.created", 1, null));
|
|
||||||
assertEquals(10, notice.getAuditStatus());
|
|
||||||
assertEquals(1, notice.getLastInstanceVersion());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== P1-3 终态不回退守卫:同版本事件完成顺序倒置(completed 先落库,随后到 task.handled) =====
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void terminalRow_rejectsNonTerminalEvent_sameVersionInversion() {
|
|
||||||
// completed(v4) 已落库(audit=20 + result json),迟到的 task.handled(v4) 不得打回 10
|
|
||||||
service.handle(event("e-t1", "flow.completed", 4, "{\"result\":\"APPROVED\",\"opinion\":\"同意\"}"));
|
|
||||||
assertEquals(20, notice.getAuditStatus());
|
|
||||||
String frozen = notice.getFlowResultJson();
|
|
||||||
|
|
||||||
service.handle(event("e-t2", "flow.task.handled", 4, "{\"opinion\":\"同意\"}")); // 不抛 → 200
|
|
||||||
assertEquals(20, notice.getAuditStatus(), "终态行拒绝非终态事件(终态不回退)");
|
|
||||||
assertEquals(frozen, notice.getFlowResultJson(), "已落定 flow_result_json 不得被覆盖");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void terminalRow_rejectsNonTerminalEvent_higherVersion() {
|
|
||||||
// 理论上不会出现(终态后引擎不再产事件),防御:v5 迟到的 in-flight 事件同样拒绝
|
|
||||||
service.handle(event("e-t3", "flow.completed", 4, "{\"result\":\"APPROVED\"}"));
|
|
||||||
service.handle(event("e-t4", "flow.task.handled", 5, null));
|
|
||||||
assertEquals(20, notice.getAuditStatus(), "终态后任何非终态事件都不回退(R4 重提由 edit 先置 0,不冲突)");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void rejectedTerminal_frozenResultNotOverwritten_byOtherTerminal() {
|
|
||||||
// 驳回(30)已带意见落档,另一终态事件不得覆盖已落定结果
|
|
||||||
service.handle(event("e-t5", "flow.rejected", 4, "{\"result\":\"REJECTED\",\"opinion\":\"材料不齐\"}"));
|
|
||||||
assertEquals(30, notice.getAuditStatus());
|
|
||||||
String frozen = notice.getFlowResultJson();
|
|
||||||
service.handle(event("e-t6", "flow.withdrawn", 4, null));
|
|
||||||
assertEquals(30, notice.getAuditStatus(), "已终态不被另一终态改写");
|
|
||||||
assertEquals(frozen, notice.getFlowResultJson());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void terminalGuard_doesNotBlockR4Resubmission_afterLocalReset() {
|
|
||||||
// R4 重提链路:edit 先本地置 audit=0 → 新一轮 submitted 正常推进到 10
|
|
||||||
service.handle(event("e-t7", "flow.rejected", 4, "{\"result\":\"REJECTED\"}"));
|
|
||||||
notice.setAuditStatus(0); // NoticeFlowService 编辑保存时本地重置
|
|
||||||
service.handle(event("e-t8", "flow.submitted", 5, null));
|
|
||||||
assertEquals(10, notice.getAuditStatus(), "重置后新一轮事件正常推进(守卫只拦终态行的回退)");
|
|
||||||
assertEquals(5, notice.getLastInstanceVersion());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void poisonJson_silent200() {
|
|
||||||
service.handle("not-a-json{{{");
|
|
||||||
assertEquals(10, notice.getAuditStatus());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void missingFields_silent200() {
|
|
||||||
service.handle("{\"event\":\"flow.completed\"}");
|
|
||||||
assertEquals(10, notice.getAuditStatus());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void foreignBizType_silent200() {
|
|
||||||
service.handle(event("e9", "flow.completed", 3, null).replace("\"notice\"", "\"svc_item\""));
|
|
||||||
assertEquals(10, notice.getAuditStatus());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void unknownNotice_silent200() {
|
|
||||||
when(noticeRepository.findById(42)).thenReturn(Optional.empty());
|
|
||||||
when(noticeRepository.findByIdForUpdate(42)).thenReturn(Optional.empty());
|
|
||||||
service.handle(event("e10", "flow.completed", 3, null)); // 不抛
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void dbFailure_throwsTransient() {
|
|
||||||
when(noticeRepository.saveAndFlush(any(Notice.class)))
|
|
||||||
.thenThrow(new org.springframework.dao.DataAccessResourceFailureException("db down"));
|
|
||||||
assertThrows(FlowCallbackService.TransientCallbackException.class,
|
|
||||||
() -> service.handle(event("e11", "flow.completed", 3, null)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void smsFailure_throwsTransient() {
|
|
||||||
doThrow(new RuntimeException("sms gateway down")).when(smsSender)
|
|
||||||
.sendNoticeSms(anyInt(), anyString(), anyList());
|
|
||||||
assertThrows(FlowCallbackService.TransientCallbackException.class,
|
|
||||||
() -> service.handle(event("e12", "flow.completed", 3, null)),
|
|
||||||
"发布后置失败也应回 5xx 让流程中心重试(事件行随事务回滚)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+22
-35
@@ -1,45 +1,28 @@
|
|||||||
package com.yunhai.notice.service.flow;
|
package com.yunhai.notice.service.flow;
|
||||||
|
|
||||||
import com.yunhai.notice.controller.oapi.OapiFlowCallbackController;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.yaml.snakeyaml.Yaml;
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.lang.reflect.Method;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 回调路径配置契约测试(防"过期值"回归)。<br><br>
|
* 回调路径配置契约测试(防"过期值"回归;2026-09-20 迁 starter 后改版)。<br><br>
|
||||||
* 不变式:{@code notice.flow.callback-path}(注册给 flow-center 的回调地址尾段)必须与
|
* 不变式:{@code yh.flow-callback.path}(starter 接收端点的真实映射来源)必须与
|
||||||
* {@link OapiFlowCallbackController} 的真实映射逐字一致——不一致时 flow-center 探活/推送全部
|
* {@code notice.flow.callback-path}(回调地址尾段的历史真源)逐字一致且等于冻结字面量
|
||||||
* 404,而本地单测因为走伪服务端不会发现。<br>
|
* {@code /oapi/notice/flow/callback}——不一致时 flow-center 探活/推送全部 404,
|
||||||
* 背景:{@code /callback/*} 自 X-Flw-* 自定义过滤器下线后已不存在,但本地 {@code application.yml}
|
* 而本地单测因为走伪服务端不会发现。<br>
|
||||||
* 一度仍写着 {@code /callback/flow}(生产 yml 与代码默认值都是 {@code /oapi/notice/flow/callback}),
|
* 迁移背景:端点由 yh-flow-callback-spring-boot-starter 的 FlowCallbackReceiverController
|
||||||
* 导致本地起链时注册到已下线的路径。
|
* 提供(@RequestMapping("${yh.flow-callback.path:...}")),不再有本地控制器类可反射。
|
||||||
*/
|
*/
|
||||||
@DisplayName("Notice 回调路径配置 - 与控制器映射咬合")
|
@DisplayName("Notice 回调路径配置 - 与 starter 端点映射咬合")
|
||||||
class NoticeFlowCallbackConfigContractTest {
|
class NoticeFlowCallbackConfigContractTest {
|
||||||
|
|
||||||
/** 控制器真实映射(反射拼类级 @RequestMapping + 方法级 @PostMapping) */
|
private static final String FROZEN_CALLBACK_PATH = "/oapi/notice/flow/callback";
|
||||||
private static String controllerMapping() {
|
|
||||||
RequestMapping classLevel = OapiFlowCallbackController.class.getAnnotation(RequestMapping.class);
|
|
||||||
assertNotNull(classLevel, "控制器必须有类级 @RequestMapping");
|
|
||||||
String base = classLevel.value()[0];
|
|
||||||
for (Method m : OapiFlowCallbackController.class.getDeclaredMethods()) {
|
|
||||||
PostMapping post = m.getAnnotation(PostMapping.class);
|
|
||||||
if (post != null) {
|
|
||||||
String sub = post.value().length > 0 ? post.value()[0] : "";
|
|
||||||
return base + sub;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new IllegalStateException("控制器缺少 @PostMapping 回调方法");
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private static Object yamlValue(String path) throws Exception {
|
private static Object yamlValue(String path) throws Exception {
|
||||||
@@ -62,17 +45,21 @@ class NoticeFlowCallbackConfigContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("application.yml 的 notice.flow.callback-path 与控制器映射一致(缺省=代码默认值)")
|
@DisplayName("yh.flow-callback.path 与 notice.flow.callback-path 一致且等于冻结字面量")
|
||||||
void callbackPathMatchesControllerMapping() throws Exception {
|
void starterPathMatchesLegacyCallbackPath() throws Exception {
|
||||||
String expected = controllerMapping();
|
Object starterConfigured = yamlValue("yh.flow-callback.path");
|
||||||
assertEquals("/oapi/notice/flow/callback", expected, "控制器路径变了,请同步生产配置与注册契约");
|
String starterPath = starterConfigured == null
|
||||||
|
? "/oapi/flow/callback" // starter 代码默认值(未配置时端点会映射到这里)
|
||||||
|
: String.valueOf(starterConfigured);
|
||||||
|
|
||||||
Object configured = yamlValue("notice.flow.callback-path");
|
Object legacyConfigured = yamlValue("notice.flow.callback-path");
|
||||||
String actual = configured == null
|
String legacyPath = legacyConfigured == null
|
||||||
? new NoticeFlowProperties().getCallbackPath() // 未配置 → 代码默认值
|
? new NoticeFlowProperties().getCallbackPath() // 未配置 → 代码默认值
|
||||||
: String.valueOf(configured);
|
: String.valueOf(legacyConfigured);
|
||||||
assertEquals(expected, actual,
|
|
||||||
"notice.flow.callback-path 与控制器映射不一致:flow-center 派发会 404");
|
assertEquals(FROZEN_CALLBACK_PATH, legacyPath, "回调路径历史真源变了,请同步生产配置与注册契约");
|
||||||
|
assertEquals(legacyPath, starterPath,
|
||||||
|
"yh.flow-callback.path 与 notice.flow.callback-path 不一致:flow-center 派发会 404");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package com.yunhai.notice.service.flow;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.yunhai.notice.entity.Notice;
|
||||||
|
import com.yunhai.notice.repository.NoticeRepository;
|
||||||
|
import com.yunhai.notice.service.sms.SmsSendService;
|
||||||
|
import com.yunhai.notice.service.sms.SmsSender;
|
||||||
|
import com.yunhai.platform.openapi.flow.model.FlowCallbackEvent;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.dao.DataAccessResourceFailureException;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回调业务映射单测(2026-09-20 迁 yh-flow-callback starter 后聚焦 Notice 侧状态机;
|
||||||
|
* 管线类用例——ping/毒消息/缺字段/外来 bizType/eventId 幂等——已由 starter 的
|
||||||
|
* FlowCallbackReceiverTest 覆盖):六事件映射;completed → status=1 + sms 恰一次;
|
||||||
|
* 单据级乱序丢弃;终态不回退守卫;cc.sent 只落抄送行不动状态机(2026-09-24);
|
||||||
|
* 异常直接外抛(starter 回 5xx)。
|
||||||
|
*/
|
||||||
|
class NoticeFlowCallbackHandlerTest {
|
||||||
|
|
||||||
|
private NoticeRepository noticeRepository;
|
||||||
|
private SmsSender smsSender;
|
||||||
|
private com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
|
||||||
|
private NoticeFlowCallbackHandler handler;
|
||||||
|
private Notice notice;
|
||||||
|
private final AtomicInteger saveCount = new AtomicInteger();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
noticeRepository = mock(NoticeRepository.class);
|
||||||
|
smsSender = mock(SmsSender.class);
|
||||||
|
noticeCcRepository = mock(com.yunhai.notice.repository.NoticeCcRepository.class);
|
||||||
|
// 统一发送链(§3.2):认领桩=1 表示未发过(首次),0=已发(重复闸门)
|
||||||
|
when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1);
|
||||||
|
handler = new NoticeFlowCallbackHandler(noticeRepository,
|
||||||
|
new SmsSendService(noticeRepository, smsSender), new ObjectMapper(), noticeCcRepository);
|
||||||
|
|
||||||
|
notice = new Notice();
|
||||||
|
notice.setId(42);
|
||||||
|
notice.setTitle("审核通知标题");
|
||||||
|
notice.setSchId(1);
|
||||||
|
notice.setStatus(0);
|
||||||
|
notice.setFlowGuid("fg-1");
|
||||||
|
notice.setAuditStatus(10);
|
||||||
|
notice.setSmsFlag(1);
|
||||||
|
notice.setReceiveList("[\"tea1\"]");
|
||||||
|
when(noticeRepository.findById(42)).thenReturn(Optional.of(notice));
|
||||||
|
when(noticeRepository.findByIdForUpdate(42)).thenReturn(Optional.of(notice));
|
||||||
|
when(noticeRepository.saveAndFlush(any(Notice.class))).thenAnswer(inv -> {
|
||||||
|
saveCount.incrementAndGet();
|
||||||
|
return inv.getArgument(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private FlowCallbackEvent event(String eventId, String event, int version, String result, String opinion) {
|
||||||
|
FlowCallbackEvent e = new FlowCallbackEvent();
|
||||||
|
e.setEventId(eventId);
|
||||||
|
e.setEvent(event);
|
||||||
|
e.setSchId(1);
|
||||||
|
e.setFlowGuid("fg-1");
|
||||||
|
e.setInstanceId(9527L);
|
||||||
|
e.setInstanceVersion(version);
|
||||||
|
e.setBizType("notice");
|
||||||
|
e.setBizId("42");
|
||||||
|
e.setBizTitle("t");
|
||||||
|
e.setTaskId(7L);
|
||||||
|
e.setOperatorUserId(201);
|
||||||
|
e.setOperatorName("张三");
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
if (result != null) {
|
||||||
|
data.put("result", result);
|
||||||
|
}
|
||||||
|
if (opinion != null) {
|
||||||
|
data.put("opinion", opinion);
|
||||||
|
}
|
||||||
|
e.setData(data);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void inFlightEvents_mapToApproving() {
|
||||||
|
handler.onEvent(event("e1", "flow.submitted", 1, null, null));
|
||||||
|
assertEquals(10, notice.getAuditStatus());
|
||||||
|
handler.onEvent(event("e2", "flow.task.created", 1, null, null));
|
||||||
|
assertEquals(10, notice.getAuditStatus());
|
||||||
|
handler.onEvent(event("e3", "flow.task.handled", 2, null, null));
|
||||||
|
assertEquals(10, notice.getAuditStatus());
|
||||||
|
assertEquals(0, notice.getStatus(), "审批中事件不得提前发布");
|
||||||
|
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completed_publishesAndSendsSmsOnce() {
|
||||||
|
handler.onEvent(event("e4", "flow.completed", 3, "APPROVED", "同意"));
|
||||||
|
assertEquals(20, notice.getAuditStatus());
|
||||||
|
assertEquals(1, notice.getStatus(), "审核通过置 status=1 正式发布");
|
||||||
|
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||||
|
assertTrue(notice.getFlowResultJson().contains("APPROVED"), "终态明细落 flow_result_json");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completed_beforeTimingPoint_smsDeferredToCatchup() {
|
||||||
|
// §3.2:审批通过早于定时点——短信此刻不发也不认领,留给 SmsCatchupJob 到点补发
|
||||||
|
notice.setTimingSendTime(java.time.LocalDateTime.now().plusHours(1));
|
||||||
|
handler.onEvent(event("e-df", "flow.completed", 3, "APPROVED", null));
|
||||||
|
assertEquals(20, notice.getAuditStatus());
|
||||||
|
assertEquals(1, notice.getStatus(), "可见性即时生效不受影响");
|
||||||
|
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||||
|
verify(noticeRepository, never()).markSmsSent(anyInt(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completedTwice_smsOnlyOnce() {
|
||||||
|
// 已发布行再收到终态事件:status 已 1 → justPublished=false → 不再发 sms
|
||||||
|
handler.onEvent(event("e4", "flow.completed", 3, "APPROVED", null));
|
||||||
|
notice.setStatus(1);
|
||||||
|
notice.setAuditStatus(20);
|
||||||
|
handler.onEvent(event("e4x", "flow.completed", 4, "APPROVED", null));
|
||||||
|
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejected_mapsTo30_withResultJson() {
|
||||||
|
handler.onEvent(event("e5", "flow.rejected", 3, "REJECTED", "内容不妥"));
|
||||||
|
assertEquals(30, notice.getAuditStatus());
|
||||||
|
assertEquals(0, notice.getStatus(), "驳回不发布");
|
||||||
|
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||||
|
assertTrue(notice.getFlowResultJson().contains("内容不妥"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withdrawn_mapsTo40() {
|
||||||
|
handler.onEvent(event("e6", "flow.withdrawn", 2, "WITHDRAWN", null));
|
||||||
|
assertEquals(40, notice.getAuditStatus());
|
||||||
|
assertEquals(0, notice.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void illegalBizId_silentReturn() {
|
||||||
|
FlowCallbackEvent e = event("e-x", "flow.completed", 3, null, null);
|
||||||
|
e.setBizId("not-a-number");
|
||||||
|
handler.onEvent(e); // 不抛(starter 回 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownNotice_silentReturn() {
|
||||||
|
when(noticeRepository.findById(42)).thenReturn(Optional.empty());
|
||||||
|
when(noticeRepository.findByIdForUpdate(42)).thenReturn(Optional.empty());
|
||||||
|
handler.onEvent(event("e10", "flow.completed", 3, null, null)); // 不抛
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void staleVersion_dropped() {
|
||||||
|
notice.setLastInstanceVersion(5);
|
||||||
|
handler.onEvent(event("e7", "flow.completed", 3, null, null)); // 迟到的低版本事件
|
||||||
|
assertEquals(10, notice.getAuditStatus(), "乱序事件丢弃");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameVersionDifferentEvent_processed() {
|
||||||
|
// submitted 与 task.created 天然同版本:等值不丢(幂等映射)
|
||||||
|
notice.setLastInstanceVersion(1);
|
||||||
|
handler.onEvent(event("e8", "flow.task.created", 1, null, null));
|
||||||
|
assertEquals(10, notice.getAuditStatus());
|
||||||
|
assertEquals(1, notice.getLastInstanceVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== P1-3 终态不回退守卫:同版本事件完成顺序倒置(completed 先落库,随后到 task.handled) =====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void terminalRow_rejectsNonTerminalEvent_sameVersionInversion() {
|
||||||
|
handler.onEvent(event("e-t1", "flow.completed", 4, "APPROVED", "同意"));
|
||||||
|
assertEquals(20, notice.getAuditStatus());
|
||||||
|
String frozen = notice.getFlowResultJson();
|
||||||
|
|
||||||
|
handler.onEvent(event("e-t2", "flow.task.handled", 4, null, "同意")); // 不抛 → 200
|
||||||
|
assertEquals(20, notice.getAuditStatus(), "终态行拒绝非终态事件(终态不回退)");
|
||||||
|
assertEquals(frozen, notice.getFlowResultJson(), "已落定 flow_result_json 不得被覆盖");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void terminalRow_rejectsNonTerminalEvent_higherVersion() {
|
||||||
|
handler.onEvent(event("e-t3", "flow.completed", 4, "APPROVED", null));
|
||||||
|
handler.onEvent(event("e-t4", "flow.task.handled", 5, null, null));
|
||||||
|
assertEquals(20, notice.getAuditStatus(), "终态后任何非终态事件都不回退(R4 重提由 edit 先置 0,不冲突)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectedTerminal_frozenResultNotOverwritten_byOtherTerminal() {
|
||||||
|
handler.onEvent(event("e-t5", "flow.rejected", 4, "REJECTED", "材料不齐"));
|
||||||
|
assertEquals(30, notice.getAuditStatus());
|
||||||
|
String frozen = notice.getFlowResultJson();
|
||||||
|
handler.onEvent(event("e-t6", "flow.withdrawn", 4, "WITHDRAWN", null));
|
||||||
|
assertEquals(30, notice.getAuditStatus(), "已终态不被另一终态改写");
|
||||||
|
assertEquals(frozen, notice.getFlowResultJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void terminalGuard_doesNotBlockR4Resubmission_afterLocalReset() {
|
||||||
|
handler.onEvent(event("e-t7", "flow.rejected", 4, "REJECTED", null));
|
||||||
|
notice.setAuditStatus(0); // NoticeFlowService 编辑保存时本地重置
|
||||||
|
handler.onEvent(event("e-t8", "flow.submitted", 5, null, null));
|
||||||
|
assertEquals(10, notice.getAuditStatus(), "重置后新一轮事件正常推进(守卫只拦终态行的回退)");
|
||||||
|
assertEquals(5, notice.getLastInstanceVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownEvent_silentReturn() {
|
||||||
|
handler.onEvent(event("e-u1", "flow.returned", 3, null, null)); // Notice 不消费的已知事件
|
||||||
|
assertEquals(10, notice.getAuditStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== cc.sent(2026-09-24):只落 notice_cc 抄送行,不动审核状态机 =====
|
||||||
|
|
||||||
|
private FlowCallbackEvent ccEvent(String eventId, int version, Integer ccUserId) {
|
||||||
|
FlowCallbackEvent e = event(eventId, "flow.cc.sent", version, null, null);
|
||||||
|
e.setTaskId(null); // 抄送无任务
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("nodeId", "cc1");
|
||||||
|
data.put("nodeName", "抄送校长");
|
||||||
|
data.put("ccId", 501L);
|
||||||
|
if (ccUserId != null) {
|
||||||
|
data.put("ccUserId", ccUserId);
|
||||||
|
data.put("ccUserName", "校长甲");
|
||||||
|
}
|
||||||
|
e.setData(data);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ccSent_recordsRow_withoutTouchingStateMachine() {
|
||||||
|
handler.onEvent(ccEvent("e-cc1", 3, 301));
|
||||||
|
verify(noticeCcRepository, times(1)).save(any(com.yunhai.notice.entity.NoticeCc.class));
|
||||||
|
org.mockito.ArgumentCaptor<com.yunhai.notice.entity.NoticeCc> captor =
|
||||||
|
org.mockito.ArgumentCaptor.forClass(com.yunhai.notice.entity.NoticeCc.class);
|
||||||
|
verify(noticeCcRepository).save(captor.capture());
|
||||||
|
com.yunhai.notice.entity.NoticeCc saved = captor.getValue();
|
||||||
|
assertEquals(42, saved.getNoticeId());
|
||||||
|
assertEquals(9527L, saved.getFlowInstanceId());
|
||||||
|
assertEquals("cc1", saved.getNodeId());
|
||||||
|
assertEquals("抄送校长", saved.getNodeName());
|
||||||
|
assertEquals(301, saved.getCcUserId());
|
||||||
|
assertEquals("校长甲", saved.getCcUserName());
|
||||||
|
// 知会不是审批:审核状态/发布状态/版本快照全部不动
|
||||||
|
assertEquals(10, notice.getAuditStatus());
|
||||||
|
assertEquals(0, notice.getStatus());
|
||||||
|
verify(noticeRepository, never()).saveAndFlush(any(Notice.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ccSent_missingCcUserId_skippedSilently() {
|
||||||
|
handler.onEvent(ccEvent("e-cc2", 3, null)); // 上游脏数据:不抛(回 200),不落行
|
||||||
|
verify(noticeCcRepository, never()).save(any(com.yunhai.notice.entity.NoticeCc.class));
|
||||||
|
assertEquals(10, notice.getAuditStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ccSent_staleVersion_dropped() {
|
||||||
|
notice.setLastInstanceVersion(5);
|
||||||
|
handler.onEvent(ccEvent("e-cc3", 3, 301)); // 迟到的低版本抄送
|
||||||
|
verify(noticeCcRepository, never()).save(any(com.yunhai.notice.entity.NoticeCc.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ccSent_dbFailure_propagates() {
|
||||||
|
when(noticeCcRepository.save(any(com.yunhai.notice.entity.NoticeCc.class)))
|
||||||
|
.thenThrow(new DataAccessResourceFailureException("db down"));
|
||||||
|
assertThrows(DataAccessResourceFailureException.class,
|
||||||
|
() -> handler.onEvent(ccEvent("e-cc4", 3, 301)),
|
||||||
|
"抄送落库失败回 5xx 让 flow-center 重试(事件行随事务回滚)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 异常外抛 = starter 回 5xx(flow-center 重试追平,免对账语义) =====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dbFailure_propagates() {
|
||||||
|
when(noticeRepository.saveAndFlush(any(Notice.class)))
|
||||||
|
.thenThrow(new DataAccessResourceFailureException("db down"));
|
||||||
|
assertThrows(DataAccessResourceFailureException.class,
|
||||||
|
() -> handler.onEvent(event("e11", "flow.completed", 3, null, null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void smsFailure_propagates() {
|
||||||
|
doThrow(new RuntimeException("sms gateway down")).when(smsSender)
|
||||||
|
.sendNoticeSms(anyInt(), anyString(), anyList());
|
||||||
|
assertThrows(RuntimeException.class,
|
||||||
|
() -> handler.onEvent(event("e12", "flow.completed", 3, null, null)),
|
||||||
|
"发布后置失败也应回 5xx 让流程中心重试(事件行随事务回滚)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,21 +19,24 @@ import static org.mockito.Mockito.mock;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 未发布通知详情放行矩阵单测(T2.4):提交人/管理员(type=1/roleId 白名单)/办理人 → 放行;
|
* 未发布通知详情放行矩阵单测(T2.4):提交人/管理员(type=1/roleId 白名单)/办理人/被抄送人 → 放行;
|
||||||
* 无关人/无身份 → 403"通知不存在或未发布"。
|
* 无关人/无身份 → 403"通知不存在或未发布"。
|
||||||
*/
|
*/
|
||||||
class NoticeFlowGuardTest {
|
class NoticeFlowGuardTest {
|
||||||
|
|
||||||
private FlowClient flowClient;
|
private FlowClient flowClient;
|
||||||
|
private com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository;
|
||||||
private NoticeFlowGuard guard;
|
private NoticeFlowGuard guard;
|
||||||
private Notice notice;
|
private Notice notice;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
flowClient = mock(FlowClient.class);
|
flowClient = mock(FlowClient.class);
|
||||||
|
noticeCcRepository = mock(com.yunhai.notice.repository.NoticeCcRepository.class);
|
||||||
NoticeFlowProperties properties = new NoticeFlowProperties();
|
NoticeFlowProperties properties = new NoticeFlowProperties();
|
||||||
properties.setEnabled(true);
|
properties.setEnabled(true);
|
||||||
guard = new NoticeFlowGuard(flowClient, properties);
|
guard = new NoticeFlowGuard(flowClient, properties, noticeCcRepository);
|
||||||
|
when(noticeCcRepository.existsByNoticeIdAndCcUserId(any(), any())).thenReturn(false);
|
||||||
notice = new Notice();
|
notice = new Notice();
|
||||||
notice.setId(42);
|
notice.setId(42);
|
||||||
notice.setSchId(1);
|
notice.setSchId(1);
|
||||||
@@ -86,6 +89,24 @@ class NoticeFlowGuardTest {
|
|||||||
guard.requireUnpublishedVisible(notice, user(301, "handler1", 0, null)));
|
guard.requireUnpublishedVisible(notice, user(301, "handler1", 0, null)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 被抄送人放行(2026-09-24 第 4 条):本地 notice_cc 有行即可见,「抄送给我的」点开依赖此规则
|
||||||
|
@Test
|
||||||
|
void ccRecipient_allowed_evenIfFlowCenterDown() {
|
||||||
|
when(flowClient.getInstanceByBiz(any(), anyString(), anyString()))
|
||||||
|
.thenThrow(new RuntimeException("flow-center down"));
|
||||||
|
when(noticeCcRepository.existsByNoticeIdAndCcUserId(42, 501)).thenReturn(true);
|
||||||
|
assertDoesNotThrow(() ->
|
||||||
|
guard.requireUnpublishedVisible(notice, user(501, "principal", 0, null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notCcRecipient_forbidden() {
|
||||||
|
when(noticeCcRepository.existsByNoticeIdAndCcUserId(any(), any())).thenReturn(false);
|
||||||
|
stubAssignees(301);
|
||||||
|
assertThrows(BizException.class, () ->
|
||||||
|
guard.requireUnpublishedVisible(notice, user(502, "not-cc", 0, null)));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unrelatedUser_forbidden() {
|
void unrelatedUser_forbidden() {
|
||||||
stubAssignees(301, 302);
|
stubAssignees(301, 302);
|
||||||
|
|||||||
@@ -137,9 +137,10 @@ class NoticeFlowPublishTest {
|
|||||||
return vo;
|
return vo;
|
||||||
});
|
});
|
||||||
|
|
||||||
noticeFlowService = new NoticeFlowService(noticeRepository, regRepository, bindingRepository,
|
noticeFlowService = new NoticeFlowService(noticeRepository, bindingRepository,
|
||||||
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(), appAuth, currentBoaUser,
|
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(),
|
||||||
new NoticeFlowGuard(flowClient, props));
|
mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class), currentBoaUser,
|
||||||
|
new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class)));
|
||||||
|
|
||||||
noticeService = new NoticeService();
|
noticeService = new NoticeService();
|
||||||
ReflectionTestUtils.setField(noticeService, "noticeUserMsgNotifier",
|
ReflectionTestUtils.setField(noticeService, "noticeUserMsgNotifier",
|
||||||
|
|||||||
@@ -44,10 +44,11 @@ class NoticeFlowTraceTest {
|
|||||||
flowClient = mock(FlowClient.class);
|
flowClient = mock(FlowClient.class);
|
||||||
NoticeFlowProperties props = new NoticeFlowProperties();
|
NoticeFlowProperties props = new NoticeFlowProperties();
|
||||||
props.setEnabled(true);
|
props.setEnabled(true);
|
||||||
service = new NoticeFlowService(noticeRepository, mock(NoticeFlowRegRepository.class),
|
service = new NoticeFlowService(noticeRepository,
|
||||||
mock(com.yunhai.notice.repository.NoticeFlowBindingRepository.class),
|
mock(com.yunhai.notice.repository.NoticeFlowBindingRepository.class),
|
||||||
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(),
|
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(),
|
||||||
new AppAuthProperties(), mock(CurrentBoaUser.class), new NoticeFlowGuard(flowClient, props));
|
mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class),
|
||||||
|
mock(CurrentBoaUser.class), new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class)));
|
||||||
|
|
||||||
bound = new Notice();
|
bound = new Notice();
|
||||||
bound.setId(42);
|
bound.setId(42);
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ class NoticeFlowWithdrawTest {
|
|||||||
flowClient = mock(FlowClient.class);
|
flowClient = mock(FlowClient.class);
|
||||||
NoticeFlowProperties props = new NoticeFlowProperties();
|
NoticeFlowProperties props = new NoticeFlowProperties();
|
||||||
props.setEnabled(true);
|
props.setEnabled(true);
|
||||||
service = new NoticeFlowService(noticeRepository, mock(NoticeFlowRegRepository.class),
|
service = new NoticeFlowService(noticeRepository,
|
||||||
mock(NoticeFlowBindingRepository.class), mock(UpFileRepository.class), flowClient, props,
|
mock(NoticeFlowBindingRepository.class), mock(UpFileRepository.class), flowClient, props,
|
||||||
new ObjectMapper(), new AppAuthProperties(), mock(CurrentBoaUser.class),
|
new ObjectMapper(), mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class),
|
||||||
new NoticeFlowGuard(flowClient, props));
|
mock(CurrentBoaUser.class), new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class)));
|
||||||
|
|
||||||
approving = new Notice();
|
approving = new Notice();
|
||||||
approving.setId(42);
|
approving.setId(42);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package com.yunhai.notice.support;
|
package com.yunhai.notice.support;
|
||||||
|
|
||||||
import com.yunhai.platform.utils.crypto.SignUtil;
|
import com.yunhai.platform.utils.crypto.SignUtil;
|
||||||
|
import org.apache.http.impl.client.HttpClientBuilder;
|
||||||
import org.springframework.http.HttpEntity;
|
import org.springframework.http.HttpEntity;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
|
||||||
|
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,6 +17,13 @@ import org.springframework.web.client.RestTemplate;
|
|||||||
*/
|
*/
|
||||||
public class OapiSignedClient {
|
public class OapiSignedClient {
|
||||||
|
|
||||||
|
static {
|
||||||
|
// JDK8 安全加固后:POST 带流式 body 收到 401 会抛 HttpRetryException
|
||||||
|
// ("cannot retry due to server authentication, in streaming mode") 而非返回 401 响应,
|
||||||
|
// 期望 401 的用例因此失败(依 JDK 小版本而异)。换 Apache HttpClient 的 request factory
|
||||||
|
//(httpclient 已在类路径),401 按普通响应返回。
|
||||||
|
}
|
||||||
|
|
||||||
private final String baseUrl;
|
private final String baseUrl;
|
||||||
private final String appKey;
|
private final String appKey;
|
||||||
private final String appSecret;
|
private final String appSecret;
|
||||||
@@ -24,7 +33,8 @@ public class OapiSignedClient {
|
|||||||
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
|
||||||
this.appKey = appKey;
|
this.appKey = appKey;
|
||||||
this.appSecret = appSecret;
|
this.appSecret = appSecret;
|
||||||
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
|
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(
|
||||||
|
HttpClientBuilder.create().build());
|
||||||
factory.setConnectTimeout(3000);
|
factory.setConnectTimeout(3000);
|
||||||
factory.setReadTimeout(5000);
|
factory.setReadTimeout(5000);
|
||||||
this.restTemplate = new RestTemplate(factory);
|
this.restTemplate = new RestTemplate(factory);
|
||||||
|
|||||||
Reference in New Issue
Block a user