From 138b226ab0745e47cfe134a99c62dab8b622a325 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 15 Sep 2026 22:09:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=88=E5=AE=A1=E5=90=8E=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/yunhai/notice/NoticeApplication.java | 2 + .../FlowCallbackFilterConfiguration.java | 166 +++++++++ .../controller/api/NoticeFlowController.java | 57 ++++ .../callback/FlowCallbackController.java | 44 +++ .../controller/oapi/OapiDictController.java | 30 ++ .../java/com/yunhai/notice/entity/Notice.java | 30 ++ .../yunhai/notice/entity/NoticeFlowEvent.java | 46 +++ .../yunhai/notice/entity/NoticeFlowReg.java | 51 +++ .../notice/enums/FlowAuditStatusEnum.java | 42 +++ .../yunhai/notice/enums/NoticeStatusEnum.java | 6 +- .../com/yunhai/notice/job/SmsCatchupJob.java | 59 ++++ .../yunhai/notice/model/flow/FlowModels.java | 83 +++++ .../notice/model/notice/NoticeDetailVo.java | 17 + .../notice/model/notice/NoticeRequest.java | 6 + .../yunhai/notice/model/notice/NoticeVo.java | 14 + .../repository/NoticeFlowEventRepository.java | 9 + .../repository/NoticeFlowRegRepository.java | 16 + .../notice/repository/NoticeRepository.java | 46 +++ .../service/flow/FlowCallbackService.java | 225 ++++++++++++ .../notice/service/flow/FlowCenterClient.java | 103 ++++++ .../notice/service/flow/FlowClient.java | 24 ++ .../notice/service/flow/NoticeFlowGuard.java | 113 +++++++ .../service/flow/NoticeFlowProperties.java | 32 ++ .../service/flow/NoticeFlowService.java | 285 ++++++++++++++++ .../notice/service/impl/ClassService.java | 1 + .../notice/service/impl/NoticeService.java | 107 +++++- .../notice/service/sms/SmsSendService.java | 50 +++ .../utils/ReReadableRequestWrapper.java | 63 ++++ .../yunhai/notice/utils/ReceiveParser.java | 48 +++ src/main/resources/application.yml | 30 +- .../config/FlowCallbackVerifyFilterTest.java | 151 +++++++++ .../notice/flow/FlowCallbackTransientIT.java | 152 +++++++++ .../yunhai/notice/flow/NoticeFlowApiIT.java | 263 +++++++++++++++ .../yunhai/notice/job/SmsCatchupJobTest.java | 137 ++++++++ .../service/flow/FlowCallbackServiceTest.java | 251 ++++++++++++++ .../service/flow/FlowCenterClientTest.java | 226 +++++++++++++ .../service/flow/NoticeFlowGuardTest.java | 120 +++++++ .../service/flow/NoticeFlowPublishTest.java | 319 ++++++++++++++++++ .../service/flow/NoticeFlowTraceTest.java | 144 ++++++++ .../service/impl/NoticeDetailReadTest.java | 89 +++++ .../impl/NoticeResubmitClearResultTest.java | 94 ++++++ .../notice/support/OapiSignedClient.java | 102 ++++++ 42 files changed, 3839 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/yunhai/notice/config/FlowCallbackFilterConfiguration.java create mode 100644 src/main/java/com/yunhai/notice/controller/api/NoticeFlowController.java create mode 100644 src/main/java/com/yunhai/notice/controller/callback/FlowCallbackController.java create mode 100644 src/main/java/com/yunhai/notice/controller/oapi/OapiDictController.java create mode 100644 src/main/java/com/yunhai/notice/entity/NoticeFlowEvent.java create mode 100644 src/main/java/com/yunhai/notice/entity/NoticeFlowReg.java create mode 100644 src/main/java/com/yunhai/notice/enums/FlowAuditStatusEnum.java create mode 100644 src/main/java/com/yunhai/notice/job/SmsCatchupJob.java create mode 100644 src/main/java/com/yunhai/notice/model/flow/FlowModels.java create mode 100644 src/main/java/com/yunhai/notice/repository/NoticeFlowEventRepository.java create mode 100644 src/main/java/com/yunhai/notice/repository/NoticeFlowRegRepository.java create mode 100644 src/main/java/com/yunhai/notice/service/flow/FlowCallbackService.java create mode 100644 src/main/java/com/yunhai/notice/service/flow/FlowCenterClient.java create mode 100644 src/main/java/com/yunhai/notice/service/flow/FlowClient.java create mode 100644 src/main/java/com/yunhai/notice/service/flow/NoticeFlowGuard.java create mode 100644 src/main/java/com/yunhai/notice/service/flow/NoticeFlowProperties.java create mode 100644 src/main/java/com/yunhai/notice/service/flow/NoticeFlowService.java create mode 100644 src/main/java/com/yunhai/notice/service/sms/SmsSendService.java create mode 100644 src/main/java/com/yunhai/notice/utils/ReReadableRequestWrapper.java create mode 100644 src/main/java/com/yunhai/notice/utils/ReceiveParser.java create mode 100644 src/test/java/com/yunhai/notice/config/FlowCallbackVerifyFilterTest.java create mode 100644 src/test/java/com/yunhai/notice/flow/FlowCallbackTransientIT.java create mode 100644 src/test/java/com/yunhai/notice/flow/NoticeFlowApiIT.java create mode 100644 src/test/java/com/yunhai/notice/job/SmsCatchupJobTest.java create mode 100644 src/test/java/com/yunhai/notice/service/flow/FlowCallbackServiceTest.java create mode 100644 src/test/java/com/yunhai/notice/service/flow/FlowCenterClientTest.java create mode 100644 src/test/java/com/yunhai/notice/service/flow/NoticeFlowGuardTest.java create mode 100644 src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java create mode 100644 src/test/java/com/yunhai/notice/service/flow/NoticeFlowTraceTest.java create mode 100644 src/test/java/com/yunhai/notice/service/impl/NoticeDetailReadTest.java create mode 100644 src/test/java/com/yunhai/notice/service/impl/NoticeResubmitClearResultTest.java create mode 100644 src/test/java/com/yunhai/notice/support/OapiSignedClient.java diff --git a/src/main/java/com/yunhai/notice/NoticeApplication.java b/src/main/java/com/yunhai/notice/NoticeApplication.java index 52e3e2c..45606e0 100644 --- a/src/main/java/com/yunhai/notice/NoticeApplication.java +++ b/src/main/java/com/yunhai/notice/NoticeApplication.java @@ -3,10 +3,12 @@ package com.yunhai.notice; import com.alibaba.fastjson.parser.ParserConfig; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.transaction.annotation.EnableTransactionManagement; @SpringBootApplication(scanBasePackages = "com.yunhai.notice") @EnableTransactionManagement +@EnableScheduling public class NoticeApplication { public static void main(String[] args) { diff --git a/src/main/java/com/yunhai/notice/config/FlowCallbackFilterConfiguration.java b/src/main/java/com/yunhai/notice/config/FlowCallbackFilterConfiguration.java new file mode 100644 index 0000000..00f44ba --- /dev/null +++ b/src/main/java/com/yunhai/notice/config/FlowCallbackFilterConfiguration.java @@ -0,0 +1,166 @@ +package com.yunhai.notice.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.notice.entity.NoticeFlowReg; +import com.yunhai.notice.repository.NoticeFlowRegRepository; +import com.yunhai.notice.utils.ReReadableRequestWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.util.StringUtils; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; + +/** + * 流程中心回调验签(方案 §5.3,svc-hall FlowCallbackFilterConfiguration 同构): + * 只拦 /callback/*(CAS url-pattern /api/*、/mobiapi/* 之外,独立于 oapi 的 appKey 签名体系)。 + * 协议对齐流程中心概览 §8.4:X-Flw-EventId / X-Flw-Timestamp(±5 分钟防重放)/ + * X-Flw-Nonce / X-Flw-Signature = Base64(HmacSHA256(secret, timestamp + "." + nonce + "." + body)), + * secret 按 payload.flowGuid 查 notice_flow_reg;常量时间比对。 + * 注册探活 ping(事件=ping)直接 200——flow-center 探活只判连接级可达(签名是字面量 "ping" 无法验)。 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +public class FlowCallbackFilterConfiguration { + + /** ±5 分钟防重放窗口(对齐概览 §8.4) */ + private static final long TIMESTAMP_WINDOW_MS = 5 * 60 * 1000L; + + private final NoticeFlowRegRepository regRepository; + private final ObjectMapper objectMapper; + + @Bean + public FilterRegistrationBean flowCallbackVerifyFilter() { + FilterRegistrationBean bean = new FilterRegistrationBean<>(); + bean.setFilter(new FlowCallbackVerifyFilter()); + bean.addUrlPatterns("/callback/*"); + // 晚于 AppAuthFilter(+10)/enforce(+11),独立回调签名体系 + bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 20); + return bean; + } + + class FlowCallbackVerifyFilter implements Filter { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest) request; + HttpServletResponse res = (HttpServletResponse) response; + if (!"POST".equalsIgnoreCase(req.getMethod())) { + res.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + return; + } + ReReadableRequestWrapper wrapper = new ReReadableRequestWrapper(req); + String body = wrapper.bodyString(); + // 注册探活:只判连接级可达,直接 200(ping 无状态语义,验签不可能通过) + if (isPing(body)) { + byte[] ok = "{\"ok\":true}".getBytes(StandardCharsets.UTF_8); + res.setContentType("application/json"); + res.setContentLength(ok.length); + res.setStatus(HttpServletResponse.SC_OK); + res.getOutputStream().write(ok); + return; + } + String eventId = req.getHeader("X-Flw-EventId"); + String timestamp = req.getHeader("X-Flw-Timestamp"); + String nonce = req.getHeader("X-Flw-Nonce"); + String signature = req.getHeader("X-Flw-Signature"); + if (!StringUtils.hasText(eventId) || !StringUtils.hasText(timestamp) + || !StringUtils.hasText(nonce) || !StringUtils.hasText(signature)) { + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing callback headers"); + return; + } + try { + long ts = Long.parseLong(timestamp.trim()); + if (Math.abs(System.currentTimeMillis() - ts) > TIMESTAMP_WINDOW_MS) { + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "timestamp out of window"); + return; + } + } catch (NumberFormatException e) { + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "bad timestamp"); + return; + } + String[] reg = extractSchAndFlowGuid(body); + String secret = reg == null ? null : secretOf(reg[0], reg[1]); + if (secret == null) { + log.warn("[notice-flow] 回调验签失败:无注册或无 secret,flowGuid={}", reg == null ? null : reg[1]); + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "unknown callback registration"); + return; + } + String expected = hmacSha256Base64(secret, timestamp.trim() + "." + nonce.trim() + "." + body); + if (!constantTimeEquals(expected, signature.trim())) { + res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "bad signature"); + return; + } + chain.doFilter(wrapper, response); + } + + private boolean isPing(String body) { + try { + JsonNode payload = objectMapper.readTree(body); + return payload != null && "ping".equals(payload.path("event").asText(null)); + } catch (Exception e) { + return false; + } + } + + /** 从推送体提取 [schId, flowGuid](验签必需的最小解析) */ + private String[] extractSchAndFlowGuid(String body) { + try { + JsonNode payload = objectMapper.readTree(body); + if (!payload.has("flowGuid") || payload.get("flowGuid").isNull() + || !payload.has("schId") || payload.get("schId").isNull()) { + return null; + } + return new String[]{payload.get("schId").asText(), payload.get("flowGuid").asText()}; + } catch (Exception e) { + return null; + } + } + + private String secretOf(String schId, String flowGuid) { + try { + NoticeFlowReg reg = regRepository + .findBySchIdAndFlowGuid(Integer.valueOf(schId), flowGuid).orElse(null); + if (reg != null && reg.getStatus() == 0 && StringUtils.hasText(reg.getSecret())) { + return reg.getSecret(); + } + } catch (NumberFormatException ignored) { + // schId 非法按无注册处理 → 401 + } + return null; + } + + private String hmacSha256Base64(String secret, String content) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] raw = mac.doFinal(content.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(raw); + } catch (Exception e) { + throw new IllegalStateException("HmacSHA256 计算失败", e); + } + } + + private boolean constantTimeEquals(String a, String b) { + return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/src/main/java/com/yunhai/notice/controller/api/NoticeFlowController.java b/src/main/java/com/yunhai/notice/controller/api/NoticeFlowController.java new file mode 100644 index 0000000..0e28945 --- /dev/null +++ b/src/main/java/com/yunhai/notice/controller/api/NoticeFlowController.java @@ -0,0 +1,57 @@ +package com.yunhai.notice.controller.api; + +import com.yunhai.notice.config.Config; +import com.yunhai.notice.model.flow.FlowModels; +import com.yunhai.notice.service.flow.NoticeFlowService; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 通知-流程中心接缝接口(先审后发)。 + *
    + *
  • GET Flow/Options:编辑页"审核流程"选择器(代理 FlowClient.listFlows,前端不直调 oapi);
  • + *
  • POST Flow/TrySubmit:提交/重提审核(幂等,bizId=notice.id;apply 失败保持待提交可重推);
  • + *
  • GET Flow/Trace:审批进度(后端 getInstanceByBiz 组装,前端不直调 oapi——放行判定必须收在后端)。
  • + *
+ */ +@RestController +@RequestMapping(value = Config.API_PATH + "/notice/flow/") +@RequiredArgsConstructor +public class NoticeFlowController { + + private final NoticeFlowService noticeFlowService; + + /** 审核流程下拉选项(流程中心未启用时返回空列表,前端隐藏选择器) */ + @GetMapping("Options") + public List options(BoaUser user, + @RequestParam(value = "schId", required = false) Integer schId) { + return noticeFlowService.options(schId != null ? schId : currentSchId(user)); + } + + /** 提交/重新提交审核(编辑页重提按钮;audit=10 审批中将被拒绝;BP-R5-3 补属主守卫) */ + @PostMapping("TrySubmit") + public void trySubmit(@RequestParam("serid") Integer serid, BoaUser user) { + noticeFlowService.trySubmitFlow(serid, user); + } + + /** 审批进度(详情页进度卡片;未绑流程返回 bound=false 的未接入语义;未发布仅三类人可见) */ + @GetMapping("Trace") + public Map trace(@RequestParam("serid") Integer serid, BoaUser user) { + return noticeFlowService.buildTrace(serid, user); + } + + private Integer currentSchId(BoaUser user) { + if (user == null || user.getCurrentRole() == null) { + return null; + } + return user.getCurrentRole().getSchId(); + } +} diff --git a/src/main/java/com/yunhai/notice/controller/callback/FlowCallbackController.java b/src/main/java/com/yunhai/notice/controller/callback/FlowCallbackController.java new file mode 100644 index 0000000..ae3f946 --- /dev/null +++ b/src/main/java/com/yunhai/notice/controller/callback/FlowCallbackController.java @@ -0,0 +1,44 @@ +package com.yunhai.notice.controller.callback; + +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 流程中心回调接收端点(方案 §5.3;/callback/* 不在 CAS url-pattern 内,由 + * FlowCallbackVerifyFilter 完成 X-Flw-* 四头验签后才到达这里)。 + *

+ * 返回语义(契约冻结 D2,免对账前提):状态推进成功 / 重复投递(去重命中)/ + * 协议毒消息 / 乱序丢弃 / 办件不属本应用 → 200(毒消息回 200 防无限重放); + * 瞬时失败(DB 异常/锁冲突,{@link FlowCallbackService.TransientCallbackException})/ + * 意外 RuntimeException → 5xx——flow-center 持续重试直至送达,消费方恢复自动追平。 + */ +@Slf4j +@RestController +@RequestMapping("/callback") +@RequiredArgsConstructor +public class FlowCallbackController { + + private final FlowCallbackService callbackService; + + @PostMapping("/flow") + public ResponseEntity flow(@RequestBody byte[] body) { + 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 触发流程中心重试): {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}"); + } catch (Exception e) { + // 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件) + log.error("[notice-flow] 回调意外异常(回 5xx 触发流程中心重试)", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}"); + } + } +} diff --git a/src/main/java/com/yunhai/notice/controller/oapi/OapiDictController.java b/src/main/java/com/yunhai/notice/controller/oapi/OapiDictController.java new file mode 100644 index 0000000..18cb226 --- /dev/null +++ b/src/main/java/com/yunhai/notice/controller/oapi/OapiDictController.java @@ -0,0 +1,30 @@ +package com.yunhai.notice.controller.oapi; + +import com.yunhai.notice.service.IDictService; +import lombok.RequiredArgsConstructor; +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.List; +import java.util.Map; + +/** + * 开放接口 demo(两级模型试点 P2.1:Notice 作为提供方开放 /oapi/**)。 + * 强制验签由 starter 提供(yml {@code yh.app-auth.server.enforce-patterns: [/oapi/*]}): + * 浏览器/匿名直访 401,注册应用带 X-App-* 签名三件套直调通过(trusted-apps 收调用方密钥)。 + */ +@RestController +@RequestMapping("/oapi/notice") +@RequiredArgsConstructor +public class OapiDictController { + + private final IDictService dictService; + + /** 部门字典(demo:type=1 部门;调用方签名鉴权由 enforce-patterns 强制) */ + @GetMapping("/getDict") + public List> getDict(@RequestParam(value = "type", defaultValue = "1") Integer type) { + return dictService.getSysDic(type); + } +} diff --git a/src/main/java/com/yunhai/notice/entity/Notice.java b/src/main/java/com/yunhai/notice/entity/Notice.java index bc1bebc..836ed94 100644 --- a/src/main/java/com/yunhai/notice/entity/Notice.java +++ b/src/main/java/com/yunhai/notice/entity/Notice.java @@ -145,4 +145,34 @@ public class Notice extends BaseEntity { */ @Column(name = "sms_flag") private Integer smsFlag = 0; + + /** + * 短信实际发送时间(防重复发送的幂等标记):NULL=未发。 + * 直发/审批通过/到点补发三条链路共用(SmsSendService 原子认领);本地随 ddl-auto=update + * 自动加列,生产执行 deploy/sql/20260913_add_sms_sent_at.sql。 + */ + @Column(name = "sms_sent_at") + private LocalDateTime smsSentAt; + + // ===== 先审后发(流程中心托管):全部可空,NULL=未绑流程(原直发链路,存量零迁移) ===== + + /** 绑定的流程 GUID;NULL=直发(原链路一字不改) */ + @Column(name = "flow_guid", length = 32) + private String flowGuid; + + /** 流程中心办件 id(apply 成功回填) */ + @Column(name = "flow_instance_id") + private Long flowInstanceId; + + /** 审核镜像状态:0 待提交 / 10 审批中 / 20 通过 / 30 驳回 / 40 已撤回(FlowAuditStatusEnum) */ + @Column(name = "audit_status") + private Integer auditStatus; + + /** 已消费的最大回调 instanceVersion(乱序丢弃闸门) */ + @Column(name = "last_instance_version") + private Integer lastInstanceVersion; + + /** 终态明细快照(result/opinion/rejectNode,详情页进度卡片展示) */ + @Column(name = "flow_result_json", columnDefinition = "LONGTEXT") + private String flowResultJson; } diff --git a/src/main/java/com/yunhai/notice/entity/NoticeFlowEvent.java b/src/main/java/com/yunhai/notice/entity/NoticeFlowEvent.java new file mode 100644 index 0000000..26ce270 --- /dev/null +++ b/src/main/java/com/yunhai/notice/entity/NoticeFlowEvent.java @@ -0,0 +1,46 @@ +package com.yunhai.notice.entity; + +import lombok.Getter; +import lombok.Setter; + +import com.yunhai.notice.common.listener.NoticeAuditListener; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EntityListeners; +import javax.persistence.Index; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +/** + * 流程中心回调事件去重表(照 svc-hall ServiceFlowEvent / flow-center 事件 uk 同构): + * eventId 唯一约束兜底并发;重复投递(flow-center 持续重试语义下必然发生)命中唯一键即跳过。 + */ +@Entity +@Table(name = "notice_flow_event", + uniqueConstraints = @UniqueConstraint(name = "uk_notice_flow_event", columnNames = "event_id"), + indexes = @Index(name = "idx_notice_flow_event_inst", columnList = "instance_id")) +@Getter +@Setter +@EntityListeners(NoticeAuditListener.class) +public class NoticeFlowEvent extends BaseEntity { + + /** 事件唯一键(32 位 uuid,flow-center 重试不变) */ + @Column(name = "event_id", length = 40, nullable = false) + private String eventId; + + /** 事件码:flow.submitted / task.created / task.handled / completed / rejected / withdrawn / ping */ + @Column(name = "event", length = 32) + private String event; + + @Column(name = "sch_id") + private Integer schId; + + @Column(name = "instance_id") + private Long instanceId; + + @Column(name = "receive_time") + private java.time.LocalDateTime receiveTime; + + @Column(name = "payload_json", columnDefinition = "LONGTEXT") + private String payloadJson; +} diff --git a/src/main/java/com/yunhai/notice/entity/NoticeFlowReg.java b/src/main/java/com/yunhai/notice/entity/NoticeFlowReg.java new file mode 100644 index 0000000..a2bfbb1 --- /dev/null +++ b/src/main/java/com/yunhai/notice/entity/NoticeFlowReg.java @@ -0,0 +1,51 @@ +package com.yunhai.notice.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.EntityListeners; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import com.yunhai.notice.common.listener.NoticeAuditListener; + +/** + * 流程中心回调注册(照 svc-hall ServiceFlowReg / flow-center FlwCallbackReg 同构): + * sch+appKey+flowGuid 唯一;secret 为 flow-center reg 接口下发时落库的回调验签密钥。 + * 注意:flow-center 重复注册会轮换 secret,故仅在缺失时注册(ensureRegistered), + * 绝不重复调用——失配恢复手段=管理端重新保存绑定。 + */ +@Entity +@Table(name = "notice_flow_reg", + uniqueConstraints = @UniqueConstraint(name = "uk_notice_flow_reg", columnNames = {"sch_id", "app_key", "flow_guid"})) +@Getter +@Setter +@EntityListeners(NoticeAuditListener.class) +public class NoticeFlowReg extends BaseEntity { + + @Column(name = "sch_id") + private Integer schId; + + /** 本应用在流程中心侧的 appKey(yh.app-auth.app-key,出站签名同一身份) */ + @Column(name = "app_key", length = 64) + private String appKey; + + @Column(name = "flow_guid", length = 32) + private String flowGuid; + + @Column(name = "callback_url", length = 500) + private String callbackUrl; + + /** 订阅事件逗号分隔,空=全部 */ + @Column(name = "event_types", length = 200) + private String eventTypes; + + /** flow-center reg 接口下发的回调验签密钥 */ + @Column(name = "secret", length = 64) + private String secret; + + /** 0 启用 / 1 停用(预留) */ + @Column(name = "status") + private Integer status = 0; +} diff --git a/src/main/java/com/yunhai/notice/enums/FlowAuditStatusEnum.java b/src/main/java/com/yunhai/notice/enums/FlowAuditStatusEnum.java new file mode 100644 index 0000000..e4a6e70 --- /dev/null +++ b/src/main/java/com/yunhai/notice/enums/FlowAuditStatusEnum.java @@ -0,0 +1,42 @@ +package com.yunhai.notice.enums; + +import lombok.Getter; + +/** + * 通知审核状态(audit_status 列,对齐 flow-center FlwEnums.InstanceStatus 与 svc-hall ApplyFlowStatusEnum; + * 未提交 0 为 Notice 特有——apply 尚未成功的暂态)。仅在绑定了审核流程(flow_guid 非空)时有意义。 + */ +@Getter +public enum FlowAuditStatusEnum { + + PENDING_SUBMIT(0, "待提交"), + APPROVING(10, "审批中"), + APPROVED(20, "已通过"), + REJECTED(30, "已驳回"), + WITHDRAWN(40, "已撤回"); + + private final int code; + private final String desc; + + FlowAuditStatusEnum(int code, String desc) { + this.code = code; + this.desc = desc; + } + + public static FlowAuditStatusEnum of(Integer code) { + if (code == null) { + return null; + } + for (FlowAuditStatusEnum e : values()) { + if (e.code == code) { + return e; + } + } + return null; + } + + public static String descOf(Integer code) { + FlowAuditStatusEnum e = of(code); + return e == null ? null : e.getDesc(); + } +} diff --git a/src/main/java/com/yunhai/notice/enums/NoticeStatusEnum.java b/src/main/java/com/yunhai/notice/enums/NoticeStatusEnum.java index 2d9cd77..203c7e9 100644 --- a/src/main/java/com/yunhai/notice/enums/NoticeStatusEnum.java +++ b/src/main/java/com/yunhai/notice/enums/NoticeStatusEnum.java @@ -3,13 +3,15 @@ package com.yunhai.notice.enums; import lombok.Getter; /** - * 通知状态。无草稿态:发通知一律置为「已发布」, + * 通知状态。status=1 已发布仍是用户端唯一可见态; + * 先审后发引入 status=0 未发布(审核载体:绑流程通知提交后置 0,回调 completed 才置 1, + * 审核进度独立成列 audit_status)。未绑流程的通知仍无草稿态(直发即 1), * 定时发送通过 timingSendTime + 查询侧「已到点」过滤控制可见性。 - * 对应原 .NET Notify_Message.status(原系统已发布=2,此处归一化为 1)。 */ @Getter public enum NoticeStatusEnum { + UNPUBLISHED(0, "未发布"), PUBLISHED(1, "已发布"); private final int code; diff --git a/src/main/java/com/yunhai/notice/job/SmsCatchupJob.java b/src/main/java/com/yunhai/notice/job/SmsCatchupJob.java new file mode 100644 index 0000000..d42a2c7 --- /dev/null +++ b/src/main/java/com/yunhai/notice/job/SmsCatchupJob.java @@ -0,0 +1,59 @@ +package com.yunhai.notice.job; + +import com.yunhai.notice.entity.Notice; +import com.yunhai.notice.repository.NoticeRepository; +import com.yunhai.notice.service.sms.SmsSendService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 短信到点补发调度(§3.2 / BP-R5-2):扫"已发布 + 要发短信 + 从未发过 + 已到定时点"的行补发。 + * 覆盖场景:定时发送的通知在定时点之前审批通过——发布链路按设计跳过短信(可见性即时生效), + * 原实现无任何补发导致短信永久丢失。幂等由 SmsSendService 的 sms_sent_at 原子认领保证 + * (并发/重复调度不双发);单条失败不阻断批次(归还认领,下轮重试)。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SmsCatchupJob { + + /** 单批上限(防御性:避免超大存量发布时一次性拉满) */ + private static final int BATCH = 200; + + private final NoticeRepository noticeRepository; + private final SmsSendService smsSendService; + + @Value("${notice.sms.catchup-enabled:true}") + private boolean enabled; + + /** fixedDelay=5min(上一轮跑完起算,单实例内天然不重叠;跨实例由原子认领闸) */ + @Scheduled(fixedDelayString = "${notice.sms.catchup-fixed-delay-ms:300000}", initialDelay = 30_000) + public void catchupDueSms() { + if (!enabled) { + return; + } + List due = noticeRepository.findSmsDue(LocalDateTime.now(), PageRequest.of(0, BATCH)); + if (due.isEmpty()) { + return; + } + log.info("[notice-sms] 到点补发扫描:{} 条待发", due.size()); + int sent = 0; + for (Notice notice : due) { + try { + if (smsSendService.sendOnce(notice)) { + sent++; + } + } catch (Exception e) { + log.warn("[notice-sms] 补发单条失败(下轮重试)notice={}:{}", notice.getId(), e.toString()); + } + } + log.info("[notice-sms] 到点补发完成:本轮发送 {} 条", sent); + } +} diff --git a/src/main/java/com/yunhai/notice/model/flow/FlowModels.java b/src/main/java/com/yunhai/notice/model/flow/FlowModels.java new file mode 100644 index 0000000..c0d3e37 --- /dev/null +++ b/src/main/java/com/yunhai/notice/model/flow/FlowModels.java @@ -0,0 +1,83 @@ +package com.yunhai.notice.model.flow; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.List; + +/** + * 流程中心 /oapi/flow/** 契约模型(与 flow-center OapiModels 逐字对齐, + * 契约锚点:《流程中心设计概览方案.md》§8 / M1 开工说明 §5——两侧已编码,改动须双向同步)。 + */ +public final class FlowModels { + + private FlowModels() { + } + + /** 流程摘要(GET /oapi/flow/list 元素) */ + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class FlowSummaryVo { + private String flowGuid; + private String name; + private Integer version; + private String nodeSummary; + } + + /** 代发起请求体(POST /oapi/flow/apply) */ + @Data + public static class ApplyBody { + private String flowGuid; + private String bizType; + private String bizId; + private String bizTitle; + private String bizUrl; + /** 业务摘要快照(对象透传,flow-center 统一落 JSON 串) */ + private Object bizSnapshot; + private Integer submitterId; + private String submitterName; + } + + /** 代发起响应(biz uk 幂等:重复 apply 返回既有办件 duplicated=true;30/40 态 R4 重启推进) */ + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ApplyResultVo { + private Long instanceId; + private boolean duplicated; + } + + /** 按业务键查办件响应(GET /oapi/flow/instance;不存在为 null) */ + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class InstanceVo { + private Long instanceId; + private Integer status; + private Integer version; + private String nodeSummary; + /** 办理轨迹 JSON(含 assigneeId,办理人放行判定用) */ + private String traceJson; + private List assigneeIds; + private String currentNodes; + private String bizTitle; + private Integer submitterId; + private String submitterName; + private String submitTime; + private String finishTime; + } + + /** 回调注册请求体(POST /oapi/flow/callback/reg) */ + @Data + public static class CallbackRegBody { + private String flowGuid; + private String callbackUrl; + /** 订阅事件逗号分隔,空=全部 */ + private String eventTypes; + } + + /** 回调注册响应(secret 注册时生成下发;重复注册会轮换 secret,绝不重复调用) */ + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CallbackRegResultVo { + private String secret; + } +} diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticeDetailVo.java b/src/main/java/com/yunhai/notice/model/notice/NoticeDetailVo.java index 3a9587f..e777ea8 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticeDetailVo.java +++ b/src/main/java/com/yunhai/notice/model/notice/NoticeDetailVo.java @@ -55,6 +55,23 @@ public class NoticeDetailVo { @JsonProperty("tlist") private Object tlist; + // ===== 先审后发(编辑页驳回/撤回态展示) ===== + + /** 绑定的审核流程 GUID(null=直发未接流程) */ + private String flowGuid; + + /** 审核状态:0 待提交 / 10 审批中 / 20 通过 / 30 驳回 / 40 已撤回(null=未绑) */ + private Integer auditStatus; + + /** 审核状态文案 */ + private String auditStatusDesc; + + /** 流程中心办件 id(撤回/催办直调 flow-center 用) */ + private Long flowInstanceId; + + /** 终态明细(result/opinion/rejectNode;null=无) */ + private Object flowResult; + /** * 附件项(旧 ChaoSend 数组元素)。 */ diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticeRequest.java b/src/main/java/com/yunhai/notice/model/notice/NoticeRequest.java index 2c58f89..da4fc7d 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticeRequest.java +++ b/src/main/java/com/yunhai/notice/model/notice/NoticeRequest.java @@ -50,4 +50,10 @@ public class NoticeRequest { /** 转发源通知ID(非空时表示转发创建,对应旧 REST 壳 forward/{sourceId}) */ private Integer sourceId; + + /** + * 审核流程 GUID(可选,先审后发):非空=提交审核(status=0 待回调 completed 才发布); + * 空=原直发链路(一字不改)。编辑时可变更绑定(变更即走新一轮审核)。 + */ + private String flowGuid; } diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticeVo.java b/src/main/java/com/yunhai/notice/model/notice/NoticeVo.java index dd74a84..406e3c3 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticeVo.java +++ b/src/main/java/com/yunhai/notice/model/notice/NoticeVo.java @@ -50,6 +50,20 @@ public class NoticeVo { /** 附件ID串("1,2,3",列表页下载入口使用;对齐旧 .NET UpFile 字段) */ private String UpFile; + // ===== 先审后发(流程中心托管) ===== + + /** 绑定的审核流程 GUID(null=直发,未接流程中心) */ + private String flowGuid; + + /** 审核状态:0 待提交 / 10 审批中 / 20 通过 / 30 驳回 / 40 已撤回(null=未绑流程) */ + private Integer auditStatus; + + /** 审核状态文案(待提交/审批中/已通过/已驳回/已撤回;null=未绑流程) */ + private String auditStatusDesc; + + /** 流程中心办件 id(提交成功后非空;详情页撤回/催办直调 flow-center 用) */ + private Long flowInstanceId; + private String createBy; private LocalDateTime createTime; private String updateBy; diff --git a/src/main/java/com/yunhai/notice/repository/NoticeFlowEventRepository.java b/src/main/java/com/yunhai/notice/repository/NoticeFlowEventRepository.java new file mode 100644 index 0000000..5207197 --- /dev/null +++ b/src/main/java/com/yunhai/notice/repository/NoticeFlowEventRepository.java @@ -0,0 +1,9 @@ +package com.yunhai.notice.repository; + +import com.yunhai.notice.entity.NoticeFlowEvent; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NoticeFlowEventRepository extends JpaRepository { + + boolean existsByEventId(String eventId); +} diff --git a/src/main/java/com/yunhai/notice/repository/NoticeFlowRegRepository.java b/src/main/java/com/yunhai/notice/repository/NoticeFlowRegRepository.java new file mode 100644 index 0000000..72f5935 --- /dev/null +++ b/src/main/java/com/yunhai/notice/repository/NoticeFlowRegRepository.java @@ -0,0 +1,16 @@ +package com.yunhai.notice.repository; + +import com.yunhai.notice.entity.NoticeFlowReg; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface NoticeFlowRegRepository extends JpaRepository { + + Optional findBySchIdAndAppKeyAndFlowGuid(Integer schId, String appKey, String flowGuid); + + Optional findBySchIdAndFlowGuid(Integer schId, String flowGuid); + + List findBySchIdAndStatusOrderByUpdateTimeDesc(Integer schId, Integer status); +} diff --git a/src/main/java/com/yunhai/notice/repository/NoticeRepository.java b/src/main/java/com/yunhai/notice/repository/NoticeRepository.java index 78a64ae..35996b0 100644 --- a/src/main/java/com/yunhai/notice/repository/NoticeRepository.java +++ b/src/main/java/com/yunhai/notice/repository/NoticeRepository.java @@ -2,6 +2,8 @@ package com.yunhai.notice.repository; import com.yunhai.notice.entity.Notice; import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -20,6 +22,50 @@ public interface NoticeRepository extends JpaRepository, */ long countByStatus(Integer status); + /** 回调事件处理专用:行级悲观锁读(并发推送串行化,防整实体保存互相覆盖丢更新) */ + @org.springframework.data.jpa.repository.Lock(javax.persistence.LockModeType.PESSIMISTIC_WRITE) + @org.springframework.data.jpa.repository.Query("select n from Notice n where n.id = :id") + Optional findByIdForUpdate(@org.springframework.data.repository.query.Param("id") Integer id); + + /** + * 提交审核后的原子回填(窄列更新,防旧快照整实体覆盖并发回调的推进): + * 置 flow_instance_id + audit=10;若回调已并发先行落定 audit=20(审核通过)则保持 20 不回退。 + */ + @org.springframework.transaction.annotation.Transactional + @org.springframework.data.jpa.repository.Modifying + @org.springframework.data.jpa.repository.Query( + "update Notice n set n.flowInstanceId = :instanceId, " + + "n.auditStatus = (case when n.auditStatus = 20 then n.auditStatus else 10 end) " + + "where n.id = :id") + int markApproving(@org.springframework.data.repository.query.Param("id") Integer id, + @org.springframework.data.repository.query.Param("instanceId") Long instanceId); + + /** + * 短信发送原子认领(§3.2 防重复核心):置 sms_sent_at,仅当当前为 NULL(未发)时生效。 + * 返回 0=已被发送过(并发/重复调度双发的闸门)。发送失败由调用方 clearSmsSent 归还。 + */ + @org.springframework.transaction.annotation.Transactional + @org.springframework.data.jpa.repository.Modifying + @org.springframework.data.jpa.repository.Query( + "update Notice n set n.smsSentAt = :sentAt where n.id = :id and n.smsSentAt is null") + int markSmsSent(@Param("id") Integer id, @Param("sentAt") LocalDateTime sentAt); + + /** 发送失败归还认领(下轮补发调度可重试) */ + @org.springframework.transaction.annotation.Transactional + @org.springframework.data.jpa.repository.Modifying + @org.springframework.data.jpa.repository.Query( + "update Notice n set n.smsSentAt = null where n.id = :id and n.smsSentAt is not null") + int clearSmsSent(@Param("id") Integer id); + + /** + * 到点待补发短信清单(§3.2 SmsCatchupJob 扫描):已发布 + 要发短信 + 从未发过 + 已到定时点 + * (无定时点即发;覆盖"审批通过早于定时点被跳过"的行——BP-R5-2 短信永久丢失缺口)。 + */ + @Query("select n from Notice n where n.status = 1 and n.smsFlag = 1 and n.smsSentAt is null" + + " and (n.timingSendTime is null or n.timingSendTime <= :now) order by n.id asc") + List findSmsDue(@Param("now") LocalDateTime now, + org.springframework.data.domain.Pageable pageable); + /** * 已发布且已到点(定时发送未到点不计入)的通知数,用于用户端未读数统计。 */ diff --git a/src/main/java/com/yunhai/notice/service/flow/FlowCallbackService.java b/src/main/java/com/yunhai/notice/service/flow/FlowCallbackService.java new file mode 100644 index 0000000..5853159 --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/flow/FlowCallbackService.java @@ -0,0 +1,225 @@ +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"); + 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 ("ping".equals(event)) { + 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/FlowCenterClient.java b/src/main/java/com/yunhai/notice/service/flow/FlowCenterClient.java new file mode 100644 index 0000000..e5097e8 --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/flow/FlowCenterClient.java @@ -0,0 +1,103 @@ +package com.yunhai.notice.service.flow; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.notice.model.flow.FlowModels; +import com.yunhai.platform.appauth.AppSignInterceptor; +import com.yunhai.platform.appauth.LocalAppIdentityProvider; +import com.yunhai.platform.openapi.TargetSpec; +import com.yunhai.platform.openapi.YschOpenCall; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * FlowClient 默认实现:直接构建在 Tier 0 {@link YschOpenCall} 之上(两级模型纪律 1"先 Tier 0", + * 不手写 RestTemplate/签名代码)。目标地址单一事实源 = {@code notice.flow.base-url}(ad-hoc + * TargetSpec、base-path 空=裸地址直连,与 flow-center 无 context-path 的部署一致); + * 签名/信封解析/错误映射全部由 YschOpenCall 统一承载(401/403→DException、code!=0→DException、 + * 其他传输错→OpenApiException)。 + * {@code notice.flow.enabled=false} 时全部方法抛业务异常(文案含"流程中心未接入")供前端降级。 + */ +@Slf4j +@Service +public class FlowCenterClient implements FlowClient { + + /** flow-center 业务类型常量(契约冻结:bizType="notice"、bizId=notice.id) */ + public static final String BIZ_TYPE = "notice"; + + private static final String NOT_CONNECTED = "流程中心未接入,该功能不可用"; + + private final NoticeFlowProperties properties; + private final YschOpenCall openCall; + + public FlowCenterClient(NoticeFlowProperties properties, + ObjectProvider openCallProvider, + ObjectProvider identityProvider, + ObjectMapper objectMapper) { + this.properties = properties; + YschOpenCall call = openCallProvider.getIfAvailable(); + if (call == null) { + // 兜底自建(正常路径由 yh-openapi-starter 装配 YschOpenCall bean);identity 缺失时 + // AppSignInterceptor 自动跳过签名(请求裸发,enforce 端会 401——配置问题尽早暴露) + call = new YschOpenCall(new com.yunhai.platform.openapi.YschCallProperties(), objectMapper, + (host, basePath) -> new AppSignInterceptor(identityProvider.getIfAvailable(), host, basePath)); + } + this.openCall = call; + } + + @Override + public List listFlows(Integer schId) { + requireEnabled(); + return openCall.getList(target(), "/oapi/flow/list", params("schId", schId), + FlowModels.FlowSummaryVo.class); + } + + @Override + public FlowModels.ApplyResultVo apply(Integer schId, FlowModels.ApplyBody body) { + requireEnabled(); + return openCall.post(target(), "/oapi/flow/apply", params("schId", schId), body, + FlowModels.ApplyResultVo.class); + } + + @Override + public FlowModels.InstanceVo getInstanceByBiz(Integer schId, String bizType, String bizId) { + requireEnabled(); + Map query = new HashMap<>(); + query.put("schId", String.valueOf(schId)); + query.put("bizType", bizType); + query.put("bizId", bizId); + return openCall.get(target(), "/oapi/flow/instance", query, FlowModels.InstanceVo.class); + } + + @Override + public FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body) { + requireEnabled(); + return openCall.post(target(), "/oapi/flow/callback/reg", params("schId", schId), body, + FlowModels.CallbackRegResultVo.class); + } + + private TargetSpec target() { + // base-path 空:本地/容器裸地址直连(两级模型 §2.2 对齐不变式的现行约定) + return TargetSpec.of(properties.getBaseUrl(), ""); + } + + private void requireEnabled() { + if (!properties.isEnabled()) { + throw new BizException(ErrorCode.FORBIDDEN, NOT_CONNECTED); + } + } + + private static Map params(String k, Object v) { + Map m = new HashMap<>(); + if (k != null && v != null) { + m.put(k, String.valueOf(v)); + } + return m; + } +} diff --git a/src/main/java/com/yunhai/notice/service/flow/FlowClient.java b/src/main/java/com/yunhai/notice/service/flow/FlowClient.java new file mode 100644 index 0000000..8049254 --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/flow/FlowClient.java @@ -0,0 +1,24 @@ +package com.yunhai.notice.service.flow; + +import com.yunhai.notice.model.flow.FlowModels; + +import java.util.List; + +/** + * 流程中心出站接缝(Notice 首接方案 §5.1,方法面与 svc-hall FlowCenterClient 一致)。 + * 接口保留三重意义:防依赖(业务层不感知 YschOpenCall)、可测试(fake 注入)、可降级(enabled 开关)。 + */ +public interface FlowClient { + + /** 已发布流程列表(编辑页"审核流程"选择器数据源) */ + List listFlows(Integer schId); + + /** 代发起(biz uk 幂等:重复 apply 同办件;30/40 态 R4 重启推进) */ + FlowModels.ApplyResultVo apply(Integer schId, FlowModels.ApplyBody body); + + /** 按业务键查办件(进度卡片/放行判定/对账兜底;不存在返回 null) */ + FlowModels.InstanceVo getInstanceByBiz(Integer schId, String bizType, String bizId); + + /** 回调注册(下发 secret;重复注册会轮换 secret——仅在 reg 缺失时调用) */ + FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body); +} diff --git a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowGuard.java b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowGuard.java new file mode 100644 index 0000000..ad811b1 --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowGuard.java @@ -0,0 +1,113 @@ +package com.yunhai.notice.service.flow; + +import com.yunhai.notice.entity.Notice; +import com.yunhai.notice.model.flow.FlowModels; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 未发布通知(status=0,审核载体)的详情放行守卫(方案 §5.5,V2.0 B.4#8 同构)。 + * 仅三类人可见:提交人本人(publisherAccount)/ 本校管理员(type=1 或 roleId∈{2,5,7})/ + * 该办件流程办理人(instance.assigneeIds 含当前用户,60s 短缓存——审批中途换人不实时,可接受)。 + * 其余用户查询即"通知不存在或未发布"(查不到语义,不泄露存在性)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NoticeFlowGuard { + + /** 管理员角色白名单(校级管理员/分管领导等,与既有菜单权限体系对齐) */ + private static final List ADMIN_ROLE_IDS = Arrays.asList(2, 5, 7); + + private static final long ASSIGNEE_CACHE_MS = 60_000L; + + private final FlowClient flowClient; + private final NoticeFlowProperties properties; + + private final Map assigneeCache = new ConcurrentHashMap<>(); + + private static class CachedAssignees { + final List userIds; + final long expireAt; + + CachedAssignees(List userIds) { + this.userIds = userIds; + this.expireAt = System.currentTimeMillis() + ASSIGNEE_CACHE_MS; + } + } + + /** + * 校验当前用户可查看未发布通知;不可见抛 FORBIDDEN(文案=查不到语义)。 + * status=1 已发布通知不走本守卫(既有行为零变化)。 + */ + public void requireUnpublishedVisible(Notice notice, BoaUser user) { + if (notice == null || user == null) { + throw notFound(); + } + // 1) 提交人本人 + if (StringUtils.hasText(notice.getPublisherAccount()) + && notice.getPublisherAccount().equals(user.getAccount())) { + return; + } + // 2) 本校管理员(type=1 校管 或 roleId 命中白名单) + if (isAdmin(user)) { + return; + } + // 3) 该办件办理人(assigneeIds 含当前用户 id;flowInstanceId 缺失=尚未提交成功,仅前两类可见) + if (notice.getFlowInstanceId() != null && properties.isEnabled() + && currentAssignees(notice).contains(user.getId())) { + return; + } + throw notFound(); + } + + private boolean isAdmin(BoaUser user) { + if (user.getType() != null && user.getType() == 1) { + return true; + } + if (user.getRoleIds() != null) { + for (Integer roleId : user.getRoleIds()) { + if (roleId != null && ADMIN_ROLE_IDS.contains(roleId)) { + return true; + } + } + } + return false; + } + + private List currentAssignees(Notice notice) { + long now = System.currentTimeMillis(); + CachedAssignees cached = assigneeCache.get(notice.getFlowInstanceId()); + if (cached != null && cached.expireAt > now) { + return cached.userIds == null ? java.util.Collections.emptyList() : cached.userIds; + } + List assignees = null; + try { + FlowModels.InstanceVo instance = flowClient.getInstanceByBiz( + notice.getSchId(), FlowCenterClient.BIZ_TYPE, String.valueOf(notice.getId())); + if (instance != null) { + assignees = instance.getAssigneeIds(); + } + } catch (Exception e) { + // 流程中心不可达:fail-closed(不放行),日志留痕 + log.warn("[notice-flow] 办理人查询失败(fail-closed):notice={} {}", notice.getId(), e.toString()); + } + assigneeCache.put(notice.getFlowInstanceId(), + new CachedAssignees(assignees == null ? java.util.Collections.emptyList() : assignees)); + return assignees == null ? java.util.Collections.emptyList() : assignees; + } + + private BizException notFound() { + return new BizException(ErrorCode.FORBIDDEN, "通知不存在或未发布"); + } +} diff --git a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowProperties.java b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowProperties.java new file mode 100644 index 0000000..aaa7788 --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowProperties.java @@ -0,0 +1,32 @@ +package com.yunhai.notice.service.flow; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 通知-流程中心接缝配置(notice.flow.* / notice.front.*)。 + */ +@Data +@Component +@ConfigurationProperties(prefix = "notice.flow") +public class NoticeFlowProperties { + + /** 是否接入流程中心(false=编辑页选择器隐藏、FlowClient 全部抛业务异常降级) */ + private boolean enabled = true; + + /** flow-center 基址(Tier 0 目标;本地 http://localhost:8085,生产由 projman 注入) */ + private String baseUrl = "http://localhost:8085"; + + /** + * 本应用对外可达基址(回调注册用):flow-center 探活与事件推送都打这里。 + * 默认回落 baseUrl 同端口的本服务直连地址;生产经网关时由 projman 注入(如 http://notice:8070/notice)。 + */ + private String callbackBase = "http://localhost:8101"; + + /** 回调路径(挂在本服务上,FlowCallbackVerifyFilter 只拦 /callback/*) */ + private String callbackPath = "/callback/flow"; + + /** Notice 前端基址(bizUrl 拼接:{front-base}/#/list/detail?serid={id},审批人点链接回通知详情) */ + private String frontBaseUrl = "http://localhost:8077"; +} diff --git a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowService.java b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowService.java new file mode 100644 index 0000000..801ad3b --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowService.java @@ -0,0 +1,285 @@ +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.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.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; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronizationAdapter; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 通知-流程中心接合服务:提交/重提(D9 AFTER_COMMIT 出站)、回调注册管理、进度组装。 + * 提交语义(方案 §5.2):apply 幂等(bizId=notice.id),失败保持 0 待提交可重推; + * 10 审批中禁止重复提交/编辑/删除(删除需先撤回)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NoticeFlowService { + + private final NoticeRepository noticeRepository; + private final NoticeFlowRegRepository regRepository; + private final UpFileRepository upFileRepository; + private final FlowClient flowClient; + private final NoticeFlowProperties properties; + private final ObjectMapper objectMapper; + private final AppAuthProperties appAuthProperties; + private final CurrentBoaUser currentBoaUser; + private final NoticeFlowGuard noticeFlowGuard; + + /** + * 注册后事务提交了再提交审核(D9:apply 出站不进 DB 事务,防 HTTP 长调用拖住连接); + * 无活动事务时立即执行(测试/兜底)。 + *

E2E 实测教训:afterCommit 回调若留在原请求线程执行,OSIV 绑定的 EntityManager + * 已随提交关闭,后续 repository 写会话静默丢失(有成功日志但库无行)——故提交动作 + * 一律切到独立线程(干净事务 + 无 ThreadLocal 残留),提交人身份在请求线程先捕获透传。 + */ + public void submitAfterCommit(Integer noticeId) { + final BoaUser user = currentUser(); + Runnable task = () -> trySubmitQuietly(noticeId, user); + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() { + @Override + public void afterCommit() { + SUBMIT_EXECUTOR.submit(task); + } + }); + } else { + task.run(); + } + } + + /** 提交动作专用单线程池(串行化避免并发双 apply;uk 幂等兜底) */ + private static final java.util.concurrent.ExecutorService SUBMIT_EXECUTOR = + java.util.concurrent.Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "notice-flow-submit"); + t.setDaemon(true); + return t; + }); + + /** 提交失败静默保持 0 待提交(行已落库不回滚;列表"待提交"态即提示,编辑页可重推) */ + private void trySubmitQuietly(Integer noticeId, BoaUser user) { + try { + trySubmitFlow(noticeId, user); + } catch (Exception e) { + log.warn("[notice-flow] 提交审核失败(保持待提交,可重推):notice={} {}", noticeId, e.toString()); + } + } + + /** + * 提交/重提审核(幂等入口:编辑页重提按钮 / create 后置共用)。 + * audit=0/30/40 可提交;10 审批中拒绝;apply 成功回填 flow_instance_id + audit=10。 + * apply 失败抛业务异常,notice 行保持 0 待提交(不回滚已落库的编辑内容)。 + */ + public void trySubmitFlow(Integer noticeId) { + trySubmitFlow(noticeId, currentUser()); + } + + /** 提交/重提(带访问者——控制器直传登录人,BP-R5-3 守卫入口) */ + public void trySubmitFlow(Integer noticeId, BoaUser user) { + Notice notice = requireNotice(noticeId); + // BP-R5-3:Trace/TrySubmit 原先仅验存在性,任意登录用户可对他人通知触发重提——补 + // NoticeFlowGuard 三分支(提交人/管理员/办理人),与 Detail 的守卫口径对齐 + noticeFlowGuard.requireUnpublishedVisible(notice, user); + if (!StringUtils.hasText(notice.getFlowGuid())) { + throw new BizException(ErrorCode.PARAM_ERROR, "该通知未绑定审核流程"); + } + Integer audit = notice.getAuditStatus(); + if (audit != null && audit == FlowAuditStatusEnum.APPROVING.getCode()) { + throw new BizException(ErrorCode.FORBIDDEN, "审批中,不可重复提交,请先撤回"); + } + ensureRegistered(notice); + FlowModels.ApplyBody body = buildApplyBody(notice, user); + FlowModels.ApplyResultVo result = flowClient.apply(notice.getSchId(), body); + // E2E 实测教训:此处曾整实体 save——apply 出站期间回调事件可能已并发推进 + // (submitted 先行置 10 / completed 置 20),旧快照盲写会把状态打回;改为窄列原子更新, + // 且 audit=20(已通过)不回退(apply 返回晚于 completed 送达的合法场景) + noticeRepository.markApproving(noticeId, result.getInstanceId()); + log.info("[notice-flow] 提交审核成功:notice={} instance={} duplicated={}", + noticeId, result.getInstanceId(), result.isDuplicated()); + } + + /** + * 回调注册:仅 reg 缺失时注册并落 secret。绝不重复调用——flow-center 重复注册会轮换 + * secret 导致已存 secret 验签失配;失配恢复手段=管理端重新保存绑定(删除 reg 行后重提)。 + */ + 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()); + 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 前端详情、快照只收摘要不收富文本正文) */ + FlowModels.ApplyBody buildApplyBody(Notice notice, BoaUser user) { + FlowModels.ApplyBody body = new FlowModels.ApplyBody(); + body.setFlowGuid(notice.getFlowGuid()); + body.setBizType(FlowCenterClient.BIZ_TYPE); + body.setBizId(String.valueOf(notice.getId())); + body.setBizTitle(notice.getTitle()); + body.setBizUrl(properties.getFrontBaseUrl() + "/#/list/detail?serid=" + notice.getId()); + body.setBizSnapshot(buildSnapshot(notice)); + if (user != null) { + body.setSubmitterId(user.getId()); + body.setSubmitterName(StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount()); + } + return body; + } + + /** 业务摘要:{title, summary(纯文本截 300 字), attachmentNames[]}——富文本正文不传(概览 §十边界) */ + private Map buildSnapshot(Notice notice) { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("title", notice.getTitle()); + snapshot.put("summary", plainSummary(notice.getContent())); + List names = new ArrayList<>(); + for (UpFile f : upFileRepository.findByTopicTypeAndTopicId("notice", notice.getId())) { + if (StringUtils.hasText(f.getFileTrueName())) { + names.add(f.getFileTrueName()); + } + } + snapshot.put("attachmentNames", names); + return snapshot; + } + + /** 富文本剥标签取纯文本摘要(截断 300 字) */ + private String plainSummary(String content) { + if (!StringUtils.hasText(content)) { + return ""; + } + String text = content.replaceAll("<[^>]+>", "").replaceAll(" ", " ").trim(); + return text.length() > 300 ? text.substring(0, 300) : text; + } + + /** 进度组装(trace 接口):auditStatus/status/version/trace[]/currentNodes/nodeSummary;未绑流程返回未接入语义 */ + public Map buildTrace(Integer noticeId) { + return buildTrace(noticeId, currentUser()); + } + + /** + * 进度组装(带访问者,BP-R5-3):未发布通知(status=0,审核载体)仅提交人/管理员/办理人 + * 可见轨迹与驳回意见(NoticeFlowGuard 三分支);已发布(status=1)内容本就公开,登录即可看。 + */ + public Map buildTrace(Integer noticeId, BoaUser user) { + Notice notice = requireNotice(noticeId); + if (notice.getStatus() == null || notice.getStatus() != 1) { + noticeFlowGuard.requireUnpublishedVisible(notice, user); + } + Map result = new LinkedHashMap<>(); + result.put("bound", StringUtils.hasText(notice.getFlowGuid())); + result.put("flowGuid", notice.getFlowGuid()); + result.put("flowInstanceId", notice.getFlowInstanceId()); + result.put("auditStatus", notice.getAuditStatus()); + result.put("auditStatusDesc", FlowAuditStatusEnum.descOf(notice.getAuditStatus())); + result.put("status", notice.getStatus()); + if (!StringUtils.hasText(notice.getFlowGuid()) || notice.getAuditStatus() == null) { + return result; // 未接入流程中心(编辑页/详情页据此隐藏进度卡片) + } + result.put("flowResult", parseJson(notice.getFlowResultJson())); + if (properties.isEnabled()) { + try { + FlowModels.InstanceVo instance = flowClient.getInstanceByBiz( + notice.getSchId(), FlowCenterClient.BIZ_TYPE, String.valueOf(notice.getId())); + if (instance != null) { + result.put("instanceStatus", instance.getStatus()); + result.put("version", instance.getVersion()); + result.put("nodeSummary", instance.getNodeSummary()); + result.put("currentNodes", instance.getCurrentNodes()); + result.put("assigneeIds", instance.getAssigneeIds()); + result.put("submitTime", instance.getSubmitTime()); + result.put("finishTime", instance.getFinishTime()); + result.put("trace", parseJson(instance.getTraceJson())); + } + } catch (Exception e) { + // 进度查询失败不阻断详情:展示本地镜像态即可(流程中心停机时详情仍可用) + log.warn("[notice-flow] 进度查询失败:notice={} {}", noticeId, e.toString()); + } + } + return result; + } + + /** 编辑页"审核流程"选择器数据源(未启用返回空列表由前端隐藏) */ + public List options(Integer schId) { + if (!properties.isEnabled()) { + return new ArrayList<>(); + } + try { + return flowClient.listFlows(schId); + } catch (BizException e) { + throw e; + } catch (Exception e) { + log.warn("[notice-flow] 流程列表查询失败:{}", e.toString()); + throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "审核流程列表获取失败,请稍后重试"); + } + } + + private BoaUser currentUser() { + return currentBoaUser == null ? null : currentBoaUser.get(); + } + + private Notice requireNotice(Integer noticeId) { + return noticeRepository.findById(noticeId) + .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)) { + return null; + } + try { + return objectMapper.readTree(json); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/yunhai/notice/service/impl/ClassService.java b/src/main/java/com/yunhai/notice/service/impl/ClassService.java index 6e9407e..6780e3c 100644 --- a/src/main/java/com/yunhai/notice/service/impl/ClassService.java +++ b/src/main/java/com/yunhai/notice/service/impl/ClassService.java @@ -46,6 +46,7 @@ public class ClassService implements IClassService { if (!StringUtils.hasText(userClassHost)) { throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "班级管理接口未配置(yh.open.userclass)"); } + String url = userClassHost + "/oapi/DbClass/GetSchteaList?Group=" + schId; try { ResponseEntity> resp = restTemplate.exchange( 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 955fc64..3de0bfd 100644 --- a/src/main/java/com/yunhai/notice/service/impl/NoticeService.java +++ b/src/main/java/com/yunhai/notice/service/impl/NoticeService.java @@ -71,6 +71,9 @@ public class NoticeService implements INoticeService { @Autowired private SmsSender smsSender; + @Autowired + private com.yunhai.notice.service.sms.SmsSendService smsSendService; + @Autowired private IFileService fileService; @@ -80,7 +83,15 @@ public class NoticeService implements INoticeService { @Autowired private CurrentBoaUser currentBoaUser; + @Autowired + private com.yunhai.notice.service.flow.NoticeFlowService noticeFlowService; + + @Autowired + private com.yunhai.notice.service.flow.NoticeFlowGuard noticeFlowGuard; + private static final int PUBLISHED = NoticeStatusEnum.PUBLISHED.getCode(); + private static final int UNPUBLISHED = NoticeStatusEnum.UNPUBLISHED.getCode(); + private static final int AUDIT_APPROVING = com.yunhai.notice.enums.FlowAuditStatusEnum.APPROVING.getCode(); /** * 当前登录人账号(来自平台 UserContextHolder,无登录态时为 null)。 @@ -120,23 +131,50 @@ public class NoticeService implements INoticeService { entity.setId(null); entity.setDeptName(resolveDeptName(request)); normalizeReceiveList(entity, request.getReceiveList()); - // 无草稿态:一律置为「已发布」;定时发送保留 timingSendTime,由查询侧按「已到点」过滤可见性 - entity.setStatus(PUBLISHED); + // 发布分流(先审后发):绑 flowGuid → status=0 未发布(审核载体)+ audit=0 待提交, + // 提交动作 AFTER_COMMIT 出站(D9:apply 不进 DB 事务,失败保持 0 可幂等重推); + // 未绑 → 原直发链路一字不改(status=1 即发布) + boolean flowBound = StringUtils.hasText(request.getFlowGuid()); + if (flowBound) { + entity.setStatus(UNPUBLISHED); + entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode()); + entity.setFlowGuid(request.getFlowGuid().trim()); + } else { + entity.setStatus(PUBLISHED); + } entity.setPublisherAccount(currentAccount()); entity.setPublisherName(currentName()); entity.setSchId(currentSchId()); entity.setIsPulled(0); entity.setReadCount(0); noticeRepository.save(entity); - // 写入接收人关联表(非全员时) + // 写入接收人关联表(非全员时;status=0 不可见,提前绑定无害) saveReceivers(entity); // 绑定附件(前端上传后回传的 fileIds 逗号串) fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); - // 立即发布且开启短信时预留短信发送(真实通道后续接入) - trySendSms(entity); + if (flowBound) { + // 短信时机后移:绑流程的通知 create 时不发,回调 completed 才发(审核通过才发) + noticeFlowService.submitAfterCommit(entity.getId()); + } else { + // 立即发布且开启短信时预留短信发送(真实通道后续接入) + trySendSms(entity); + } return entity.getId(); } + /** + * 未发布通知(status=0 审核载体)详情放行:仅提交人本人/本校管理员/该办件办理人可见, + * 其余一律"通知不存在或未发布"(查不到语义)。status=1 已发布零变化。 + */ + private void requireVisibleIfUnpublished(Integer serid) { + Notice notice = noticeRepository.findById(serid).orElse(null); + if (notice == null || notice.getStatus() == null + || notice.getStatus() == NoticeStatusEnum.PUBLISHED.getCode()) { + return; // 不存在(沿用后续查询的报错)或已发布(既有行为) + } + noticeFlowGuard.requireUnpublishedVisible(notice, currentBoaUser.get()); + } + // ============================ 编辑 ============================ @Override @@ -145,6 +183,12 @@ public class NoticeService implements INoticeService { Objects.requireNonNull(id, "通知ID不得为空"); Notice entity = noticeRepository.findById(id) .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + id)); + // 审批中锁定:防审批做一半被抽走(删除需先撤回);驳回/撤回态允许编辑后重提 + if (entity.getAuditStatus() != null && entity.getAuditStatus() == AUDIT_APPROVING) { + throw new BizException(ErrorCode.FORBIDDEN, "审批中不可编辑,请先撤回"); + } + boolean wasFlowBound = StringUtils.hasText(entity.getFlowGuid()); + boolean nowFlowBound = StringUtils.hasText(request.getFlowGuid()); entity.setTitle(request.getTitle()); entity.setContent(request.getContent()); entity.setType(request.getType()); @@ -169,7 +213,25 @@ public class NoticeService implements INoticeService { } fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); } + // 审核绑定变更:新绑/换绑 → 回到 0 待提交(提交动作 AFTER_COMMIT 出站);解绑 → 直发即发布 + if (nowFlowBound) { + entity.setFlowGuid(request.getFlowGuid().trim()); + entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode()); + // 清上一轮驳回意见:Notice 侧 flow_result_json 只在终态事件写入(FlowCallbackService:173), + // 不清则重提后整段新审批期都挂着旧驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能) + entity.setFlowResultJson(null); + } else if (wasFlowBound) { + entity.setFlowGuid(null); + entity.setAuditStatus(null); + entity.setFlowInstanceId(null); + entity.setLastInstanceVersion(null); + entity.setFlowResultJson(null); + } noticeRepository.save(entity); + if (nowFlowBound) { + // 编辑重提:复用同一 bizId(notice.id)→ flow-center R4 语义重启推进,轨迹连续 + noticeFlowService.submitAfterCommit(entity.getId()); + } } /** 解析 "1,2,3" 形式的附件ID串,忽略非法片段 */ @@ -201,9 +263,14 @@ public class NoticeService implements INoticeService { return; } for (Integer id : ids) { - if (!noticeRepository.existsById(id)) { + Notice row = noticeRepository.findById(id).orElse(null); + if (row == null) { continue; } + if (row.getAuditStatus() != null && row.getAuditStatus() == AUDIT_APPROVING) { + throw new BizException(ErrorCode.FORBIDDEN, + "通知「" + row.getTitle() + "」审批中不可删除,请先撤回"); + } noticeReadRepository.deleteByNoticeId(id); noticeReceiverRepository.deleteByNoticeId(id); upFileRepository.deleteByTopicTypeAndTopicId("notice", id); @@ -410,7 +477,9 @@ entity.setIsPulled(0); String account = currentAccount(); boolean read = noticeReadRepository.existsByNoticeIdAndReaderAccount(id, account); NoticeVo vo = toVo(entity, read); - if (!read) { + // 仅已发布通知标读:审批人(NoticeFlowGuard 放行的三类人)查看未发布通知属审核动作, + // 不得记入阅读统计污染已读名单(走查报告 §七.4) + if (!read && entity.getStatus() != null && entity.getStatus() == PUBLISHED) { markRead(id); vo.setRead(true); } @@ -739,6 +808,7 @@ entity.setIsPulled(0); @Override public NoticeDetailVo getDetailMap(Integer serid) { + requireVisibleIfUnpublished(serid); NoticeVo vo = getDetail(serid); NoticeDetailVo data = new NoticeDetailVo(); data.setSerid(serid); @@ -770,6 +840,20 @@ entity.setIsPulled(0); } else { data.setTlist(parseReceiveAccounts(rl)); } + // 先审后发镜像(编辑页驳回/撤回态展示意见;详情页进度卡片入口) + data.setFlowGuid(vo.getFlowGuid()); + data.setAuditStatus(vo.getAuditStatus()); + data.setAuditStatusDesc(vo.getAuditStatusDesc()); + data.setFlowInstanceId(vo.getFlowInstanceId()); + Notice flowSource = noticeRepository.findById(serid).orElse(null); + if (flowSource != null && StringUtils.hasText(flowSource.getFlowResultJson())) { + try { + data.setFlowResult(new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(flowSource.getFlowResultJson(), Object.class)); + } catch (Exception ignore) { + data.setFlowResult(null); + } + } return data; } @@ -835,6 +919,11 @@ entity.setIsPulled(0); } vo.setRead(read); vo.setReadCount(noticeReadRepository.countByNoticeId(entity.getId())); + // 先审后发镜像(未绑流程为 null,前端据此隐藏审核列/进度卡片) + vo.setFlowGuid(entity.getFlowGuid()); + vo.setAuditStatus(entity.getAuditStatus()); + vo.setAuditStatusDesc(com.yunhai.notice.enums.FlowAuditStatusEnum.descOf(entity.getAuditStatus())); + vo.setFlowInstanceId(entity.getFlowInstanceId()); return vo; } @@ -853,10 +942,10 @@ entity.setIsPulled(0); return; } if (!isImmediatePublish(entity)) { + // 定时未到点:此刻不发(可见性靠查询谓词延后),到点由 SmsCatchupJob 补发(§3.2) return; } - smsSender.sendNoticeSms(entity.getId(), entity.getTitle(), - parseReceiveList(entity.getReceiveList())); + smsSendService.sendOnce(entity); } private boolean isImmediatePublish(Notice entity) { diff --git a/src/main/java/com/yunhai/notice/service/sms/SmsSendService.java b/src/main/java/com/yunhai/notice/service/sms/SmsSendService.java new file mode 100644 index 0000000..7a181b6 --- /dev/null +++ b/src/main/java/com/yunhai/notice/service/sms/SmsSendService.java @@ -0,0 +1,50 @@ +package com.yunhai.notice.service.sms; + +import com.yunhai.notice.entity.Notice; +import com.yunhai.notice.repository.NoticeRepository; +import com.yunhai.notice.utils.ReceiveParser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 短信统一发送(§3.2 / BP-R5-2):直发(publish 即时)、审批通过(completed 回调后置)、 + * 到点补发(SmsCatchupJob)三条链路共用,以 sms_sent_at 原子认领防重复—— + * 认领成功才发送,失败归还标记待下轮补发。修复"审批通过时未到定时点 → 短信永久丢失"缺口。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SmsSendService { + + private final NoticeRepository noticeRepository; + private final SmsSender smsSender; + + /** + * 发一次短信(幂等):sms_flag!=1 或已发过(认领失败)直接跳过。 + * 发送抛异常时归还认领(下轮补发可重试)后原样上抛——发布/回调链路维持既有 5xx 语义。 + */ + @Transactional + public boolean sendOnce(Notice notice) { + if (notice == null || notice.getSmsFlag() == null || notice.getSmsFlag() != 1) { + return false; + } + if (noticeRepository.markSmsSent(notice.getId(), LocalDateTime.now()) == 0) { + return false; // 已发送过(并发/重复调度/重发布轮次的闸门) + } + try { + smsSender.sendNoticeSms(notice.getId(), notice.getTitle(), + ReceiveParser.parse(notice.getReceiveList())); + log.info("[notice-sms] 短信已发送 notice={} title={}", notice.getId(), notice.getTitle()); + return true; + } catch (Exception e) { + noticeRepository.clearSmsSent(notice.getId()); + log.warn("[notice-sms] 短信发送失败(归还认领,待补发重试)notice={}:{}", + notice.getId(), e.toString()); + throw e; + } + } +} diff --git a/src/main/java/com/yunhai/notice/utils/ReReadableRequestWrapper.java b/src/main/java/com/yunhai/notice/utils/ReReadableRequestWrapper.java new file mode 100644 index 0000000..2cc71f2 --- /dev/null +++ b/src/main/java/com/yunhai/notice/utils/ReReadableRequestWrapper.java @@ -0,0 +1,63 @@ +package com.yunhai.notice.utils; + +import javax.servlet.ReadListener; +import javax.servlet.ServletInputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * 可重读请求包装(回调验签用):FlowCallbackVerifyFilter 读 body 验签后, + * 控制器仍需 @RequestBody 反序列化——包装类缓存原始字节供二次读取。 + */ +public class ReReadableRequestWrapper extends HttpServletRequestWrapper { + + private final byte[] body; + + public ReReadableRequestWrapper(HttpServletRequest request) throws IOException { + super(request); + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (InputStream in = request.getInputStream()) { + byte[] chunk = new byte[4096]; + int read; + while ((read = in.read(chunk)) > 0) { + buffer.write(chunk, 0, read); + } + } + this.body = buffer.toByteArray(); + } + + public String bodyString() { + return new String(body, StandardCharsets.UTF_8); + } + + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream source = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public boolean isFinished() { + return source.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener listener) { + throw new UnsupportedOperationException(); + } + + @Override + public int read() { + return source.read(); + } + }; + } +} diff --git a/src/main/java/com/yunhai/notice/utils/ReceiveParser.java b/src/main/java/com/yunhai/notice/utils/ReceiveParser.java new file mode 100644 index 0000000..a439e49 --- /dev/null +++ b/src/main/java/com/yunhai/notice/utils/ReceiveParser.java @@ -0,0 +1,48 @@ +package com.yunhai.notice.utils; + +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 接收人列表解析工具(自 NoticeService.parseReceiveAccounts 提炼为公共类): + * "ALL"/"UserAll"/"all"=全员(返回空列表);JSON 数组串或逗号串 → 账号列表; + * 为空或解析失败返回空列表(表示全员通知)。 + */ +public final class ReceiveParser { + + private ReceiveParser() { + } + + public static List parse(String receiveList) { + if (receiveList == null || receiveList.trim().isEmpty()) { + return Collections.emptyList(); + } + String rl = receiveList.trim(); + if ("ALL".equalsIgnoreCase(rl) || "UserAll".equalsIgnoreCase(rl) || "all".equalsIgnoreCase(rl)) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + if (rl.startsWith("[")) { + String inner = rl.substring(1, rl.length() - 1).trim(); + if (!inner.isEmpty()) { + for (String part : inner.split(",")) { + String account = part.trim().replace("\"", "").replace("'", ""); + if (StringUtils.hasText(account) && !result.contains(account)) { + result.add(account); + } + } + } + } else { + for (String part : rl.split(",")) { + String account = part.trim(); + if (StringUtils.hasText(account) && !result.contains(account)) { + result.add(account); + } + } + } + return result; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3e3a7c5..e485ce8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -50,6 +50,23 @@ sms: enabled: true module-id: notice +# 先审后发(流程中心托管接缝):enabled=false 时编辑页选择器隐藏、FlowClient 降级 +notice: + flow: + enabled: true + # flow-center 基址(Tier 0 目标,裸地址直连 base-path 空) + base-url: http://localhost:8085 + # 本应用对外可达基址(flow-center 回调探活/事件推送打这里;本地=后端直连端口) + callback-base: http://localhost:8101 + callback-path: /callback/flow + # Notice 前端基址(apply bizUrl 拼接,审批人点击跳回通知详情) + front-base-url: http://localhost:8077 + # 短信到点补发调度(§3.2 修 BP-R5-2):fixedDelay 5min,扫"已发布+要短信+未发过+已到点" + # 补发(幂等由 notice.sms_sent_at 原子认领保证)。生产加列见 deploy/sql/20260913_add_sms_sent_at.sql + sms: + catchup-enabled: true + catchup-fixed-delay-ms: 300000 + # 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth) yh: sso: @@ -61,9 +78,16 @@ yh: mobi-api-pattern: /mobiapi/* app-auth: enabled: true - # 本应用自身身份(客户端签名用) - app-key: 100001 - app-secret: ec4e860206374c80938719fff8e14a86 + # 本应用自身身份(客户端签名用):本地联调一律用 100617 与生产权威(deploy/notice.yaml)一致, + # 消除与 UserHome 撞号的 100001 占位(升级方案 D8);flow-center trusted-apps 已同步收此号段 + app-key: 100617 + app-secret: 2a3b4c5d6e7f8091a2b3c4d5e6f70819 + # 提供方强制验签(两级模型 §4.1 试点 P2.1):/oapi/** 匿名 401、注册应用签名直调通过 + server: + enforce-patterns: [/oapi/*] + # 入站验签白名单:flow-center(回调注册探活外的 oapi 调用方 demo;无 Redis 不开 trust-registry) + trusted-apps: + "100019": "flow-center-dev-secret-100019" open: enabled: true base-host: http://base.yunhaixingtong.com diff --git a/src/test/java/com/yunhai/notice/config/FlowCallbackVerifyFilterTest.java b/src/test/java/com/yunhai/notice/config/FlowCallbackVerifyFilterTest.java new file mode 100644 index 0000000..340f4d3 --- /dev/null +++ b/src/test/java/com/yunhai/notice/config/FlowCallbackVerifyFilterTest.java @@ -0,0 +1,151 @@ +package com.yunhai.notice.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.notice.entity.NoticeFlowReg; +import com.yunhai.notice.repository.NoticeFlowRegRepository; +import com.yunhai.notice.utils.ReReadableRequestWrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.util.Base64; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * 回调验签过滤器单测(T2.3):四头缺失/时间窗外/签名错 → 401;合法 → 放行; + * ping 探活 → 直接 200(不进业务链)。 + */ +class FlowCallbackVerifyFilterTest { + + private static final String SECRET = "verify-test-secret-0123456789"; + private static final String BODY = + "{\"eventId\":\"e1\",\"event\":\"flow.completed\",\"schId\":1,\"flowGuid\":\"fg-1\"}"; + + private NoticeFlowRegRepository regRepository; + private FlowCallbackFilterConfiguration.FlowCallbackVerifyFilter filter; + + @BeforeEach + void setUp() { + regRepository = Mockito.mock(NoticeFlowRegRepository.class); + NoticeFlowReg reg = new NoticeFlowReg(); + reg.setSchId(1); + reg.setFlowGuid("fg-1"); + reg.setSecret(SECRET); + reg.setStatus(0); + Mockito.when(regRepository.findBySchIdAndFlowGuid(1, "fg-1")).thenReturn(Optional.of(reg)); + FlowCallbackFilterConfiguration configuration = + new FlowCallbackFilterConfiguration(regRepository, new ObjectMapper()); + filter = configuration.new FlowCallbackVerifyFilter(); + } + + private MockHttpServletRequest request() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/callback/flow"); + request.setContent(BODY.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return request; + } + + private String sign(String ts, String nonce, String body) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET.getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA256")); + byte[] raw = mac.doFinal((ts + "." + nonce + "." + body).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(raw); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + @Test + void missingHeaders_401() throws Exception { + MockHttpServletRequest request = request(); // 无任何 X-Flw-* 头 + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, new MockFilterChain()); + assertEquals(401, response.getStatus()); + } + + @Test + void timestampOutOfWindow_401() throws Exception { + MockHttpServletRequest request = request(); + String oldTs = String.valueOf(System.currentTimeMillis() - 10 * 60 * 1000L); // 10 分钟前 + request.addHeader("X-Flw-EventId", "e1"); + request.addHeader("X-Flw-Timestamp", oldTs); + request.addHeader("X-Flw-Nonce", "nonce00000000001"); + request.addHeader("X-Flw-Signature", sign(oldTs, "nonce00000000001", BODY)); + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, new MockFilterChain()); + assertEquals(401, response.getStatus()); + } + + @Test + void badSignature_401() throws Exception { + MockHttpServletRequest request = request(); + String ts = String.valueOf(System.currentTimeMillis()); + request.addHeader("X-Flw-EventId", "e1"); + request.addHeader("X-Flw-Timestamp", ts); + request.addHeader("X-Flw-Nonce", "nonce00000000001"); + request.addHeader("X-Flw-Signature", "forged-signature-not-valid"); + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, new MockFilterChain()); + assertEquals(401, response.getStatus()); + } + + @Test + void validSignature_chainProceeds() throws Exception { + MockHttpServletRequest request = request(); + String ts = String.valueOf(System.currentTimeMillis()); + request.addHeader("X-Flw-EventId", "e1"); + request.addHeader("X-Flw-Timestamp", ts); + request.addHeader("X-Flw-Nonce", "nonce00000000001"); + request.addHeader("X-Flw-Signature", sign(ts, "nonce00000000001", BODY)); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + filter.doFilter(request, response, chain); + assertEquals(200, response.getStatus()); + assertNotNull(chain.getRequest(), "合法签名应放行"); + // 放行给下游的是可重读 wrapper,控制器 @RequestBody 仍能拿到完整 body + assertEquals(BODY, ((ReReadableRequestWrapper) chain.getRequest()).bodyString()); + } + + @Test + void pingProbe_200Immediately() throws Exception { + String ping = "{\"event\":\"ping\",\"flowGuid\":\"fg-1\"}"; + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/callback/flow"); + request.setContent(ping.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + filter.doFilter(request, response, chain); + assertEquals(200, response.getStatus(), "注册探活 ping 应直接 200"); + assertNull(chain.getRequest(), "ping 不应进业务链"); + } + + @Test + void unknownRegistration_401() throws Exception { + Mockito.when(regRepository.findBySchIdAndFlowGuid(1, "fg-1")).thenReturn(Optional.empty()); + MockHttpServletRequest request = request(); + String ts = String.valueOf(System.currentTimeMillis()); + request.addHeader("X-Flw-EventId", "e1"); + request.addHeader("X-Flw-Timestamp", ts); + request.addHeader("X-Flw-Nonce", "nonce00000000001"); + request.addHeader("X-Flw-Signature", sign(ts, "nonce00000000001", BODY)); + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, new MockFilterChain()); + assertEquals(401, response.getStatus()); + } + + @Test + void nonPost_405() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/callback/flow"); + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, new MockFilterChain()); + assertEquals(405, response.getStatus()); + } +} diff --git a/src/test/java/com/yunhai/notice/flow/FlowCallbackTransientIT.java b/src/test/java/com/yunhai/notice/flow/FlowCallbackTransientIT.java new file mode 100644 index 0000000..fccf62a --- /dev/null +++ b/src/test/java/com/yunhai/notice/flow/FlowCallbackTransientIT.java @@ -0,0 +1,152 @@ +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; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; + +/** + * 回调瞬时失败 5xx 语义 IT(T2.3 核心断言,"免对账"成立的前提): + * 发布后置(短信)故障 → 回调 500(非 200),事务整体回滚(事件行不留); + * 故障恢复后重投同一事件 → 200 + 状态正确追平——证明 flow-center 持续重试可治愈。 + */ +@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"}) +class FlowCallbackTransientIT { + + private static final String REG_SECRET = "it-transient-secret-0123456789"; + private static final String FLOW_GUID = "it-transient-guid-01"; + + @LocalServerPort + private int port; + + @Autowired + private NoticeRepository noticeRepository; + @Autowired + private NoticeFlowRegRepository regRepository; + @Autowired + private NoticeFlowEventRepository eventRepository; + + @MockBean + private SmsSender smsSender; + + private Notice notice; + + @BeforeEach + void setUp() { + eventRepository.deleteAll(); + regRepository.deleteAll(); + noticeRepository.deleteAll(); + + NoticeFlowReg reg = new NoticeFlowReg(); + reg.setSchId(1); + reg.setAppKey("100617"); + reg.setFlowGuid(FLOW_GUID); + reg.setCallbackUrl("http://localhost:" + port + "/callback/flow"); + reg.setSecret(REG_SECRET); + reg.setStatus(0); + regRepository.save(reg); + + notice = new Notice(); + notice.setTitle("瞬时失败通知"); + notice.setType(1); + notice.setStatus(0); + notice.setAuditStatus(10); + notice.setFlowGuid(FLOW_GUID); + notice.setSchId(1); + notice.setPublisherAccount("it-user"); + notice.setSmsFlag(1); + notice.setReceiveList("[\"tea1\"]"); + notice = noticeRepository.save(notice); + } + + @Test + void transientFailure_returns5xx_thenRecoversOnRetry() { + // 阶段一:短信后置抛异常 → 500 + 事务回滚(事件行/notice 推进均不留) + Mockito.doThrow(new RuntimeException("sms gateway down")) + .when(smsSender).sendNoticeSms(anyInt(), anyString(), anyList()); + String eventId = uuid(); + ResponseEntity first = postCompleted(eventId); + assertEquals(500, first.getStatusCodeValue(), "瞬时失败必须回 5xx(重试治愈的唯一信号)"); + Notice after = reload(); + assertEquals(10, after.getAuditStatus(), "失败事务整体回滚"); + assertEquals(0, after.getStatus()); + assertEquals(0L, eventRepository.count(), "事件去重行随事务回滚(重投不撞 uk)"); + + // 阶段二:恢复后重投同一事件 → 200 + 状态追平(flow-center 持续重试语义闭环) + Mockito.doNothing().when(smsSender).sendNoticeSms(anyInt(), anyString(), anyList()); + ResponseEntity second = postCompleted(eventId); + assertEquals(200, second.getStatusCodeValue()); + Notice recovered = reload(); + assertEquals(20, recovered.getAuditStatus()); + assertEquals(1, recovered.getStatus(), "恢复后审核通过正常发布"); + assertEquals(1L, eventRepository.count()); + // 短信共尝试 2 次:故障期 1 次(抛异常、随事务回滚不算已发)+ 恢复后成功 1 次 + Mockito.verify(smsSender, Mockito.times(2)).sendNoticeSms(anyInt(), anyString(), anyList()); + } + + private Notice reload() { + return noticeRepository.findById(notice.getId()).orElseThrow(IllegalStateException::new); + } + + private ResponseEntity postCompleted(String eventId) { + String body = "{" + + "\"eventId\":\"" + eventId + "\",\"event\":\"flow.completed\"," + + "\"schId\":1,\"flowGuid\":\"" + FLOW_GUID + "\",\"instanceId\":9527," + + "\"instanceVersion\":3," + + "\"bizType\":\"notice\",\"bizId\":\"" + notice.getId() + "\",\"bizTitle\":\"瞬时失败通知\"," + + "\"data\":{\"result\":\"APPROVED\",\"opinion\":\"同意\"}}"; + String ts = String.valueOf(System.currentTimeMillis()); + String nonce = uuid().substring(0, 16); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Flw-EventId", eventId); + headers.set("X-Flw-Timestamp", ts); + headers.set("X-Flw-Nonce", nonce); + headers.set("X-Flw-Signature", hmac(REG_SECRET, ts, nonce, body)); + return new org.springframework.boot.test.web.client.TestRestTemplate().postForEntity( + "http://localhost:" + port + "/callback/flow", + new HttpEntity<>(body.getBytes(StandardCharsets.UTF_8), headers), String.class); + } + + private static String hmac(String secret, String ts, String nonce, String body) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] raw = mac.doFinal((ts + "." + nonce + "." + body).getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(raw); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static String uuid() { + return UUID.randomUUID().toString().replace("-", ""); + } +} diff --git a/src/test/java/com/yunhai/notice/flow/NoticeFlowApiIT.java b/src/test/java/com/yunhai/notice/flow/NoticeFlowApiIT.java new file mode 100644 index 0000000..31c519c --- /dev/null +++ b/src/test/java/com/yunhai/notice/flow/NoticeFlowApiIT.java @@ -0,0 +1,263 @@ +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.support.OapiSignedClient; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Notice 流程接缝 IT(T2.1/T2.3/T2.5,RANDOM_PORT 真实过滤链 + bsch_notice_test): + * ① X-Flw 回调:合法签名 200 推进状态 / 坏签名 401 / 毒 body(缺字段)200 / ping 200 / 重复 eventId 幂等; + * ② /oapi/notice/getDict 提供方三态(匿名 401 / 合法签名 200 / 错误密钥 401); + * ③ notice 表 5 新列与 notice_flow_reg uk 的真实落库。 + */ +@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"}) +class NoticeFlowApiIT { + + private static final String REG_SECRET = "it-callback-secret-0123456789"; + private static final String FLOW_GUID = "it-flow-guid-0001"; + + @LocalServerPort + private int port; + + @Autowired + private NoticeRepository noticeRepository; + @Autowired + private NoticeFlowRegRepository regRepository; + @Autowired + private NoticeFlowEventRepository eventRepository; + @Autowired + private ObjectMapper objectMapper; + + private Notice notice; + + @BeforeEach + void setUp() { + eventRepository.deleteAll(); + regRepository.deleteAll(); + noticeRepository.deleteAll(); + + NoticeFlowReg reg = new NoticeFlowReg(); + reg.setSchId(1); + reg.setAppKey("100617"); + reg.setFlowGuid(FLOW_GUID); + reg.setCallbackUrl("http://localhost:" + port + "/callback/flow"); + reg.setSecret(REG_SECRET); + reg.setStatus(0); + regRepository.save(reg); + + notice = new Notice(); + notice.setTitle("IT 审核通知"); + notice.setContent("

it

"); + notice.setType(1); + notice.setStatus(0); + notice.setAuditStatus(10); + notice.setFlowGuid(FLOW_GUID); + notice.setSchId(1); + notice.setPublisherAccount("it-user"); + notice.setSmsFlag(1); + notice.setReceiveList("[\"tea1\"]"); + notice = noticeRepository.save(notice); + } + + // ==================== ① X-Flw 回调 ==================== + + @Test + void signedCompletedEvent_advancesNoticeToPublished() { + String eventId = uuid(); + String body = eventBody(eventId, "flow.completed", 3, + "{\"result\":\"APPROVED\",\"opinion\":\"同意\"}"); + ResponseEntity resp = postSignedCallback(REG_SECRET, body); + assertEquals(200, resp.getStatusCodeValue(), "合法签名事件应 200"); + + Notice updated = reload(); + assertEquals(20, updated.getAuditStatus()); + assertEquals(1, updated.getStatus(), "completed → status=1 正式发布"); + assertEquals(3, updated.getLastInstanceVersion()); + assertNotNull(updated.getFlowResultJson()); + assertTrue(updated.getFlowResultJson().contains("同意")); + // 事件去重行落库(事务提交) + assertEquals(1L, eventRepository.count()); + } + + @Test + void duplicateEventId_idempotentSecondDelivery() { + String eventId = uuid(); + String body = eventBody(eventId, "flow.completed", 3, "{\"result\":\"APPROVED\"}"); + assertEquals(200, postSignedCallback(REG_SECRET, body).getStatusCodeValue()); + assertEquals(200, postSignedCallback(REG_SECRET, body).getStatusCodeValue(), + "重复投递必须回 200(flow-center 持续重试语义下的常态)"); + assertEquals(20, reload().getAuditStatus()); + assertEquals(1L, eventRepository.count(), "eventId 唯一:重复投递不落第二行"); + } + + @Test + void missingFieldsBody_200_silentDrop() { + // 合法 JSON 但缺事件字段:过滤链可过(含 flowGuid/schId 可验签),业务层判毒丢弃回 200 + String body = "{\"eventId\":\"" + uuid() + "\",\"schId\":1,\"flowGuid\":\"" + FLOW_GUID + "\"}"; + ResponseEntity resp = postSignedCallback(REG_SECRET, body); + assertEquals(200, resp.getStatusCodeValue(), "缺字段毒消息回 200 防无限重放"); + assertEquals(10, reload().getAuditStatus(), "状态不得被毒消息推进"); + } + + @Test + void badSignature_401() { + String body = eventBody(uuid(), "flow.completed", 3, "{}"); + ResponseEntity resp = postCallback(body, "forged-signature"); + assertEquals(401, resp.getStatusCodeValue()); + assertEquals(10, reload().getAuditStatus()); + } + + @Test + void pingProbe_200_withoutSignature() { + String body = "{\"event\":\"ping\",\"flowGuid\":\"" + FLOW_GUID + "\",\"occurredTime\":\"2026-09-13 00:00:00\"}"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity resp = restTemplate().postForEntity(url("/callback/flow"), + new HttpEntity<>(body.getBytes(StandardCharsets.UTF_8), headers), String.class); + assertEquals(200, resp.getStatusCodeValue(), "注册探活 ping 直接 200"); + assertEquals(0L, eventRepository.count()); + } + + // ==================== ② /oapi/notice/getDict 三态 ==================== + + @Test + void oapiGetDict_threeStates() { + OapiSignedClient client = new OapiSignedClient(url(""), "100019", "flow-center-dev-secret-100019"); + // 匿名(无签名头)→ 401 + assertEquals(401, client.getRaw("/oapi/notice/getDict?type=1").getStatusCodeValue()); + // 错误密钥 → 401 + assertEquals(401, client.getWithBadSecret("/oapi/notice/getDict?type=1").getStatusCodeValue()); + // 合法签名 → 200(信封 data 为字典数组——空校库也是合法数组) + ResponseEntity ok = client.get("/oapi/notice/getDict?type=1", JsonNode.class); + assertEquals(200, ok.getStatusCodeValue()); + assertTrue(ok.getBody() != null, "应返回信封(code=0)"); + } + + // ==================== ③ 实体落库 ==================== + + @Test + void flowColumns_roundtrip_andRegUk() { + Notice row = new Notice(); + row.setTitle("列回读"); + row.setType(1); + row.setStatus(0); + row.setFlowGuid("fg-cols"); + row.setFlowInstanceId(12345L); + row.setAuditStatus(30); + row.setLastInstanceVersion(4); + row.setFlowResultJson("{\"opinion\":\"改后重提\"}"); + row.setSchId(1); + row.setPublisherAccount("it-user"); + Notice saved = noticeRepository.save(row); + Notice loaded = noticeRepository.findById(saved.getId()).orElseThrow(IllegalStateException::new); + assertEquals("fg-cols", loaded.getFlowGuid()); + assertEquals(12345L, loaded.getFlowInstanceId()); + assertEquals(30, loaded.getAuditStatus()); + assertEquals(4, loaded.getLastInstanceVersion()); + assertEquals("{\"opinion\":\"改后重提\"}", loaded.getFlowResultJson()); + // reg uk(sch,app,flow) 冲突路径:同键二连插抛约束冲突 + NoticeFlowReg dup = new NoticeFlowReg(); + dup.setSchId(1); + dup.setAppKey("100617"); + dup.setFlowGuid(FLOW_GUID); + dup.setSecret("another-secret-0123456789"); + dup.setStatus(0); + try { + regRepository.saveAndFlush(dup); + throw new AssertionError("uk 冲突应抛异常"); + } catch (org.springframework.dao.DataIntegrityViolationException expected) { + // uk 生效 + } + } + + // ==================== 工具 ==================== + + private Notice reload() { + return noticeRepository.findById(notice.getId()).orElseThrow(IllegalStateException::new); + } + + private String url(String path) { + return "http://localhost:" + port + path; + } + + private org.springframework.boot.test.web.client.TestRestTemplate restTemplate() { + return new org.springframework.boot.test.web.client.TestRestTemplate(); + } + + private String eventBody(String eventId, String event, int version, String dataJson) { + return "{" + + "\"eventId\":\"" + eventId + "\",\"event\":\"" + event + "\"," + + "\"schId\":1,\"flowGuid\":\"" + FLOW_GUID + "\",\"instanceId\":9527," + + "\"instanceVersion\":" + version + "," + + "\"bizType\":\"notice\",\"bizId\":\"" + notice.getId() + "\",\"bizTitle\":\"IT 审核通知\"," + + "\"occurredTime\":\"2026-09-13 00:00:00\"," + + "\"data\":" + dataJson + "}"; + } + + private ResponseEntity postCallback(String body, String signature) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Flw-EventId", uuid()); + headers.set("X-Flw-Timestamp", String.valueOf(System.currentTimeMillis())); + headers.set("X-Flw-Nonce", uuid().substring(0, 16)); + headers.set("X-Flw-Signature", signature); + return restTemplate().postForEntity(url("/callback/flow"), + new HttpEntity<>(body.getBytes(StandardCharsets.UTF_8), headers), String.class); + } + + /** 合法签名回调(与生产 CallbackPushService 同构:ts/nonce/sign 三者同源) */ + private ResponseEntity postSignedCallback(String secret, String body) { + String ts = String.valueOf(System.currentTimeMillis()); + String nonce = uuid().substring(0, 16); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Flw-EventId", uuid()); + headers.set("X-Flw-Timestamp", ts); + headers.set("X-Flw-Nonce", nonce); + headers.set("X-Flw-Signature", hmac(secret, ts, nonce, body)); + return restTemplate().postForEntity(url("/callback/flow"), + new HttpEntity<>(body.getBytes(StandardCharsets.UTF_8), headers), String.class); + } + + private static String hmac(String secret, String ts, String nonce, String body) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] raw = mac.doFinal((ts + "." + nonce + "." + body).getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(raw); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static String uuid() { + return UUID.randomUUID().toString().replace("-", ""); + } +} diff --git a/src/test/java/com/yunhai/notice/job/SmsCatchupJobTest.java b/src/test/java/com/yunhai/notice/job/SmsCatchupJobTest.java new file mode 100644 index 0000000..3fd88a2 --- /dev/null +++ b/src/test/java/com/yunhai/notice/job/SmsCatchupJobTest.java @@ -0,0 +1,137 @@ +package com.yunhai.notice.job; + +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Collections; + + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +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.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; + +/** + * 短信到点补发单测(§3.2 修 BP-R5-2:审批通过早于定时点 → 短信永久丢失): + * 到点恰发一次;未到点(扫描谓词不含)不发;并发/重复调度认领失败不双发; + * 发送失败归还认领不阻断批次。 + */ +@DisplayName("SmsCatchupJob/SmsSendService - 到点补发与防重复") +class SmsCatchupJobTest { + + private NoticeRepository noticeRepository; + private SmsSender smsSender; + private SmsSendService smsSendService; + private SmsCatchupJob job; + private Notice notice; + + @BeforeEach + void setUp() { + noticeRepository = mock(NoticeRepository.class); + smsSender = mock(SmsSender.class); + smsSendService = new SmsSendService(noticeRepository, smsSender); + job = new SmsCatchupJob(noticeRepository, smsSendService); + ReflectionTestUtils.setField(job, "enabled", true); + + notice = new Notice(); + notice.setId(42); + notice.setTitle("定时通知"); + notice.setSchId(1); + notice.setStatus(1); + notice.setSmsFlag(1); + notice.setTimingSendTime(LocalDateTime.now().minusMinutes(10)); // 已到点 + notice.setReceiveList("[\"tea1\"]"); + } + + @Test + @DisplayName("到点补发恰一次:认领成功 → 发送一次;下轮扫描已空 → 不再发") + void dueNotice_sentExactlyOnce() { + when(noticeRepository.findSmsDue(any(LocalDateTime.class), any())) + .thenReturn(Collections.singletonList(notice)); + when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1); + + job.catchupDueSms(); + verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList()); + + // 下一轮:扫描已无待发行(sms_sent_at 已置) + when(noticeRepository.findSmsDue(any(LocalDateTime.class), any())) + .thenReturn(Collections.emptyList()); + job.catchupDueSms(); + verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList()); + } + + @Test + @DisplayName("未到点不发:扫描谓词(JPQL timingSendTime<=now)不含未到点行 → 空批不发送") + void notDueNotice_notSent() { + when(noticeRepository.findSmsDue(any(LocalDateTime.class), any())) + .thenReturn(Collections.emptyList()); // 未到点的行不进扫描结果 + job.catchupDueSms(); + verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList()); + } + + @Test + @DisplayName("并发/重复调度不双发:认领返回 0(他处已发)→ 跳过发送") + void concurrentOrDuplicateClaim_noDoubleSend() { + when(noticeRepository.findSmsDue(any(LocalDateTime.class), any())) + .thenReturn(Collections.singletonList(notice)); + when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(0); // 已被认领 + job.catchupDueSms(); + verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList()); + } + + @Test + @DisplayName("发送失败:归还认领(可下轮重试)且不阻断批次(后续行继续)") + void sendFailure_returnsClaimAndContinuesBatch() { + Notice ok = new Notice(); + ok.setId(43); + ok.setTitle("第二条"); + ok.setStatus(1); + ok.setSmsFlag(1); + ok.setReceiveList("[\"tea2\"]"); + when(noticeRepository.findSmsDue(any(LocalDateTime.class), any())) + .thenReturn(java.util.Arrays.asList(notice, ok)); + when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1); + org.mockito.Mockito.doThrow(new RuntimeException("sms gateway down")) + .doNothing() + .when(smsSender).sendNoticeSms(anyInt(), anyString(), anyList()); + + assertDoesNotThrow(() -> job.catchupDueSms()); + verify(smsSender, times(2)).sendNoticeSms(anyInt(), anyString(), anyList()); // 第二条仍被尝试 + verify(noticeRepository).clearSmsSent(42); // 失败行归还认领 + } + + @Test + @DisplayName("认领时间落库:markSmsSent 携带当前时间(审计可追溯)") + void claimCarriesTimestamp() { + when(noticeRepository.findSmsDue(any(LocalDateTime.class), any())) + .thenReturn(Collections.singletonList(notice)); + when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1); + job.catchupDueSms(); + ArgumentCaptor captor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(noticeRepository).markSmsSent(anyInt(), captor.capture()); + assertEquals(true, captor.getValue().isAfter(LocalDateTime.now().minusMinutes(1))); + } + + @Test + @DisplayName("开关关闭(notice.sms.catchup-enabled=false)不扫描") + void disabledSwitch_skips() { + ReflectionTestUtils.setField(job, "enabled", false); + job.catchupDueSms(); + verify(noticeRepository, never()).findSmsDue(any(LocalDateTime.class), any()); + } +} diff --git a/src/test/java/com/yunhai/notice/service/flow/FlowCallbackServiceTest.java b/src/test/java/com/yunhai/notice/service/flow/FlowCallbackServiceTest.java new file mode 100644 index 0000000..f1c235b --- /dev/null +++ b/src/test/java/com/yunhai/notice/service/flow/FlowCallbackServiceTest.java @@ -0,0 +1,251 @@ +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/FlowCenterClientTest.java b/src/test/java/com/yunhai/notice/service/flow/FlowCenterClientTest.java new file mode 100644 index 0000000..19f1f81 --- /dev/null +++ b/src/test/java/com/yunhai/notice/service/flow/FlowCenterClientTest.java @@ -0,0 +1,226 @@ +package com.yunhai.notice.service.flow; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.notice.model.flow.FlowModels; +import com.yunhai.platform.openapi.TargetSpec; +import com.yunhai.platform.openapi.YschOpenCall; +import com.yunhai.platform.openapi.YschCallProperties; +import com.yunhai.platform.apistd.BizException; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * FlowCenterClient 契约对齐测试(T2.1):JDK HttpServer 伪 flow-center,捕获 Notice 出站请求, + * 断言 URL/入参 JSON 与 flow-center /oapi/flow/** 契约逐字对齐(bizType="notice"); + * enabled=false → 全部方法抛业务异常(文案含"流程中心未接入")。 + * 客户端真实经 YschOpenCall(Tier 0)出站——同时验证信封解析与错误映射。 + */ +class FlowCenterClientTest { + + private HttpServer server; + private int port; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final List captured = new CopyOnWriteArrayList<>(); + + static class Captured { + String method; + String path; + String query; + JsonNode body; + } + + @BeforeEach + void startStub() throws IOException { + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + port = server.getAddress().getPort(); + server.createContext("/", exchange -> { + Captured c = new Captured(); + c.method = exchange.getRequestMethod(); + c.path = exchange.getRequestURI().getPath(); + c.query = exchange.getRequestURI().getRawQuery(); + try (InputStream in = exchange.getRequestBody()) { + byte[] buf = new byte[4096]; + int n; + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + String raw = new String(out.toByteArray(), StandardCharsets.UTF_8); + c.body = raw.isEmpty() ? null : objectMapper.readTree(raw); + } + captured.add(c); + route(exchange); + }); + server.start(); + } + + @AfterEach + void stopStub() { + if (server != null) { + server.stop(0); + } + } + + /** 伪 flow-center 四端点(信封 {code:0,data:...}) */ + private void route(HttpExchange exchange) throws IOException { + String path = exchange.getRequestURI().getPath(); + if ("/oapi/flow/list".equals(path)) { + respond(exchange, 200, "{\"code\":0,\"data\":[{\"flowGuid\":\"fg-1\",\"name\":\"信息发布审核\",\"version\":3,\"nodeSummary\":\"编辑审核→终审\"}]}"); + } else if ("/oapi/flow/apply".equals(path)) { + respond(exchange, 200, "{\"code\":0,\"data\":{\"instanceId\":9527,\"duplicated\":false}}"); + } else if ("/oapi/flow/instance".equals(path)) { + respond(exchange, 200, "{\"code\":0,\"data\":{\"instanceId\":9527,\"status\":10,\"version\":2,\"nodeSummary\":\"n1\",\"traceJson\":\"[]\",\"assigneeIds\":[301,302],\"currentNodes\":\"n1\"}}"); + } else if ("/oapi/flow/callback/reg".equals(path)) { + respond(exchange, 200, "{\"code\":0,\"data\":{\"secret\":\"reg-secret-0123456789abcdef\"}}"); + } else { + respond(exchange, 404, "{\"code\":5,\"msg\":\"not found\"}"); + } + } + + /** 固定返回的 ObjectProvider 桩(构造注入用) */ + static class FixedProvider implements org.springframework.beans.factory.ObjectProvider { + private final T value; + + FixedProvider(T value) { + this.value = value; + } + + @Override + public T getObject(Object... args) { + if (value == null) throw new org.springframework.beans.factory.NoSuchBeanDefinitionException("stub"); + return value; + } + + @Override + public T getObject() { + if (value == null) throw new org.springframework.beans.factory.NoSuchBeanDefinitionException("stub"); + return value; + } + + @Override + public T getIfAvailable() { + return value; + } + + @Override + public T getIfUnique() { + return value; + } + + @Override + public java.util.stream.Stream stream() { + return value == null ? java.util.stream.Stream.empty() : java.util.stream.Stream.of(value); + } + } + + private FlowCenterClient newClient(boolean enabled) { + NoticeFlowProperties props = new NoticeFlowProperties(); + props.setEnabled(enabled); + props.setBaseUrl("http://localhost:" + port); + YschOpenCall openCall = new YschOpenCall(new YschCallProperties(), objectMapper, + (host, basePath) -> (request, body, execution) -> execution.execute(request, body)); + return new FlowCenterClient(props, new FixedProvider<>(openCall), null, objectMapper); + } + + private FlowCenterClient newClient() { + return newClient(true); + } + + @Test + void listFlows_contractShape() { + List flows = newClient().listFlows(1); + assertEquals(1, flows.size()); + assertEquals("fg-1", flows.get(0).getFlowGuid()); + assertEquals("信息发布审核", flows.get(0).getName()); + assertEquals(3, flows.get(0).getVersion()); + Captured c = captured.get(0); + assertEquals("GET", c.method); + assertEquals("/oapi/flow/list", c.path); + assertEquals("schId=1", c.query); + } + + @Test + void apply_contractFields_bizTypeNotice() { + FlowModels.ApplyBody body = new FlowModels.ApplyBody(); + body.setFlowGuid("fg-1"); + body.setBizType(FlowCenterClient.BIZ_TYPE); + body.setBizId("42"); + body.setBizTitle("九月开学安排"); + body.setBizUrl("http://localhost:8077/#/list/detail?serid=42"); + body.setSubmitterId(201); + body.setSubmitterName("张三"); + FlowModels.ApplyResultVo result = newClient().apply(1, body); + assertEquals(9527L, result.getInstanceId()); + Captured c = captured.get(0); + assertEquals("POST", c.method); + assertEquals("/oapi/flow/apply", c.path); + assertEquals("schId=1", c.query); + assertEquals("notice", c.body.get("bizType").asText(), "契约冻结:bizType=notice"); + assertEquals("42", c.body.get("bizId").asText()); + assertEquals("fg-1", c.body.get("flowGuid").asText()); + assertEquals("九月开学安排", c.body.get("bizTitle").asText()); + assertEquals(201, c.body.get("submitterId").asInt()); + } + + @Test + void instanceByBiz_contractFields() { + FlowModels.InstanceVo vo = newClient().getInstanceByBiz(1, "notice", "42"); + assertEquals(9527L, vo.getInstanceId()); + assertEquals(10, vo.getStatus()); + assertEquals(2, vo.getVersion()); + assertEquals(2, vo.getAssigneeIds().size()); + Captured c = captured.get(0); + assertEquals("/oapi/flow/instance", c.path); + assertTrue(c.query.contains("bizType=notice") && c.query.contains("bizId=42") && c.query.contains("schId=1"), + "instance 查询应带三参数: " + c.query); + } + + @Test + void registerCallback_contractFields() { + FlowModels.CallbackRegBody body = new FlowModels.CallbackRegBody(); + body.setFlowGuid("fg-1"); + body.setCallbackUrl("http://localhost:8101/callback/flow"); + FlowModels.CallbackRegResultVo result = newClient().registerCallback(1, body); + assertEquals("reg-secret-0123456789abcdef", result.getSecret()); + Captured c = captured.get(0); + assertEquals("POST", c.method); + assertEquals("/oapi/flow/callback/reg", c.path); + assertEquals("http://localhost:8101/callback/flow", c.body.get("callbackUrl").asText()); + } + + @Test + void disabled_allMethodsThrowBusinessException() { + FlowCenterClient client = newClient(false); + BizException ex = assertThrows(BizException.class, () -> client.listFlows(1)); + assertTrue(ex.getMessage().contains("流程中心未接入"), ex.getMessage()); + assertThrows(BizException.class, () -> client.apply(1, new FlowModels.ApplyBody())); + assertThrows(BizException.class, () -> client.getInstanceByBiz(1, "notice", "1")); + assertThrows(BizException.class, () -> client.registerCallback(1, new FlowModels.CallbackRegBody())); + } + + private static void respond(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } +} diff --git a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowGuardTest.java b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowGuardTest.java new file mode 100644 index 0000000..8b53708 --- /dev/null +++ b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowGuardTest.java @@ -0,0 +1,120 @@ +package com.yunhai.notice.service.flow; + +import com.yunhai.notice.entity.Notice; +import com.yunhai.notice.model.flow.FlowModels; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 未发布通知详情放行矩阵单测(T2.4):提交人/管理员(type=1/roleId 白名单)/办理人 → 放行; + * 无关人/无身份 → 403"通知不存在或未发布"。 + */ +class NoticeFlowGuardTest { + + private FlowClient flowClient; + private NoticeFlowGuard guard; + private Notice notice; + + @BeforeEach + void setUp() { + flowClient = mock(FlowClient.class); + NoticeFlowProperties properties = new NoticeFlowProperties(); + properties.setEnabled(true); + guard = new NoticeFlowGuard(flowClient, properties); + notice = new Notice(); + notice.setId(42); + notice.setSchId(1); + notice.setStatus(0); + notice.setFlowGuid("fg-1"); + notice.setFlowInstanceId(9527L); + notice.setPublisherAccount("zhangsan"); + } + + private BoaUser user(Integer id, String account, Integer type, java.util.List roleIds) { + BoaUser user = new BoaUser(); + user.setId(id); + user.setAccount(account); + user.setType(type); + user.setRoleIds(roleIds); + BoaRoleInfo role = new BoaRoleInfo(); + role.setSchId(1); + user.setCurrentRole(role); + return user; + } + + private void stubAssignees(Integer... ids) { + FlowModels.InstanceVo instance = new FlowModels.InstanceVo(); + instance.setAssigneeIds(Arrays.asList(ids)); + when(flowClient.getInstanceByBiz(any(), anyString(), anyString())).thenReturn(instance); + } + + @Test + void submitter_allowed() { + assertDoesNotThrow(() -> + guard.requireUnpublishedVisible(notice, user(201, "zhangsan", 0, null))); + } + + @Test + void schoolAdmin_byType_allowed() { + assertDoesNotThrow(() -> + guard.requireUnpublishedVisible(notice, user(999, "admin1", 1, null))); + } + + @Test + void schoolAdmin_byRoleId_allowed() { + assertDoesNotThrow(() -> + guard.requireUnpublishedVisible(notice, user(999, "teacher5", 0, Collections.singletonList(5)))); + } + + @Test + void assignee_allowed() { + stubAssignees(301, 302); + assertDoesNotThrow(() -> + guard.requireUnpublishedVisible(notice, user(301, "handler1", 0, null))); + } + + @Test + void unrelatedUser_forbidden() { + stubAssignees(301, 302); + assertThrows(BizException.class, () -> + guard.requireUnpublishedVisible(notice, user(404, "stranger", 0, null))); + } + + @Test + void noIdentity_forbidden() { + assertThrows(BizException.class, () -> + guard.requireUnpublishedVisible(notice, null)); + } + + @Test + void notYetSubmitted_flowInstanceMissing_forbiddenExceptPrivileged() { + notice.setFlowInstanceId(null); // 待提交(apply 未成功)→ 办理人维度不存在 + stubAssignees(301); + assertThrows(BizException.class, () -> + guard.requireUnpublishedVisible(notice, user(301, "handler1", 0, null))); + assertDoesNotThrow(() -> + guard.requireUnpublishedVisible(notice, user(201, "zhangsan", 0, null))); + } + + @Test + void flowCenterDown_failClosed() { + when(flowClient.getInstanceByBiz(any(), anyString(), anyString())) + .thenThrow(new RuntimeException("flow-center down")); + assertThrows(BizException.class, () -> + guard.requireUnpublishedVisible(notice, user(301, "handler1", 0, null)), + "流程中心不可达时 fail-closed 不放行"); + } +} diff --git a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java new file mode 100644 index 0000000..7a00f70 --- /dev/null +++ b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java @@ -0,0 +1,319 @@ +package com.yunhai.notice.service.flow; + +import com.yunhai.notice.entity.Notice; +import com.yunhai.notice.entity.NoticeFlowReg; +import com.yunhai.notice.model.flow.FlowModels; +import com.yunhai.notice.model.notice.NoticeRequest; +import com.yunhai.notice.repository.NoticeFlowEventRepository; +import com.yunhai.notice.repository.NoticeFlowRegRepository; +import com.yunhai.notice.repository.NoticeReadRepository; +import com.yunhai.notice.repository.NoticeReceiverRepository; +import com.yunhai.notice.repository.NoticeRepository; +import com.yunhai.notice.repository.UpFileRepository; +import com.yunhai.notice.service.IFileService; +import com.yunhai.notice.service.IDictService; +import com.yunhai.notice.service.impl.NoticeService; +import com.yunhai.notice.service.sms.SmsSender; +import com.yunhai.platform.appauth.AppAuthProperties; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import com.yunhai.platform.auth.spring.CurrentBoaUser; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +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.assertNotNull; +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.eq; +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.2,fake FlowClient 注入): + * 未绑流程=原直发链路零破坏哨兵;绑流程=status=0/audit=0→apply→回填 10;apply 失败保持 0 可重推; + * 10 审批中锁编辑/删除/重提;30/40 可编辑重提复用同 bizId;sms 时序后移。 + */ +class NoticeFlowPublishTest { + + private NoticeService noticeService; + private NoticeRepository noticeRepository; + private FlowClient flowClient; + private NoticeFlowService noticeFlowService; + private SmsSender smsSender; + private IDictService dictService; + private CurrentBoaUser currentBoaUser; + + private final AtomicInteger applyCalls = new AtomicInteger(); + private Notice savedEntity; + private final java.util.List auditAtSave = new java.util.ArrayList<>(); + private final java.util.List statusAtSave = new java.util.ArrayList<>(); + + @BeforeEach + void setUp() { + auditAtSave.clear(); + statusAtSave.clear(); + noticeRepository = mock(NoticeRepository.class); + smsSender = mock(SmsSender.class); + dictService = mock(IDictService.class); + flowClient = mock(FlowClient.class); + currentBoaUser = mock(CurrentBoaUser.class); + + // repository.save 回带 id 的实体(模拟自增主键);findById 回最近保存实体 + when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> { + Notice n = inv.getArgument(0); + if (n.getId() == null) { + n.setId(77); + } + savedEntity = n; + auditAtSave.add(n.getAuditStatus()); // 快照(避免 captor 引用别名) + statusAtSave.add(n.getStatus()); + return n; + }); + when(noticeRepository.findById(77)).thenAnswer(inv -> Optional.ofNullable(savedEntity)); + // 短信统一发送链(§3.2):认领桩=1 表示未发过(首次发送放行) + when(noticeRepository.markSmsSent(anyInt(), any())).thenReturn(1); + // 模拟窄列原子更新(作用于当前 findById 可见的实体;audit=20 不回退语义一并体现) + when(noticeRepository.markApproving(anyInt(), any())).thenAnswer(inv -> { + Notice current = noticeRepository.findById(inv.getArgument(0)).orElse(null); + if (current != null) { + current.setFlowInstanceId(inv.getArgument(1)); + if (current.getAuditStatus() == null || current.getAuditStatus() != 20) { + current.setAuditStatus(10); + } + } + return 1; + }); + when(dictService.resolveDeptName(any())).thenReturn("办公室"); + + NoticeFlowProperties props = new NoticeFlowProperties(); + props.setEnabled(true); + props.setFrontBaseUrl("http://localhost:8077"); + AppAuthProperties appAuth = new AppAuthProperties(); + appAuth.setAppKey("100617"); + + NoticeFlowRegRepository regRepository = mock(NoticeFlowRegRepository.class); + when(regRepository.findBySchIdAndAppKeyAndFlowGuid(any(), any(), any())).thenReturn(Optional.empty()); + when(flowClient.registerCallback(any(), any())).thenAnswer(inv -> { + FlowModels.CallbackRegResultVo vo = new FlowModels.CallbackRegResultVo(); + vo.setSecret("reg-secret-0123456789"); + return vo; + }); + applyCalls.set(0); + when(flowClient.apply(anyInt(), any())).thenAnswer(inv -> { + applyCalls.incrementAndGet(); + FlowModels.ApplyResultVo vo = new FlowModels.ApplyResultVo(); + vo.setInstanceId(9527L); + vo.setDuplicated(applyCalls.get() > 1); + return vo; + }); + + noticeFlowService = new NoticeFlowService(noticeRepository, regRepository, + mock(UpFileRepository.class), flowClient, props, new ObjectMapper(), appAuth, currentBoaUser, + new NoticeFlowGuard(flowClient, props)); + + noticeService = new NoticeService(); + ReflectionTestUtils.setField(noticeService, "noticeRepository", noticeRepository); + ReflectionTestUtils.setField(noticeService, "noticeReadRepository", mock(NoticeReadRepository.class)); + ReflectionTestUtils.setField(noticeService, "noticeReceiverRepository", mock(NoticeReceiverRepository.class)); + ReflectionTestUtils.setField(noticeService, "upFileRepository", mock(UpFileRepository.class)); + ReflectionTestUtils.setField(noticeService, "smsSender", smsSender); + ReflectionTestUtils.setField(noticeService, "smsSendService", + new com.yunhai.notice.service.sms.SmsSendService(noticeRepository, smsSender)); + ReflectionTestUtils.setField(noticeService, "fileService", mock(IFileService.class)); + ReflectionTestUtils.setField(noticeService, "dictService", dictService); + ReflectionTestUtils.setField(noticeService, "currentBoaUser", currentBoaUser); + ReflectionTestUtils.setField(noticeService, "noticeFlowService", noticeFlowService); + ReflectionTestUtils.setField(noticeService, "noticeFlowGuard", mock(NoticeFlowGuard.class)); + + // 登录态:张三(201)/本校 1 + LoginUser loginUser = new LoginUser(); + loginUser.setLoginName("zhangsan"); + loginUser.setDisplayName("张三"); + UserContextHolder.set(loginUser); + BoaUser boaUser = new BoaUser(); + boaUser.setId(201); + boaUser.setAccount("zhangsan"); + boaUser.setName("张三"); + BoaRoleInfo role = new BoaRoleInfo(); + role.setSchId(1); + boaUser.setCurrentRole(role); + when(currentBoaUser.get()).thenReturn(boaUser); + } + + @AfterEach + void tearDown() { + UserContextHolder.clear(); + } + + private NoticeRequest request(String flowGuid) { + NoticeRequest request = new NoticeRequest(); + request.setTitle("九月开学安排"); + request.setContent("

内容

"); + request.setType(1); + request.setDeptId("d1"); + request.setReceiveList("[\"tea1\",\"tea2\"]"); + request.setSmsFlag(1); + request.setFlowGuid(flowGuid); + return request; + } + + @Test + void unboundFlow_directPublish_zeroSideEffects() { + // 存量零破坏哨兵:未绑流程 → status=1、sms 即时、flow 零交互 + Integer id = noticeService.publish(request(null)); + ArgumentCaptor captor = ArgumentCaptor.forClass(Notice.class); + verify(noticeRepository, times(1)).save(captor.capture()); + assertEquals(1, captor.getValue().getStatus()); + assertEquals("zhangsan", captor.getValue().getPublisherAccount()); + verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList()); + verify(flowClient, never()).apply(anyInt(), any()); + verify(flowClient, never()).registerCallback(anyInt(), any()); + assertNotNull(id); + } + + @Test + void boundFlow_status0_audit0_thenApplyBackfills() { + // 单测无活动事务 → submitAfterCommit 立即执行(等价 AFTER_COMMIT 语义验证) + noticeService.publish(request("fg-review")); + verify(noticeRepository, times(1)).save(any(Notice.class)); // 仅落库;apply 后走窄列原子更新 + assertEquals(0, auditAtSave.get(0), "首存 audit=0 待提交"); + assertEquals(0, firstSavedStatus(), "绑流程通知应落 status=0 未发布"); + assertEquals("fg-review", savedEntity.getFlowGuid()); + // 窄列原子回填:audit=10 + instance(markApproving 桩已作用于实体) + assertEquals(10, savedEntity.getAuditStatus()); + assertEquals(9527L, savedEntity.getFlowInstanceId()); + verify(noticeRepository).markApproving(eq(77), eq(9527L)); + // sms 后置:create 时不发 + verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList()); + } + + @Test + void applyFails_rowStaysPendingSubmit_thenRetrySucceeds() { + when(flowClient.apply(anyInt(), any())) + .thenThrow(new RuntimeException("flow-center down")) + .thenAnswer(inv -> { + FlowModels.ApplyResultVo vo = new FlowModels.ApplyResultVo(); + vo.setInstanceId(9527L); + return vo; + }); + noticeService.publish(request("fg-review")); // 出站失败被吞(保持 0 待提交) + assertEquals(0, savedEntity.getAuditStatus(), "apply 失败保持 0 待提交(行不回滚)"); + // 重推入口:trySubmitFlow 幂等治愈 → 10 + noticeFlowService.trySubmitFlow(77); + assertEquals(10, savedEntity.getAuditStatus()); + assertEquals(9527L, savedEntity.getFlowInstanceId()); + verify(noticeRepository, times(1)).markApproving(eq(77), any()); + } + + @Test + void trySubmit_rejectedWhenApproving() { + noticeService.publish(request("fg-review")); // → 10 + BizException ex = assertThrows(BizException.class, () -> noticeFlowService.trySubmitFlow(77)); + assertTrue(ex.getMessage().contains("审批中")); + } + + @Test + void trySubmit_byStranger_denied() { + // BP-R5-3:无关登录用户不得对他人通知触发重提(403,且不得触达 apply 出站) + Notice rejected = pendingNotice(30); + when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected)); + BoaUser stranger = new BoaUser(); + stranger.setId(999); + stranger.setAccount("stranger"); + BizException ex = assertThrows(BizException.class, () -> noticeFlowService.trySubmitFlow(77, stranger)); + assertTrue(ex.getMessage().contains("通知不存在或未发布")); + verify(flowClient, never()).apply(anyInt(), any()); + } + + @Test + void editAndDelete_rejectedWhenApproving() { + noticeService.publish(request("fg-review")); // → audit=10 + assertEquals(10, savedEntity.getAuditStatus()); + assertThrows(BizException.class, () -> noticeService.edit(77, request("fg-review"))); + assertThrows(BizException.class, () -> noticeService.deleteBatch(java.util.Collections.singletonList(77))); + } + + @Test + void editAfterRejected_resubmitsSameBizId() { + Notice rejected = pendingNotice(30); + when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected)); + noticeService.edit(77, request("fg-review")); + // 编辑后 AFTER_COMMIT(单测立即)重提,复用同一 bizId=notice.id + ArgumentCaptor body = ArgumentCaptor.forClass(FlowModels.ApplyBody.class); + verify(flowClient, times(1)).apply(anyInt(), body.capture()); + assertEquals("77", body.getValue().getBizId()); + assertEquals(10, rejected.getAuditStatus()); + } + + @Test + void applyParams_contract() { + noticeService.publish(request("fg-review")); + ArgumentCaptor body = ArgumentCaptor.forClass(FlowModels.ApplyBody.class); + verify(flowClient).apply(anyInt(), body.capture()); + FlowModels.ApplyBody b = body.getValue(); + assertEquals("fg-review", b.getFlowGuid()); + assertEquals("notice", b.getBizType()); + assertEquals("77", b.getBizId()); + assertEquals("九月开学安排", b.getBizTitle()); + assertEquals("http://localhost:8077/#/list/detail?serid=77", b.getBizUrl()); + assertEquals(201, b.getSubmitterId()); + assertEquals("张三", b.getSubmitterName()); + // 快照:title + summary(纯文本) + attachmentNames[] + @SuppressWarnings("unchecked") + java.util.Map snapshot = (java.util.Map) b.getBizSnapshot(); + assertEquals("九月开学安排", snapshot.get("title")); + assertEquals("内容", snapshot.get("summary")); + assertTrue(snapshot.get("attachmentNames") instanceof java.util.List); + } + + @Test + void unbindFlowOnEdit_clearsAuditAndPublishes() { + Notice rejected = pendingNotice(30); + when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected)); + noticeService.edit(77, request(null)); // 解绑 → 直发 + assertEquals(null, rejected.getFlowGuid()); + assertEquals(null, rejected.getAuditStatus()); + verify(flowClient, never()).apply(anyInt(), any()); + } + + /** 首次保存时的 status 快照 */ + private Integer firstSavedStatus() { + return statusAtSave.get(0); + } + + private Notice pendingNotice(int auditStatus) { + // 模拟已落库的绑流程通知(status=0,audit=指定态) + Notice n = new Notice(); + n.setId(77); + n.setTitle("九月开学安排"); + n.setContent("

内容

"); + n.setStatus(0); + n.setAuditStatus(auditStatus); + n.setFlowGuid("fg-review"); + n.setSchId(1); + n.setPublisherAccount("zhangsan"); + n.setSmsFlag(1); + n.setReceiveList("[\"tea1\",\"tea2\"]"); + when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> inv.getArgument(0)); + return n; + } +} diff --git a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowTraceTest.java b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowTraceTest.java new file mode 100644 index 0000000..7a4fee5 --- /dev/null +++ b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowTraceTest.java @@ -0,0 +1,144 @@ +package com.yunhai.notice.service.flow; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.notice.entity.Notice; +import com.yunhai.notice.model.flow.FlowModels; +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.auth.spring.CurrentBoaUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 审批进度组装单测(T2.4):未绑流程返回"未接入"语义(bound=false,前端隐藏卡片); + * 绑定通知合并本地镜像(audit/flowResult)+ flow-center 实时(version/trace/assignee); + * 流程中心不可达时降级为本地镜像不抛异常。 + */ +class NoticeFlowTraceTest { + + private NoticeRepository noticeRepository; + private FlowClient flowClient; + private NoticeFlowService service; + private Notice bound; + private Notice unbound; + + @BeforeEach + void setUp() { + noticeRepository = mock(NoticeRepository.class); + flowClient = mock(FlowClient.class); + NoticeFlowProperties props = new NoticeFlowProperties(); + props.setEnabled(true); + service = new NoticeFlowService(noticeRepository, mock(NoticeFlowRegRepository.class), + mock(UpFileRepository.class), flowClient, props, new ObjectMapper(), + new AppAuthProperties(), mock(CurrentBoaUser.class), new NoticeFlowGuard(flowClient, props)); + + bound = new Notice(); + bound.setId(42); + bound.setSchId(1); + bound.setStatus(0); + bound.setFlowGuid("fg-1"); + bound.setAuditStatus(10); + bound.setFlowInstanceId(9527L); + bound.setFlowResultJson("{\"opinion\":\"同意\"}"); + bound.setPublisherAccount("zhangsan"); + + unbound = new Notice(); + unbound.setId(43); + unbound.setSchId(1); + unbound.setStatus(1); + + when(noticeRepository.findById(42)).thenReturn(Optional.of(bound)); + when(noticeRepository.findById(43)).thenReturn(Optional.of(unbound)); + } + + /** 未发布通知的合法访问者(提交人本人) */ + private static com.yunhai.platform.utils.model.BoaUser owner() { + com.yunhai.platform.utils.model.BoaUser u = new com.yunhai.platform.utils.model.BoaUser(); + org.springframework.test.util.ReflectionTestUtils.setField(u, "id", 201); + u.setAccount("zhangsan"); + return u; + } + + /** 无关登录用户(非提交人/管理员/办理人) */ + private static com.yunhai.platform.utils.model.BoaUser stranger() { + com.yunhai.platform.utils.model.BoaUser u = new com.yunhai.platform.utils.model.BoaUser(); + org.springframework.test.util.ReflectionTestUtils.setField(u, "id", 999); + u.setAccount("stranger"); + return u; + } + + @Test + void unboundNotice_returnsNotIntegrated() { + // 已发布(status=1)内容公开:任意登录用户可看(不受守卫限制) + Map trace = service.buildTrace(43, stranger()); + assertEquals(false, trace.get("bound")); + assertEquals(null, trace.get("auditStatus")); + assertEquals(1, trace.get("status")); + assertFalse(trace.containsKey("trace"), "未绑流程不应有进度明细"); + } + + @Test + void unpublishedTrace_byStranger_denied() { + // BP-R5-3:未发布通知的轨迹/驳回意见仅三类人可见,其余 403(查不到语义) + com.yunhai.platform.apistd.BizException e = org.junit.jupiter.api.Assertions.assertThrows( + com.yunhai.platform.apistd.BizException.class, + () -> service.buildTrace(42, stranger())); + assertTrue(e.getMessage().contains("通知不存在或未发布")); + } + + @Test + void unpublishedTrace_byOwnerVisible() { + when(flowClient.getInstanceByBiz(any(), any(), any())).thenReturn(null); + Map trace = service.buildTrace(42, owner()); + assertEquals(true, trace.get("bound")); + } + + @Test + void boundNotice_mergesLocalMirrorAndLiveInstance() { + FlowModels.InstanceVo instance = new FlowModels.InstanceVo(); + instance.setInstanceId(9527L); + instance.setStatus(10); + instance.setVersion(3); + instance.setNodeSummary("编辑审核→终审"); + instance.setCurrentNodes("n2"); + instance.setAssigneeIds(Arrays.asList(301, 302)); + instance.setTraceJson("[{\"node\":\"n1\",\"status\":1}]"); + when(flowClient.getInstanceByBiz(any(), any(), any())).thenReturn(instance); + + Map trace = service.buildTrace(42, owner()); + assertEquals(true, trace.get("bound")); + assertEquals(10, trace.get("auditStatus")); + assertEquals("审批中", trace.get("auditStatusDesc")); + assertEquals(3, trace.get("version")); + assertEquals("n2", trace.get("currentNodes")); + assertNotNull(trace.get("trace")); + assertNotNull(trace.get("flowResult")); + assertEquals(Arrays.asList(301, 302), trace.get("assigneeIds")); + } + + @Test + void flowCenterDown_degradesToLocalMirror() { + when(flowClient.getInstanceByBiz(any(), any(), any())) + .thenThrow(new RuntimeException("flow-center down")); + Map trace = service.buildTrace(42, owner()); + assertEquals(true, trace.get("bound")); + assertEquals(10, trace.get("auditStatus"), "流程中心停机时详情仍可用(本地镜像)"); + assertTrue(((Map) trace).containsKey("flowResult")); + } +} diff --git a/src/test/java/com/yunhai/notice/service/impl/NoticeDetailReadTest.java b/src/test/java/com/yunhai/notice/service/impl/NoticeDetailReadTest.java new file mode 100644 index 0000000..463d57b --- /dev/null +++ b/src/test/java/com/yunhai/notice/service/impl/NoticeDetailReadTest.java @@ -0,0 +1,89 @@ +package com.yunhai.notice.service.impl; + +import com.yunhai.notice.entity.Notice; +import com.yunhai.notice.repository.NoticeReadRepository; +import com.yunhai.notice.repository.NoticeRepository; +import com.yunhai.platform.auth.LoginUser; +import com.yunhai.platform.auth.UserContextHolder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 详情标读门禁单测(走查报告 §七.4):仅已发布(status=1)通知在 detail 时标记已读—— + * 审批人查看未发布通知(NoticeFlowGuard 放行的审核动作)不再被记入已读名单污染阅读统计。 + */ +@DisplayName("NoticeService.detail - 标读门禁(仅已发布)") +class NoticeDetailReadTest { + + private NoticeRepository noticeRepository; + private NoticeReadRepository noticeReadRepository; + private NoticeService service; + + @BeforeEach + void setUp() { + noticeRepository = mock(NoticeRepository.class); + noticeReadRepository = mock(NoticeReadRepository.class); + service = new NoticeService(); + ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository); + ReflectionTestUtils.setField(service, "noticeReadRepository", noticeReadRepository); + ReflectionTestUtils.setField(service, "dictService", mock(com.yunhai.notice.service.IDictService.class)); + + LoginUser loginUser = new LoginUser(); + loginUser.setLoginName("approver1"); + loginUser.setDisplayName("审批人甲"); + UserContextHolder.set(loginUser); + when(noticeReadRepository.existsByNoticeIdAndReaderAccount(anyInt(), anyString())).thenReturn(false); + when(noticeReadRepository.save(any(com.yunhai.notice.entity.NoticeRead.class))) + .thenAnswer(inv -> inv.getArgument(0)); + } + + @AfterEach + void tearDown() { + UserContextHolder.clear(); + } + + private Notice notice(int status) { + Notice n = new Notice(); + n.setId(42); + n.setTitle("审核中通知"); + n.setSchId(1); + n.setStatus(status); + when(noticeRepository.findById(42)).thenReturn(Optional.of(n)); + return n; + } + + @Test + void publishedNotice_detailMarksRead() { + notice(1); + service.detail(42); + verify(noticeReadRepository).save(any(com.yunhai.notice.entity.NoticeRead.class)); + } + + @Test + void unpublishedNotice_detailDoesNotMarkRead() { + notice(0); // 审批人经 NoticeFlowGuard 查看未发布通知:不标读 + service.detail(42); + verify(noticeReadRepository, never()).save(any(com.yunhai.notice.entity.NoticeRead.class)); + } + + @Test + void alreadyRead_notMarkedAgain() { + notice(1); + when(noticeReadRepository.existsByNoticeIdAndReaderAccount(42, "approver1")).thenReturn(true); + service.detail(42); + verify(noticeReadRepository, never()).save(any(com.yunhai.notice.entity.NoticeRead.class)); + } +} diff --git a/src/test/java/com/yunhai/notice/service/impl/NoticeResubmitClearResultTest.java b/src/test/java/com/yunhai/notice/service/impl/NoticeResubmitClearResultTest.java new file mode 100644 index 0000000..31e4d92 --- /dev/null +++ b/src/test/java/com/yunhai/notice/service/impl/NoticeResubmitClearResultTest.java @@ -0,0 +1,94 @@ +package com.yunhai.notice.service.impl; + +import com.yunhai.notice.entity.Notice; +import com.yunhai.notice.enums.FlowAuditStatusEnum; +import com.yunhai.notice.model.notice.NoticeRequest; +import com.yunhai.notice.repository.NoticeRepository; +import com.yunhai.notice.service.flow.NoticeFlowService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 驳回后编辑重提的清态单测(业务人视角走查 NEW-3): + * Notice 侧 flow_result_json 只在终态事件写入,若不显式清,重提后整段新审批期 + * 都挂着上一轮的驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能)。 + */ +@DisplayName("NoticeService.edit - 重提清旧驳回意见") +class NoticeResubmitClearResultTest { + + private NoticeRepository noticeRepository; + private NoticeFlowService noticeFlowService; + private NoticeService service; + + @BeforeEach + void setUp() { + noticeRepository = mock(NoticeRepository.class); + noticeFlowService = mock(NoticeFlowService.class); + service = new NoticeService(); + ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository); + ReflectionTestUtils.setField(service, "noticeFlowService", noticeFlowService); + ReflectionTestUtils.setField(service, "dictService", mock(com.yunhai.notice.service.IDictService.class)); + } + + /** 被驳回的通知:audit=30 + 已落定上一轮驳回意见 */ + private Notice rejectedNotice() { + Notice n = new Notice(); + n.setId(42); + n.setTitle("旧标题"); + n.setSchId(1); + n.setStatus(0); + n.setFlowGuid("fg-1"); + n.setFlowInstanceId(9011L); + n.setAuditStatus(FlowAuditStatusEnum.REJECTED.getCode()); + n.setFlowResultJson("{\"result\":\"REJECTED\",\"opinion\":\"内容不妥\"}"); + when(noticeRepository.findById(42)).thenReturn(Optional.of(n)); + return n; + } + + private NoticeRequest editRequest(String flowGuid) { + NoticeRequest req = new NoticeRequest(); + ReflectionTestUtils.setField(req, "title", "改后标题"); + ReflectionTestUtils.setField(req, "content", "改后正文"); + ReflectionTestUtils.setField(req, "flowGuid", flowGuid); + return req; + } + + @Test + void editResubmit_clearsStaleRejectOpinion() { + Notice n = rejectedNotice(); + when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.edit(42, editRequest("fg-1")); + + assertEquals(FlowAuditStatusEnum.PENDING_SUBMIT.getCode(), n.getAuditStatus(), + "重提回到 0 待提交"); + assertNull(n.getFlowResultJson(), "旧驳回意见须清空,否则新审批期一直显示"); + assertEquals("改后标题", n.getTitle()); + verify(noticeFlowService).submitAfterCommit(42); + } + + @Test + void unbindFlow_alsoClearsResultAndInstance() { + Notice n = rejectedNotice(); + when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> inv.getArgument(0)); + + service.edit(42, editRequest(null)); // 解绑流程 → 直发 + + assertNull(n.getFlowGuid()); + assertNull(n.getFlowInstanceId()); + assertNull(n.getFlowResultJson()); + assertNull(n.getAuditStatus()); + } +} diff --git a/src/test/java/com/yunhai/notice/support/OapiSignedClient.java b/src/test/java/com/yunhai/notice/support/OapiSignedClient.java new file mode 100644 index 0000000..6ebc048 --- /dev/null +++ b/src/test/java/com/yunhai/notice/support/OapiSignedClient.java @@ -0,0 +1,102 @@ +package com.yunhai.notice.support; + +import com.yunhai.platform.utils.crypto.SignUtil; +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.web.client.RestTemplate; + +/** + * oapi 契约测试客户端(照 flow-center OapiSignedClient 同构):手工三件套签名, + * 故意不走共享 IDHttp——测试要能看到 401 原始响应。 + */ +public class OapiSignedClient { + + private final String baseUrl; + private final String appKey; + private final String appSecret; + private final RestTemplate restTemplate; + + public OapiSignedClient(String baseUrl, String appKey, String appSecret) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.appKey = appKey; + this.appSecret = appSecret; + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(3000); + factory.setReadTimeout(5000); + this.restTemplate = new RestTemplate(factory); + this.restTemplate.setErrorHandler(new org.springframework.web.client.ResponseErrorHandler() { + @Override + public boolean hasError(org.springframework.http.client.ClientHttpResponse response) { + return false; + } + + @Override + public void handleError(org.springframework.http.client.ClientHttpResponse response) { + } + }); + } + + public ResponseEntity get(String pathAndQuery, Class type) { + return exchange(HttpMethod.GET, pathAndQuery, null, type); + } + + public ResponseEntity post(String pathAndQuery, Object body, Class type) { + return exchange(HttpMethod.POST, pathAndQuery, body, type); + } + + private ResponseEntity exchange(HttpMethod method, String pathAndQuery, Object body, Class type) { + String target = signTarget(pathAndQuery); + String ts = String.valueOf(System.currentTimeMillis()); + String sign = SignUtil.signRequest(method.name(), target, ts, appSecret); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-App-Key", appKey); + headers.set("X-Timestamp", ts); + headers.set("X-Signature", sign); + if (body != null) { + headers.setContentType(MediaType.APPLICATION_JSON); + } + return restTemplate.exchange(baseUrl + pathAndQuery, method, + body == null ? new HttpEntity<>(headers) : new HttpEntity<>(body, headers), type); + } + + private static String signTarget(String pathAndQuery) { + int q = pathAndQuery.indexOf('?'); + if (q < 0) { + return pathAndQuery; + } + String path = pathAndQuery.substring(0, q); + String[] pairs = pathAndQuery.substring(q + 1).split("&"); + java.util.Arrays.sort(pairs); + StringBuilder sb = new StringBuilder(path); + for (String pair : pairs) { + if (sb.indexOf("?") < 0) { + sb.append("?"); + } else if (sb.charAt(sb.length() - 1) != '?') { + sb.append("&"); + } + sb.append(pair); + } + return sb.toString(); + } + + /** 无签名调用(401 用例) */ + public ResponseEntity getRaw(String pathAndQuery) { + return restTemplate.getForEntity(baseUrl + pathAndQuery, String.class); + } + + /** 带错误密钥的调用(401 用例) */ + public ResponseEntity getWithBadSecret(String pathAndQuery) { + String ts = String.valueOf(System.currentTimeMillis()); + String sign = SignUtil.signRequest("GET", signTarget(pathAndQuery), ts, "bad-secret-000000000000"); + HttpHeaders headers = new HttpHeaders(); + headers.set("X-App-Key", appKey); + headers.set("X-Timestamp", ts); + headers.set("X-Signature", sign); + return restTemplate.exchange(baseUrl + pathAndQuery, HttpMethod.GET, + new HttpEntity<>(headers), String.class); + } +}