From bfb50a205d0d0b27b671d74928634e881337b928 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 16 Sep 2026 01:21:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B5=81=E7=A8=8B=E5=AF=B9=E6=8E=A5?= =?UTF-8?q?=E5=88=87=20SDK=20=E7=B1=BB=E5=9E=8B=E5=8C=96=E9=97=A8=E9=9D=A2?= =?UTF-8?q?=20YschFlowOpenApi=EF=BC=88=E6=9C=8D=E5=8A=A1=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=20flowcenter=20=E4=BC=98=E5=85=88=EF=BC=8Clegacy=20base-url=20?= =?UTF-8?q?=E5=9B=9E=E8=90=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FlowModels 全部改为 extends SDK FlowDto(字段单一事实源收敛到 SDK,消费方签名零改动) - FlowCenterClient:目录显式配置 flowcenter(yh.call.targets/yh.open.services)走门面命名目标, 未配置回落 notice.flow.base-url ad-hoc(迁移期兼容) - 含 2026-09-13/14 存量:重提清旧驳回意见(F4)/消息中心/文件上传改造 --- .../FlowCallbackFilterConfiguration.java | 166 -------------- .../controller/api/NoticeFlowController.java | 18 ++ .../OapiFlowCallbackController.java} | 27 ++- .../notice/entity/NoticeFlowBinding.java | 39 ++++ .../yunhai/notice/model/flow/FlowModels.java | 62 ++---- .../notice/model/notice/NoticeQuery.java | 7 + .../notice/model/notice/NoticeRequest.java | 7 +- .../NoticeFlowBindingRepository.java | 11 + .../service/flow/FlowCallbackService.java | 8 +- .../notice/service/flow/FlowCenterClient.java | 33 ++- .../notice/service/flow/NoticeFlowGuard.java | 16 +- .../service/flow/NoticeFlowProperties.java | 14 +- .../service/flow/NoticeFlowService.java | 84 +++++++ .../notice/service/impl/NoticeService.java | 89 ++++++-- .../utils/ReReadableRequestWrapper.java | 63 ------ .../sql/notice_flow_binding_create.sql | 16 ++ .../config/FlowCallbackVerifyFilterTest.java | 151 ------------- .../notice/flow/FlowCallbackTransientIT.java | 35 +-- .../yunhai/notice/flow/NoticeFlowApiIT.java | 143 ++++++------ .../service/flow/NoticeFlowPublishTest.java | 210 +++++++++++++++--- .../service/flow/NoticeFlowTraceTest.java | 1 + .../impl/NoticeResubmitClearResultTest.java | 27 ++- .../notice/support/OapiSignedClient.java | 21 ++ 23 files changed, 641 insertions(+), 607 deletions(-) delete mode 100644 src/main/java/com/yunhai/notice/config/FlowCallbackFilterConfiguration.java rename src/main/java/com/yunhai/notice/controller/{callback/FlowCallbackController.java => oapi/OapiFlowCallbackController.java} (55%) create mode 100644 src/main/java/com/yunhai/notice/entity/NoticeFlowBinding.java create mode 100644 src/main/java/com/yunhai/notice/repository/NoticeFlowBindingRepository.java delete mode 100644 src/main/java/com/yunhai/notice/utils/ReReadableRequestWrapper.java create mode 100644 src/main/resources/sql/notice_flow_binding_create.sql delete mode 100644 src/test/java/com/yunhai/notice/config/FlowCallbackVerifyFilterTest.java diff --git a/src/main/java/com/yunhai/notice/config/FlowCallbackFilterConfiguration.java b/src/main/java/com/yunhai/notice/config/FlowCallbackFilterConfiguration.java deleted file mode 100644 index 00f44ba..0000000 --- a/src/main/java/com/yunhai/notice/config/FlowCallbackFilterConfiguration.java +++ /dev/null @@ -1,166 +0,0 @@ -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 index 0e28945..bb7e509 100644 --- a/src/main/java/com/yunhai/notice/controller/api/NoticeFlowController.java +++ b/src/main/java/com/yunhai/notice/controller/api/NoticeFlowController.java @@ -18,6 +18,8 @@ import java.util.Map; * 通知-流程中心接缝接口(先审后发)。 *
    *
  • GET Flow/Options:编辑页"审核流程"选择器(代理 FlowClient.listFlows,前端不直调 oapi);
  • + *
  • GET Flow/Binding:应用设置 > 流程绑定 的读取(登录可读;发通知页据此判断是否走审核);
  • + *
  • POST Flow/Binding/Save:保存/解除应用级绑定(仅本校管理员);
  • *
  • POST Flow/TrySubmit:提交/重提审核(幂等,bizId=notice.id;apply 失败保持待提交可重推);
  • *
  • GET Flow/Trace:审批进度(后端 getInstanceByBiz 组装,前端不直调 oapi——放行判定必须收在后端)。
  • *
@@ -36,6 +38,22 @@ public class NoticeFlowController { return noticeFlowService.options(schId != null ? schId : currentSchId(user)); } + /** + * 应用设置 > 流程绑定:本校绑定视图(bound/flowGuid/flowName/flowEnabled)。 + * 登录即可读——发通知页靠它决定「提交审核」还是「发送」。 + */ + @GetMapping("Binding") + public Map binding(BoaUser user) { + return noticeFlowService.bindingView(currentSchId(user), user); + } + + /** 保存/解除绑定(flowGuid 空=解绑);仅本校管理员可改 */ + @PostMapping("Binding/Save") + public Map saveBinding(@RequestParam(value = "flowGuid", required = false) String flowGuid, + BoaUser user) { + return noticeFlowService.saveBinding(user, flowGuid); + } + /** 提交/重新提交审核(编辑页重提按钮;audit=10 审批中将被拒绝;BP-R5-3 补属主守卫) */ @PostMapping("TrySubmit") public void trySubmit(@RequestParam("serid") Integer serid, BoaUser user) { diff --git a/src/main/java/com/yunhai/notice/controller/callback/FlowCallbackController.java b/src/main/java/com/yunhai/notice/controller/oapi/OapiFlowCallbackController.java similarity index 55% rename from src/main/java/com/yunhai/notice/controller/callback/FlowCallbackController.java rename to src/main/java/com/yunhai/notice/controller/oapi/OapiFlowCallbackController.java index ae3f946..9d4e5cb 100644 --- a/src/main/java/com/yunhai/notice/controller/callback/FlowCallbackController.java +++ b/src/main/java/com/yunhai/notice/controller/oapi/OapiFlowCallbackController.java @@ -1,4 +1,4 @@ -package com.yunhai.notice.controller.callback; +package com.yunhai.notice.controller.oapi; import com.yunhai.notice.service.flow.FlowCallbackService; import lombok.RequiredArgsConstructor; @@ -6,13 +6,21 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** - * 流程中心回调接收端点(方案 §5.3;/callback/* 不在 CAS url-pattern 内,由 - * FlowCallbackVerifyFilter 完成 X-Flw-* 四头验签后才到达这里)。 + * 流程中心回调接收端点(挂在开放接口域 /oapi/notice/flow/callback)。 + *

+ * 验签(复用平台应用验签能力):不建独立回调验签过滤器——回调路径落在 + * {@code yh.app-auth.server.enforce-patterns: [/oapi/*]} 内,由平台 + * {@code AppAuthFilter}(X-App-Key/X-Timestamp/X-Signature,HmacSHA256(method\npath\n ts), + * 密钥取 trusted-apps 里 flow-center 的 appKey=100019)验签 + {@code RequireAppSignFilter} + * 拒绝匿名请求。推送方 flow-center 侧由 {@code CallbackAppSigner} 用同一套 canonical 规则签名 + * (注册时申报的 base-path 即本服务 context-path,见 {@link com.yunhai.notice.service.flow.NoticeFlowProperties})。 + * 原来的 /callback/* + X-Flw-* 自定义过滤器已下线;X-Flw-* 头仍由 flow-center 保留发送,本服务不再校验。 *

* 返回语义(契约冻结 D2,免对账前提):状态推进成功 / 重复投递(去重命中)/ * 协议毒消息 / 乱序丢弃 / 办件不属本应用 → 200(毒消息回 200 防无限重放); @@ -21,23 +29,24 @@ import org.springframework.web.bind.annotation.RestController; */ @Slf4j @RestController -@RequestMapping("/callback") +@RequestMapping("/oapi/notice/flow") @RequiredArgsConstructor -public class FlowCallbackController { +public class OapiFlowCallbackController { private final FlowCallbackService callbackService; - @PostMapping("/flow") - public ResponseEntity flow(@RequestBody byte[] body) { + @PostMapping("/callback") + public ResponseEntity callback(@RequestBody byte[] body, + @RequestAttribute(name = "yh.app.auth", required = false) String appKey) { try { callbackService.handle(new String(body, java.nio.charset.StandardCharsets.UTF_8)); return ResponseEntity.ok("{\"ok\":true}"); } catch (FlowCallbackService.TransientCallbackException e) { - log.error("[notice-flow] 回调瞬时失败(回 5xx 触发流程中心重试): {}", e.getMessage(), e); + log.error("[notice-flow] 回调瞬时失败(回 5xx 触发流程中心重试): 调用方={} {}", appKey, e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}"); } catch (Exception e) { // 意外异常同样按瞬时失败处理(宁可多重试一次,不可丢事件) - log.error("[notice-flow] 回调意外异常(回 5xx 触发流程中心重试)", e); + log.error("[notice-flow] 回调意外异常(回 5xx 触发流程中心重试)调用方={}", appKey, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}"); } } diff --git a/src/main/java/com/yunhai/notice/entity/NoticeFlowBinding.java b/src/main/java/com/yunhai/notice/entity/NoticeFlowBinding.java new file mode 100644 index 0000000..ca6743c --- /dev/null +++ b/src/main/java/com/yunhai/notice/entity/NoticeFlowBinding.java @@ -0,0 +1,39 @@ +package com.yunhai.notice.entity; + +import com.yunhai.notice.common.listener.NoticeAuditListener; +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; + +/** + * 应用级审核流程绑定(应用设置 > 流程绑定,按学校一条)。 + *

+ * 语义(先审后发规则单一事实源):「通知对象=全部用户」且本校已绑定流程时,发通知必须走审核 + * (status=0 审核载体,回调 completed 才发布);「通知对象=指定人员」与未绑定一律直发。 + * 行不存在 = 未绑定。通知落库时把 flow_guid 快照进 notice 行,故改绑/解绑不影响在途审批。 + */ +@Entity +@Table(name = "notice_flow_binding", + uniqueConstraints = @UniqueConstraint(name = "uk_notice_flow_binding_sch", columnNames = {"sch_id"})) +@Getter +@Setter +@EntityListeners(NoticeAuditListener.class) +public class NoticeFlowBinding extends BaseEntity { + + /** 租户键(一校一条绑定) */ + @Column(name = "sch_id") + private Integer schId; + + /** 绑定的流程 GUID(flow-center flw_def.flow_guid,跨版本稳定) */ + @Column(name = "flow_guid", length = 32) + private String flowGuid; + + /** 流程名快照(流程中心不可达时设置页/提示条仍可显示) */ + @Column(name = "flow_name", length = 128) + private String flowName; +} diff --git a/src/main/java/com/yunhai/notice/model/flow/FlowModels.java b/src/main/java/com/yunhai/notice/model/flow/FlowModels.java index c0d3e37..75c4e2a 100644 --- a/src/main/java/com/yunhai/notice/model/flow/FlowModels.java +++ b/src/main/java/com/yunhai/notice/model/flow/FlowModels.java @@ -1,13 +1,16 @@ package com.yunhai.notice.model.flow; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.yunhai.platform.openapi.dto.FlowDto; import lombok.Data; - -import java.util.List; +import lombok.EqualsAndHashCode; /** - * 流程中心 /oapi/flow/** 契约模型(与 flow-center OapiModels 逐字对齐, - * 契约锚点:《流程中心设计概览方案.md》§8 / M1 开工说明 §5——两侧已编码,改动须双向同步)。 + * 流程中心 /oapi/flow/** 契约模型。
+ * 2026-09-16 起全部改为 {@code extends} SDK 门面 DTO({@code yh-openapi-client FlowDto}, + * 与 flow-center OapiModels 逐字对齐,契约锚点:《流程中心设计概览方案.md》§8 / M1 开工说明 §5) + * ——字段单一事实源收敛到 SDK,本类仅保留 Notice 侧类型名(消费方签名零改动), + * 并可直接作为 {@code YschFlowOpenApi} 泛型出入参传递。 */ public final class FlowModels { @@ -16,68 +19,41 @@ public final class FlowModels { /** 流程摘要(GET /oapi/flow/list 元素) */ @Data + @EqualsAndHashCode(callSuper = true) @JsonIgnoreProperties(ignoreUnknown = true) - public static class FlowSummaryVo { - private String flowGuid; - private String name; - private Integer version; - private String nodeSummary; + public static class FlowSummaryVo extends FlowDto.FlowSummary { } /** 代发起请求体(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; + @EqualsAndHashCode(callSuper = true) + public static class ApplyBody extends FlowDto.ApplyBody { } /** 代发起响应(biz uk 幂等:重复 apply 返回既有办件 duplicated=true;30/40 态 R4 重启推进) */ @Data + @EqualsAndHashCode(callSuper = true) @JsonIgnoreProperties(ignoreUnknown = true) - public static class ApplyResultVo { - private Long instanceId; - private boolean duplicated; + public static class ApplyResultVo extends FlowDto.ApplyResult { } /** 按业务键查办件响应(GET /oapi/flow/instance;不存在为 null) */ @Data + @EqualsAndHashCode(callSuper = true) @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; + public static class InstanceVo extends FlowDto.Instance { } /** 回调注册请求体(POST /oapi/flow/callback/reg) */ @Data - public static class CallbackRegBody { - private String flowGuid; - private String callbackUrl; - /** 订阅事件逗号分隔,空=全部 */ - private String eventTypes; + @EqualsAndHashCode(callSuper = true) + public static class CallbackRegBody extends FlowDto.CallbackRegBody { } /** 回调注册响应(secret 注册时生成下发;重复注册会轮换 secret,绝不重复调用) */ @Data + @EqualsAndHashCode(callSuper = true) @JsonIgnoreProperties(ignoreUnknown = true) - public static class CallbackRegResultVo { - private String secret; + public static class CallbackRegResultVo extends FlowDto.CallbackRegResult { } } diff --git a/src/main/java/com/yunhai/notice/model/notice/NoticeQuery.java b/src/main/java/com/yunhai/notice/model/notice/NoticeQuery.java index 91b4122..b4bd842 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticeQuery.java +++ b/src/main/java/com/yunhai/notice/model/notice/NoticeQuery.java @@ -20,6 +20,13 @@ public class NoticeQuery extends BasePageQuery { /** 状态过滤:null 不过滤;1 已发布 */ private Integer status; + /** + * 审核状态过滤(管理端列表「状态」筛选,对应列 audit_status): + * 0 待提交 / 10 审批中 / 20 已通过 / 30 已驳回 / 40 已撤回; + * -1 = 无需审核(audit_status 为 NULL,未走审核轨道的直发通知);null 不过滤。 + */ + private Integer auditStatus; + /** 类型过滤:1 普通 / 2 重要 */ private Integer type; 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 da4fc7d..28c9b0a 100644 --- a/src/main/java/com/yunhai/notice/model/notice/NoticeRequest.java +++ b/src/main/java/com/yunhai/notice/model/notice/NoticeRequest.java @@ -51,9 +51,6 @@ public class NoticeRequest { /** 转发源通知ID(非空时表示转发创建,对应旧 REST 壳 forward/{sourceId}) */ private Integer sourceId; - /** - * 审核流程 GUID(可选,先审后发):非空=提交审核(status=0 待回调 completed 才发布); - * 空=原直发链路(一字不改)。编辑时可变更绑定(变更即走新一轮审核)。 - */ - private String flowGuid; + // 审核流程不再由请求传入:绑定在「应用设置 > 流程绑定」(notice_flow_binding,按学校一条), + // 由 NoticeService 按「已绑定 + 通知对象=全部用户」判定,避免前端/请求绕过审核规则。 } diff --git a/src/main/java/com/yunhai/notice/repository/NoticeFlowBindingRepository.java b/src/main/java/com/yunhai/notice/repository/NoticeFlowBindingRepository.java new file mode 100644 index 0000000..2d19801 --- /dev/null +++ b/src/main/java/com/yunhai/notice/repository/NoticeFlowBindingRepository.java @@ -0,0 +1,11 @@ +package com.yunhai.notice.repository; + +import com.yunhai.notice.entity.NoticeFlowBinding; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface NoticeFlowBindingRepository extends JpaRepository { + + Optional findBySchId(Integer schId); +} diff --git a/src/main/java/com/yunhai/notice/service/flow/FlowCallbackService.java b/src/main/java/com/yunhai/notice/service/flow/FlowCallbackService.java index 5853159..a0a15e5 100644 --- a/src/main/java/com/yunhai/notice/service/flow/FlowCallbackService.java +++ b/src/main/java/com/yunhai/notice/service/flow/FlowCallbackService.java @@ -57,6 +57,11 @@ public class FlowCallbackService { } String eventId = text(payload, "eventId"); String event = text(payload, "event"); + // 注册探活回声:ping 体只有 eventId/event/flowGuid,无 bizType/bizId,必须在必填校验之前返回 + // (原实现靠回调验签过滤器短路;迁到 /oapi 后 ping 会真到 controller,由应用验签保证来源可信) + if ("ping".equals(event)) { + return; + } String bizType = text(payload, "bizType"); String bizId = text(payload, "bizId"); Long instanceId = longVal(payload, "instanceId"); @@ -66,9 +71,6 @@ public class FlowCallbackService { 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 diff --git a/src/main/java/com/yunhai/notice/service/flow/FlowCenterClient.java b/src/main/java/com/yunhai/notice/service/flow/FlowCenterClient.java index e5097e8..b533b0a 100644 --- a/src/main/java/com/yunhai/notice/service/flow/FlowCenterClient.java +++ b/src/main/java/com/yunhai/notice/service/flow/FlowCenterClient.java @@ -17,11 +17,11 @@ 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)。 + * FlowClient 默认实现:主路径走 SDK 类型化门面 {@link YschFlowOpenApi}(服务目录 key= + * {@code flowcenter}:yh.call.targets > yh.open.services > 枚举 base-host 兜底, + * 《子应用配置优化方案》§4.2 零专属地址配置);目录未显式配置时回落本应用 legacy 键 + * {@code notice.flow.base-url}(ad-hoc TargetSpec,迁移期兼容)。签名/信封解析/错误映射 + * 全部由 SDK 统一承载(401/403→DException、code!=0→DException、传输错→OpenApiException)。 * {@code notice.flow.enabled=false} 时全部方法抛业务异常(文案含"流程中心未接入")供前端降级。 */ @Slf4j @@ -36,6 +36,8 @@ public class FlowCenterClient implements FlowClient { private final NoticeFlowProperties properties; private final YschOpenCall openCall; + private final boolean directoryResolved; + public FlowCenterClient(NoticeFlowProperties properties, ObjectProvider openCallProvider, ObjectProvider identityProvider, @@ -49,11 +51,20 @@ public class FlowCenterClient implements FlowClient { (host, basePath) -> new AppSignInterceptor(identityProvider.getIfAvailable(), host, basePath)); } this.openCall = call; + // 服务目录显式配置(targets/services)则走门面命名目标;否则回落 legacy base-url ad-hoc + this.directoryResolved = call.hasTarget(com.yunhai.platform.openapi.PlatformService.FLOWCENTER.key()); + if (directoryResolved) { + com.yunhai.platform.openapi.client.YschFlowOpenApi.setCall(call); + } } @Override public List listFlows(Integer schId) { requireEnabled(); + if (directoryResolved) { + return com.yunhai.platform.openapi.client.YschFlowOpenApi.listFlows( + schId, FlowModels.FlowSummaryVo.class); + } return openCall.getList(target(), "/oapi/flow/list", params("schId", schId), FlowModels.FlowSummaryVo.class); } @@ -61,6 +72,10 @@ public class FlowCenterClient implements FlowClient { @Override public FlowModels.ApplyResultVo apply(Integer schId, FlowModels.ApplyBody body) { requireEnabled(); + if (directoryResolved) { + return com.yunhai.platform.openapi.client.YschFlowOpenApi.apply( + schId, body, FlowModels.ApplyResultVo.class); + } return openCall.post(target(), "/oapi/flow/apply", params("schId", schId), body, FlowModels.ApplyResultVo.class); } @@ -68,6 +83,10 @@ public class FlowCenterClient implements FlowClient { @Override public FlowModels.InstanceVo getInstanceByBiz(Integer schId, String bizType, String bizId) { requireEnabled(); + if (directoryResolved) { + return com.yunhai.platform.openapi.client.YschFlowOpenApi.instance( + bizType, bizId, schId, FlowModels.InstanceVo.class); + } Map query = new HashMap<>(); query.put("schId", String.valueOf(schId)); query.put("bizType", bizType); @@ -78,6 +97,10 @@ public class FlowCenterClient implements FlowClient { @Override public FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body) { requireEnabled(); + if (directoryResolved) { + return com.yunhai.platform.openapi.client.YschFlowOpenApi.registerCallback( + schId, body, FlowModels.CallbackRegResultVo.class); + } return openCall.post(target(), "/oapi/flow/callback/reg", params("schId", schId), body, FlowModels.CallbackRegResultVo.class); } diff --git a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowGuard.java b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowGuard.java index ad811b1..dd1730b 100644 --- a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowGuard.java +++ b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowGuard.java @@ -71,7 +71,21 @@ public class NoticeFlowGuard { throw notFound(); } - private boolean isAdmin(BoaUser user) { + /** + * 管理员校验(应用设置 > 流程绑定保存用):绑定决定全校"发全部用户是否需要审核", + * 与"谁能看未发布通知"同一口径({@link #ADMIN_ROLE_IDS})。 + */ + public void requireAdmin(BoaUser user) { + if (user == null || !isAdmin(user)) { + throw new BizException(ErrorCode.FORBIDDEN, "仅本校管理员可修改审核流程绑定"); + } + } + + /** 是否本校管理员(口径同下方守卫;绑定页据此决定是否可编辑) */ + public boolean isAdmin(BoaUser user) { + if (user == null) { + return false; + } if (user.getType() != null && user.getType() == 1) { return true; } diff --git a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowProperties.java b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowProperties.java index aaa7788..25c3e0e 100644 --- a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowProperties.java +++ b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowProperties.java @@ -24,8 +24,18 @@ public class NoticeFlowProperties { */ private String callbackBase = "http://localhost:8101"; - /** 回调路径(挂在本服务上,FlowCallbackVerifyFilter 只拦 /callback/*) */ - private String callbackPath = "/callback/flow"; + /** + * 回调路径:挂在开放接口域下,由平台应用验签(/oapi/* 强制)保护,不再有独立回调过滤器。 + */ + private String callbackPath = "/oapi/notice/flow/callback"; + + /** + * 本服务部署 context-path(可选,无则空):注册时随 basePath 一并申报给 flow-center, + * 推送方按此从回调 URL 的 path 中剥离后再签 X-Signature,与平台 AppAuthFilter 用 + * request.contextPath 剥离后的 path 对齐。生产 Notice 部署在 /notice 前缀下,须配 /notice; + * 配错的表现是回调 401(推送方签的 target 与本服务验签 target 不一致)。 + */ + private String callbackBasePath = ""; /** 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 index 801ad3b..0397d70 100644 --- a/src/main/java/com/yunhai/notice/service/flow/NoticeFlowService.java +++ b/src/main/java/com/yunhai/notice/service/flow/NoticeFlowService.java @@ -3,10 +3,12 @@ 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.NoticeFlowBinding; import com.yunhai.notice.entity.NoticeFlowReg; import com.yunhai.notice.entity.UpFile; import com.yunhai.notice.enums.FlowAuditStatusEnum; import com.yunhai.notice.model.flow.FlowModels; +import com.yunhai.notice.repository.NoticeFlowBindingRepository; import com.yunhai.notice.repository.NoticeFlowRegRepository; import com.yunhai.notice.repository.NoticeRepository; import com.yunhai.notice.repository.UpFileRepository; @@ -18,6 +20,7 @@ import com.yunhai.platform.utils.model.BoaUser; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronizationAdapter; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.util.StringUtils; @@ -39,6 +42,7 @@ public class NoticeFlowService { private final NoticeRepository noticeRepository; private final NoticeFlowRegRepository regRepository; + private final NoticeFlowBindingRepository bindingRepository; private final UpFileRepository upFileRepository; private final FlowClient flowClient; private final NoticeFlowProperties properties; @@ -136,6 +140,8 @@ public class NoticeFlowService { FlowModels.CallbackRegBody body = new FlowModels.CallbackRegBody(); body.setFlowGuid(flowGuid); body.setCallbackUrl(callbackUrl()); + // 申报本服务 context-path:推送方按它剥离后再签应用签名(与平台 AppAuthFilter 剥 contextPath 对齐) + body.setBasePath(properties.getCallbackBasePath()); FlowModels.CallbackRegResultVo result = flowClient.registerCallback(schId, body); if (reg == null) { reg = new NoticeFlowReg(); @@ -256,6 +262,84 @@ public class NoticeFlowService { return currentBoaUser == null ? null : currentBoaUser.get(); } + // ==================== 应用级流程绑定(应用设置 > 流程绑定) ==================== + + /** + * 本校绑定视图(设置页展示 / 发通知页按钮显隐):bound / flowGuid / flowName / flowEnabled / canEdit。 + * 无学校身份或未绑定 → bound=false。读取不需要管理员(发通知页人人都要据此判断是否走审核)。 + */ + public Map bindingView(Integer schId, BoaUser user) { + Map view = new LinkedHashMap<>(); + NoticeFlowBinding binding = schId == null ? null : bindingRepository.findBySchId(schId).orElse(null); + boolean bound = binding != null && StringUtils.hasText(binding.getFlowGuid()); + view.put("bound", bound); + view.put("flowGuid", bound ? binding.getFlowGuid() : null); + view.put("flowName", bound ? binding.getFlowName() : null); + view.put("flowEnabled", properties.isEnabled()); + view.put("canEdit", noticeFlowGuard.isAdmin(user)); + return view; + } + + /** + * 发通知分流唯一判定入口:本校已绑定且流程中心可用时返回绑定 GUID;否则 null(走直发)。 + * 流程中心未接入(properties.enabled=false)时不进审核轨道——否则通知会永远停在未发布态。 + */ + public String boundFlowGuid(Integer schId) { + if (schId == null || !properties.isEnabled()) { + return null; + } + NoticeFlowBinding binding = bindingRepository.findBySchId(schId).orElse(null); + if (binding == null || !StringUtils.hasText(binding.getFlowGuid())) { + return null; + } + return binding.getFlowGuid(); + } + + /** + * 保存绑定(空 flowGuid=解绑,直接删行)。绑定的流程必须是流程中心"已发布"流程—— + * 值由 options() 同一数据源校验,避免手改请求把不存在的 GUID 落库(发通知时才发现)。 + */ + @Transactional + public Map saveBinding(BoaUser user, String flowGuid) { + noticeFlowGuard.requireAdmin(user); + Integer schId = currentSchId(user); + NoticeFlowBinding binding = bindingRepository.findBySchId(schId).orElse(null); + if (!StringUtils.hasText(flowGuid)) { + if (binding != null) { + bindingRepository.delete(binding); + log.info("[notice-flow] 解除流程绑定:schId={} 原流程={}", schId, binding.getFlowGuid()); + } + return bindingView(schId, user); + } + String name = requirePublishedFlowName(schId, flowGuid.trim()); + if (binding == null) { + binding = new NoticeFlowBinding(); + binding.setSchId(schId); + } + binding.setFlowGuid(flowGuid.trim()); + binding.setFlowName(name); + bindingRepository.save(binding); + log.info("[notice-flow] 保存流程绑定:schId={} flowGuid={} name={}", schId, binding.getFlowGuid(), name); + return bindingView(schId, user); + } + + /** 校验流程 GUID 属于本校已发布流程并返回流程名(流程中心不可达时抛业务异常,不落脏绑定) */ + private String requirePublishedFlowName(Integer schId, String flowGuid) { + for (FlowModels.FlowSummaryVo flow : options(schId)) { + if (flowGuid.equals(flow.getFlowGuid())) { + return flow.getName(); + } + } + throw new BizException(ErrorCode.PARAM_ERROR, "流程不存在或未发布:" + flowGuid); + } + + private Integer currentSchId(BoaUser user) { + if (user == null || user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "无法确定当前学校身份,请重新登录后再试"); + } + return user.getCurrentRole().getSchId(); + } + private Notice requireNotice(Integer noticeId) { return noticeRepository.findById(noticeId) .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId)); 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 3de0bfd..d793c2c 100644 --- a/src/main/java/com/yunhai/notice/service/impl/NoticeService.java +++ b/src/main/java/com/yunhai/notice/service/impl/NoticeService.java @@ -131,29 +131,32 @@ public class NoticeService implements INoticeService { entity.setId(null); entity.setDeptName(resolveDeptName(request)); normalizeReceiveList(entity, request.getReceiveList()); - // 发布分流(先审后发):绑 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); + // 发布分流(先审后发):应用设置绑定了流程 且 通知对象=全部用户 → status=0 审核载体 + audit=0 + // 待提交,提交动作 AFTER_COMMIT 出站(D9:apply 不进 DB 事务,失败保持 0 可幂等重推); + // 指定人员 / 未绑定 → 原直发链路一字不改(status=1 即发布)——绑定值由后端判定,请求无法绕过 + String boundFlowGuid = noticeFlowService.boundFlowGuid(entity.getSchId()); + boolean needAudit = boundFlowGuid != null && isAllReceiver(entity.getReceiveList()); + if (needAudit) { + entity.setStatus(UNPUBLISHED); + entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode()); + entity.setFlowGuid(boundFlowGuid); + // 定时发送与审核互斥(审核通过前本就不发布;前端已隐藏定时入口,此处兜底清空) + entity.setTimingSendTime(null); + } else { + entity.setStatus(PUBLISHED); + } noticeRepository.save(entity); // 写入接收人关联表(非全员时;status=0 不可见,提前绑定无害) saveReceivers(entity); // 绑定附件(前端上传后回传的 fileIds 逗号串) fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); - if (flowBound) { - // 短信时机后移:绑流程的通知 create 时不发,回调 completed 才发(审核通过才发) + if (needAudit) { + // 短信时机后移:走审核的通知 create 时不发,回调 completed 才发(审核通过才发) noticeFlowService.submitAfterCommit(entity.getId()); } else { // 立即发布且开启短信时预留短信发送(真实通道后续接入) @@ -162,6 +165,11 @@ public class NoticeService implements INoticeService { return entity.getId(); } + /** 通知对象是否全员(normalizeReceiveList 已把 all/UserAll/ALL 归一为 "ALL") */ + private boolean isAllReceiver(String receiveList) { + return receiveList != null && "ALL".equalsIgnoreCase(receiveList.trim()); + } + /** * 未发布通知(status=0 审核载体)详情放行:仅提交人本人/本校管理员/该办件办理人可见, * 其余一律"通知不存在或未发布"(查不到语义)。status=1 已发布零变化。 @@ -187,8 +195,6 @@ public class NoticeService implements INoticeService { 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()); @@ -213,24 +219,37 @@ public class NoticeService implements INoticeService { } fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId()); } - // 审核绑定变更:新绑/换绑 → 回到 0 待提交(提交动作 AFTER_COMMIT 出站);解绑 → 直发即发布 - if (nowFlowBound) { - entity.setFlowGuid(request.getFlowGuid().trim()); + // 分流重算(每次保存都按当前绑定与通知对象判定,规则与 create 一致): + // 全体用户 + 已绑定 → 进/留审核轨道;指定人员 → 退出轨道直发 + boolean wasFlowBound = StringUtils.hasText(entity.getFlowGuid()); + String boundFlowGuid = noticeFlowService.boundFlowGuid(entity.getSchId()); + boolean needAudit = isAllReceiver(entity.getReceiveList()) && boundFlowGuid != null; + if (needAudit) { + // 沿用该通知已有流程快照(在途/已通过的沿用原流程重审,不受期间改绑影响),无则取当前绑定 + entity.setFlowGuid(wasFlowBound ? entity.getFlowGuid() : boundFlowGuid); 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.setStatus(UNPUBLISHED); // 审核通过前对用户不可见 + entity.setTimingSendTime(null); // 定时发送与审核互斥 + } else { + // 直发(指定人员 / 未绑定):清审核镜像字段并置发布 + // (原实现在此漏置 status,会把解绑/改指定人员的通知永久留在未发布态) entity.setFlowGuid(null); entity.setAuditStatus(null); entity.setFlowInstanceId(null); entity.setLastInstanceVersion(null); entity.setFlowResultJson(null); + entity.setStatus(PUBLISHED); } noticeRepository.save(entity); - if (nowFlowBound) { + if (needAudit) { // 编辑重提:复用同一 bizId(notice.id)→ flow-center R4 语义重启推进,轨迹连续 noticeFlowService.submitAfterCommit(entity.getId()); + } else if (wasFlowBound) { + // 由审核轨道转直发:审核期从未发布过,此刻才真正可见——补发布后置(sms_sent_at 原子认领防重发) + trySendSms(entity); } } @@ -321,17 +340,31 @@ public class NoticeService implements INoticeService { entity.setId(null); entity.setDeptName(resolveDeptName(request)); normalizeReceiveList(entity, request.getReceiveList()); - entity.setStatus(PUBLISHED); entity.setPublisherAccount(currentAccount()); entity.setPublisherName(currentName()); - entity.setParentId(sourceId); -entity.setIsPulled(0); + entity.setIsPulled(0); entity.setReadCount(0); entity.setParentId(sourceId); entity.setSchId(currentSchId()); + // 转发同样是"发通知":分流规则与 publish 一致(绑定 + 全员 → 审核;否则直发)。 + // 不这样收口的话,全员通知可绕开审核——转发一条给全部用户即绕过先审后发。 + String boundFlowGuid = noticeFlowService.boundFlowGuid(entity.getSchId()); + boolean needAudit = boundFlowGuid != null && isAllReceiver(entity.getReceiveList()); + if (needAudit) { + entity.setStatus(UNPUBLISHED); + entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode()); + entity.setFlowGuid(boundFlowGuid); + entity.setTimingSendTime(null); + } else { + entity.setStatus(PUBLISHED); + } noticeRepository.save(entity); saveReceivers(entity); - trySendSms(entity); + if (needAudit) { + noticeFlowService.submitAfterCommit(entity.getId()); + } else { + trySendSms(entity); + } return entity.getId(); } @@ -423,6 +456,14 @@ entity.setIsPulled(0); if (query.getStatus() != null) { predicates.add(cb.equal(root.get("status"), query.getStatus())); } + // 审核状态筛选:-1 = 无需审核(audit_status is null,直发通知;审批态列显示 "—" 的那些行) + if (query.getAuditStatus() != null) { + if (query.getAuditStatus() < 0) { + predicates.add(cb.isNull(root.get("auditStatus"))); + } else { + predicates.add(cb.equal(root.get("auditStatus"), query.getAuditStatus())); + } + } appendCommonPredicates(query, root, cb, predicates); // scope="user" → 只看自己发布的通知 if ("user".equalsIgnoreCase(query.getScope())) { diff --git a/src/main/java/com/yunhai/notice/utils/ReReadableRequestWrapper.java b/src/main/java/com/yunhai/notice/utils/ReReadableRequestWrapper.java deleted file mode 100644 index 2cc71f2..0000000 --- a/src/main/java/com/yunhai/notice/utils/ReReadableRequestWrapper.java +++ /dev/null @@ -1,63 +0,0 @@ -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/resources/sql/notice_flow_binding_create.sql b/src/main/resources/sql/notice_flow_binding_create.sql new file mode 100644 index 0000000..5d5496b --- /dev/null +++ b/src/main/resources/sql/notice_flow_binding_create.sql @@ -0,0 +1,16 @@ +-- 应用级审核流程绑定(应用设置 > 流程绑定,按学校一条) +-- 语义:绑定后「通知对象=全部用户」必须审核通过才发布;「指定人员」与未绑定一律直发。 +-- 通知落库时把 flow_guid 快照进 notice 行,改绑/解绑不影响在途审批。 +-- 本地 ddl-auto=update 会自动建表;生产执行本脚本。 +CREATE TABLE IF NOT EXISTS `notice_flow_binding` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `sch_id` INT NULL COMMENT '学校ID(一校一条)', + `flow_guid` VARCHAR(32) NULL COMMENT 'flow-center 流程 GUID(flw_def.flow_guid,跨版本稳定)', + `flow_name` VARCHAR(128) NULL COMMENT '流程名快照(流程中心不可达时展示兜底)', + `create_by` VARCHAR(64) NULL COMMENT '创建人', + `create_time` DATETIME NULL COMMENT '创建时间', + `update_by` VARCHAR(64) NULL COMMENT '更新人', + `update_time` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_notice_flow_binding_sch` (`sch_id`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT ='应用级审核流程绑定'; diff --git a/src/test/java/com/yunhai/notice/config/FlowCallbackVerifyFilterTest.java b/src/test/java/com/yunhai/notice/config/FlowCallbackVerifyFilterTest.java deleted file mode 100644 index 340f4d3..0000000 --- a/src/test/java/com/yunhai/notice/config/FlowCallbackVerifyFilterTest.java +++ /dev/null @@ -1,151 +0,0 @@ -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 index fccf62a..fad0669 100644 --- a/src/test/java/com/yunhai/notice/flow/FlowCallbackTransientIT.java +++ b/src/test/java/com/yunhai/notice/flow/FlowCallbackTransientIT.java @@ -14,15 +14,8 @@ 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; @@ -68,7 +61,7 @@ class FlowCallbackTransientIT { reg.setSchId(1); reg.setAppKey("100617"); reg.setFlowGuid(FLOW_GUID); - reg.setCallbackUrl("http://localhost:" + port + "/callback/flow"); + reg.setCallbackUrl("http://localhost:" + port + "/oapi/notice/flow/callback"); reg.setSecret(REG_SECRET); reg.setStatus(0); regRepository.save(reg); @@ -122,28 +115,10 @@ class FlowCallbackTransientIT { + "\"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); - } + // 回调端点挂在 /oapi/* 下:以推送方 flow-center(100019)身份带应用签名投递 + return new com.yunhai.notice.support.OapiSignedClient( + "http://localhost:" + port, "100019", "flow-center-dev-secret-100019") + .post("/oapi/notice/flow/callback", body, String.class); } private static String uuid() { diff --git a/src/test/java/com/yunhai/notice/flow/NoticeFlowApiIT.java b/src/test/java/com/yunhai/notice/flow/NoticeFlowApiIT.java index 31c519c..c71977a 100644 --- a/src/test/java/com/yunhai/notice/flow/NoticeFlowApiIT.java +++ b/src/test/java/com/yunhai/notice/flow/NoticeFlowApiIT.java @@ -1,7 +1,9 @@ package com.yunhai.notice.flow; import com.yunhai.notice.entity.Notice; +import com.yunhai.notice.entity.NoticeFlowBinding; import com.yunhai.notice.entity.NoticeFlowReg; +import com.yunhai.notice.repository.NoticeFlowBindingRepository; import com.yunhai.notice.repository.NoticeFlowEventRepository; import com.yunhai.notice.repository.NoticeFlowRegRepository; import com.yunhai.notice.repository.NoticeRepository; @@ -13,16 +15,8 @@ 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; @@ -31,15 +25,22 @@ 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/flow/callback 的应用验签(复用平台能力):以 flow-center 身份 + * (appKey=100019,密钥取 trusted-apps)合法签名 200 推进状态 / 坏签名 401 / 匿名 401 / + * 毒 body(缺字段)200 / ping 200 / 重复 eventId 幂等; * ② /oapi/notice/getDict 提供方三态(匿名 401 / 合法签名 200 / 错误密钥 401); - * ③ notice 表 5 新列与 notice_flow_reg uk 的真实落库。 + * ③ notice 表 5 新列、notice_flow_reg uk、notice_flow_binding 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.datasource.url=jdbc:mysql://localhost:3306/bsch_notice_test?createDatabaseIfNotExist=true&useUnicode=true&connectionCollation=utf8mb4_general_ci&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai", "spring.jpa.hibernate.ddl-auto=update"}) class NoticeFlowApiIT { + /** 回调端点(开放接口域;验签由 yh.app-auth enforce-patterns=/oapi/* 强制) */ + private static final String CALLBACK_PATH = "/oapi/notice/flow/callback"; + /** 推送方身份:flow-center 的 appKey/secret(Notice trusted-apps 白名单) */ + private static final String FLOW_APP_KEY = "100019"; + private static final String FLOW_APP_SECRET = "flow-center-dev-secret-100019"; private static final String REG_SECRET = "it-callback-secret-0123456789"; private static final String FLOW_GUID = "it-flow-guid-0001"; @@ -53,6 +54,8 @@ class NoticeFlowApiIT { @Autowired private NoticeFlowEventRepository eventRepository; @Autowired + private NoticeFlowBindingRepository bindingRepository; + @Autowired private ObjectMapper objectMapper; private Notice notice; @@ -61,13 +64,14 @@ class NoticeFlowApiIT { void setUp() { eventRepository.deleteAll(); regRepository.deleteAll(); + bindingRepository.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.setCallbackUrl("http://localhost:" + port + CALLBACK_PATH); reg.setSecret(REG_SECRET); reg.setStatus(0); regRepository.save(reg); @@ -86,15 +90,14 @@ class NoticeFlowApiIT { notice = noticeRepository.save(notice); } - // ==================== ① X-Flw 回调 ==================== + // ==================== ① 回调端点应用验签 ==================== @Test void signedCompletedEvent_advancesNoticeToPublished() { - String eventId = uuid(); - String body = eventBody(eventId, "flow.completed", 3, + String body = eventBody(uuid(), "flow.completed", 3, "{\"result\":\"APPROVED\",\"opinion\":\"同意\"}"); - ResponseEntity resp = postSignedCallback(REG_SECRET, body); - assertEquals(200, resp.getStatusCodeValue(), "合法签名事件应 200"); + ResponseEntity resp = flowCenter().post(CALLBACK_PATH, body, String.class); + assertEquals(200, resp.getStatusCodeValue(), "合法应用签名事件应 200"); Notice updated = reload(); assertEquals(20, updated.getAuditStatus()); @@ -108,10 +111,9 @@ class NoticeFlowApiIT { @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(), + String body = eventBody(uuid(), "flow.completed", 3, "{\"result\":\"APPROVED\"}"); + assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue()); + assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(), "重复投递必须回 200(flow-center 持续重试语义下的常态)"); assertEquals(20, reload().getAuditStatus()); assertEquals(1L, eventRepository.count(), "eventId 唯一:重复投递不落第二行"); @@ -119,9 +121,9 @@ class NoticeFlowApiIT { @Test void missingFieldsBody_200_silentDrop() { - // 合法 JSON 但缺事件字段:过滤链可过(含 flowGuid/schId 可验签),业务层判毒丢弃回 200 + // 合法 JSON 但缺事件字段:应用验签可过,业务层判毒丢弃回 200 String body = "{\"eventId\":\"" + uuid() + "\",\"schId\":1,\"flowGuid\":\"" + FLOW_GUID + "\"}"; - ResponseEntity resp = postSignedCallback(REG_SECRET, body); + ResponseEntity resp = flowCenter().post(CALLBACK_PATH, body, String.class); assertEquals(200, resp.getStatusCodeValue(), "缺字段毒消息回 200 防无限重放"); assertEquals(10, reload().getAuditStatus(), "状态不得被毒消息推进"); } @@ -129,27 +131,36 @@ class NoticeFlowApiIT { @Test void badSignature_401() { String body = eventBody(uuid(), "flow.completed", 3, "{}"); - ResponseEntity resp = postCallback(body, "forged-signature"); - assertEquals(401, resp.getStatusCodeValue()); + ResponseEntity resp = flowCenter().postWithBadSecret(CALLBACK_PATH, body); + assertEquals(401, resp.getStatusCodeValue(), "坏签名必须 401"); assertEquals(10, reload().getAuditStatus()); } @Test - void pingProbe_200_withoutSignature() { + void anonymousCallback_401() { + // 回调不再有独立 X-Flw 验签过滤器:匿名直访由 platform enforce-patterns 拦下 + String body = eventBody(uuid(), "flow.completed", 3, "{}"); + ResponseEntity resp = flowCenter().postRaw(CALLBACK_PATH, body); + assertEquals(401, resp.getStatusCodeValue(), "匿名回调必须 401(/oapi/* 强制验签)"); + assertEquals(10, reload().getAuditStatus()); + } + + @Test + void pingProbe_requiresAppSignature() { 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()); + // 探活 ping 同样要带应用签名(推送方 CallbackAppSigner 会给探活补头) + assertEquals(401, flowCenter().postRaw(CALLBACK_PATH, body).getStatusCodeValue(), + "无签名 ping 应被 401 拦下"); + assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(), + "带签名的 ping 回 200(注册探活只判可达)"); + assertEquals(0L, eventRepository.count(), "ping 不落事件行"); } // ==================== ② /oapi/notice/getDict 三态 ==================== @Test void oapiGetDict_threeStates() { - OapiSignedClient client = new OapiSignedClient(url(""), "100019", "flow-center-dev-secret-100019"); + OapiSignedClient client = new OapiSignedClient(url(""), FLOW_APP_KEY, FLOW_APP_SECRET); // 匿名(无签名头)→ 401 assertEquals(401, client.getRaw("/oapi/notice/getDict?type=1").getStatusCodeValue()); // 错误密钥 → 401 @@ -197,8 +208,34 @@ class NoticeFlowApiIT { } } + @Test + void flowBinding_ukPerSchool_andRoundtrip() { + NoticeFlowBinding binding = new NoticeFlowBinding(); + binding.setSchId(9); + binding.setFlowGuid(FLOW_GUID); + binding.setFlowName("信息发布审核"); + bindingRepository.saveAndFlush(binding); + NoticeFlowBinding loaded = bindingRepository.findBySchId(9).orElseThrow(IllegalStateException::new); + assertEquals(FLOW_GUID, loaded.getFlowGuid()); + assertEquals("信息发布审核", loaded.getFlowName()); + // uk(sch_id):一校只能有一条绑定 + NoticeFlowBinding dup = new NoticeFlowBinding(); + dup.setSchId(9); + dup.setFlowGuid("another-guid"); + try { + bindingRepository.saveAndFlush(dup); + throw new AssertionError("uk 冲突应抛异常"); + } catch (org.springframework.dao.DataIntegrityViolationException expected) { + // uk 生效 + } + } + // ==================== 工具 ==================== + private OapiSignedClient flowCenter() { + return new OapiSignedClient(url(""), FLOW_APP_KEY, FLOW_APP_SECRET); + } + private Notice reload() { return noticeRepository.findById(notice.getId()).orElseThrow(IllegalStateException::new); } @@ -207,10 +244,6 @@ class NoticeFlowApiIT { 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 + "\"," @@ -221,42 +254,6 @@ class NoticeFlowApiIT { + "\"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/service/flow/NoticeFlowPublishTest.java b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java index 7a00f70..4178c9c 100644 --- a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java +++ b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowPublishTest.java @@ -1,9 +1,12 @@ package com.yunhai.notice.service.flow; import com.yunhai.notice.entity.Notice; +import com.yunhai.notice.entity.NoticeFlowBinding; +import com.yunhai.notice.entity.NoticeFlowEvent; 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.NoticeFlowBindingRepository; import com.yunhai.notice.repository.NoticeFlowEventRepository; import com.yunhai.notice.repository.NoticeFlowRegRepository; import com.yunhai.notice.repository.NoticeReadRepository; @@ -33,6 +36,7 @@ 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.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -40,7 +44,6 @@ 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; @@ -48,14 +51,19 @@ 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 时序后移。 + * 发布分流/守卫/重提单测(fake FlowClient 注入)。

+ * 分流规则(应用级绑定,规则单一事实源=NoticeService): + * 已绑定 + 通知对象=全部用户 → 审核(status=0/audit=0→apply→回填 10); + * 已绑定 + 指定人员 → 直发(原链路零变化);未绑定 → 直发(存量零破坏哨兵)。 + * 另覆盖:apply 失败保持 0 可重推、10 审批中锁编辑/删除/重提、30/40 编辑重提复用同 bizId、短信时机后移。 */ class NoticeFlowPublishTest { + private static final String BINDING_GUID = "fg-review"; + private NoticeService noticeService; private NoticeRepository noticeRepository; + private NoticeFlowBindingRepository bindingRepository; private FlowClient flowClient; private NoticeFlowService noticeFlowService; private SmsSender smsSender; @@ -72,6 +80,7 @@ class NoticeFlowPublishTest { auditAtSave.clear(); statusAtSave.clear(); noticeRepository = mock(NoticeRepository.class); + bindingRepository = mock(NoticeFlowBindingRepository.class); smsSender = mock(SmsSender.class); dictService = mock(IDictService.class); flowClient = mock(FlowClient.class); @@ -103,6 +112,8 @@ class NoticeFlowPublishTest { return 1; }); when(dictService.resolveDeptName(any())).thenReturn("办公室"); + // 默认未绑定;绑定场景由 bindFlow() 显式打开 + when(bindingRepository.findBySchId(any())).thenReturn(Optional.empty()); NoticeFlowProperties props = new NoticeFlowProperties(); props.setEnabled(true); @@ -126,7 +137,7 @@ class NoticeFlowPublishTest { return vo; }); - noticeFlowService = new NoticeFlowService(noticeRepository, regRepository, + noticeFlowService = new NoticeFlowService(noticeRepository, regRepository, bindingRepository, mock(UpFileRepository.class), flowClient, props, new ObjectMapper(), appAuth, currentBoaUser, new NoticeFlowGuard(flowClient, props)); @@ -164,25 +175,37 @@ class NoticeFlowPublishTest { UserContextHolder.clear(); } - private NoticeRequest request(String flowGuid) { + /** 打开本校应用级流程绑定(应用设置 > 流程绑定) */ + private void bindFlow(String flowGuid) { + NoticeFlowBinding binding = new NoticeFlowBinding(); + binding.setId(1); + binding.setSchId(1); + binding.setFlowGuid(flowGuid); + binding.setFlowName("信息发布审核"); + when(bindingRepository.findBySchId(1)).thenReturn(Optional.of(binding)); + } + + /** 请求(receiveList="all" 全员 / 指定人员串) */ + private NoticeRequest request(String receiveList) { NoticeRequest request = new NoticeRequest(); request.setTitle("九月开学安排"); request.setContent("

内容

"); request.setType(1); request.setDeptId("d1"); - request.setReceiveList("[\"tea1\",\"tea2\"]"); + request.setReceiveList(receiveList); request.setSmsFlag(1); - request.setFlowGuid(flowGuid); return request; } @Test void unboundFlow_directPublish_zeroSideEffects() { - // 存量零破坏哨兵:未绑流程 → status=1、sms 即时、flow 零交互 - Integer id = noticeService.publish(request(null)); + // 存量零破坏哨兵:未绑流程 → 即便全员也 status=1、sms 即时、flow 零交互 + Integer id = noticeService.publish(request("all")); ArgumentCaptor captor = ArgumentCaptor.forClass(Notice.class); verify(noticeRepository, times(1)).save(captor.capture()); assertEquals(1, captor.getValue().getStatus()); + assertNull(captor.getValue().getAuditStatus()); + assertNull(captor.getValue().getFlowGuid()); assertEquals("zhangsan", captor.getValue().getPublisherAccount()); verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList()); verify(flowClient, never()).apply(anyInt(), any()); @@ -191,13 +214,14 @@ class NoticeFlowPublishTest { } @Test - void boundFlow_status0_audit0_thenApplyBackfills() { - // 单测无活动事务 → submitAfterCommit 立即执行(等价 AFTER_COMMIT 语义验证) - noticeService.publish(request("fg-review")); + void boundFlow_allReceivers_entersAudit() { + // 核心新规则:已绑定 + 通知对象=全部用户 → 审核载体 + bindFlow(BINDING_GUID); + noticeService.publish(request("all")); 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()); + assertEquals(0, firstSavedStatus(), "走审核的通知应落 status=0 未发布"); + assertEquals(BINDING_GUID, savedEntity.getFlowGuid(), "flow_guid 取应用绑定快照"); // 窄列原子回填:audit=10 + instance(markApproving 桩已作用于实体) assertEquals(10, savedEntity.getAuditStatus()); assertEquals(9527L, savedEntity.getFlowInstanceId()); @@ -206,8 +230,31 @@ class NoticeFlowPublishTest { verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList()); } + @Test + void boundFlow_specifiedReceivers_directPublish() { + // 核心新规则:已绑定但通知对象=指定人员 → 直发(与绑定前完全一致) + bindFlow(BINDING_GUID); + noticeService.publish(request("[\"tea1\",\"tea2\"]")); + assertEquals(1, firstSavedStatus(), "指定人员不受绑定影响,直接发布"); + assertNull(savedEntity.getAuditStatus()); + assertNull(savedEntity.getFlowGuid()); + verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList()); + verify(flowClient, never()).apply(anyInt(), any()); + } + + @Test + void boundFlow_allReceivers_timingSendTimeCleared() { + // 定时发送与审核互斥:走审核时清空(前端隐藏入口,后端兜底) + bindFlow(BINDING_GUID); + NoticeRequest request = request("all"); + request.setTimingSendTime(java.time.LocalDateTime.now().plusDays(1)); + noticeService.publish(request); + assertNull(savedEntity.getTimingSendTime(), "走审核的通知不保留定时发送时间"); + } + @Test void applyFails_rowStaysPendingSubmit_thenRetrySucceeds() { + bindFlow(BINDING_GUID); when(flowClient.apply(anyInt(), any())) .thenThrow(new RuntimeException("flow-center down")) .thenAnswer(inv -> { @@ -215,7 +262,7 @@ class NoticeFlowPublishTest { vo.setInstanceId(9527L); return vo; }); - noticeService.publish(request("fg-review")); // 出站失败被吞(保持 0 待提交) + noticeService.publish(request("all")); // 出站失败被吞(保持 0 待提交) assertEquals(0, savedEntity.getAuditStatus(), "apply 失败保持 0 待提交(行不回滚)"); // 重推入口:trySubmitFlow 幂等治愈 → 10 noticeFlowService.trySubmitFlow(77); @@ -226,7 +273,8 @@ class NoticeFlowPublishTest { @Test void trySubmit_rejectedWhenApproving() { - noticeService.publish(request("fg-review")); // → 10 + bindFlow(BINDING_GUID); + noticeService.publish(request("all")); // → 10 BizException ex = assertThrows(BizException.class, () -> noticeFlowService.trySubmitFlow(77)); assertTrue(ex.getMessage().contains("审批中")); } @@ -246,31 +294,35 @@ class NoticeFlowPublishTest { @Test void editAndDelete_rejectedWhenApproving() { - noticeService.publish(request("fg-review")); // → audit=10 + bindFlow(BINDING_GUID); + noticeService.publish(request("all")); // → audit=10 assertEquals(10, savedEntity.getAuditStatus()); - assertThrows(BizException.class, () -> noticeService.edit(77, request("fg-review"))); + assertThrows(BizException.class, () -> noticeService.edit(77, request("all"))); assertThrows(BizException.class, () -> noticeService.deleteBatch(java.util.Collections.singletonList(77))); } @Test void editAfterRejected_resubmitsSameBizId() { + bindFlow(BINDING_GUID); Notice rejected = pendingNotice(30); when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected)); - noticeService.edit(77, request("fg-review")); + noticeService.edit(77, request("all")); // 编辑后 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()); + assertEquals(0, rejected.getStatus(), "重审期间仍未发布"); } @Test void applyParams_contract() { - noticeService.publish(request("fg-review")); + bindFlow(BINDING_GUID); + noticeService.publish(request("all")); ArgumentCaptor body = ArgumentCaptor.forClass(FlowModels.ApplyBody.class); verify(flowClient).apply(anyInt(), body.capture()); FlowModels.ApplyBody b = body.getValue(); - assertEquals("fg-review", b.getFlowGuid()); + assertEquals(BINDING_GUID, b.getFlowGuid()); assertEquals("notice", b.getBizType()); assertEquals("77", b.getBizId()); assertEquals("九月开学安排", b.getBizTitle()); @@ -286,13 +338,49 @@ class NoticeFlowPublishTest { } @Test - void unbindFlowOnEdit_clearsAuditAndPublishes() { + void editToSpecified_publishesAndClearsAudit() { + // 由审核轨道改投指定人员 → 退出轨道直发(原实现漏置 status,会把通知留在未发布态) 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()); + noticeService.edit(77, request("[\"tea1\"]")); // 未绑定 + 指定人员 + assertNull(rejected.getFlowGuid()); + assertNull(rejected.getAuditStatus()); + assertEquals(1, rejected.getStatus(), "退出审核轨道必须置为已发布"); verify(flowClient, never()).apply(anyInt(), any()); + verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList()); + } + + @Test + void unbindWhileRejected_editToAll_publishesDirectly() { + // 解绑后存量通知编辑:不再强制审核(规则=未绑定即直发) + Notice rejected = pendingNotice(30); + when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected)); + noticeService.edit(77, request("all")); + assertEquals(1, rejected.getStatus()); + assertNull(rejected.getFlowGuid()); + verify(flowClient, never()).apply(anyInt(), any()); + } + + @Test + void bindingViewAndBoundFlowGuid() { + // 未绑定视图 + java.util.Map view = noticeFlowService.bindingView(1, adminUser()); + assertEquals(false, view.get("bound")); + assertEquals(true, view.get("canEdit"), "校管可编辑"); + assertNull(noticeFlowService.boundFlowGuid(1)); + // 绑定后:视图与分流入口同时生效 + bindFlow(BINDING_GUID); + java.util.Map bound = noticeFlowService.bindingView(1, adminUser()); + assertEquals(true, bound.get("bound")); + assertEquals(BINDING_GUID, bound.get("flowGuid")); + assertEquals("信息发布审核", bound.get("flowName")); + assertEquals(BINDING_GUID, noticeFlowService.boundFlowGuid(1)); + // 普通教师:能看绑定态但不能编辑 + BoaUser plain = new BoaUser(); + plain.setId(301); + plain.setAccount("teacher"); + plain.setRoleIds(java.util.Collections.singletonList(9)); + assertEquals(false, noticeFlowService.bindingView(1, plain).get("canEdit")); } /** 首次保存时的 status 快照 */ @@ -300,6 +388,74 @@ class NoticeFlowPublishTest { return statusAtSave.get(0); } + // ==================== 应用设置 > 流程绑定 保存(管理员守卫 + 值校验) ==================== + + @Test + void saveBinding_nonAdmin_denied() { + BoaUser plain = new BoaUser(); + plain.setId(301); + plain.setAccount("teacher"); + plain.setRoleIds(java.util.Collections.singletonList(9)); // 非管理员角色 + BoaRoleInfo role = new BoaRoleInfo(); + role.setSchId(1); + plain.setCurrentRole(role); + BizException ex = assertThrows(BizException.class, () -> noticeFlowService.saveBinding(plain, BINDING_GUID)); + assertTrue(ex.getMessage().contains("管理员")); + verify(bindingRepository, never()).save(any(NoticeFlowBinding.class)); + } + + @Test + void saveBinding_unknownFlow_rejected() { + when(flowClient.listFlows(1)).thenReturn(new java.util.ArrayList<>()); // 该流程不存在/未发布 + BizException ex = assertThrows(BizException.class, + () -> noticeFlowService.saveBinding(adminUser(), BINDING_GUID)); + assertTrue(ex.getMessage().contains("流程不存在或未发布")); + verify(bindingRepository, never()).save(any(NoticeFlowBinding.class)); + } + + @Test + void saveBinding_adminBound_thenUnbindDeletesRow() { + FlowModels.FlowSummaryVo flow = new FlowModels.FlowSummaryVo(); + flow.setFlowGuid(BINDING_GUID); + flow.setName("信息发布审核"); + when(flowClient.listFlows(1)).thenReturn(java.util.Collections.singletonList(flow)); + // 内存库:save 落进 store、findBySchId 从 store 读(saveBinding 末尾回读视图) + java.util.concurrent.atomic.AtomicReference store = new java.util.concurrent.atomic.AtomicReference<>(); + when(bindingRepository.findBySchId(1)).thenAnswer(inv -> Optional.ofNullable(store.get())); + when(bindingRepository.save(any(NoticeFlowBinding.class))).thenAnswer(inv -> { + store.set(inv.getArgument(0)); + return inv.getArgument(0); + }); + org.mockito.Mockito.doAnswer(inv -> { + store.set(null); + return null; + }).when(bindingRepository).delete(any(NoticeFlowBinding.class)); + + java.util.Map view = noticeFlowService.saveBinding(adminUser(), BINDING_GUID); + assertEquals(true, view.get("bound")); + assertEquals("信息发布审核", view.get("flowName")); + assertEquals(1, store.get().getSchId()); + assertEquals(BINDING_GUID, store.get().getFlowGuid()); + + // 解绑(flowGuid 空)→ 删行 + 视图回到未绑定 + NoticeFlowBinding existing = store.get(); + java.util.Map after = noticeFlowService.saveBinding(adminUser(), " "); + assertEquals(false, after.get("bound")); + verify(bindingRepository).delete(existing); + } + + /** 本校管理员(type=1 校管) */ + private BoaUser adminUser() { + BoaUser admin = new BoaUser(); + admin.setId(1); + admin.setAccount("principal"); + ReflectionTestUtils.setField(admin, "type", 1); + BoaRoleInfo role = new BoaRoleInfo(); + role.setSchId(1); + admin.setCurrentRole(role); + return admin; + } + private Notice pendingNotice(int auditStatus) { // 模拟已落库的绑流程通知(status=0,audit=指定态) Notice n = new Notice(); @@ -308,7 +464,7 @@ class NoticeFlowPublishTest { n.setContent("

内容

"); n.setStatus(0); n.setAuditStatus(auditStatus); - n.setFlowGuid("fg-review"); + n.setFlowGuid(BINDING_GUID); n.setSchId(1); n.setPublisherAccount("zhangsan"); n.setSmsFlag(1); diff --git a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowTraceTest.java b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowTraceTest.java index 7a4fee5..82cfd45 100644 --- a/src/test/java/com/yunhai/notice/service/flow/NoticeFlowTraceTest.java +++ b/src/test/java/com/yunhai/notice/service/flow/NoticeFlowTraceTest.java @@ -45,6 +45,7 @@ class NoticeFlowTraceTest { NoticeFlowProperties props = new NoticeFlowProperties(); props.setEnabled(true); service = new NoticeFlowService(noticeRepository, mock(NoticeFlowRegRepository.class), + mock(com.yunhai.notice.repository.NoticeFlowBindingRepository.class), mock(UpFileRepository.class), flowClient, props, new ObjectMapper(), new AppAuthProperties(), mock(CurrentBoaUser.class), new NoticeFlowGuard(flowClient, props)); diff --git a/src/test/java/com/yunhai/notice/service/impl/NoticeResubmitClearResultTest.java b/src/test/java/com/yunhai/notice/service/impl/NoticeResubmitClearResultTest.java index 31e4d92..5061c3a 100644 --- a/src/test/java/com/yunhai/notice/service/impl/NoticeResubmitClearResultTest.java +++ b/src/test/java/com/yunhai/notice/service/impl/NoticeResubmitClearResultTest.java @@ -17,6 +17,7 @@ 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.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -24,6 +25,9 @@ import static org.mockito.Mockito.when; * 驳回后编辑重提的清态单测(业务人视角走查 NEW-3): * Notice 侧 flow_result_json 只在终态事件写入,若不显式清,重提后整段新审批期 * 都挂着上一轮的驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能)。 + *

+ * 分流判定按新规则(应用级绑定 + 通知对象=全部用户才审核),故用例需显式给出 + * boundFlowGuid(应用设置里的绑定)与 receiveList。 */ @DisplayName("NoticeService.edit - 重提清旧驳回意见") class NoticeResubmitClearResultTest { @@ -39,6 +43,10 @@ class NoticeResubmitClearResultTest { service = new NoticeService(); ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository); ReflectionTestUtils.setField(service, "noticeFlowService", noticeFlowService); + ReflectionTestUtils.setField(service, "noticeReceiverRepository", + mock(com.yunhai.notice.repository.NoticeReceiverRepository.class)); + ReflectionTestUtils.setField(service, "upFileRepository", + mock(com.yunhai.notice.repository.UpFileRepository.class)); ReflectionTestUtils.setField(service, "dictService", mock(com.yunhai.notice.service.IDictService.class)); } @@ -57,11 +65,12 @@ class NoticeResubmitClearResultTest { return n; } - private NoticeRequest editRequest(String flowGuid) { + /** 编辑请求(receiveList:全员 "all" / 指定人员串) */ + private NoticeRequest editRequest(String receiveList) { NoticeRequest req = new NoticeRequest(); ReflectionTestUtils.setField(req, "title", "改后标题"); ReflectionTestUtils.setField(req, "content", "改后正文"); - ReflectionTestUtils.setField(req, "flowGuid", flowGuid); + ReflectionTestUtils.setField(req, "receiveList", receiveList); return req; } @@ -69,26 +78,34 @@ class NoticeResubmitClearResultTest { void editResubmit_clearsStaleRejectOpinion() { Notice n = rejectedNotice(); when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> inv.getArgument(0)); + // 应用设置绑定了流程 fg-1 且通知对象=全部用户 → 仍在审核轨道 + when(noticeFlowService.boundFlowGuid(1)).thenReturn("fg-1"); - service.edit(42, editRequest("fg-1")); + service.edit(42, editRequest("all")); assertEquals(FlowAuditStatusEnum.PENDING_SUBMIT.getCode(), n.getAuditStatus(), "重提回到 0 待提交"); assertNull(n.getFlowResultJson(), "旧驳回意见须清空,否则新审批期一直显示"); + assertEquals("fg-1", n.getFlowGuid(), "沿用该通知已有流程快照"); + assertEquals(0, n.getStatus(), "重审期间对用户不可见"); assertEquals("改后标题", n.getTitle()); verify(noticeFlowService).submitAfterCommit(42); } @Test - void unbindFlow_alsoClearsResultAndInstance() { + void editToSpecified_publishesDirectlyAndClearsResult() { Notice n = rejectedNotice(); when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> inv.getArgument(0)); + // 未绑定(或已解绑)→ 指定人员直发 + when(noticeFlowService.boundFlowGuid(1)).thenReturn(null); - service.edit(42, editRequest(null)); // 解绑流程 → 直发 + service.edit(42, editRequest("[\"tea1\"]")); assertNull(n.getFlowGuid()); assertNull(n.getFlowInstanceId()); assertNull(n.getFlowResultJson()); assertNull(n.getAuditStatus()); + assertEquals(1, n.getStatus(), "退出审核轨道必须置为已发布"); + verify(noticeFlowService, never()).submitAfterCommit(anyInt()); } } diff --git a/src/test/java/com/yunhai/notice/support/OapiSignedClient.java b/src/test/java/com/yunhai/notice/support/OapiSignedClient.java index 6ebc048..94ecbf7 100644 --- a/src/test/java/com/yunhai/notice/support/OapiSignedClient.java +++ b/src/test/java/com/yunhai/notice/support/OapiSignedClient.java @@ -88,6 +88,27 @@ public class OapiSignedClient { return restTemplate.getForEntity(baseUrl + pathAndQuery, String.class); } + /** 无签名的 POST(回调端点匿名 401 用例) */ + public ResponseEntity postRaw(String pathAndQuery, Object body) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + return restTemplate.exchange(baseUrl + pathAndQuery, HttpMethod.POST, + new HttpEntity<>(body, headers), String.class); + } + + /** 带错误密钥的 POST(回调端点坏签名 401 用例) */ + public ResponseEntity postWithBadSecret(String pathAndQuery, Object body) { + String ts = String.valueOf(System.currentTimeMillis()); + String sign = SignUtil.signRequest("POST", 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); + headers.setContentType(MediaType.APPLICATION_JSON); + return restTemplate.exchange(baseUrl + pathAndQuery, HttpMethod.POST, + new HttpEntity<>(body, headers), String.class); + } + /** 带错误密钥的调用(401 用例) */ public ResponseEntity getWithBadSecret(String pathAndQuery) { String ts = String.valueOf(System.currentTimeMillis());