feat: 流程对接切 SDK 类型化门面 YschFlowOpenApi(服务目录 flowcenter 优先,legacy base-url 回落)
build / build-push (push) Failing after 26s
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:
@@ -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>
|
* <ul>
|
||||||
* <li>GET Flow/Options:编辑页"审核流程"选择器(代理 FlowClient.listFlows,前端不直调 oapi);</li>
|
* <li>GET Flow/Options:编辑页"审核流程"选择器(代理 FlowClient.listFlows,前端不直调 oapi);</li>
|
||||||
|
* <li>GET Flow/Binding:应用设置 > 流程绑定 的读取(登录可读;发通知页据此判断是否走审核);</li>
|
||||||
|
* <li>POST Flow/Binding/Save:保存/解除应用级绑定(仅本校管理员);</li>
|
||||||
* <li>POST Flow/TrySubmit:提交/重提审核(幂等,bizId=notice.id;apply 失败保持待提交可重推);</li>
|
* <li>POST Flow/TrySubmit:提交/重提审核(幂等,bizId=notice.id;apply 失败保持待提交可重推);</li>
|
||||||
* <li>GET Flow/Trace:审批进度(后端 getInstanceByBiz 组装,前端不直调 oapi——放行判定必须收在后端)。</li>
|
* <li>GET Flow/Trace:审批进度(后端 getInstanceByBiz 组装,前端不直调 oapi——放行判定必须收在后端)。</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
@@ -36,6 +38,22 @@ public class NoticeFlowController {
|
|||||||
return noticeFlowService.options(schId != null ? schId : currentSchId(user));
|
return noticeFlowService.options(schId != null ? schId : currentSchId(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用设置 > 流程绑定:本校绑定视图(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 补属主守卫) */
|
/** 提交/重新提交审核(编辑页重提按钮;audit=10 审批中将被拒绝;BP-R5-3 补属主守卫) */
|
||||||
@PostMapping("TrySubmit")
|
@PostMapping("TrySubmit")
|
||||||
public void trySubmit(@RequestParam("serid") Integer serid, BoaUser user) {
|
public void trySubmit(@RequestParam("serid") Integer serid, BoaUser user) {
|
||||||
|
|||||||
+18
-9
@@ -1,4 +1,4 @@
|
|||||||
package com.yunhai.notice.controller.callback;
|
package com.yunhai.notice.controller.oapi;
|
||||||
|
|
||||||
import com.yunhai.notice.service.flow.FlowCallbackService;
|
import com.yunhai.notice.service.flow.FlowCallbackService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -6,13 +6,21 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 流程中心回调接收端点(方案 §5.3;/callback/* 不在 CAS url-pattern 内,由
|
* 流程中心回调接收端点(挂在开放接口域 /oapi/notice/flow/callback)。
|
||||||
* FlowCallbackVerifyFilter 完成 X-Flw-* 四头验签后才到达这里)。
|
* <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>
|
* <p>
|
||||||
* <b>返回语义(契约冻结 D2,免对账前提)</b>:状态推进成功 / 重复投递(去重命中)/
|
* <b>返回语义(契约冻结 D2,免对账前提)</b>:状态推进成功 / 重复投递(去重命中)/
|
||||||
* 协议毒消息 / 乱序丢弃 / 办件不属本应用 → <b>200</b>(毒消息回 200 防无限重放);
|
* 协议毒消息 / 乱序丢弃 / 办件不属本应用 → <b>200</b>(毒消息回 200 防无限重放);
|
||||||
@@ -21,23 +29,24 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/callback")
|
@RequestMapping("/oapi/notice/flow")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class FlowCallbackController {
|
public class OapiFlowCallbackController {
|
||||||
|
|
||||||
private final FlowCallbackService callbackService;
|
private final FlowCallbackService callbackService;
|
||||||
|
|
||||||
@PostMapping("/flow")
|
@PostMapping("/callback")
|
||||||
public ResponseEntity<String> flow(@RequestBody byte[] body) {
|
public ResponseEntity<String> callback(@RequestBody byte[] body,
|
||||||
|
@RequestAttribute(name = "yh.app.auth", required = false) String appKey) {
|
||||||
try {
|
try {
|
||||||
callbackService.handle(new String(body, java.nio.charset.StandardCharsets.UTF_8));
|
callbackService.handle(new String(body, java.nio.charset.StandardCharsets.UTF_8));
|
||||||
return ResponseEntity.ok("{\"ok\":true}");
|
return ResponseEntity.ok("{\"ok\":true}");
|
||||||
} catch (FlowCallbackService.TransientCallbackException e) {
|
} 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}");
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("{\"ok\":false}");
|
||||||
} catch (Exception e) {
|
} 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}");
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用级审核流程绑定(应用设置 > 流程绑定,按学校一条)。
|
||||||
|
* <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;
|
package com.yunhai.notice.model.flow;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.yunhai.platform.openapi.dto.FlowDto;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 流程中心 /oapi/flow/** 契约模型(与 flow-center OapiModels 逐字对齐,
|
* 流程中心 /oapi/flow/** 契约模型。<br>
|
||||||
* 契约锚点:《流程中心设计概览方案.md》§8 / M1 开工说明 §5——两侧已编码,改动须双向同步)。
|
* 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 {
|
public final class FlowModels {
|
||||||
|
|
||||||
@@ -16,68 +19,41 @@ public final class FlowModels {
|
|||||||
|
|
||||||
/** 流程摘要(GET /oapi/flow/list 元素) */
|
/** 流程摘要(GET /oapi/flow/list 元素) */
|
||||||
@Data
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public static class FlowSummaryVo {
|
public static class FlowSummaryVo extends FlowDto.FlowSummary {
|
||||||
private String flowGuid;
|
|
||||||
private String name;
|
|
||||||
private Integer version;
|
|
||||||
private String nodeSummary;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 代发起请求体(POST /oapi/flow/apply) */
|
/** 代发起请求体(POST /oapi/flow/apply) */
|
||||||
@Data
|
@Data
|
||||||
public static class ApplyBody {
|
@EqualsAndHashCode(callSuper = true)
|
||||||
private String flowGuid;
|
public static class ApplyBody extends FlowDto.ApplyBody {
|
||||||
private String bizType;
|
|
||||||
private String bizId;
|
|
||||||
private String bizTitle;
|
|
||||||
private String bizUrl;
|
|
||||||
/** 业务摘要快照(对象透传,flow-center 统一落 JSON 串) */
|
|
||||||
private Object bizSnapshot;
|
|
||||||
private Integer submitterId;
|
|
||||||
private String submitterName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 代发起响应(biz uk 幂等:重复 apply 返回既有办件 duplicated=true;30/40 态 R4 重启推进) */
|
/** 代发起响应(biz uk 幂等:重复 apply 返回既有办件 duplicated=true;30/40 态 R4 重启推进) */
|
||||||
@Data
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public static class ApplyResultVo {
|
public static class ApplyResultVo extends FlowDto.ApplyResult {
|
||||||
private Long instanceId;
|
|
||||||
private boolean duplicated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按业务键查办件响应(GET /oapi/flow/instance;不存在为 null) */
|
/** 按业务键查办件响应(GET /oapi/flow/instance;不存在为 null) */
|
||||||
@Data
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public static class InstanceVo {
|
public static class InstanceVo extends FlowDto.Instance {
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 回调注册请求体(POST /oapi/flow/callback/reg) */
|
/** 回调注册请求体(POST /oapi/flow/callback/reg) */
|
||||||
@Data
|
@Data
|
||||||
public static class CallbackRegBody {
|
@EqualsAndHashCode(callSuper = true)
|
||||||
private String flowGuid;
|
public static class CallbackRegBody extends FlowDto.CallbackRegBody {
|
||||||
private String callbackUrl;
|
|
||||||
/** 订阅事件逗号分隔,空=全部 */
|
|
||||||
private String eventTypes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 回调注册响应(secret 注册时生成下发;重复注册会轮换 secret,绝不重复调用) */
|
/** 回调注册响应(secret 注册时生成下发;重复注册会轮换 secret,绝不重复调用) */
|
||||||
@Data
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public static class CallbackRegResultVo {
|
public static class CallbackRegResultVo extends FlowDto.CallbackRegResult {
|
||||||
private String secret;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ public class NoticeQuery extends BasePageQuery {
|
|||||||
/** 状态过滤:null 不过滤;1 已发布 */
|
/** 状态过滤:null 不过滤;1 已发布 */
|
||||||
private Integer status;
|
private Integer status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审核状态过滤(管理端列表「状态」筛选,对应列 audit_status):
|
||||||
|
* 0 待提交 / 10 审批中 / 20 已通过 / 30 已驳回 / 40 已撤回;
|
||||||
|
* <b>-1 = 无需审核</b>(audit_status 为 NULL,未走审核轨道的直发通知);null 不过滤。
|
||||||
|
*/
|
||||||
|
private Integer auditStatus;
|
||||||
|
|
||||||
/** 类型过滤:1 普通 / 2 重要 */
|
/** 类型过滤:1 普通 / 2 重要 */
|
||||||
private Integer type;
|
private Integer type;
|
||||||
|
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ public class NoticeRequest {
|
|||||||
/** 转发源通知ID(非空时表示转发创建,对应旧 REST 壳 forward/{sourceId}) */
|
/** 转发源通知ID(非空时表示转发创建,对应旧 REST 壳 forward/{sourceId}) */
|
||||||
private Integer sourceId;
|
private Integer sourceId;
|
||||||
|
|
||||||
/**
|
// 审核流程不再由请求传入:绑定在「应用设置 > 流程绑定」(notice_flow_binding,按学校一条),
|
||||||
* 审核流程 GUID(可选,先审后发):非空=提交审核(status=0 待回调 completed 才发布);
|
// 由 NoticeService 按「已绑定 + 通知对象=全部用户」判定,避免前端/请求绕过审核规则。
|
||||||
* 空=原直发链路(一字不改)。编辑时可变更绑定(变更即走新一轮审核)。
|
|
||||||
*/
|
|
||||||
private String flowGuid;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 eventId = text(payload, "eventId");
|
||||||
String event = text(payload, "event");
|
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 bizType = text(payload, "bizType");
|
||||||
String bizId = text(payload, "bizId");
|
String bizId = text(payload, "bizId");
|
||||||
Long instanceId = longVal(payload, "instanceId");
|
Long instanceId = longVal(payload, "instanceId");
|
||||||
@@ -66,9 +71,6 @@ public class FlowCallbackService {
|
|||||||
log.warn("[notice-flow] 回调缺必要字段(丢弃):event={} eventId={}", event, eventId);
|
log.warn("[notice-flow] 回调缺必要字段(丢弃):event={} eventId={}", event, eventId);
|
||||||
return; // 协议毒消息 → 200
|
return; // 协议毒消息 → 200
|
||||||
}
|
}
|
||||||
if ("ping".equals(event)) {
|
|
||||||
return; // 注册探活回声(本不应到这——过滤器已直接 200,防御性兜底)
|
|
||||||
}
|
|
||||||
if (!FlowCenterClient.BIZ_TYPE.equals(bizType)) {
|
if (!FlowCenterClient.BIZ_TYPE.equals(bizType)) {
|
||||||
log.warn("[notice-flow] 回调办件不属本应用(丢弃):bizType={} eventId={}", bizType, eventId);
|
log.warn("[notice-flow] 回调办件不属本应用(丢弃):bizType={} eventId={}", bizType, eventId);
|
||||||
return; // 不可重试事件 → 200
|
return; // 不可重试事件 → 200
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FlowClient 默认实现:直接构建在 Tier 0 {@link YschOpenCall} 之上(两级模型纪律 1"先 Tier 0",
|
* FlowClient 默认实现:主路径走 SDK 类型化门面 {@link YschFlowOpenApi}(服务目录 key=
|
||||||
* 不手写 RestTemplate/签名代码)。目标地址单一事实源 = {@code notice.flow.base-url}(ad-hoc
|
* {@code flowcenter}:yh.call.targets > yh.open.services > 枚举 base-host 兜底,
|
||||||
* TargetSpec、base-path 空=裸地址直连,与 flow-center 无 context-path 的部署一致);
|
* 《子应用配置优化方案》§4.2 零专属地址配置);目录未显式配置时回落本应用 legacy 键
|
||||||
* 签名/信封解析/错误映射全部由 YschOpenCall 统一承载(401/403→DException、code!=0→DException、
|
* {@code notice.flow.base-url}(ad-hoc TargetSpec,迁移期兼容)。签名/信封解析/错误映射
|
||||||
* 其他传输错→OpenApiException)。
|
* 全部由 SDK 统一承载(401/403→DException、code!=0→DException、传输错→OpenApiException)。
|
||||||
* {@code notice.flow.enabled=false} 时全部方法抛业务异常(文案含"流程中心未接入")供前端降级。
|
* {@code notice.flow.enabled=false} 时全部方法抛业务异常(文案含"流程中心未接入")供前端降级。
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -36,6 +36,8 @@ public class FlowCenterClient implements FlowClient {
|
|||||||
private final NoticeFlowProperties properties;
|
private final NoticeFlowProperties properties;
|
||||||
private final YschOpenCall openCall;
|
private final YschOpenCall openCall;
|
||||||
|
|
||||||
|
private final boolean directoryResolved;
|
||||||
|
|
||||||
public FlowCenterClient(NoticeFlowProperties properties,
|
public FlowCenterClient(NoticeFlowProperties properties,
|
||||||
ObjectProvider<YschOpenCall> openCallProvider,
|
ObjectProvider<YschOpenCall> openCallProvider,
|
||||||
ObjectProvider<LocalAppIdentityProvider> identityProvider,
|
ObjectProvider<LocalAppIdentityProvider> identityProvider,
|
||||||
@@ -49,11 +51,20 @@ public class FlowCenterClient implements FlowClient {
|
|||||||
(host, basePath) -> new AppSignInterceptor(identityProvider.getIfAvailable(), host, basePath));
|
(host, basePath) -> new AppSignInterceptor(identityProvider.getIfAvailable(), host, basePath));
|
||||||
}
|
}
|
||||||
this.openCall = call;
|
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
|
@Override
|
||||||
public List<FlowModels.FlowSummaryVo> listFlows(Integer schId) {
|
public List<FlowModels.FlowSummaryVo> listFlows(Integer schId) {
|
||||||
requireEnabled();
|
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),
|
return openCall.getList(target(), "/oapi/flow/list", params("schId", schId),
|
||||||
FlowModels.FlowSummaryVo.class);
|
FlowModels.FlowSummaryVo.class);
|
||||||
}
|
}
|
||||||
@@ -61,6 +72,10 @@ public class FlowCenterClient implements FlowClient {
|
|||||||
@Override
|
@Override
|
||||||
public FlowModels.ApplyResultVo apply(Integer schId, FlowModels.ApplyBody body) {
|
public FlowModels.ApplyResultVo apply(Integer schId, FlowModels.ApplyBody body) {
|
||||||
requireEnabled();
|
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,
|
return openCall.post(target(), "/oapi/flow/apply", params("schId", schId), body,
|
||||||
FlowModels.ApplyResultVo.class);
|
FlowModels.ApplyResultVo.class);
|
||||||
}
|
}
|
||||||
@@ -68,6 +83,10 @@ public class FlowCenterClient implements FlowClient {
|
|||||||
@Override
|
@Override
|
||||||
public FlowModels.InstanceVo getInstanceByBiz(Integer schId, String bizType, String bizId) {
|
public FlowModels.InstanceVo getInstanceByBiz(Integer schId, String bizType, String bizId) {
|
||||||
requireEnabled();
|
requireEnabled();
|
||||||
|
if (directoryResolved) {
|
||||||
|
return com.yunhai.platform.openapi.client.YschFlowOpenApi.instance(
|
||||||
|
bizType, bizId, schId, FlowModels.InstanceVo.class);
|
||||||
|
}
|
||||||
Map<String, String> query = new HashMap<>();
|
Map<String, String> query = new HashMap<>();
|
||||||
query.put("schId", String.valueOf(schId));
|
query.put("schId", String.valueOf(schId));
|
||||||
query.put("bizType", bizType);
|
query.put("bizType", bizType);
|
||||||
@@ -78,6 +97,10 @@ public class FlowCenterClient implements FlowClient {
|
|||||||
@Override
|
@Override
|
||||||
public FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body) {
|
public FlowModels.CallbackRegResultVo registerCallback(Integer schId, FlowModels.CallbackRegBody body) {
|
||||||
requireEnabled();
|
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,
|
return openCall.post(target(), "/oapi/flow/callback/reg", params("schId", schId), body,
|
||||||
FlowModels.CallbackRegResultVo.class);
|
FlowModels.CallbackRegResultVo.class);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,21 @@ public class NoticeFlowGuard {
|
|||||||
throw notFound();
|
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) {
|
if (user.getType() != null && user.getType() == 1) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,18 @@ public class NoticeFlowProperties {
|
|||||||
*/
|
*/
|
||||||
private String callbackBase = "http://localhost:8101";
|
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},审批人点链接回通知详情) */
|
/** Notice 前端基址(bizUrl 拼接:{front-base}/#/list/detail?serid={id},审批人点链接回通知详情) */
|
||||||
private String frontBaseUrl = "http://localhost:8077";
|
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.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.yunhai.notice.entity.Notice;
|
import com.yunhai.notice.entity.Notice;
|
||||||
|
import com.yunhai.notice.entity.NoticeFlowBinding;
|
||||||
import com.yunhai.notice.entity.NoticeFlowReg;
|
import com.yunhai.notice.entity.NoticeFlowReg;
|
||||||
import com.yunhai.notice.entity.UpFile;
|
import com.yunhai.notice.entity.UpFile;
|
||||||
import com.yunhai.notice.enums.FlowAuditStatusEnum;
|
import com.yunhai.notice.enums.FlowAuditStatusEnum;
|
||||||
import com.yunhai.notice.model.flow.FlowModels;
|
import com.yunhai.notice.model.flow.FlowModels;
|
||||||
|
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
|
||||||
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
||||||
import com.yunhai.notice.repository.NoticeRepository;
|
import com.yunhai.notice.repository.NoticeRepository;
|
||||||
import com.yunhai.notice.repository.UpFileRepository;
|
import com.yunhai.notice.repository.UpFileRepository;
|
||||||
@@ -18,6 +20,7 @@ import com.yunhai.platform.utils.model.BoaUser;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationAdapter;
|
import org.springframework.transaction.support.TransactionSynchronizationAdapter;
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
@@ -39,6 +42,7 @@ public class NoticeFlowService {
|
|||||||
|
|
||||||
private final NoticeRepository noticeRepository;
|
private final NoticeRepository noticeRepository;
|
||||||
private final NoticeFlowRegRepository regRepository;
|
private final NoticeFlowRegRepository regRepository;
|
||||||
|
private final NoticeFlowBindingRepository bindingRepository;
|
||||||
private final UpFileRepository upFileRepository;
|
private final UpFileRepository upFileRepository;
|
||||||
private final FlowClient flowClient;
|
private final FlowClient flowClient;
|
||||||
private final NoticeFlowProperties properties;
|
private final NoticeFlowProperties properties;
|
||||||
@@ -136,6 +140,8 @@ public class NoticeFlowService {
|
|||||||
FlowModels.CallbackRegBody body = new FlowModels.CallbackRegBody();
|
FlowModels.CallbackRegBody body = new FlowModels.CallbackRegBody();
|
||||||
body.setFlowGuid(flowGuid);
|
body.setFlowGuid(flowGuid);
|
||||||
body.setCallbackUrl(callbackUrl());
|
body.setCallbackUrl(callbackUrl());
|
||||||
|
// 申报本服务 context-path:推送方按它剥离后再签应用签名(与平台 AppAuthFilter 剥 contextPath 对齐)
|
||||||
|
body.setBasePath(properties.getCallbackBasePath());
|
||||||
FlowModels.CallbackRegResultVo result = flowClient.registerCallback(schId, body);
|
FlowModels.CallbackRegResultVo result = flowClient.registerCallback(schId, body);
|
||||||
if (reg == null) {
|
if (reg == null) {
|
||||||
reg = new NoticeFlowReg();
|
reg = new NoticeFlowReg();
|
||||||
@@ -256,6 +262,84 @@ public class NoticeFlowService {
|
|||||||
return currentBoaUser == null ? null : currentBoaUser.get();
|
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) {
|
private Notice requireNotice(Integer noticeId) {
|
||||||
return noticeRepository.findById(noticeId)
|
return noticeRepository.findById(noticeId)
|
||||||
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
|
.orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId));
|
||||||
|
|||||||
@@ -131,29 +131,32 @@ public class NoticeService implements INoticeService {
|
|||||||
entity.setId(null);
|
entity.setId(null);
|
||||||
entity.setDeptName(resolveDeptName(request));
|
entity.setDeptName(resolveDeptName(request));
|
||||||
normalizeReceiveList(entity, request.getReceiveList());
|
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.setPublisherAccount(currentAccount());
|
||||||
entity.setPublisherName(currentName());
|
entity.setPublisherName(currentName());
|
||||||
entity.setSchId(currentSchId());
|
entity.setSchId(currentSchId());
|
||||||
entity.setIsPulled(0);
|
entity.setIsPulled(0);
|
||||||
entity.setReadCount(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);
|
noticeRepository.save(entity);
|
||||||
// 写入接收人关联表(非全员时;status=0 不可见,提前绑定无害)
|
// 写入接收人关联表(非全员时;status=0 不可见,提前绑定无害)
|
||||||
saveReceivers(entity);
|
saveReceivers(entity);
|
||||||
// 绑定附件(前端上传后回传的 fileIds 逗号串)
|
// 绑定附件(前端上传后回传的 fileIds 逗号串)
|
||||||
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
|
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
|
||||||
if (flowBound) {
|
if (needAudit) {
|
||||||
// 短信时机后移:绑流程的通知 create 时不发,回调 completed 才发(审核通过才发)
|
// 短信时机后移:走审核的通知 create 时不发,回调 completed 才发(审核通过才发)
|
||||||
noticeFlowService.submitAfterCommit(entity.getId());
|
noticeFlowService.submitAfterCommit(entity.getId());
|
||||||
} else {
|
} else {
|
||||||
// 立即发布且开启短信时预留短信发送(真实通道后续接入)
|
// 立即发布且开启短信时预留短信发送(真实通道后续接入)
|
||||||
@@ -162,6 +165,11 @@ public class NoticeService implements INoticeService {
|
|||||||
return entity.getId();
|
return entity.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 通知对象是否全员(normalizeReceiveList 已把 all/UserAll/ALL 归一为 "ALL") */
|
||||||
|
private boolean isAllReceiver(String receiveList) {
|
||||||
|
return receiveList != null && "ALL".equalsIgnoreCase(receiveList.trim());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 未发布通知(status=0 审核载体)详情放行:仅提交人本人/本校管理员/该办件办理人可见,
|
* 未发布通知(status=0 审核载体)详情放行:仅提交人本人/本校管理员/该办件办理人可见,
|
||||||
* 其余一律"通知不存在或未发布"(查不到语义)。status=1 已发布零变化。
|
* 其余一律"通知不存在或未发布"(查不到语义)。status=1 已发布零变化。
|
||||||
@@ -187,8 +195,6 @@ public class NoticeService implements INoticeService {
|
|||||||
if (entity.getAuditStatus() != null && entity.getAuditStatus() == AUDIT_APPROVING) {
|
if (entity.getAuditStatus() != null && entity.getAuditStatus() == AUDIT_APPROVING) {
|
||||||
throw new BizException(ErrorCode.FORBIDDEN, "审批中不可编辑,请先撤回");
|
throw new BizException(ErrorCode.FORBIDDEN, "审批中不可编辑,请先撤回");
|
||||||
}
|
}
|
||||||
boolean wasFlowBound = StringUtils.hasText(entity.getFlowGuid());
|
|
||||||
boolean nowFlowBound = StringUtils.hasText(request.getFlowGuid());
|
|
||||||
entity.setTitle(request.getTitle());
|
entity.setTitle(request.getTitle());
|
||||||
entity.setContent(request.getContent());
|
entity.setContent(request.getContent());
|
||||||
entity.setType(request.getType());
|
entity.setType(request.getType());
|
||||||
@@ -213,24 +219,37 @@ public class NoticeService implements INoticeService {
|
|||||||
}
|
}
|
||||||
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
|
fileService.bindToTopic(parseFileIds(request.getFileIds()), entity.getId());
|
||||||
}
|
}
|
||||||
// 审核绑定变更:新绑/换绑 → 回到 0 待提交(提交动作 AFTER_COMMIT 出站);解绑 → 直发即发布
|
// 分流重算(每次保存都按当前绑定与通知对象判定,规则与 create 一致):
|
||||||
if (nowFlowBound) {
|
// 全体用户 + 已绑定 → 进/留审核轨道;指定人员 → 退出轨道直发
|
||||||
entity.setFlowGuid(request.getFlowGuid().trim());
|
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());
|
entity.setAuditStatus(com.yunhai.notice.enums.FlowAuditStatusEnum.PENDING_SUBMIT.getCode());
|
||||||
// 清上一轮驳回意见:Notice 侧 flow_result_json 只在终态事件写入(FlowCallbackService:173),
|
// 清上一轮驳回意见:Notice 侧 flow_result_json 只在终态事件写入(FlowCallbackService:173),
|
||||||
// 不清则重提后整段新审批期都挂着旧驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能)
|
// 不清则重提后整段新审批期都挂着旧驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能)
|
||||||
entity.setFlowResultJson(null);
|
entity.setFlowResultJson(null);
|
||||||
} else if (wasFlowBound) {
|
entity.setStatus(UNPUBLISHED); // 审核通过前对用户不可见
|
||||||
|
entity.setTimingSendTime(null); // 定时发送与审核互斥
|
||||||
|
} else {
|
||||||
|
// 直发(指定人员 / 未绑定):清审核镜像字段并置发布
|
||||||
|
// (原实现在此漏置 status,会把解绑/改指定人员的通知永久留在未发布态)
|
||||||
entity.setFlowGuid(null);
|
entity.setFlowGuid(null);
|
||||||
entity.setAuditStatus(null);
|
entity.setAuditStatus(null);
|
||||||
entity.setFlowInstanceId(null);
|
entity.setFlowInstanceId(null);
|
||||||
entity.setLastInstanceVersion(null);
|
entity.setLastInstanceVersion(null);
|
||||||
entity.setFlowResultJson(null);
|
entity.setFlowResultJson(null);
|
||||||
|
entity.setStatus(PUBLISHED);
|
||||||
}
|
}
|
||||||
noticeRepository.save(entity);
|
noticeRepository.save(entity);
|
||||||
if (nowFlowBound) {
|
if (needAudit) {
|
||||||
// 编辑重提:复用同一 bizId(notice.id)→ flow-center R4 语义重启推进,轨迹连续
|
// 编辑重提:复用同一 bizId(notice.id)→ flow-center R4 语义重启推进,轨迹连续
|
||||||
noticeFlowService.submitAfterCommit(entity.getId());
|
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.setId(null);
|
||||||
entity.setDeptName(resolveDeptName(request));
|
entity.setDeptName(resolveDeptName(request));
|
||||||
normalizeReceiveList(entity, request.getReceiveList());
|
normalizeReceiveList(entity, request.getReceiveList());
|
||||||
entity.setStatus(PUBLISHED);
|
|
||||||
entity.setPublisherAccount(currentAccount());
|
entity.setPublisherAccount(currentAccount());
|
||||||
entity.setPublisherName(currentName());
|
entity.setPublisherName(currentName());
|
||||||
entity.setParentId(sourceId);
|
entity.setIsPulled(0);
|
||||||
entity.setIsPulled(0);
|
|
||||||
entity.setReadCount(0);
|
entity.setReadCount(0);
|
||||||
entity.setParentId(sourceId);
|
entity.setParentId(sourceId);
|
||||||
entity.setSchId(currentSchId());
|
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);
|
noticeRepository.save(entity);
|
||||||
saveReceivers(entity);
|
saveReceivers(entity);
|
||||||
trySendSms(entity);
|
if (needAudit) {
|
||||||
|
noticeFlowService.submitAfterCommit(entity.getId());
|
||||||
|
} else {
|
||||||
|
trySendSms(entity);
|
||||||
|
}
|
||||||
return entity.getId();
|
return entity.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +456,14 @@ entity.setIsPulled(0);
|
|||||||
if (query.getStatus() != null) {
|
if (query.getStatus() != null) {
|
||||||
predicates.add(cb.equal(root.get("status"), query.getStatus()));
|
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);
|
appendCommonPredicates(query, root, cb, predicates);
|
||||||
// scope="user" → 只看自己发布的通知
|
// scope="user" → 只看自己发布的通知
|
||||||
if ("user".equalsIgnoreCase(query.getScope())) {
|
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.context.SpringBootTest;
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
import org.springframework.boot.web.server.LocalServerPort;
|
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 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 java.util.UUID;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
@@ -68,7 +61,7 @@ class FlowCallbackTransientIT {
|
|||||||
reg.setSchId(1);
|
reg.setSchId(1);
|
||||||
reg.setAppKey("100617");
|
reg.setAppKey("100617");
|
||||||
reg.setFlowGuid(FLOW_GUID);
|
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.setSecret(REG_SECRET);
|
||||||
reg.setStatus(0);
|
reg.setStatus(0);
|
||||||
regRepository.save(reg);
|
regRepository.save(reg);
|
||||||
@@ -122,28 +115,10 @@ class FlowCallbackTransientIT {
|
|||||||
+ "\"instanceVersion\":3,"
|
+ "\"instanceVersion\":3,"
|
||||||
+ "\"bizType\":\"notice\",\"bizId\":\"" + notice.getId() + "\",\"bizTitle\":\"瞬时失败通知\","
|
+ "\"bizType\":\"notice\",\"bizId\":\"" + notice.getId() + "\",\"bizTitle\":\"瞬时失败通知\","
|
||||||
+ "\"data\":{\"result\":\"APPROVED\",\"opinion\":\"同意\"}}";
|
+ "\"data\":{\"result\":\"APPROVED\",\"opinion\":\"同意\"}}";
|
||||||
String ts = String.valueOf(System.currentTimeMillis());
|
// 回调端点挂在 /oapi/* 下:以推送方 flow-center(100019)身份带应用签名投递
|
||||||
String nonce = uuid().substring(0, 16);
|
return new com.yunhai.notice.support.OapiSignedClient(
|
||||||
HttpHeaders headers = new HttpHeaders();
|
"http://localhost:" + port, "100019", "flow-center-dev-secret-100019")
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
.post("/oapi/notice/flow/callback", body, String.class);
|
||||||
headers.set("X-Flw-EventId", eventId);
|
|
||||||
headers.set("X-Flw-Timestamp", ts);
|
|
||||||
headers.set("X-Flw-Nonce", nonce);
|
|
||||||
headers.set("X-Flw-Signature", hmac(REG_SECRET, ts, nonce, body));
|
|
||||||
return new org.springframework.boot.test.web.client.TestRestTemplate().postForEntity(
|
|
||||||
"http://localhost:" + port + "/callback/flow",
|
|
||||||
new HttpEntity<>(body.getBytes(StandardCharsets.UTF_8), headers), String.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String hmac(String secret, String ts, String nonce, String body) {
|
|
||||||
try {
|
|
||||||
Mac mac = Mac.getInstance("HmacSHA256");
|
|
||||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
|
||||||
byte[] raw = mac.doFinal((ts + "." + nonce + "." + body).getBytes(StandardCharsets.UTF_8));
|
|
||||||
return Base64.getEncoder().encodeToString(raw);
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new IllegalStateException(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String uuid() {
|
private static String uuid() {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.yunhai.notice.flow;
|
package com.yunhai.notice.flow;
|
||||||
|
|
||||||
import com.yunhai.notice.entity.Notice;
|
import com.yunhai.notice.entity.Notice;
|
||||||
|
import com.yunhai.notice.entity.NoticeFlowBinding;
|
||||||
import com.yunhai.notice.entity.NoticeFlowReg;
|
import com.yunhai.notice.entity.NoticeFlowReg;
|
||||||
|
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
|
||||||
import com.yunhai.notice.repository.NoticeFlowEventRepository;
|
import com.yunhai.notice.repository.NoticeFlowEventRepository;
|
||||||
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
||||||
import com.yunhai.notice.repository.NoticeRepository;
|
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.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.boot.web.server.LocalServerPort;
|
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 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 java.util.UUID;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
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):
|
* 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);
|
* ② /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 = {
|
@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"})
|
"spring.jpa.hibernate.ddl-auto=update"})
|
||||||
class NoticeFlowApiIT {
|
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 REG_SECRET = "it-callback-secret-0123456789";
|
||||||
private static final String FLOW_GUID = "it-flow-guid-0001";
|
private static final String FLOW_GUID = "it-flow-guid-0001";
|
||||||
|
|
||||||
@@ -53,6 +54,8 @@ class NoticeFlowApiIT {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private NoticeFlowEventRepository eventRepository;
|
private NoticeFlowEventRepository eventRepository;
|
||||||
@Autowired
|
@Autowired
|
||||||
|
private NoticeFlowBindingRepository bindingRepository;
|
||||||
|
@Autowired
|
||||||
private ObjectMapper objectMapper;
|
private ObjectMapper objectMapper;
|
||||||
|
|
||||||
private Notice notice;
|
private Notice notice;
|
||||||
@@ -61,13 +64,14 @@ class NoticeFlowApiIT {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
eventRepository.deleteAll();
|
eventRepository.deleteAll();
|
||||||
regRepository.deleteAll();
|
regRepository.deleteAll();
|
||||||
|
bindingRepository.deleteAll();
|
||||||
noticeRepository.deleteAll();
|
noticeRepository.deleteAll();
|
||||||
|
|
||||||
NoticeFlowReg reg = new NoticeFlowReg();
|
NoticeFlowReg reg = new NoticeFlowReg();
|
||||||
reg.setSchId(1);
|
reg.setSchId(1);
|
||||||
reg.setAppKey("100617");
|
reg.setAppKey("100617");
|
||||||
reg.setFlowGuid(FLOW_GUID);
|
reg.setFlowGuid(FLOW_GUID);
|
||||||
reg.setCallbackUrl("http://localhost:" + port + "/callback/flow");
|
reg.setCallbackUrl("http://localhost:" + port + CALLBACK_PATH);
|
||||||
reg.setSecret(REG_SECRET);
|
reg.setSecret(REG_SECRET);
|
||||||
reg.setStatus(0);
|
reg.setStatus(0);
|
||||||
regRepository.save(reg);
|
regRepository.save(reg);
|
||||||
@@ -86,15 +90,14 @@ class NoticeFlowApiIT {
|
|||||||
notice = noticeRepository.save(notice);
|
notice = noticeRepository.save(notice);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== ① X-Flw 回调 ====================
|
// ==================== ① 回调端点应用验签 ====================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void signedCompletedEvent_advancesNoticeToPublished() {
|
void signedCompletedEvent_advancesNoticeToPublished() {
|
||||||
String eventId = uuid();
|
String body = eventBody(uuid(), "flow.completed", 3,
|
||||||
String body = eventBody(eventId, "flow.completed", 3,
|
|
||||||
"{\"result\":\"APPROVED\",\"opinion\":\"同意\"}");
|
"{\"result\":\"APPROVED\",\"opinion\":\"同意\"}");
|
||||||
ResponseEntity<String> resp = postSignedCallback(REG_SECRET, body);
|
ResponseEntity<String> resp = flowCenter().post(CALLBACK_PATH, body, String.class);
|
||||||
assertEquals(200, resp.getStatusCodeValue(), "合法签名事件应 200");
|
assertEquals(200, resp.getStatusCodeValue(), "合法应用签名事件应 200");
|
||||||
|
|
||||||
Notice updated = reload();
|
Notice updated = reload();
|
||||||
assertEquals(20, updated.getAuditStatus());
|
assertEquals(20, updated.getAuditStatus());
|
||||||
@@ -108,10 +111,9 @@ class NoticeFlowApiIT {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void duplicateEventId_idempotentSecondDelivery() {
|
void duplicateEventId_idempotentSecondDelivery() {
|
||||||
String eventId = uuid();
|
String body = eventBody(uuid(), "flow.completed", 3, "{\"result\":\"APPROVED\"}");
|
||||||
String body = eventBody(eventId, "flow.completed", 3, "{\"result\":\"APPROVED\"}");
|
assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue());
|
||||||
assertEquals(200, postSignedCallback(REG_SECRET, body).getStatusCodeValue());
|
assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(),
|
||||||
assertEquals(200, postSignedCallback(REG_SECRET, body).getStatusCodeValue(),
|
|
||||||
"重复投递必须回 200(flow-center 持续重试语义下的常态)");
|
"重复投递必须回 200(flow-center 持续重试语义下的常态)");
|
||||||
assertEquals(20, reload().getAuditStatus());
|
assertEquals(20, reload().getAuditStatus());
|
||||||
assertEquals(1L, eventRepository.count(), "eventId 唯一:重复投递不落第二行");
|
assertEquals(1L, eventRepository.count(), "eventId 唯一:重复投递不落第二行");
|
||||||
@@ -119,9 +121,9 @@ class NoticeFlowApiIT {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void missingFieldsBody_200_silentDrop() {
|
void missingFieldsBody_200_silentDrop() {
|
||||||
// 合法 JSON 但缺事件字段:过滤链可过(含 flowGuid/schId 可验签),业务层判毒丢弃回 200
|
// 合法 JSON 但缺事件字段:应用验签可过,业务层判毒丢弃回 200
|
||||||
String body = "{\"eventId\":\"" + uuid() + "\",\"schId\":1,\"flowGuid\":\"" + FLOW_GUID + "\"}";
|
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(200, resp.getStatusCodeValue(), "缺字段毒消息回 200 防无限重放");
|
||||||
assertEquals(10, reload().getAuditStatus(), "状态不得被毒消息推进");
|
assertEquals(10, reload().getAuditStatus(), "状态不得被毒消息推进");
|
||||||
}
|
}
|
||||||
@@ -129,27 +131,36 @@ class NoticeFlowApiIT {
|
|||||||
@Test
|
@Test
|
||||||
void badSignature_401() {
|
void badSignature_401() {
|
||||||
String body = eventBody(uuid(), "flow.completed", 3, "{}");
|
String body = eventBody(uuid(), "flow.completed", 3, "{}");
|
||||||
ResponseEntity<String> resp = postCallback(body, "forged-signature");
|
ResponseEntity<String> resp = flowCenter().postWithBadSecret(CALLBACK_PATH, body);
|
||||||
assertEquals(401, resp.getStatusCodeValue());
|
assertEquals(401, resp.getStatusCodeValue(), "坏签名必须 401");
|
||||||
assertEquals(10, reload().getAuditStatus());
|
assertEquals(10, reload().getAuditStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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\"}";
|
String body = "{\"event\":\"ping\",\"flowGuid\":\"" + FLOW_GUID + "\",\"occurredTime\":\"2026-09-13 00:00:00\"}";
|
||||||
HttpHeaders headers = new HttpHeaders();
|
// 探活 ping 同样要带应用签名(推送方 CallbackAppSigner 会给探活补头)
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
assertEquals(401, flowCenter().postRaw(CALLBACK_PATH, body).getStatusCodeValue(),
|
||||||
ResponseEntity<String> resp = restTemplate().postForEntity(url("/callback/flow"),
|
"无签名 ping 应被 401 拦下");
|
||||||
new HttpEntity<>(body.getBytes(StandardCharsets.UTF_8), headers), String.class);
|
assertEquals(200, flowCenter().post(CALLBACK_PATH, body, String.class).getStatusCodeValue(),
|
||||||
assertEquals(200, resp.getStatusCodeValue(), "注册探活 ping 直接 200");
|
"带签名的 ping 回 200(注册探活只判可达)");
|
||||||
assertEquals(0L, eventRepository.count());
|
assertEquals(0L, eventRepository.count(), "ping 不落事件行");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== ② /oapi/notice/getDict 三态 ====================
|
// ==================== ② /oapi/notice/getDict 三态 ====================
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void oapiGetDict_threeStates() {
|
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
|
// 匿名(无签名头)→ 401
|
||||||
assertEquals(401, client.getRaw("/oapi/notice/getDict?type=1").getStatusCodeValue());
|
assertEquals(401, client.getRaw("/oapi/notice/getDict?type=1").getStatusCodeValue());
|
||||||
// 错误密钥 → 401
|
// 错误密钥 → 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() {
|
private Notice reload() {
|
||||||
return noticeRepository.findById(notice.getId()).orElseThrow(IllegalStateException::new);
|
return noticeRepository.findById(notice.getId()).orElseThrow(IllegalStateException::new);
|
||||||
}
|
}
|
||||||
@@ -207,10 +244,6 @@ class NoticeFlowApiIT {
|
|||||||
return "http://localhost:" + port + path;
|
return "http://localhost:" + port + path;
|
||||||
}
|
}
|
||||||
|
|
||||||
private org.springframework.boot.test.web.client.TestRestTemplate restTemplate() {
|
|
||||||
return new org.springframework.boot.test.web.client.TestRestTemplate();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String eventBody(String eventId, String event, int version, String dataJson) {
|
private String eventBody(String eventId, String event, int version, String dataJson) {
|
||||||
return "{"
|
return "{"
|
||||||
+ "\"eventId\":\"" + eventId + "\",\"event\":\"" + event + "\","
|
+ "\"eventId\":\"" + eventId + "\",\"event\":\"" + event + "\","
|
||||||
@@ -221,42 +254,6 @@ class NoticeFlowApiIT {
|
|||||||
+ "\"data\":" + dataJson + "}";
|
+ "\"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() {
|
private static String uuid() {
|
||||||
return UUID.randomUUID().toString().replace("-", "");
|
return UUID.randomUUID().toString().replace("-", "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.yunhai.notice.service.flow;
|
package com.yunhai.notice.service.flow;
|
||||||
|
|
||||||
import com.yunhai.notice.entity.Notice;
|
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.entity.NoticeFlowReg;
|
||||||
import com.yunhai.notice.model.flow.FlowModels;
|
import com.yunhai.notice.model.flow.FlowModels;
|
||||||
import com.yunhai.notice.model.notice.NoticeRequest;
|
import com.yunhai.notice.model.notice.NoticeRequest;
|
||||||
|
import com.yunhai.notice.repository.NoticeFlowBindingRepository;
|
||||||
import com.yunhai.notice.repository.NoticeFlowEventRepository;
|
import com.yunhai.notice.repository.NoticeFlowEventRepository;
|
||||||
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
import com.yunhai.notice.repository.NoticeFlowRegRepository;
|
||||||
import com.yunhai.notice.repository.NoticeReadRepository;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
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.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
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.eq;
|
||||||
import static org.mockito.ArgumentMatchers.anyList;
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.doThrow;
|
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
@@ -48,14 +51,19 @@ import static org.mockito.Mockito.verify;
|
|||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发布分流/守卫/重提单测(T2.2,fake FlowClient 注入):
|
* 发布分流/守卫/重提单测(fake FlowClient 注入)。<p>
|
||||||
* 未绑流程=原直发链路零破坏哨兵;绑流程=status=0/audit=0→apply→回填 10;apply 失败保持 0 可重推;
|
* 分流规则(应用级绑定,规则单一事实源=NoticeService):
|
||||||
* 10 审批中锁编辑/删除/重提;30/40 可编辑重提复用同 bizId;sms 时序后移。
|
* <b>已绑定 + 通知对象=全部用户 → 审核</b>(status=0/audit=0→apply→回填 10);
|
||||||
|
* <b>已绑定 + 指定人员 → 直发</b>(原链路零变化);<b>未绑定 → 直发</b>(存量零破坏哨兵)。
|
||||||
|
* 另覆盖:apply 失败保持 0 可重推、10 审批中锁编辑/删除/重提、30/40 编辑重提复用同 bizId、短信时机后移。
|
||||||
*/
|
*/
|
||||||
class NoticeFlowPublishTest {
|
class NoticeFlowPublishTest {
|
||||||
|
|
||||||
|
private static final String BINDING_GUID = "fg-review";
|
||||||
|
|
||||||
private NoticeService noticeService;
|
private NoticeService noticeService;
|
||||||
private NoticeRepository noticeRepository;
|
private NoticeRepository noticeRepository;
|
||||||
|
private NoticeFlowBindingRepository bindingRepository;
|
||||||
private FlowClient flowClient;
|
private FlowClient flowClient;
|
||||||
private NoticeFlowService noticeFlowService;
|
private NoticeFlowService noticeFlowService;
|
||||||
private SmsSender smsSender;
|
private SmsSender smsSender;
|
||||||
@@ -72,6 +80,7 @@ class NoticeFlowPublishTest {
|
|||||||
auditAtSave.clear();
|
auditAtSave.clear();
|
||||||
statusAtSave.clear();
|
statusAtSave.clear();
|
||||||
noticeRepository = mock(NoticeRepository.class);
|
noticeRepository = mock(NoticeRepository.class);
|
||||||
|
bindingRepository = mock(NoticeFlowBindingRepository.class);
|
||||||
smsSender = mock(SmsSender.class);
|
smsSender = mock(SmsSender.class);
|
||||||
dictService = mock(IDictService.class);
|
dictService = mock(IDictService.class);
|
||||||
flowClient = mock(FlowClient.class);
|
flowClient = mock(FlowClient.class);
|
||||||
@@ -103,6 +112,8 @@ class NoticeFlowPublishTest {
|
|||||||
return 1;
|
return 1;
|
||||||
});
|
});
|
||||||
when(dictService.resolveDeptName(any())).thenReturn("办公室");
|
when(dictService.resolveDeptName(any())).thenReturn("办公室");
|
||||||
|
// 默认未绑定;绑定场景由 bindFlow() 显式打开
|
||||||
|
when(bindingRepository.findBySchId(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
NoticeFlowProperties props = new NoticeFlowProperties();
|
NoticeFlowProperties props = new NoticeFlowProperties();
|
||||||
props.setEnabled(true);
|
props.setEnabled(true);
|
||||||
@@ -126,7 +137,7 @@ class NoticeFlowPublishTest {
|
|||||||
return vo;
|
return vo;
|
||||||
});
|
});
|
||||||
|
|
||||||
noticeFlowService = new NoticeFlowService(noticeRepository, regRepository,
|
noticeFlowService = new NoticeFlowService(noticeRepository, regRepository, bindingRepository,
|
||||||
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(), appAuth, currentBoaUser,
|
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(), appAuth, currentBoaUser,
|
||||||
new NoticeFlowGuard(flowClient, props));
|
new NoticeFlowGuard(flowClient, props));
|
||||||
|
|
||||||
@@ -164,25 +175,37 @@ class NoticeFlowPublishTest {
|
|||||||
UserContextHolder.clear();
|
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();
|
NoticeRequest request = new NoticeRequest();
|
||||||
request.setTitle("九月开学安排");
|
request.setTitle("九月开学安排");
|
||||||
request.setContent("<p>内容</p>");
|
request.setContent("<p>内容</p>");
|
||||||
request.setType(1);
|
request.setType(1);
|
||||||
request.setDeptId("d1");
|
request.setDeptId("d1");
|
||||||
request.setReceiveList("[\"tea1\",\"tea2\"]");
|
request.setReceiveList(receiveList);
|
||||||
request.setSmsFlag(1);
|
request.setSmsFlag(1);
|
||||||
request.setFlowGuid(flowGuid);
|
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unboundFlow_directPublish_zeroSideEffects() {
|
void unboundFlow_directPublish_zeroSideEffects() {
|
||||||
// 存量零破坏哨兵:未绑流程 → status=1、sms 即时、flow 零交互
|
// 存量零破坏哨兵:未绑流程 → 即便全员也 status=1、sms 即时、flow 零交互
|
||||||
Integer id = noticeService.publish(request(null));
|
Integer id = noticeService.publish(request("all"));
|
||||||
ArgumentCaptor<Notice> captor = ArgumentCaptor.forClass(Notice.class);
|
ArgumentCaptor<Notice> captor = ArgumentCaptor.forClass(Notice.class);
|
||||||
verify(noticeRepository, times(1)).save(captor.capture());
|
verify(noticeRepository, times(1)).save(captor.capture());
|
||||||
assertEquals(1, captor.getValue().getStatus());
|
assertEquals(1, captor.getValue().getStatus());
|
||||||
|
assertNull(captor.getValue().getAuditStatus());
|
||||||
|
assertNull(captor.getValue().getFlowGuid());
|
||||||
assertEquals("zhangsan", captor.getValue().getPublisherAccount());
|
assertEquals("zhangsan", captor.getValue().getPublisherAccount());
|
||||||
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
verify(smsSender, times(1)).sendNoticeSms(anyInt(), anyString(), anyList());
|
||||||
verify(flowClient, never()).apply(anyInt(), any());
|
verify(flowClient, never()).apply(anyInt(), any());
|
||||||
@@ -191,13 +214,14 @@ class NoticeFlowPublishTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void boundFlow_status0_audit0_thenApplyBackfills() {
|
void boundFlow_allReceivers_entersAudit() {
|
||||||
// 单测无活动事务 → submitAfterCommit 立即执行(等价 AFTER_COMMIT 语义验证)
|
// 核心新规则:已绑定 + 通知对象=全部用户 → 审核载体
|
||||||
noticeService.publish(request("fg-review"));
|
bindFlow(BINDING_GUID);
|
||||||
|
noticeService.publish(request("all"));
|
||||||
verify(noticeRepository, times(1)).save(any(Notice.class)); // 仅落库;apply 后走窄列原子更新
|
verify(noticeRepository, times(1)).save(any(Notice.class)); // 仅落库;apply 后走窄列原子更新
|
||||||
assertEquals(0, auditAtSave.get(0), "首存 audit=0 待提交");
|
assertEquals(0, auditAtSave.get(0), "首存 audit=0 待提交");
|
||||||
assertEquals(0, firstSavedStatus(), "绑流程通知应落 status=0 未发布");
|
assertEquals(0, firstSavedStatus(), "走审核的通知应落 status=0 未发布");
|
||||||
assertEquals("fg-review", savedEntity.getFlowGuid());
|
assertEquals(BINDING_GUID, savedEntity.getFlowGuid(), "flow_guid 取应用绑定快照");
|
||||||
// 窄列原子回填:audit=10 + instance(markApproving 桩已作用于实体)
|
// 窄列原子回填:audit=10 + instance(markApproving 桩已作用于实体)
|
||||||
assertEquals(10, savedEntity.getAuditStatus());
|
assertEquals(10, savedEntity.getAuditStatus());
|
||||||
assertEquals(9527L, savedEntity.getFlowInstanceId());
|
assertEquals(9527L, savedEntity.getFlowInstanceId());
|
||||||
@@ -206,8 +230,31 @@ class NoticeFlowPublishTest {
|
|||||||
verify(smsSender, never()).sendNoticeSms(anyInt(), anyString(), anyList());
|
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
|
@Test
|
||||||
void applyFails_rowStaysPendingSubmit_thenRetrySucceeds() {
|
void applyFails_rowStaysPendingSubmit_thenRetrySucceeds() {
|
||||||
|
bindFlow(BINDING_GUID);
|
||||||
when(flowClient.apply(anyInt(), any()))
|
when(flowClient.apply(anyInt(), any()))
|
||||||
.thenThrow(new RuntimeException("flow-center down"))
|
.thenThrow(new RuntimeException("flow-center down"))
|
||||||
.thenAnswer(inv -> {
|
.thenAnswer(inv -> {
|
||||||
@@ -215,7 +262,7 @@ class NoticeFlowPublishTest {
|
|||||||
vo.setInstanceId(9527L);
|
vo.setInstanceId(9527L);
|
||||||
return vo;
|
return vo;
|
||||||
});
|
});
|
||||||
noticeService.publish(request("fg-review")); // 出站失败被吞(保持 0 待提交)
|
noticeService.publish(request("all")); // 出站失败被吞(保持 0 待提交)
|
||||||
assertEquals(0, savedEntity.getAuditStatus(), "apply 失败保持 0 待提交(行不回滚)");
|
assertEquals(0, savedEntity.getAuditStatus(), "apply 失败保持 0 待提交(行不回滚)");
|
||||||
// 重推入口:trySubmitFlow 幂等治愈 → 10
|
// 重推入口:trySubmitFlow 幂等治愈 → 10
|
||||||
noticeFlowService.trySubmitFlow(77);
|
noticeFlowService.trySubmitFlow(77);
|
||||||
@@ -226,7 +273,8 @@ class NoticeFlowPublishTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void trySubmit_rejectedWhenApproving() {
|
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));
|
BizException ex = assertThrows(BizException.class, () -> noticeFlowService.trySubmitFlow(77));
|
||||||
assertTrue(ex.getMessage().contains("审批中"));
|
assertTrue(ex.getMessage().contains("审批中"));
|
||||||
}
|
}
|
||||||
@@ -246,31 +294,35 @@ class NoticeFlowPublishTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void editAndDelete_rejectedWhenApproving() {
|
void editAndDelete_rejectedWhenApproving() {
|
||||||
noticeService.publish(request("fg-review")); // → audit=10
|
bindFlow(BINDING_GUID);
|
||||||
|
noticeService.publish(request("all")); // → audit=10
|
||||||
assertEquals(10, savedEntity.getAuditStatus());
|
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)));
|
assertThrows(BizException.class, () -> noticeService.deleteBatch(java.util.Collections.singletonList(77)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void editAfterRejected_resubmitsSameBizId() {
|
void editAfterRejected_resubmitsSameBizId() {
|
||||||
|
bindFlow(BINDING_GUID);
|
||||||
Notice rejected = pendingNotice(30);
|
Notice rejected = pendingNotice(30);
|
||||||
when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected));
|
when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected));
|
||||||
noticeService.edit(77, request("fg-review"));
|
noticeService.edit(77, request("all"));
|
||||||
// 编辑后 AFTER_COMMIT(单测立即)重提,复用同一 bizId=notice.id
|
// 编辑后 AFTER_COMMIT(单测立即)重提,复用同一 bizId=notice.id
|
||||||
ArgumentCaptor<FlowModels.ApplyBody> body = ArgumentCaptor.forClass(FlowModels.ApplyBody.class);
|
ArgumentCaptor<FlowModels.ApplyBody> body = ArgumentCaptor.forClass(FlowModels.ApplyBody.class);
|
||||||
verify(flowClient, times(1)).apply(anyInt(), body.capture());
|
verify(flowClient, times(1)).apply(anyInt(), body.capture());
|
||||||
assertEquals("77", body.getValue().getBizId());
|
assertEquals("77", body.getValue().getBizId());
|
||||||
assertEquals(10, rejected.getAuditStatus());
|
assertEquals(10, rejected.getAuditStatus());
|
||||||
|
assertEquals(0, rejected.getStatus(), "重审期间仍未发布");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void applyParams_contract() {
|
void applyParams_contract() {
|
||||||
noticeService.publish(request("fg-review"));
|
bindFlow(BINDING_GUID);
|
||||||
|
noticeService.publish(request("all"));
|
||||||
ArgumentCaptor<FlowModels.ApplyBody> body = ArgumentCaptor.forClass(FlowModels.ApplyBody.class);
|
ArgumentCaptor<FlowModels.ApplyBody> body = ArgumentCaptor.forClass(FlowModels.ApplyBody.class);
|
||||||
verify(flowClient).apply(anyInt(), body.capture());
|
verify(flowClient).apply(anyInt(), body.capture());
|
||||||
FlowModels.ApplyBody b = body.getValue();
|
FlowModels.ApplyBody b = body.getValue();
|
||||||
assertEquals("fg-review", b.getFlowGuid());
|
assertEquals(BINDING_GUID, b.getFlowGuid());
|
||||||
assertEquals("notice", b.getBizType());
|
assertEquals("notice", b.getBizType());
|
||||||
assertEquals("77", b.getBizId());
|
assertEquals("77", b.getBizId());
|
||||||
assertEquals("九月开学安排", b.getBizTitle());
|
assertEquals("九月开学安排", b.getBizTitle());
|
||||||
@@ -286,13 +338,49 @@ class NoticeFlowPublishTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unbindFlowOnEdit_clearsAuditAndPublishes() {
|
void editToSpecified_publishesAndClearsAudit() {
|
||||||
|
// 由审核轨道改投指定人员 → 退出轨道直发(原实现漏置 status,会把通知留在未发布态)
|
||||||
Notice rejected = pendingNotice(30);
|
Notice rejected = pendingNotice(30);
|
||||||
when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected));
|
when(noticeRepository.findById(77)).thenReturn(Optional.of(rejected));
|
||||||
noticeService.edit(77, request(null)); // 解绑 → 直发
|
noticeService.edit(77, request("[\"tea1\"]")); // 未绑定 + 指定人员
|
||||||
assertEquals(null, rejected.getFlowGuid());
|
assertNull(rejected.getFlowGuid());
|
||||||
assertEquals(null, rejected.getAuditStatus());
|
assertNull(rejected.getAuditStatus());
|
||||||
|
assertEquals(1, rejected.getStatus(), "退出审核轨道必须置为已发布");
|
||||||
verify(flowClient, never()).apply(anyInt(), any());
|
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 快照 */
|
/** 首次保存时的 status 快照 */
|
||||||
@@ -300,6 +388,74 @@ class NoticeFlowPublishTest {
|
|||||||
return statusAtSave.get(0);
|
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) {
|
private Notice pendingNotice(int auditStatus) {
|
||||||
// 模拟已落库的绑流程通知(status=0,audit=指定态)
|
// 模拟已落库的绑流程通知(status=0,audit=指定态)
|
||||||
Notice n = new Notice();
|
Notice n = new Notice();
|
||||||
@@ -308,7 +464,7 @@ class NoticeFlowPublishTest {
|
|||||||
n.setContent("<p>内容</p>");
|
n.setContent("<p>内容</p>");
|
||||||
n.setStatus(0);
|
n.setStatus(0);
|
||||||
n.setAuditStatus(auditStatus);
|
n.setAuditStatus(auditStatus);
|
||||||
n.setFlowGuid("fg-review");
|
n.setFlowGuid(BINDING_GUID);
|
||||||
n.setSchId(1);
|
n.setSchId(1);
|
||||||
n.setPublisherAccount("zhangsan");
|
n.setPublisherAccount("zhangsan");
|
||||||
n.setSmsFlag(1);
|
n.setSmsFlag(1);
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class NoticeFlowTraceTest {
|
|||||||
NoticeFlowProperties props = new NoticeFlowProperties();
|
NoticeFlowProperties props = new NoticeFlowProperties();
|
||||||
props.setEnabled(true);
|
props.setEnabled(true);
|
||||||
service = new NoticeFlowService(noticeRepository, mock(NoticeFlowRegRepository.class),
|
service = new NoticeFlowService(noticeRepository, mock(NoticeFlowRegRepository.class),
|
||||||
|
mock(com.yunhai.notice.repository.NoticeFlowBindingRepository.class),
|
||||||
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(),
|
mock(UpFileRepository.class), flowClient, props, new ObjectMapper(),
|
||||||
new AppAuthProperties(), mock(CurrentBoaUser.class), new NoticeFlowGuard(flowClient, props));
|
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.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@ import static org.mockito.Mockito.when;
|
|||||||
* 驳回后编辑重提的清态单测(业务人视角走查 NEW-3):
|
* 驳回后编辑重提的清态单测(业务人视角走查 NEW-3):
|
||||||
* Notice 侧 flow_result_json 只在终态事件写入,若不显式清,重提后整段新审批期
|
* Notice 侧 flow_result_json 只在终态事件写入,若不显式清,重提后整段新审批期
|
||||||
* 都挂着上一轮的驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能)。
|
* 都挂着上一轮的驳回意见(svc-hall 每事件覆盖可自愈,Notice 不能)。
|
||||||
|
* <p>
|
||||||
|
* 分流判定按新规则(应用级绑定 + 通知对象=全部用户才审核),故用例需显式给出
|
||||||
|
* boundFlowGuid(应用设置里的绑定)与 receiveList。
|
||||||
*/
|
*/
|
||||||
@DisplayName("NoticeService.edit - 重提清旧驳回意见")
|
@DisplayName("NoticeService.edit - 重提清旧驳回意见")
|
||||||
class NoticeResubmitClearResultTest {
|
class NoticeResubmitClearResultTest {
|
||||||
@@ -39,6 +43,10 @@ class NoticeResubmitClearResultTest {
|
|||||||
service = new NoticeService();
|
service = new NoticeService();
|
||||||
ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository);
|
ReflectionTestUtils.setField(service, "noticeRepository", noticeRepository);
|
||||||
ReflectionTestUtils.setField(service, "noticeFlowService", noticeFlowService);
|
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));
|
ReflectionTestUtils.setField(service, "dictService", mock(com.yunhai.notice.service.IDictService.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +65,12 @@ class NoticeResubmitClearResultTest {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
private NoticeRequest editRequest(String flowGuid) {
|
/** 编辑请求(receiveList:全员 "all" / 指定人员串) */
|
||||||
|
private NoticeRequest editRequest(String receiveList) {
|
||||||
NoticeRequest req = new NoticeRequest();
|
NoticeRequest req = new NoticeRequest();
|
||||||
ReflectionTestUtils.setField(req, "title", "改后标题");
|
ReflectionTestUtils.setField(req, "title", "改后标题");
|
||||||
ReflectionTestUtils.setField(req, "content", "改后正文");
|
ReflectionTestUtils.setField(req, "content", "改后正文");
|
||||||
ReflectionTestUtils.setField(req, "flowGuid", flowGuid);
|
ReflectionTestUtils.setField(req, "receiveList", receiveList);
|
||||||
return req;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,26 +78,34 @@ class NoticeResubmitClearResultTest {
|
|||||||
void editResubmit_clearsStaleRejectOpinion() {
|
void editResubmit_clearsStaleRejectOpinion() {
|
||||||
Notice n = rejectedNotice();
|
Notice n = rejectedNotice();
|
||||||
when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> inv.getArgument(0));
|
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(),
|
assertEquals(FlowAuditStatusEnum.PENDING_SUBMIT.getCode(), n.getAuditStatus(),
|
||||||
"重提回到 0 待提交");
|
"重提回到 0 待提交");
|
||||||
assertNull(n.getFlowResultJson(), "旧驳回意见须清空,否则新审批期一直显示");
|
assertNull(n.getFlowResultJson(), "旧驳回意见须清空,否则新审批期一直显示");
|
||||||
|
assertEquals("fg-1", n.getFlowGuid(), "沿用该通知已有流程快照");
|
||||||
|
assertEquals(0, n.getStatus(), "重审期间对用户不可见");
|
||||||
assertEquals("改后标题", n.getTitle());
|
assertEquals("改后标题", n.getTitle());
|
||||||
verify(noticeFlowService).submitAfterCommit(42);
|
verify(noticeFlowService).submitAfterCommit(42);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unbindFlow_alsoClearsResultAndInstance() {
|
void editToSpecified_publishesDirectlyAndClearsResult() {
|
||||||
Notice n = rejectedNotice();
|
Notice n = rejectedNotice();
|
||||||
when(noticeRepository.save(any(Notice.class))).thenAnswer(inv -> inv.getArgument(0));
|
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.getFlowGuid());
|
||||||
assertNull(n.getFlowInstanceId());
|
assertNull(n.getFlowInstanceId());
|
||||||
assertNull(n.getFlowResultJson());
|
assertNull(n.getFlowResultJson());
|
||||||
assertNull(n.getAuditStatus());
|
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);
|
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 用例) */
|
/** 带错误密钥的调用(401 用例) */
|
||||||
public ResponseEntity<String> getWithBadSecret(String pathAndQuery) {
|
public ResponseEntity<String> getWithBadSecret(String pathAndQuery) {
|
||||||
String ts = String.valueOf(System.currentTimeMillis());
|
String ts = String.valueOf(System.currentTimeMillis());
|
||||||
|
|||||||
Reference in New Issue
Block a user