feat: 流程对接切 SDK 类型化门面 YschFlowOpenApi(服务目录 flowcenter 优先,legacy base-url 回落)
build / build-push (push) Failing after 26s

- FlowModels 全部改为 extends SDK FlowDto(字段单一事实源收敛到 SDK,消费方签名零改动)
- FlowCenterClient:目录显式配置 flowcenter(yh.call.targets/yh.open.services)走门面命名目标,
  未配置回落 notice.flow.base-url ad-hoc(迁移期兼容)
- 含 2026-09-13/14 存量:重提清旧驳回意见(F4)/消息中心/文件上传改造
This commit is contained in:
chenxing
2026-09-16 01:21:23 +08:00
parent 138b226ab0
commit bfb50a205d
23 changed files with 641 additions and 607 deletions
@@ -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> flowCallbackVerifyFilter() {
FilterRegistrationBean<FlowCallbackVerifyFilter> 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));
}
}
}
@@ -18,6 +18,8 @@ import java.util.Map;
* 通知-流程中心接缝接口(先审后发)。
* <ul>
* <li>GET Flow/Options:编辑页"审核流程"选择器(代理 FlowClient.listFlows,前端不直调 oapi);</li>
* <li>GET Flow/Binding:应用设置 &gt; 流程绑定 的读取(登录可读;发通知页据此判断是否走审核);</li>
* <li>POST Flow/Binding/Save:保存/解除应用级绑定(仅本校管理员);</li>
* <li>POST Flow/TrySubmit:提交/重提审核(幂等,bizId=notice.id;apply 失败保持待提交可重推);</li>
* <li>GET Flow/Trace:审批进度(后端 getInstanceByBiz 组装,前端不直调 oapi——放行判定必须收在后端)。</li>
* </ul>
@@ -36,6 +38,22 @@ public class NoticeFlowController {
return noticeFlowService.options(schId != null ? schId : currentSchId(user));
}
/**
* 应用设置 &gt; 流程绑定:本校绑定视图(bound/flowGuid/flowName/flowEnabled)。
* 登录即可读——发通知页靠它决定「提交审核」还是「发送」。
*/
@GetMapping("Binding")
public Map<String, Object> binding(BoaUser user) {
return noticeFlowService.bindingView(currentSchId(user), user);
}
/** 保存/解除绑定(flowGuid 空=解绑);仅本校管理员可改 */
@PostMapping("Binding/Save")
public Map<String, Object> 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) {
@@ -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)。
* <p>
* <b>验签(复用平台应用验签能力)</b>:不建独立回调验签过滤器——回调路径落在
* {@code yh.app-auth.server.enforce-patterns: [/oapi/*]} 内,由平台
* {@code AppAuthFilter}(X-App-Key/X-Timestamp/X-Signature,HmacSHA256(method\npath\n ts),
* 密钥取 trusted-apps 里 flow-center 的 appKey=100019)验签 + {@code RequireAppSignFilter}
* 拒绝匿名请求。推送方 flow-center 侧由 {@code CallbackAppSigner} 用同一套 canonical 规则签名
* (注册时申报的 base-path 即本服务 context-path,见 {@link com.yunhai.notice.service.flow.NoticeFlowProperties})。
* 原来的 /callback/* + X-Flw-* 自定义过滤器已下线;X-Flw-* 头仍由 flow-center 保留发送,本服务不再校验。
* <p>
* <b>返回语义(契约冻结 D2,免对账前提)</b>:状态推进成功 / 重复投递(去重命中)/
* 协议毒消息 / 乱序丢弃 / 办件不属本应用 → <b>200</b>(毒消息回 200 防无限重放);
@@ -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<String> flow(@RequestBody byte[] body) {
@PostMapping("/callback")
public ResponseEntity<String> callback(@RequestBody byte[] body,
@RequestAttribute(name = "yh.app.auth", required = false) String appKey) {
try {
callbackService.handle(new String(body, java.nio.charset.StandardCharsets.UTF_8));
return ResponseEntity.ok("{\"ok\":true}");
} catch (FlowCallbackService.TransientCallbackException e) {
log.error("[notice-flow] 回调瞬时失败(回 5xx 触发流程中心重试): {}", 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}");
}
}
@@ -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;
/**
* 应用级审核流程绑定(应用设置 &gt; 流程绑定,按学校一条)。
* <p>
* 语义(先审后发规则单一事实源):「通知对象=全部用户」且本校已绑定流程时,发通知必须走审核
* (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;
}
@@ -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/** 契约模型。<br>
* 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<Integer> 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 {
}
}
@@ -20,6 +20,13 @@ public class NoticeQuery extends BasePageQuery {
/** 状态过滤:null 不过滤;1 已发布 */
private Integer status;
/**
* 审核状态过滤(管理端列表「状态」筛选,对应列 audit_status):
* 0 待提交 / 10 审批中 / 20 已通过 / 30 已驳回 / 40 已撤回;
* <b>-1 = 无需审核</b>(audit_status 为 NULL,未走审核轨道的直发通知);null 不过滤。
*/
private Integer auditStatus;
/** 类型过滤:1 普通 / 2 重要 */
private Integer type;
@@ -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 按「已绑定 + 通知对象=全部用户」判定,避免前端/请求绕过审核规则。
}
@@ -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<NoticeFlowBinding, Integer> {
Optional<NoticeFlowBinding> findBySchId(Integer schId);
}
@@ -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
@@ -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 &gt; yh.open.services &gt; 枚举 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<YschOpenCall> openCallProvider,
ObjectProvider<LocalAppIdentityProvider> 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<FlowModels.FlowSummaryVo> 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<String, String> 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);
}
@@ -71,7 +71,21 @@ public class NoticeFlowGuard {
throw notFound();
}
private boolean isAdmin(BoaUser user) {
/**
* 管理员校验(应用设置 &gt; 流程绑定保存用):绑定决定全校"发全部用户是否需要审核",
* 与"谁能看未发布通知"同一口径({@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;
}
@@ -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";
@@ -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<String, Object> bindingView(Integer schId, BoaUser user) {
Map<String, Object> 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<String, Object> 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));
@@ -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);
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())) {
@@ -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();
}
};
}
}
@@ -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 ='应用级审核流程绑定';
@@ -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());
}
}
@@ -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() {
@@ -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 幂等;
* ① 回调端点 <b>/oapi/notice/flow/callback</b> 的应用验签(复用平台能力):以 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<String> resp = postSignedCallback(REG_SECRET, body);
assertEquals(200, resp.getStatusCodeValue(), "合法签名事件应 200");
ResponseEntity<String> 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<String> resp = postSignedCallback(REG_SECRET, body);
ResponseEntity<String> 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<String> resp = postCallback(body, "forged-signature");
assertEquals(401, resp.getStatusCodeValue());
ResponseEntity<String> 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<String> 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<String> 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<String> 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<String> 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("-", "");
}
@@ -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 注入)。<p>
* 分流规则(应用级绑定,规则单一事实源=NoticeService):
* <b>已绑定 + 通知对象=全部用户 → 审核</b>(status=0/audit=0→apply→回填 10);
* <b>已绑定 + 指定人员 → 直发</b>(原链路零变化);<b>未绑定 → 直发</b>(存量零破坏哨兵)。
* 另覆盖: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("<p>内容</p>");
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<Notice> 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<FlowModels.ApplyBody> 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<FlowModels.ApplyBody> 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<String, Object> 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<String, Object> 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<NoticeFlowBinding> 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<String, Object> 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<String, Object> 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("<p>内容</p>");
n.setStatus(0);
n.setAuditStatus(auditStatus);
n.setFlowGuid("fg-review");
n.setFlowGuid(BINDING_GUID);
n.setSchId(1);
n.setPublisherAccount("zhangsan");
n.setSmsFlag(1);
@@ -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));
@@ -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 不能)。
* <p>
* 分流判定按新规则(应用级绑定 + 通知对象=全部用户才审核),故用例需显式给出
* 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());
}
}
@@ -88,6 +88,27 @@ public class OapiSignedClient {
return restTemplate.getForEntity(baseUrl + pathAndQuery, String.class);
}
/** 无签名的 POST(回调端点匿名 401 用例) */
public ResponseEntity<String> 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<String> 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<String> getWithBadSecret(String pathAndQuery) {
String ts = String.valueOf(System.currentTimeMillis());