diff --git a/pom.xml b/pom.xml index d945928..983e954 100644 --- a/pom.xml +++ b/pom.xml @@ -120,6 +120,13 @@ yh-spring-boot-starter + + + com.yunhai.platform + yh-flow-callback-spring-boot-starter + + diff --git a/src/main/java/com/yunhai/notice/controller/api/NoticeCcController.java b/src/main/java/com/yunhai/notice/controller/api/NoticeCcController.java new file mode 100644 index 0000000..867fcd5 --- /dev/null +++ b/src/main/java/com/yunhai/notice/controller/api/NoticeCcController.java @@ -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 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 ccPage = noticeCcRepository.findBySchIdAndCcUserIdOrderBySentTimeDescIdDesc( + user.getCurrentRole().getSchId(), user.getId(), PageRequest.of(pageIndex - 1, pageSize)); + + // 批量回填通知信息(被删通知保留抄送行,标题置空由前端兜底) + Map 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> content = new ArrayList<>(); + for (NoticeCc cc : ccPage.getContent()) { + Map 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 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; + } +} diff --git a/src/main/java/com/yunhai/notice/controller/oapi/OapiFlowCallbackController.java b/src/main/java/com/yunhai/notice/controller/oapi/OapiFlowCallbackController.java deleted file mode 100644 index 9d4e5cb..0000000 --- a/src/main/java/com/yunhai/notice/controller/oapi/OapiFlowCallbackController.java +++ /dev/null @@ -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)。 - *

- * 验签(复用平台应用验签能力):不建独立回调验签过滤器——回调路径落在 - * {@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 保留发送,本服务不再校验。 - *

- * 返回语义(契约冻结 D2,免对账前提):状态推进成功 / 重复投递(去重命中)/ - * 协议毒消息 / 乱序丢弃 / 办件不属本应用 → 200(毒消息回 200 防无限重放); - * 瞬时失败(DB 异常/锁冲突,{@link FlowCallbackService.TransientCallbackException})/ - * 意外 RuntimeException → 5xx——flow-center 持续重试直至送达,消费方恢复自动追平。 - */ -@Slf4j -@RestController -@RequestMapping("/oapi/notice/flow") -@RequiredArgsConstructor -public class OapiFlowCallbackController { - - private final FlowCallbackService callbackService; - - @PostMapping("/callback") - public ResponseEntity 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}"); - } - } -} diff --git a/src/main/java/com/yunhai/notice/controller/oapi/OapiFlowRouteController.java b/src/main/java/com/yunhai/notice/controller/oapi/OapiFlowRouteController.java new file mode 100644 index 0000000..8bf7db7 --- /dev/null +++ b/src/main/java/com/yunhai/notice/controller/oapi/OapiFlowRouteController.java @@ -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)激活时同步调用——发文部门=校长室走"校长室"线(免校长审核),其余走"其他部门"线。 + *

    + *
  • 验签:收在 /oapi/* 下由 AppAuthFilter 强制验 flow-center(100019)的应用签名,零新概念;
  • + *
  • 纯读幂等:凭 bizId 查 notice 表比对发文部门(variables 里有 formData 的部门则免查库,两口径一致); + *
  • 契约纪律:返回 outlet 必须命中 outlets——流程定义与端点的出口名是同一份契约,不命中抛业务错 + * (flow-center 侧按判断失败挂起,绝不默认放行);
  • + *
  • 轻实现:同步 3s 超时,只做一次主键查询。
  • + *
+ */ +@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 outlets; + private Map variables; + } + + @PostMapping("/dept-judge") + public Map 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 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 variables = body.getVariables(); + if (variables == null) { + return ""; + } + Object v = variables.get("deptName"); + return v == null ? "" : String.valueOf(v).trim(); + } +} diff --git a/src/main/java/com/yunhai/notice/entity/NoticeCc.java b/src/main/java/com/yunhai/notice/entity/NoticeCc.java new file mode 100644 index 0000000..3cba0ae --- /dev/null +++ b/src/main/java/com/yunhai/notice/entity/NoticeCc.java @@ -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; +} diff --git a/src/main/java/com/yunhai/notice/repository/NoticeCcRepository.java b/src/main/java/com/yunhai/notice/repository/NoticeCcRepository.java new file mode 100644 index 0000000..3d3d0d0 --- /dev/null +++ b/src/main/java/com/yunhai/notice/repository/NoticeCcRepository.java @@ -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 { + + /** 按学校 + 被抄送人分页(sent_time 倒序,倒序索引 idx_notice_cc_user 覆盖) */ + Page findBySchIdAndCcUserIdOrderBySentTimeDescIdDesc(Integer schId, Integer ccUserId, + Pageable pageable); + + /** 未发布通知可见守卫第 4 条:当前用户是否被抄送该通知(NoticeFlowGuard) */ + boolean existsByNoticeIdAndCcUserId(Integer noticeId, Integer ccUserId); +} diff --git a/src/main/java/com/yunhai/notice/service/flow/FlowCallbackService.java b/src/main/java/com/yunhai/notice/service/flow/FlowCallbackService.java deleted file mode 100644 index a0a15e5..0000000 --- a/src/main/java/com/yunhai/notice/service/flow/FlowCallbackService.java +++ /dev/null @@ -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)。
- * 返回语义(免对账核心,契约冻结):瞬时失败抛 {@link TransientCallbackException} → HTTP 5xx - * (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(); - } -} diff --git a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowCallbackHandler.java b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowCallbackHandler.java new file mode 100644 index 0000000..4d8ff76 --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowCallbackHandler.java @@ -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 接收管线承担;本类只做通知侧状态机推进—— + *
事件映射: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(详情页进度卡片)。 + *
应答语义不变:本方法抛任何异常 → 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 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()); + } +} diff --git a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowGuard.java b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowGuard.java index dd1730b..d8f5e0d 100644 --- a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowGuard.java +++ b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowGuard.java @@ -17,8 +17,9 @@ import java.util.concurrent.ConcurrentHashMap; /** * 未发布通知(status=0,审核载体)的详情放行守卫(方案 §5.5,V2.0 B.4#8 同构)。 - * 仅三类人可见:提交人本人(publisherAccount)/ 本校管理员(type=1 或 roleId∈{2,5,7})/ - * 该办件流程办理人(instance.assigneeIds 含当前用户,60s 短缓存——审批中途换人不实时,可接受)。 + * 四类人可见:提交人本人(publisherAccount)/ 本校管理员(type=1 或 roleId∈{2,5,7})/ + * 该办件流程办理人(instance.assigneeIds 含当前用户,60s 短缓存——审批中途换人不实时,可接受)/ + * 该办件被抄送人(notice_cc 有行,2026-09-24——抄送是知会,应用内「抄送给我的」点开必须可见)。 * 其余用户查询即"通知不存在或未发布"(查不到语义,不泄露存在性)。 */ @Slf4j @@ -33,6 +34,7 @@ public class NoticeFlowGuard { private final FlowClient flowClient; private final NoticeFlowProperties properties; + private final com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository; private final Map assigneeCache = new ConcurrentHashMap<>(); @@ -68,6 +70,10 @@ public class NoticeFlowGuard { && currentAssignees(notice).contains(user.getId())) { return; } + // 4) 该办件被抄送人(notice_cc 本地表有行即放行——抄送发生即知会,无需查流程中心) + if (noticeCcRepository.existsByNoticeIdAndCcUserId(notice.getId(), user.getId())) { + return; + } throw notFound(); } diff --git a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowService.java b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowService.java index 74d070d..6866b31 100644 --- a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowService.java +++ b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowService.java @@ -4,15 +4,12 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.notice.entity.Notice; import com.yunhai.notice.entity.NoticeFlowBinding; -import com.yunhai.notice.entity.NoticeFlowReg; import com.yunhai.notice.entity.UpFile; import com.yunhai.notice.enums.FlowAuditStatusEnum; import com.yunhai.notice.model.flow.FlowModels; import com.yunhai.notice.repository.NoticeFlowBindingRepository; -import com.yunhai.notice.repository.NoticeFlowRegRepository; import com.yunhai.notice.repository.NoticeRepository; import com.yunhai.notice.repository.UpFileRepository; -import com.yunhai.platform.appauth.AppAuthProperties; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.auth.spring.CurrentBoaUser; import com.yunhai.platform.utils.exception.ErrorCode; @@ -41,13 +38,12 @@ import java.util.Map; public class NoticeFlowService { private final NoticeRepository noticeRepository; - private final NoticeFlowRegRepository regRepository; private final NoticeFlowBindingRepository bindingRepository; private final UpFileRepository upFileRepository; private final FlowClient flowClient; private final NoticeFlowProperties properties; private final ObjectMapper objectMapper; - private final AppAuthProperties appAuthProperties; + private final com.yunhai.platform.flowcallback.FlowCallbackRegistrar callbackRegistrar; private final CurrentBoaUser currentBoaUser; private final NoticeFlowGuard noticeFlowGuard; @@ -124,39 +120,16 @@ public class NoticeFlowService { } /** - * 回调注册:仅 reg 缺失时注册并落 secret。绝不重复调用——flow-center 重复注册会轮换 - * secret 导致已存 secret 验签失配;失配恢复手段=管理端重新保存绑定(删除 reg 行后重提)。 + * 回调注册(2026-09-20 迁 starter 的 {@link com.yunhai.platform.flowcallback.FlowCallbackRegistrar}): + * 仅本地镜像行(flow_callback_reg)缺失/失效时注册并落 secret。绝不重复调用—— + * flow-center 重复注册会轮换 secret;失配恢复手段=删除镜像行后重新保存绑定。 */ void ensureRegistered(Notice notice) { - Integer schId = notice.getSchId(); - String flowGuid = notice.getFlowGuid(); - String appKey = appAuthProperties.getAppKey(); - NoticeFlowReg reg = regRepository - .findBySchIdAndAppKeyAndFlowGuid(schId, appKey, flowGuid) - .orElse(null); - if (reg != null && StringUtils.hasText(reg.getSecret()) && reg.getStatus() == 0) { - return; - } - FlowModels.CallbackRegBody body = new FlowModels.CallbackRegBody(); - body.setFlowGuid(flowGuid); - body.setCallbackUrl(callbackUrl()); - // 申报本服务 context-path:推送方按它剥离后再签应用签名(与平台 AppAuthFilter 剥 contextPath 对齐) - body.setBasePath(properties.getCallbackBasePath()); - FlowModels.CallbackRegResultVo result = flowClient.registerCallback(schId, body); - if (reg == null) { - reg = new NoticeFlowReg(); - reg.setSchId(schId); - reg.setAppKey(appKey); - reg.setFlowGuid(flowGuid); - } - reg.setCallbackUrl(callbackUrl()); - reg.setEventTypes(null); - reg.setSecret(result.getSecret()); - reg.setStatus(0); - regRepository.save(reg); + callbackRegistrar.ensureRegistered(notice.getSchId(), notice.getFlowGuid()); } - /** apply 出参组装(契约冻结:bizId=notice.id、bizUrl 前端详情、快照只收摘要不收富文本正文) */ + /** apply 出参组装(契约冻结:bizId=notice.id、bizUrl 前端详情、快照只收摘要不收富文本正文; + * formData 带发文部门(2026-09-24)——条件路由判断端点优先读它免查库,缺字段回落查表同口径) */ FlowModels.ApplyBody buildApplyBody(Notice notice, BoaUser user) { FlowModels.ApplyBody body = new FlowModels.ApplyBody(); body.setFlowGuid(notice.getFlowGuid()); @@ -165,6 +138,10 @@ public class NoticeFlowService { body.setBizTitle(notice.getTitle()); body.setBizUrl(properties.getFrontBaseUrl() + "/#/list/detail?serid=" + notice.getId()); body.setBizSnapshot(buildSnapshot(notice)); + Map formData = new LinkedHashMap<>(); + formData.put("deptId", notice.getDeptId()); + formData.put("deptName", notice.getDeptName()); + body.setFormData(formData); if (user != null) { body.setSubmitterId(user.getId()); body.setSubmitterName(StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount()); @@ -396,19 +373,7 @@ public class NoticeFlowService { .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId)); } - private String callbackUrl() { - String base = properties.getCallbackBase(); - if (base == null) { - base = ""; - } - while (base.endsWith("/")) { - base = base.substring(0, base.length() - 1); - } - return base + properties.getCallbackPath(); - } - - private JsonNode parseJson(String json) { - if (!StringUtils.hasText(json)) { + private JsonNode parseJson(String json) { if (!StringUtils.hasText(json)) { return null; } try { diff --git a/src/main/java/com/yunhai/notice/service/impl/NoticeService.java b/src/main/java/com/yunhai/notice/service/impl/NoticeService.java index df38fa9..c47b569 100644 --- a/src/main/java/com/yunhai/notice/service/impl/NoticeService.java +++ b/src/main/java/com/yunhai/notice/service/impl/NoticeService.java @@ -235,7 +235,7 @@ public class NoticeService implements INoticeService { // 沿用该通知已有流程快照(在途/已通过的沿用原流程重审,不受期间改绑影响),无则取当前绑定 entity.setFlowGuid(wasFlowBound ? entity.getFlowGuid() : boundFlowGuid); entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode()); - // 清上一轮驳回意见:Notice 侧 flow_result_json 只在终态事件写入(FlowCallbackService:173), + // 清上一轮驳回意见:Notice 侧 flow_result_json 只在终态事件写入(NoticeFlowCallbackHandler 终态快照), // 不清则重提后整段新审批期都挂着旧驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能) entity.setFlowResultJson(null); entity.setStatus(UNPUBLISHED); // 审核通过前对用户不可见 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index cc13ac7..c61187b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -102,6 +102,13 @@ yh: # trusted-apps: # "100009": "dcc36c98299a4d36bb0ec39d42008e8c" 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: # 四域服务目录零配置:userbase/userclass/usermsg/flowcenter 枚举兜底 = base-host + 默认 ctx base-host: http://base.yunhaixingtong.com diff --git a/src/main/resources/sql/notice_cc_create.sql b/src/main/resources/sql/notice_cc_create.sql new file mode 100644 index 0000000..5ea4418 --- /dev/null +++ b/src/main/resources/sql/notice_cc_create.sql @@ -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 回调落行)'; diff --git a/src/test/java/com/yunhai/notice/controller/oapi/OapiFlowRouteControllerTest.java b/src/test/java/com/yunhai/notice/controller/oapi/OapiFlowRouteControllerTest.java new file mode 100644 index 0000000..15d31bf --- /dev/null +++ b/src/test/java/com/yunhai/notice/controller/oapi/OapiFlowRouteControllerTest.java @@ -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 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 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 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))); + } +} diff --git a/src/test/java/com/yunhai/notice/flow/FlowCallbackTransientIT.java b/src/test/java/com/yunhai/notice/flow/FlowCallbackTransientIT.java index fad0669..6bedacf 100644 --- a/src/test/java/com/yunhai/notice/flow/FlowCallbackTransientIT.java +++ b/src/test/java/com/yunhai/notice/flow/FlowCallbackTransientIT.java @@ -1,9 +1,6 @@ package com.yunhai.notice.flow; 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.service.sms.SmsSender; import com.fasterxml.jackson.databind.ObjectMapper; @@ -30,10 +27,12 @@ import static org.mockito.ArgumentMatchers.anyString; */ @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.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 { - private static final String REG_SECRET = "it-transient-secret-0123456789"; private static final String FLOW_GUID = "it-transient-guid-01"; @LocalServerPort @@ -42,9 +41,7 @@ class FlowCallbackTransientIT { @Autowired private NoticeRepository noticeRepository; @Autowired - private NoticeFlowRegRepository regRepository; - @Autowired - private NoticeFlowEventRepository eventRepository; + private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; @MockBean private SmsSender smsSender; @@ -53,19 +50,9 @@ class FlowCallbackTransientIT { @BeforeEach void setUp() { - eventRepository.deleteAll(); - regRepository.deleteAll(); + jdbcTemplate.update("DELETE FROM flow_callback_event"); 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.setTitle("瞬时失败通知"); notice.setType(1); @@ -90,7 +77,7 @@ class FlowCallbackTransientIT { Notice after = reload(); assertEquals(10, after.getAuditStatus(), "失败事务整体回滚"); assertEquals(0, after.getStatus()); - assertEquals(0L, eventRepository.count(), "事件去重行随事务回滚(重投不撞 uk)"); + assertEquals(0L, callbackEventCount(), "事件去重行随事务回滚(重投不撞 uk)"); // 阶段二:恢复后重投同一事件 → 200 + 状态追平(flow-center 持续重试语义闭环) Mockito.doNothing().when(smsSender).sendNoticeSms(anyInt(), anyString(), anyList()); @@ -99,7 +86,7 @@ class FlowCallbackTransientIT { Notice recovered = reload(); assertEquals(20, recovered.getAuditStatus()); assertEquals(1, recovered.getStatus(), "恢复后审核通过正常发布"); - assertEquals(1L, eventRepository.count()); + assertEquals(1L, callbackEventCount()); // 短信共尝试 2 次:故障期 1 次(抛异常、随事务回滚不算已发)+ 恢复后成功 1 次 Mockito.verify(smsSender, Mockito.times(2)).sendNoticeSms(anyInt(), anyString(), anyList()); } @@ -124,4 +111,10 @@ class FlowCallbackTransientIT { private static String uuid() { 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; + } } diff --git a/src/test/java/com/yunhai/notice/flow/NoticeFlowApiIT.java b/src/test/java/com/yunhai/notice/flow/NoticeFlowApiIT.java index c71977a..68de39d 100644 --- a/src/test/java/com/yunhai/notice/flow/NoticeFlowApiIT.java +++ b/src/test/java/com/yunhai/notice/flow/NoticeFlowApiIT.java @@ -33,7 +33,12 @@ import static org.junit.jupiter.api.Assertions.assertTrue; */ @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.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 { /** 回调端点(开放接口域;验签由 yh.app-auth enforce-patterns=/oapi/* 强制) */ @@ -52,7 +57,7 @@ class NoticeFlowApiIT { @Autowired private NoticeFlowRegRepository regRepository; @Autowired - private NoticeFlowEventRepository eventRepository; + private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; @Autowired private NoticeFlowBindingRepository bindingRepository; @Autowired @@ -62,7 +67,8 @@ class NoticeFlowApiIT { @BeforeEach void setUp() { - eventRepository.deleteAll(); + jdbcTemplate.update("DELETE FROM flow_callback_event"); + jdbcTemplate.update("DELETE FROM flow_callback_reg"); regRepository.deleteAll(); bindingRepository.deleteAll(); noticeRepository.deleteAll(); @@ -106,7 +112,7 @@ class NoticeFlowApiIT { assertNotNull(updated.getFlowResultJson()); assertTrue(updated.getFlowResultJson().contains("同意")); // 事件去重行落库(事务提交) - assertEquals(1L, eventRepository.count()); + assertEquals(1L, callbackEventCount()); } @Test @@ -116,7 +122,7 @@ class NoticeFlowApiIT { assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(), "重复投递必须回 200(flow-center 持续重试语义下的常态)"); assertEquals(20, reload().getAuditStatus()); - assertEquals(1L, eventRepository.count(), "eventId 唯一:重复投递不落第二行"); + assertEquals(1L, callbackEventCount(), "eventId 唯一:重复投递不落第二行"); } @Test @@ -153,7 +159,7 @@ class NoticeFlowApiIT { "无签名 ping 应被 401 拦下"); assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(), "带签名的 ping 回 200(注册探活只判可达)"); - assertEquals(0L, eventRepository.count(), "ping 不落事件行"); + assertEquals(0L, callbackEventCount(), "ping 不落事件行"); } // ==================== ② /oapi/notice/getDict 三态 ==================== @@ -257,4 +263,10 @@ class NoticeFlowApiIT { private static String uuid() { 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; + } } diff --git a/src/test/java/com/yunhai/notice/service/flow/FlowCallbackServiceTest.java b/src/test/java/com/yunhai/notice/service/flow/FlowCallbackServiceTest.java deleted file mode 100644 index f1c235b..0000000 --- a/src/test/java/com/yunhai/notice/service/flow/FlowCallbackServiceTest.java +++ /dev/null @@ -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 让流程中心重试(事件行随事务回滚)"); - } -} diff --git a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowCallbackConfigContractTest.java b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowCallbackConfigContractTest.java index af23159..94dc534 100644 --- a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowCallbackConfigContractTest.java +++ b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowCallbackConfigContractTest.java @@ -1,45 +1,28 @@ package com.yunhai.notice.service.flow; -import com.yunhai.notice.controller.oapi.OapiFlowCallbackController; import org.junit.jupiter.api.DisplayName; 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 java.io.InputStream; -import java.lang.reflect.Method; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; /** - * 回调路径配置契约测试(防"过期值"回归)。

- * 不变式:{@code notice.flow.callback-path}(注册给 flow-center 的回调地址尾段)必须与 - * {@link OapiFlowCallbackController} 的真实映射逐字一致——不一致时 flow-center 探活/推送全部 - * 404,而本地单测因为走伪服务端不会发现。
- * 背景:{@code /callback/*} 自 X-Flw-* 自定义过滤器下线后已不存在,但本地 {@code application.yml} - * 一度仍写着 {@code /callback/flow}(生产 yml 与代码默认值都是 {@code /oapi/notice/flow/callback}), - * 导致本地起链时注册到已下线的路径。 + * 回调路径配置契约测试(防"过期值"回归;2026-09-20 迁 starter 后改版)。

+ * 不变式:{@code yh.flow-callback.path}(starter 接收端点的真实映射来源)必须与 + * {@code notice.flow.callback-path}(回调地址尾段的历史真源)逐字一致且等于冻结字面量 + * {@code /oapi/notice/flow/callback}——不一致时 flow-center 探活/推送全部 404, + * 而本地单测因为走伪服务端不会发现。
+ * 迁移背景:端点由 yh-flow-callback-spring-boot-starter 的 FlowCallbackReceiverController + * 提供(@RequestMapping("${yh.flow-callback.path:...}")),不再有本地控制器类可反射。 */ -@DisplayName("Notice 回调路径配置 - 与控制器映射咬合") +@DisplayName("Notice 回调路径配置 - 与 starter 端点映射咬合") class NoticeFlowCallbackConfigContractTest { - /** 控制器真实映射(反射拼类级 @RequestMapping + 方法级 @PostMapping) */ - 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 回调方法"); - } + private static final String FROZEN_CALLBACK_PATH = "/oapi/notice/flow/callback"; @SuppressWarnings("unchecked") private static Object yamlValue(String path) throws Exception { @@ -62,17 +45,21 @@ class NoticeFlowCallbackConfigContractTest { } @Test - @DisplayName("application.yml 的 notice.flow.callback-path 与控制器映射一致(缺省=代码默认值)") - void callbackPathMatchesControllerMapping() throws Exception { - String expected = controllerMapping(); - assertEquals("/oapi/notice/flow/callback", expected, "控制器路径变了,请同步生产配置与注册契约"); + @DisplayName("yh.flow-callback.path 与 notice.flow.callback-path 一致且等于冻结字面量") + void starterPathMatchesLegacyCallbackPath() throws Exception { + Object starterConfigured = yamlValue("yh.flow-callback.path"); + String starterPath = starterConfigured == null + ? "/oapi/flow/callback" // starter 代码默认值(未配置时端点会映射到这里) + : String.valueOf(starterConfigured); - Object configured = yamlValue("notice.flow.callback-path"); - String actual = configured == null + Object legacyConfigured = yamlValue("notice.flow.callback-path"); + String legacyPath = legacyConfigured == null ? new NoticeFlowProperties().getCallbackPath() // 未配置 → 代码默认值 - : String.valueOf(configured); - assertEquals(expected, actual, - "notice.flow.callback-path 与控制器映射不一致:flow-center 派发会 404"); + : String.valueOf(legacyConfigured); + + assertEquals(FROZEN_CALLBACK_PATH, legacyPath, "回调路径历史真源变了,请同步生产配置与注册契约"); + assertEquals(legacyPath, starterPath, + "yh.flow-callback.path 与 notice.flow.callback-path 不一致:flow-center 派发会 404"); } @Test diff --git a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowCallbackHandlerTest.java b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowCallbackHandlerTest.java new file mode 100644 index 0000000..1486ecd --- /dev/null +++ b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowCallbackHandlerTest.java @@ -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 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 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 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 让流程中心重试(事件行随事务回滚)"); + } +} diff --git a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowGuardTest.java b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowGuardTest.java index 8b53708..81fb39d 100644 --- a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowGuardTest.java +++ b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowGuardTest.java @@ -19,21 +19,24 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; /** - * 未发布通知详情放行矩阵单测(T2.4):提交人/管理员(type=1/roleId 白名单)/办理人 → 放行; + * 未发布通知详情放行矩阵单测(T2.4):提交人/管理员(type=1/roleId 白名单)/办理人/被抄送人 → 放行; * 无关人/无身份 → 403"通知不存在或未发布"。 */ class NoticeFlowGuardTest { private FlowClient flowClient; + private com.yunhai.notice.repository.NoticeCcRepository noticeCcRepository; private NoticeFlowGuard guard; private Notice notice; @BeforeEach void setUp() { flowClient = mock(FlowClient.class); + noticeCcRepository = mock(com.yunhai.notice.repository.NoticeCcRepository.class); NoticeFlowProperties properties = new NoticeFlowProperties(); 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.setId(42); notice.setSchId(1); @@ -86,6 +89,24 @@ class NoticeFlowGuardTest { 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 void unrelatedUser_forbidden() { stubAssignees(301, 302); diff --git a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java index 2b517d0..da8a46d 100644 --- a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java +++ b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java @@ -137,9 +137,10 @@ class NoticeFlowPublishTest { return vo; }); - noticeFlowService = new NoticeFlowService(noticeRepository, regRepository, bindingRepository, - mock(UpFileRepository.class), flowClient, props, new ObjectMapper(), appAuth, currentBoaUser, - new NoticeFlowGuard(flowClient, props)); + noticeFlowService = new NoticeFlowService(noticeRepository, bindingRepository, + mock(UpFileRepository.class), flowClient, props, new ObjectMapper(), + mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class), currentBoaUser, + new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class))); noticeService = new NoticeService(); ReflectionTestUtils.setField(noticeService, "noticeUserMsgNotifier", diff --git a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowTraceTest.java b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowTraceTest.java index 82cfd45..6739430 100644 --- a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowTraceTest.java +++ b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowTraceTest.java @@ -44,10 +44,11 @@ class NoticeFlowTraceTest { flowClient = mock(FlowClient.class); NoticeFlowProperties props = new NoticeFlowProperties(); props.setEnabled(true); - service = new NoticeFlowService(noticeRepository, mock(NoticeFlowRegRepository.class), + service = new NoticeFlowService(noticeRepository, mock(com.yunhai.notice.repository.NoticeFlowBindingRepository.class), 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.setId(42); diff --git a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowWithdrawTest.java b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowWithdrawTest.java index b269c7e..02c43b2 100644 --- a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowWithdrawTest.java +++ b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowWithdrawTest.java @@ -40,10 +40,10 @@ class NoticeFlowWithdrawTest { flowClient = mock(FlowClient.class); NoticeFlowProperties props = new NoticeFlowProperties(); props.setEnabled(true); - service = new NoticeFlowService(noticeRepository, mock(NoticeFlowRegRepository.class), + service = new NoticeFlowService(noticeRepository, mock(NoticeFlowBindingRepository.class), mock(UpFileRepository.class), flowClient, props, - new ObjectMapper(), new AppAuthProperties(), mock(CurrentBoaUser.class), - new NoticeFlowGuard(flowClient, props)); + new ObjectMapper(), mock(com.yunhai.platform.flowcallback.FlowCallbackRegistrar.class), + mock(CurrentBoaUser.class), new NoticeFlowGuard(flowClient, props, mock(com.yunhai.notice.repository.NoticeCcRepository.class))); approving = new Notice(); approving.setId(42); diff --git a/src/test/java/com/yunhai/notice/support/OapiSignedClient.java b/src/test/java/com/yunhai/notice/support/OapiSignedClient.java index 94ecbf7..504bf4e 100644 --- a/src/test/java/com/yunhai/notice/support/OapiSignedClient.java +++ b/src/test/java/com/yunhai/notice/support/OapiSignedClient.java @@ -1,12 +1,14 @@ package com.yunhai.notice.support; import com.yunhai.platform.utils.crypto.SignUtil; +import org.apache.http.impl.client.HttpClientBuilder; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.http.client.SimpleClientHttpRequestFactory; + +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; /** @@ -15,6 +17,13 @@ import org.springframework.web.client.RestTemplate; */ 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 appKey; private final String appSecret; @@ -24,7 +33,8 @@ public class OapiSignedClient { this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; this.appKey = appKey; this.appSecret = appSecret; - SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory( + HttpClientBuilder.create().build()); factory.setConnectTimeout(3000); factory.setReadTimeout(5000); this.restTemplate = new RestTemplate(factory);